Skip to main content

zzz_arc/
utils.rs

1//! utility functions for file size calculations and formatting
2
3use crate::Result;
4use anyhow::Context;
5use filetime::FileTime;
6use std::path::Path;
7use std::time::SystemTime;
8
9/// sanitize an archive entry path and apply strip_components
10pub fn sanitize_archive_entry_path(
11    entry_path: &Path,
12    strip_components: usize,
13) -> Result<Option<std::path::PathBuf>> {
14    use std::path::{Component, PathBuf};
15
16    let mut components: Vec<std::ffi::OsString> = Vec::new();
17
18    for component in entry_path.components() {
19        match component {
20            Component::Prefix(_) | Component::RootDir | Component::ParentDir => {
21                return Err(anyhow::anyhow!(
22                    "unsafe archive path: {}",
23                    entry_path.display()
24                ));
25            }
26            Component::CurDir => {}
27            Component::Normal(part) => components.push(part.to_os_string()),
28        }
29    }
30
31    if strip_components >= components.len() {
32        return Ok(None);
33    }
34
35    let mut sanitized = PathBuf::new();
36    for component in components.into_iter().skip(strip_components) {
37        sanitized.push(component);
38    }
39
40    Ok(Some(sanitized))
41}
42
43/// normalize archive entry path separators to '/' on Windows
44pub fn normalize_archive_path(path: &Path) -> String {
45    let path_str = path.to_string_lossy().to_string();
46    if cfg!(windows) {
47        path_str.replace('\\', "/")
48    } else {
49        path_str
50    }
51}
52
53fn resolve_symlink_target(link_path: &Path) -> Result<std::path::PathBuf> {
54    let target = std::fs::read_link(link_path).with_context(|| {
55        format!(
56            "Failed to read symlink target for '{}'",
57            link_path.display()
58        )
59    })?;
60    let resolved = if target.is_absolute() {
61        target
62    } else {
63        let parent = link_path.parent().unwrap_or_else(|| Path::new("."));
64        parent.join(target)
65    };
66    std::fs::canonicalize(&resolved).with_context(|| {
67        format!(
68            "Failed to resolve symlink target for '{}'",
69            link_path.display()
70        )
71    })
72}
73
74pub fn ensure_symlink_within_root(root: &Path, link_path: &Path) -> Result<()> {
75    let target = resolve_symlink_target(link_path)?;
76    if !target.starts_with(root) {
77        return Err(anyhow::anyhow!(
78            "symlink '{}' escapes input root '{}' (use --allow-symlink-escape to include targets outside)",
79            link_path.display(),
80            root.display()
81        ));
82    }
83    Ok(())
84}
85
86pub fn apply_mtime(path: &Path, system_time: SystemTime) -> Result<()> {
87    let file_time = FileTime::from_system_time(system_time);
88    filetime::set_file_mtime(path, file_time)
89        .with_context(|| format!("Failed to set modification time for '{}'", path.display()))?;
90    Ok(())
91}
92
93#[cfg(unix)]
94pub fn apply_permissions(path: &Path, mode: u32) -> Result<()> {
95    use std::os::unix::fs::PermissionsExt;
96
97    let permissions = std::fs::Permissions::from_mode(mode & 0o7777);
98    std::fs::set_permissions(path, permissions)
99        .with_context(|| format!("Failed to set permissions for '{}'", path.display()))?;
100    Ok(())
101}
102
103#[cfg(not(unix))]
104pub fn apply_permissions(_path: &Path, _mode: u32) -> Result<()> {
105    Ok(())
106}
107
108/// ensure no symlink exists in the target path's ancestor chain under root
109pub fn ensure_no_symlink_ancestors(root: &Path, target: &Path) -> Result<()> {
110    use std::io::ErrorKind;
111    use std::path::PathBuf;
112
113    let relative = target.strip_prefix(root).map_err(|_| {
114        anyhow::anyhow!(
115            "target path '{}' is outside extraction root '{}'",
116            target.display(),
117            root.display()
118        )
119    })?;
120
121    let mut current = PathBuf::from(root);
122    for component in relative.components() {
123        current.push(component);
124        match std::fs::symlink_metadata(&current) {
125            Ok(metadata) => {
126                if metadata.file_type().is_symlink() {
127                    return Err(anyhow::anyhow!(
128                        "unsafe archive path: symlink ancestor '{}'",
129                        current.display()
130                    ));
131                }
132            }
133            Err(err) if err.kind() == ErrorKind::NotFound => {}
134            Err(err) => return Err(err.into()),
135        }
136    }
137
138    Ok(())
139}
140
141/// prepare a safe output path for extracting an archive entry
142pub fn extract_entry_to_path(
143    output_dir: &Path,
144    entry_path: &Path,
145    strip_components: usize,
146    overwrite: bool,
147    entry_is_dir: bool,
148) -> Result<Option<std::path::PathBuf>> {
149    match prepare_extract_target(
150        output_dir,
151        entry_path,
152        strip_components,
153        overwrite,
154        entry_is_dir,
155    )? {
156        ExtractTarget::Target(path) => Ok(Some(path)),
157        ExtractTarget::SkipStrip => Ok(None),
158        ExtractTarget::SkipExisting(path) => Err(anyhow::anyhow!(
159            "output file '{}' already exists. Use --overwrite to replace.",
160            path.display()
161        )),
162    }
163}
164
165/// extraction target resolution outcomes
166pub enum ExtractTarget {
167    SkipStrip,
168    SkipExisting(std::path::PathBuf),
169    Target(std::path::PathBuf),
170}
171
172/// prepare a safe extraction target path with skip reasons
173pub fn prepare_extract_target(
174    output_dir: &Path,
175    entry_path: &Path,
176    strip_components: usize,
177    overwrite: bool,
178    entry_is_dir: bool,
179) -> Result<ExtractTarget> {
180    let relative_path = sanitize_archive_entry_path(entry_path, strip_components)?;
181    let Some(relative_path) = relative_path else {
182        return Ok(ExtractTarget::SkipStrip);
183    };
184    let target_path = output_dir.join(relative_path);
185
186    ensure_no_symlink_ancestors(output_dir, &target_path)?;
187
188    if target_path.exists() && !overwrite {
189        if entry_is_dir && target_path.is_dir() {
190            return Ok(ExtractTarget::Target(target_path));
191        }
192        return Ok(ExtractTarget::SkipExisting(target_path));
193    }
194
195    Ok(ExtractTarget::Target(target_path))
196}
197
198/// calculate total size of a directory recursively
199pub fn calculate_dir_size(path: &Path) -> Result<u64> {
200    let mut total = 0;
201
202    if path.is_file() {
203        return Ok(path.metadata()?.len());
204    }
205
206    for entry in walkdir::WalkDir::new(path) {
207        let entry = entry?;
208        if entry.file_type().is_file() {
209            total += entry.metadata()?.len();
210        }
211    }
212
213    Ok(total)
214}
215
216/// calculate total size of a directory with file filtering
217pub fn calculate_directory_size(
218    path: &Path,
219    filter: &crate::filter::FileFilter,
220    follow_symlinks: bool,
221    allow_symlink_escape: bool,
222) -> Result<u64> {
223    let mut total = 0;
224    let canonical_root = if follow_symlinks && !allow_symlink_escape {
225        Some(
226            std::fs::canonicalize(path)
227                .with_context(|| format!("Failed to resolve input root '{}'", path.display()))?,
228        )
229    } else {
230        None
231    };
232
233    let metadata = std::fs::symlink_metadata(path)?;
234    if metadata.file_type().is_symlink() {
235        if !follow_symlinks {
236            return Err(anyhow::anyhow!(
237                "symlink '{}' is not supported for archiving (use --follow-symlinks to include targets)",
238                path.display()
239            ));
240        }
241        if let Some(root) = &canonical_root {
242            ensure_symlink_within_root(root, path)?;
243        }
244    }
245
246    if path.is_file() {
247        if let Some(filename) = path.file_name() {
248            if !filter.should_include_relative(Path::new(filename)) {
249                return Ok(0);
250            }
251        }
252        return Ok(path.metadata()?.len());
253    }
254
255    for entry in filter.walk_entries_with_follow(path, follow_symlinks) {
256        let entry = entry?;
257        if entry.path_is_symlink() {
258            if !follow_symlinks {
259                return Err(anyhow::anyhow!(
260                    "symlink '{}' is not supported for archiving (use --follow-symlinks to include targets)",
261                    entry.path().display()
262                ));
263            }
264            if let Some(root) = &canonical_root {
265                ensure_symlink_within_root(root, entry.path())?;
266            }
267        }
268        if entry.file_type().is_file() {
269            total += entry.metadata()?.len();
270        }
271    }
272
273    Ok(total)
274}
275
276/// format bytes in human-readable format
277pub fn format_bytes(bytes: u64) -> String {
278    const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"];
279    let mut size = bytes as f64;
280    let mut unit_index = 0;
281
282    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
283        size /= 1024.0;
284        unit_index += 1;
285    }
286
287    if unit_index == 0 {
288        format!("{} {}", bytes, UNITS[unit_index])
289    } else {
290        format!("{:.2} {}", size, UNITS[unit_index])
291    }
292}
293
294/// prompt user for yes/no confirmation
295pub fn prompt_yes_no(message: &str) -> bool {
296    use std::io::{self, Write};
297
298    print!("{message} [y/N]: ");
299    io::stdout().flush().unwrap();
300
301    let mut input = String::new();
302    if io::stdin().read_line(&mut input).is_ok() {
303        let input = input.trim().to_lowercase();
304        input == "y" || input == "yes"
305    } else {
306        false
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use std::fs;
314    use tempfile::TempDir;
315
316    #[test]
317    fn test_format_bytes() {
318        assert_eq!(format_bytes(0), "0 B");
319        assert_eq!(format_bytes(512), "512 B");
320        assert_eq!(format_bytes(1024), "1.00 KiB");
321        assert_eq!(format_bytes(1536), "1.50 KiB");
322        assert_eq!(format_bytes(1024 * 1024), "1.00 MiB");
323        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GiB");
324        assert_eq!(format_bytes(1024_u64.pow(4)), "1.00 TiB");
325        assert_eq!(format_bytes(1024_u64.pow(5)), "1024.00 TiB");
326    }
327
328    #[cfg(windows)]
329    #[test]
330    fn test_normalize_archive_path_windows() {
331        let path = Path::new(r"dir\file.txt");
332        assert_eq!(normalize_archive_path(path), "dir/file.txt");
333    }
334
335    #[cfg(not(windows))]
336    #[test]
337    fn test_normalize_archive_path_non_windows() {
338        let path = Path::new("dir\\file.txt");
339        assert_eq!(normalize_archive_path(path), "dir\\file.txt");
340    }
341
342    #[test]
343    fn test_calculate_dir_size_single_file() -> Result<()> {
344        let temp_dir = TempDir::new()?;
345        let file_path = temp_dir.path().join("test.txt");
346
347        // Create a file with known content
348        fs::write(&file_path, "Hello, World!")?;
349
350        let size = calculate_dir_size(&file_path)?;
351        assert_eq!(size, 13); // "Hello, World!" is 13 bytes
352
353        Ok(())
354    }
355
356    #[test]
357    fn test_calculate_dir_size_directory() -> Result<()> {
358        let temp_dir = TempDir::new()?;
359
360        // Create multiple files
361        fs::write(temp_dir.path().join("file1.txt"), "12345")?; // 5 bytes
362        fs::write(temp_dir.path().join("file2.txt"), "abcdef")?; // 6 bytes
363
364        // Create subdirectory with file
365        let sub_dir = temp_dir.path().join("subdir");
366        fs::create_dir(&sub_dir)?;
367        fs::write(sub_dir.join("file3.txt"), "xyz")?; // 3 bytes
368
369        let total_size = calculate_dir_size(temp_dir.path())?;
370        assert_eq!(total_size, 14); // 5 + 6 + 3 = 14 bytes
371
372        Ok(())
373    }
374
375    #[test]
376    fn test_prepare_extract_target_allows_existing_dir() -> Result<()> {
377        let temp_dir = TempDir::new()?;
378        let output_dir = temp_dir.path().join("out");
379        fs::create_dir(&output_dir)?;
380
381        let existing_dir = output_dir.join("existing");
382        fs::create_dir(&existing_dir)?;
383
384        let result = prepare_extract_target(&output_dir, Path::new("existing"), 0, false, true)?;
385
386        match result {
387            ExtractTarget::Target(path) => assert_eq!(path, existing_dir),
388            _ => anyhow::bail!("expected target for existing directory"),
389        }
390
391        Ok(())
392    }
393
394    #[test]
395    fn test_prepare_extract_target_rejects_existing_file() -> Result<()> {
396        let temp_dir = TempDir::new()?;
397        let output_dir = temp_dir.path().join("out");
398        fs::create_dir(&output_dir)?;
399
400        let existing_file = output_dir.join("file.txt");
401        fs::write(&existing_file, "data")?;
402
403        let result = prepare_extract_target(&output_dir, Path::new("file.txt"), 0, false, false)?;
404
405        match result {
406            ExtractTarget::SkipExisting(path) => assert_eq!(path, existing_file),
407            _ => anyhow::bail!("expected skip existing for file"),
408        }
409
410        Ok(())
411    }
412
413    #[test]
414    fn test_calculate_dir_size_empty_directory() -> Result<()> {
415        let temp_dir = TempDir::new()?;
416        let size = calculate_dir_size(temp_dir.path())?;
417        assert_eq!(size, 0);
418        Ok(())
419    }
420}