1use crate::Result;
4use anyhow::Context;
5use filetime::FileTime;
6use std::path::Path;
7use std::time::SystemTime;
8
9pub 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
43pub 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
108pub 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(¤t) {
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
141pub 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
165pub enum ExtractTarget {
167 SkipStrip,
168 SkipExisting(std::path::PathBuf),
169 Target(std::path::PathBuf),
170}
171
172pub 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
198pub 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
216pub 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
276pub 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
294pub 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 fs::write(&file_path, "Hello, World!")?;
349
350 let size = calculate_dir_size(&file_path)?;
351 assert_eq!(size, 13); Ok(())
354 }
355
356 #[test]
357 fn test_calculate_dir_size_directory() -> Result<()> {
358 let temp_dir = TempDir::new()?;
359
360 fs::write(temp_dir.path().join("file1.txt"), "12345")?; fs::write(temp_dir.path().join("file2.txt"), "abcdef")?; let sub_dir = temp_dir.path().join("subdir");
366 fs::create_dir(&sub_dir)?;
367 fs::write(sub_dir.join("file3.txt"), "xyz")?; let total_size = calculate_dir_size(temp_dir.path())?;
370 assert_eq!(total_size, 14); 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}