agentmux_srv\backend/
tarcopy.rs

1#![allow(dead_code)]
2// Copyright 2025-2026, AgentMux Corp.
3// SPDX-License-Identifier: Apache-2.0
4
5//! Tar copy utilities for streaming file transfers over channels.
6//! Port of Go's `pkg/util/tarcopy/tarcopy.go`.
7//!
8//! Provides path normalization with directory traversal protection
9//! and single-file mode for tar streams.
10
11
12use std::path::{Path, PathBuf};
13
14/// Custom flag to indicate that the source is a single file.
15pub const SINGLE_FILE: &str = "singlefile";
16
17/// File chunk size for tar streaming (matches Go's wshrpc.FileChunkSize).
18pub const FILE_CHUNK_SIZE: usize = 64 * 1024;
19
20/// Tar entry metadata extracted from a tar header.
21#[derive(Debug, Clone)]
22pub struct TarEntryMeta {
23    /// Entry name/path within the tar.
24    pub name: String,
25    /// Size in bytes.
26    pub size: u64,
27    /// Whether this entry is a directory.
28    pub is_dir: bool,
29    /// Whether this is a single-file tar (from PAX records).
30    pub single_file: bool,
31    /// Modification time as Unix timestamp.
32    pub mod_time: i64,
33    /// File mode/permissions.
34    pub mode: u32,
35}
36
37/// Errors from tar copy operations.
38#[derive(Debug, Clone, PartialEq)]
39pub enum TarCopyError {
40    /// Path contains directory traversal sequences.
41    PathTraversal(String),
42    /// Attempted to write multiple files in single-file mode.
43    MultipleSingleFiles,
44    /// Invalid tar path.
45    InvalidPath(String),
46}
47
48impl std::fmt::Display for TarCopyError {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            Self::PathTraversal(path) => write!(f, "invalid tar path containing directory traversal: {}", path),
52            Self::MultipleSingleFiles => write!(f, "attempting to write multiple files to a single file tar stream"),
53            Self::InvalidPath(path) => write!(f, "invalid tar path: {}", path),
54        }
55    }
56}
57
58impl std::error::Error for TarCopyError {}
59
60/// Fix and normalize a path by removing a prefix and checking for directory traversal.
61///
62/// Matches Go's `fixPath`: strips prefix, cleans, strips leading separators,
63/// then checks for remaining `..` components.
64///
65/// Returns an empty string if the cleaned path is empty or equals root.
66/// Returns an error if the path contains `..` after cleaning.
67pub fn fix_path(path: &str, prefix: &str) -> Result<String, TarCopyError> {
68    // Remove prefix
69    let stripped = path.strip_prefix(prefix).unwrap_or(path);
70
71    // Clean the path (equivalent to filepath.Clean)
72    let cleaned = clean_path(stripped);
73
74    // Remove leading separator (matches Go's TrimPrefix for "/" and "\\")
75    let result = cleaned.trim_start_matches('/').trim_start_matches('\\');
76
77    // "." means empty/root path after cleaning
78    let result = if result == "." { "" } else { result };
79
80    // Check for directory traversal
81    if result.contains("..") {
82        return Err(TarCopyError::PathTraversal(path.to_string()));
83    }
84
85    Ok(result.to_string())
86}
87
88/// Clean a path, resolving `.` and `..` components and normalizing separators.
89/// Matches Go's `filepath.Clean` behavior.
90fn clean_path(path: &str) -> String {
91    // Normalize separators to /
92    let normalized = path.replace('\\', "/");
93    let is_absolute = normalized.starts_with('/');
94    let mut components: Vec<&str> = Vec::new();
95
96    for part in normalized.split('/') {
97        match part {
98            "" | "." => {} // skip empty and current dir
99            ".." => {
100                // Only pop if last component is a real directory (not "..")
101                if !components.is_empty() && *components.last().unwrap() != ".." {
102                    components.pop();
103                } else if !is_absolute {
104                    // For relative paths, keep the ".."
105                    components.push("..");
106                }
107                // For absolute paths, ".." at root is just ignored
108            }
109            s => {
110                components.push(s);
111            }
112        }
113    }
114
115    if components.is_empty() {
116        if is_absolute {
117            "/".to_string()
118        } else {
119            ".".to_string()
120        }
121    } else if is_absolute {
122        format!("/{}", components.join("/"))
123    } else {
124        components.join("/")
125    }
126}
127
128/// State tracker for single-file mode in tar source operations.
129#[derive(Debug, Default)]
130pub struct SingleFileTracker {
131    flag_set: bool,
132}
133
134impl SingleFileTracker {
135    pub fn new() -> Self {
136        Self { flag_set: false }
137    }
138
139    /// Mark as single file. Returns error if already marked.
140    pub fn mark_single_file(&mut self) -> Result<(), TarCopyError> {
141        if self.flag_set {
142            return Err(TarCopyError::MultipleSingleFiles);
143        }
144        self.flag_set = true;
145        Ok(())
146    }
147
148    /// Check if single-file mode has been set.
149    pub fn is_single_file(&self) -> bool {
150        self.flag_set
151    }
152}
153
154/// Validate a tar entry name for directory traversal.
155pub fn validate_tar_name(name: &str) -> Result<(), TarCopyError> {
156    if name.contains("..") {
157        return Err(TarCopyError::PathTraversal(name.to_string()));
158    }
159    Ok(())
160}
161
162/// Construct a destination path from a base directory and tar entry name.
163/// Ensures the resulting path is within the base directory.
164pub fn safe_join(base: &Path, name: &str) -> Result<PathBuf, TarCopyError> {
165    validate_tar_name(name)?;
166
167    let joined = base.join(name);
168
169    // Verify the canonical path stays within base
170    // (this is a defense-in-depth check)
171    let joined_str = joined.to_string_lossy();
172    let base_str = base.to_string_lossy();
173    if !joined_str.starts_with(base_str.as_ref()) {
174        return Err(TarCopyError::PathTraversal(name.to_string()));
175    }
176
177    Ok(joined)
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    // ---- fix_path Tests ----
185
186    #[test]
187    fn test_fix_path_simple() {
188        assert_eq!(fix_path("/root/file.txt", "/root").unwrap(), "file.txt");
189    }
190
191    #[test]
192    fn test_fix_path_with_subdir() {
193        assert_eq!(fix_path("/root/sub/file.txt", "/root").unwrap(), "sub/file.txt");
194    }
195
196    #[test]
197    fn test_fix_path_root_becomes_empty() {
198        assert_eq!(fix_path("/root", "/root").unwrap(), "");
199    }
200
201    #[test]
202    fn test_fix_path_no_prefix_match() {
203        assert_eq!(fix_path("/other/file.txt", "/root").unwrap(), "other/file.txt");
204    }
205
206    #[test]
207    fn test_fix_path_traversal_resolved_by_clean() {
208        // Go's filepath.Clean resolves ".." so /root/../etc/passwd → /etc/passwd
209        // After prefix strip and clean, no ".." remains → allowed
210        let result = fix_path("/root/../etc/passwd", "/root").unwrap();
211        assert_eq!(result, "etc/passwd");
212    }
213
214    #[test]
215    fn test_fix_path_raw_traversal_rejected() {
216        // A path that has ".." remaining after clean is rejected
217        // This happens when ".." appears at the beginning and can't be resolved further
218        // e.g. "../../etc/passwd" with no prefix → clean doesn't resolve leading ".."
219        assert!(fix_path("../../etc/passwd", "").is_err());
220    }
221
222    #[test]
223    fn test_fix_path_empty() {
224        assert_eq!(fix_path("", "").unwrap(), "");
225    }
226
227    #[test]
228    fn test_fix_path_dot_segments() {
229        assert_eq!(fix_path("/root/./file.txt", "/root").unwrap(), "file.txt");
230    }
231
232    // ---- clean_path Tests ----
233
234    #[test]
235    fn test_clean_path_simple() {
236        assert_eq!(clean_path("a/b/c"), "a/b/c");
237    }
238
239    #[test]
240    fn test_clean_path_dot() {
241        assert_eq!(clean_path("a/./b"), "a/b");
242    }
243
244    #[test]
245    fn test_clean_path_double_dot() {
246        assert_eq!(clean_path("a/b/../c"), "a/c");
247    }
248
249    #[test]
250    fn test_clean_path_trailing_slash() {
251        assert_eq!(clean_path("a/b/"), "a/b");
252    }
253
254    #[test]
255    fn test_clean_path_backslash() {
256        assert_eq!(clean_path("a\\b\\c"), "a/b/c");
257    }
258
259    // ---- SingleFileTracker Tests ----
260
261    #[test]
262    fn test_single_file_tracker_first() {
263        let mut tracker = SingleFileTracker::new();
264        assert!(!tracker.is_single_file());
265        tracker.mark_single_file().unwrap();
266        assert!(tracker.is_single_file());
267    }
268
269    #[test]
270    fn test_single_file_tracker_double() {
271        let mut tracker = SingleFileTracker::new();
272        tracker.mark_single_file().unwrap();
273        assert!(tracker.mark_single_file().is_err());
274    }
275
276    // ---- validate_tar_name Tests ----
277
278    #[test]
279    fn test_validate_normal_name() {
280        assert!(validate_tar_name("dir/file.txt").is_ok());
281    }
282
283    #[test]
284    fn test_validate_traversal_name() {
285        assert!(validate_tar_name("../etc/passwd").is_err());
286    }
287
288    #[test]
289    fn test_validate_embedded_traversal() {
290        assert!(validate_tar_name("dir/../../../etc/passwd").is_err());
291    }
292
293    // ---- safe_join Tests ----
294
295    #[test]
296    fn test_safe_join_normal() {
297        let base = Path::new("/tmp/extract");
298        let result = safe_join(base, "file.txt").unwrap();
299        assert_eq!(result, PathBuf::from("/tmp/extract/file.txt"));
300    }
301
302    #[test]
303    fn test_safe_join_subdir() {
304        let base = Path::new("/tmp/extract");
305        let result = safe_join(base, "sub/file.txt").unwrap();
306        assert_eq!(result, PathBuf::from("/tmp/extract/sub/file.txt"));
307    }
308
309    #[test]
310    fn test_safe_join_traversal() {
311        let base = Path::new("/tmp/extract");
312        assert!(safe_join(base, "../etc/passwd").is_err());
313    }
314
315    // ---- TarEntryMeta Tests ----
316
317    #[test]
318    fn test_tar_entry_meta() {
319        let meta = TarEntryMeta {
320            name: "test.txt".to_string(),
321            size: 1024,
322            is_dir: false,
323            single_file: true,
324            mod_time: 1700000000,
325            mode: 0o644,
326        };
327        assert_eq!(meta.name, "test.txt");
328        assert_eq!(meta.size, 1024);
329        assert!(!meta.is_dir);
330        assert!(meta.single_file);
331    }
332
333    // ---- TarCopyError Display ----
334
335    #[test]
336    fn test_error_display_traversal() {
337        let err = TarCopyError::PathTraversal("../bad".into());
338        assert!(err.to_string().contains("directory traversal"));
339    }
340
341    #[test]
342    fn test_error_display_multiple() {
343        let err = TarCopyError::MultipleSingleFiles;
344        assert!(err.to_string().contains("multiple files"));
345    }
346}