Skip to main content

malbox_plugin_sdk/runtime/guest/
files.rs

1//! File push/pull and path resolution helpers for the guest runtime.
2//!
3//! All paths supplied by the daemon are resolved against the runtime's
4//! work directory. Absolute paths and `..` traversal are rejected.
5
6use std::path::{Component, Path, PathBuf};
7
8/// Push file contents into the work directory under `dest`.
9pub(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
25/// Read a file from the work directory.
26pub(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
36/// Resolve a relative path against `work_dir`, rejecting traversal attempts.
37///
38/// - Absolute paths are rejected.
39/// - `..` components that would escape `work_dir` are rejected.
40pub(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
59/// Normalize a path by resolving `.` and `..` components without filesystem access.
60fn 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    // -- normalize_path tests --
81
82    #[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    // -- resolve_path tests --
101
102    #[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    // -- push_file / pull_file tests --
126
127    #[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}