agentmux_srv\backend/
tarcopy.rs1#![allow(dead_code)]
2use std::path::{Path, PathBuf};
13
14pub const SINGLE_FILE: &str = "singlefile";
16
17pub const FILE_CHUNK_SIZE: usize = 64 * 1024;
19
20#[derive(Debug, Clone)]
22pub struct TarEntryMeta {
23 pub name: String,
25 pub size: u64,
27 pub is_dir: bool,
29 pub single_file: bool,
31 pub mod_time: i64,
33 pub mode: u32,
35}
36
37#[derive(Debug, Clone, PartialEq)]
39pub enum TarCopyError {
40 PathTraversal(String),
42 MultipleSingleFiles,
44 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
60pub fn fix_path(path: &str, prefix: &str) -> Result<String, TarCopyError> {
68 let stripped = path.strip_prefix(prefix).unwrap_or(path);
70
71 let cleaned = clean_path(stripped);
73
74 let result = cleaned.trim_start_matches('/').trim_start_matches('\\');
76
77 let result = if result == "." { "" } else { result };
79
80 if result.contains("..") {
82 return Err(TarCopyError::PathTraversal(path.to_string()));
83 }
84
85 Ok(result.to_string())
86}
87
88fn clean_path(path: &str) -> String {
91 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 "" | "." => {} ".." => {
100 if !components.is_empty() && *components.last().unwrap() != ".." {
102 components.pop();
103 } else if !is_absolute {
104 components.push("..");
106 }
107 }
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#[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 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 pub fn is_single_file(&self) -> bool {
150 self.flag_set
151 }
152}
153
154pub 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
162pub fn safe_join(base: &Path, name: &str) -> Result<PathBuf, TarCopyError> {
165 validate_tar_name(name)?;
166
167 let joined = base.join(name);
168
169 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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}