malbox_plugin_sdk/runtime/guest/
files.rs1use std::path::{Component, Path, PathBuf};
7
8pub(super) async fn push_file(
10 work_dir: &Path,
11 dest: &str,
12 data: Vec<u8>,
13) -> std::result::Result<(), String> {
14 let path = resolve_path(work_dir, dest)?;
15 if let Some(parent) = path.parent() {
16 tokio::fs::create_dir_all(parent)
17 .await
18 .map_err(|e| format!("failed to create parent dirs: {}", e))?;
19 }
20 tokio::fs::write(&path, &data)
21 .await
22 .map_err(|e| format!("failed to write file: {}", e))
23}
24
25pub(super) async fn pull_file(
27 work_dir: &Path,
28 source: &str,
29) -> std::result::Result<Vec<u8>, String> {
30 let path = resolve_path(work_dir, source)?;
31 tokio::fs::read(&path)
32 .await
33 .map_err(|e| format!("failed to read file: {}", e))
34}
35
36pub(super) fn resolve_path(
41 work_dir: &Path,
42 relative: &str,
43) -> std::result::Result<PathBuf, String> {
44 if Path::new(relative).is_absolute() {
45 return Err(format!("absolute paths not allowed: {}", relative));
46 }
47
48 let joined = work_dir.join(relative);
49 let normalized = normalize_path(&joined);
50 let normalized_work_dir = normalize_path(work_dir);
51
52 if !normalized.starts_with(&normalized_work_dir) {
53 return Err(format!("path escapes work directory: {}", relative));
54 }
55
56 Ok(normalized)
57}
58
59fn normalize_path(path: &Path) -> PathBuf {
61 let mut components = Vec::new();
62 for component in path.components() {
63 match component {
64 Component::ParentDir => {
65 if matches!(components.last(), Some(Component::Normal(_))) {
66 components.pop();
67 }
68 }
69 Component::CurDir => {}
70 other => components.push(other),
71 }
72 }
73 components.iter().collect()
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79
80 #[test]
83 fn normalize_path_resolves_dot_components() {
84 let result = normalize_path(&PathBuf::from("/a/b/./c"));
85 assert_eq!(result, PathBuf::from("/a/b/c"));
86 }
87
88 #[test]
89 fn normalize_path_resolves_dotdot_components() {
90 let result = normalize_path(&PathBuf::from("/a/b/../c"));
91 assert_eq!(result, PathBuf::from("/a/c"));
92 }
93
94 #[test]
95 fn normalize_path_handles_trailing_dotdot() {
96 let result = normalize_path(&PathBuf::from("/a/b/.."));
97 assert_eq!(result, PathBuf::from("/a"));
98 }
99
100 #[test]
103 fn resolve_path_accepts_simple_relative() {
104 let dir = tempfile::tempdir().unwrap();
105 let result = resolve_path(dir.path(), "samples/test.exe");
106 assert!(result.is_ok());
107 assert!(result.unwrap().starts_with(dir.path()));
108 }
109
110 #[test]
111 fn resolve_path_rejects_traversal() {
112 let dir = tempfile::tempdir().unwrap();
113 let result = resolve_path(dir.path(), "../etc/passwd");
114 assert!(result.is_err());
115 assert!(result.unwrap_err().contains("escapes work directory"));
116 }
117
118 #[test]
119 fn resolve_path_rejects_absolute() {
120 let dir = tempfile::tempdir().unwrap();
121 let result = resolve_path(dir.path(), "/etc/passwd");
122 assert!(result.is_err());
123 }
124
125 #[tokio::test]
128 async fn push_file_writes_to_work_dir() {
129 let dir = tempfile::tempdir().unwrap();
130 let result = push_file(dir.path(), "samples/test.exe", b"MZ\x90\x00".to_vec()).await;
131 assert!(result.is_ok());
132
133 let written = std::fs::read(dir.path().join("samples/test.exe")).unwrap();
134 assert_eq!(written, b"MZ\x90\x00");
135 }
136
137 #[tokio::test]
138 async fn push_file_rejects_traversal() {
139 let dir = tempfile::tempdir().unwrap();
140 let result = push_file(dir.path(), "../escape.txt", b"bad".to_vec()).await;
141 assert!(result.is_err());
142 }
143
144 #[tokio::test]
145 async fn pull_file_reads_from_work_dir() {
146 let dir = tempfile::tempdir().unwrap();
147 let file_path = dir.path().join("results/output.json");
148 std::fs::create_dir_all(file_path.parent().unwrap()).unwrap();
149 std::fs::write(&file_path, b"{\"key\": \"value\"}").unwrap();
150
151 let result = pull_file(dir.path(), "results/output.json").await;
152 assert!(result.is_ok());
153 assert_eq!(result.unwrap(), b"{\"key\": \"value\"}");
154 }
155
156 #[tokio::test]
157 async fn pull_file_rejects_traversal() {
158 let dir = tempfile::tempdir().unwrap();
159 let result = pull_file(dir.path(), "../../etc/passwd").await;
160 assert!(result.is_err());
161 }
162}