diff --git a/AUDIT.md b/AUDIT.md index 7720649..ff8f9d5 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -1,3 +1,47 @@ +# Аудит zap — глубокий hardening и оптимизация (2026-07-12) + +Дата: 2026-07-12. Объём: полный destructive-контур `zap` / `zapw` / `zapg`, +защита путей, bulk-удаление, batch outcomes, журналирование и pipeline. + +## Исправлено + +- **[HIGH] Обход GUI-защиты через нек canonicalный путь.** GUI теперь + классифицирует опасность через `symlink_metadata` + канонизацию; разрешение + dangerous deletion передаётся воркеру только после явного подтверждения. +- **[HIGH] Bulk API доверял UI/CLI и мог удалить подменённую директорию.** Все + destructive entry points используют единый `validate_delete_target`; bulk + повторно валидирует цель непосредственно перед unlink и принимает только + файл или symlink, сохраняя link-only семантику. +- **[MEDIUM] Late batch failure не влиял на exit code.** Результаты начального + и позднего прогонов теперь агрегируются. +- **[LOW] Несогласованный `zapg --no-journal`.** Флаг добавлен в GUI parser и + реально отключает запись журнала. +- **[LOW] Конфликтующий `%TEMP%\\zap-errors.log`.** Логи создаются через + `create_new` с PID и уникальным идентификатором запуска; пользователю + выводится фактический путь. +- **[PERF] O(paths × errors) при журналировании.** Ошибки индексируются в + `HashMap` один раз в `zap` и `zapw`. +- **[PERF/RELIABILITY] Неограниченная очередь готовых delete-batches.** Между + сканером и Rayon оставлено ограниченное число batches, поэтому быстрый scan + больше не может удержать всё дерево в памяти при медленном удалении. + +## Новые регрессии + +Добавлены проверки отказа bulk API от директории (включая TOCTOU-класс +«file заменён директорией») и parser-тест `zapg --no-journal`. Существующие +тесты продолжают покрывать symlink semantics, filesystem roots, protected +paths, частичные bulk failures и cancellation accounting. + +## Проверка текущего изменения + +- `git diff --check` — успешно. +- `cargo fmt`, `cargo clippy`, `cargo test` — не запускались в текущем Vercel + Sandbox: Rust toolchain отсутствует (`cargo` не найден). Эти команды должны + быть выполнены Windows/Linux CI до merge; Windows CI особенно важен для + protected-path, junction/reparse и Recycle Bin веток. + +--- + # Аудит zap — повторный (2026-07-05) Дата: 2026-07-05. Объём: изменения после аудита 2026-06-11 (журнал операций, @@ -101,7 +145,7 @@ batch-координация между процессами Explorer, акку режиме корзины: пользователь мог думать, что в корзину уходят только `*.log`, а перемещалось всё дерево целиком. **Фикс:** комбинация отклоняется с явной ошибкой («recycle всегда переносит -элемент целиком»). + тест. +элемент целик��м»). + тест. ### [MEDIUM] Один нечитаемый файл прерывал всю операцию В обоих сканерах (`scan_into_channel`, `scan_directory_plan_inner`) ошибка diff --git a/src/bin/zapg/app.rs b/src/bin/zapg/app.rs index a0d4fb1..d5ff296 100644 --- a/src/bin/zapg/app.rs +++ b/src/bin/zapg/app.rs @@ -127,6 +127,7 @@ pub struct ZapApp { pub danger_confirmed: bool, pub recycle: bool, pub shred: bool, + pub no_journal: bool, /// Taskbar progress mirror (Windows only). Lazily created on the UI /// thread once the window exists; `None` + `taskbar_unavailable` set /// means COM failed and we stop retrying. @@ -148,7 +149,7 @@ impl ZapApp { threads: Option, batch_session: Option, ) -> Self { - let has_dangerous = paths.iter().any(|p| protect::is_protected_path(p)); + let has_dangerous = paths.iter().any(|p| protect::is_dangerous_target(p)); let total_size = Arc::new(Mutex::new(None)); let item_sizes: ItemSizes = Arc::new(Mutex::new(None)); @@ -165,6 +166,7 @@ impl ZapApp { dry_run: false, recycle: false, shred: false, + no_journal: false, #[cfg(windows)] taskbar: None, #[cfg(windows)] @@ -214,7 +216,7 @@ impl ZapApp { self.has_dangerous_paths = self .items .iter() - .any(|item| protect::is_protected_path(&item.path)); + .any(|item| protect::is_dangerous_target(&item.path)); self.batch_session = session; self.recalculate_total_size(); } @@ -295,6 +297,8 @@ impl ZapApp { let dry_run = self.dry_run; let recycle = self.recycle; let shred = self.shred; + let allow_dangerous = self.danger_confirmed; + let no_journal = self.no_journal; let (sender, receiver) = mpsc::channel(); self.receiver = Some(receiver); self.started_at = Some(Instant::now()); @@ -307,7 +311,12 @@ impl ZapApp { let start = Instant::now(); let mut outcomes: Vec = Vec::with_capacity(paths.len()); if should_bulk_delete_file_roots(&paths, dry_run, recycle) { - outcomes = run_bulk_file_roots_gui(paths, shred, sender.clone()); + outcomes = run_bulk_file_roots_gui( + paths, + shred, + allow_dangerous, + sender.clone(), + ); } else { for path in paths { // Stop button: skip everything not yet started. The item @@ -322,7 +331,15 @@ impl ZapApp { continue; } let _ = sender.send(WorkerEvent::Started(path.clone())); - let result = run_delete_path(&path, threads, dry_run, recycle, shred, &sender); + let result = run_delete_path( + &path, + threads, + dry_run, + recycle, + shred, + allow_dangerous, + &sender, + ); let result = normalize_worker_result(result, dry_run); outcomes.push((path.clone(), result.as_ref().err().cloned())); let _ = sender.send(WorkerEvent::Done(path, result)); @@ -330,7 +347,7 @@ impl ZapApp { } // Record the run in the operation journal (audit trail). A dry // run changes nothing on disk, so it is not journaled. - if !dry_run && !journal::is_disabled_by_env() { + if !dry_run && !no_journal && !journal::is_disabled_by_env() { let action = if recycle { JournalAction::Recycle } else if shred { @@ -540,12 +557,12 @@ impl ZapApp { } if changed { // Late-arriving batch paths may include protected system paths. - // The worker always runs with allow_dangerous, so the danger - // checkbox is the only guard — it must be re-armed here. + // Re-arm confirmation so the worker never receives dangerous + // permission inherited from an earlier, safer selection. let has_dangerous = self .items .iter() - .any(|item| protect::is_protected_path(&item.path)); + .any(|item| protect::is_dangerous_target(&item.path)); if has_dangerous && !self.has_dangerous_paths { self.danger_confirmed = false; } @@ -582,7 +599,7 @@ impl ZapApp { self.has_dangerous_paths = self .items .iter() - .any(|item| protect::is_protected_path(&item.path)); + .any(|item| protect::is_dangerous_target(&item.path)); if self.has_dangerous_paths { self.danger_confirmed = false; } @@ -634,6 +651,7 @@ fn should_bulk_delete_file_roots(paths: &[PathBuf], dry_run: bool, recycle: bool fn run_bulk_file_roots_gui( paths: Vec, shred: bool, + allow_dangerous: bool, sender: Sender, ) -> Vec { let total = paths.len() as u64; @@ -656,7 +674,8 @@ fn run_bulk_file_roots_gui( send_bulk_progress(&monitor_sender, &monitor_paths, &monitor_bar, total); }); - let summary = delete::delete_file_roots_bulk(&paths, shred, Some(&bar)); + let summary = + delete::delete_file_roots_bulk(&paths, shred, allow_dangerous, Some(&bar)); monitor_stop.store(true, Ordering::Relaxed); let _ = monitor.join(); @@ -771,6 +790,7 @@ pub fn run_delete_path( dry_run: bool, recycle: bool, shred: bool, + allow_dangerous: bool, sender: &Sender, ) -> io::Result<()> { if dry_run { @@ -789,10 +809,10 @@ pub fn run_delete_path( } send_progress(&monitor_sender, &monitor_path, &monitor_bar); }); - let mut opts = DeleteOptions::default() - .with_threads(threads) - .with_bar(bar) - .allow_dangerous(); + let mut opts = DeleteOptions::default().with_threads(threads).with_bar(bar); + if allow_dangerous { + opts = opts.allow_dangerous(); + } if recycle { opts = opts.recycle(); } diff --git a/src/bin/zapg/main.rs b/src/bin/zapg/main.rs index 94f743b..d48076b 100644 --- a/src/bin/zapg/main.rs +++ b/src/bin/zapg/main.rs @@ -81,6 +81,7 @@ fn main() -> eframe::Result<()> { ZapApp::new(paths, args.threads, None) }; app.recycle = args.recycle; + app.no_journal = args.no_journal; Ok(Box::new(app)) }), ) @@ -158,17 +159,23 @@ fn try_become_coordinator(own_paths: &[PathBuf]) -> CoordinatorOutcome { struct GuiArgs { batch: bool, recycle: bool, + no_journal: bool, threads: Option, paths: Vec, } fn parse_args() -> GuiArgs { + parse_args_from(std::env::args_os().skip(1)) +} + +fn parse_args_from(args: impl IntoIterator) -> GuiArgs { let mut parsed = GuiArgs::default(); - let mut args = std::env::args_os().skip(1); + let mut args = args.into_iter(); while let Some(arg) = args.next() { match arg.to_str() { Some("--batch") => parsed.batch = true, Some("--recycle") => parsed.recycle = true, + Some("--no-journal") => parsed.no_journal = true, Some("--threads") | Some("-j") => { if let Some(value) = args.next() { parsed.threads = value @@ -283,3 +290,22 @@ fn load_app_icon() -> Option { height: h, }) } + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + + #[test] + fn parse_args_supports_no_journal_without_treating_it_as_a_path() { + let args = parse_args_from([ + OsString::from("--no-journal"), + OsString::from("--recycle"), + OsString::from("target.txt"), + ]); + + assert!(args.no_journal); + assert!(args.recycle); + assert_eq!(args.paths, vec![PathBuf::from("target.txt")]); + } +} diff --git a/src/bin/zapw.rs b/src/bin/zapw.rs index 622c077..dbcb9b6 100644 --- a/src/bin/zapw.rs +++ b/src/bin/zapw.rs @@ -1,7 +1,7 @@ #![cfg_attr(windows, windows_subsystem = "windows")] use rayon::prelude::*; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::ffi::OsString; use std::fs::{self, File}; use std::path::{Path, PathBuf}; @@ -176,16 +176,14 @@ fn run_silent_delete_outcomes(options: &cli::CliOptions) -> Vec { #[cfg(windows)] if options.recycle && !options.dry_run { let errors = delete::recycle_paths_validated(&options.paths, false); + let error_index: HashMap<&Path, String> = errors + .iter() + .map(|(path, err)| (path.as_path(), err.to_string())) + .collect(); return options .paths .iter() - .map(|path| { - let error = errors - .iter() - .find(|(failed, _)| failed == path) - .map(|(_, err)| err.to_string()); - (path.clone(), error) - }) + .map(|path| (path.clone(), error_index.get(path.as_path()).cloned())) .collect(); } diff --git a/src/delete.rs b/src/delete.rs index 5232559..a1a61d6 100644 --- a/src/delete.rs +++ b/src/delete.rs @@ -14,7 +14,7 @@ use rayon::iter::{ParallelBridge, ParallelIterator}; use rayon::slice::ParallelSlice; use crate::filter::FilterConfig; -use crate::protect::{is_protected_path, sanitize_path}; +use crate::protect::{is_protected_path, sanitize_path, validate_delete_target}; use crate::scan::{ scan_directory, scan_directory_plan_for_filter, scan_directory_plan_with_bar_for_filter, scan_into_channel, EntryKind, ScannedEntry, @@ -325,8 +325,12 @@ fn delete_directory_pipeline(path: &Path, bar: Option, shred: bool) let queue_cap = (rayon::current_num_threads() * 4).max(64); let (scan_tx, scan_rx) = crossbeam_channel::bounded::(queue_cap); - // Channel from consumer-thread to rayon workers: unbounded batches. - let (batch_tx, batch_rx) = crossbeam_channel::unbounded::>(); + // Keep only a small number of ready batches ahead of Rayon. An unbounded + // queue could retain the whole tree when scanning outran a slow disk or + // locked-file retries, defeating the bounded scan channel above. + let ready_batches = (rayon::current_num_threads() * 2).max(4); + let (batch_tx, batch_rx) = + crossbeam_channel::bounded::>(ready_batches); // ── thread A: jwalk scan ────────────────────────────────────────────── let scan_path = path.to_path_buf(); @@ -618,49 +622,19 @@ pub fn recycle_paths_validated( let mut to_recycle: Vec<&std::path::PathBuf> = Vec::with_capacity(paths.len()); for path in paths { - let metadata = match std::fs::symlink_metadata(path) { - Ok(m) => m, + let target = match validate_delete_target(path, allow_dangerous) { + Ok(target) => target, Err(err) => { errors.push((path.clone(), err)); continue; } }; - if metadata.file_type().is_symlink() { + if target.metadata.file_type().is_symlink() { if let Err(err) = delete_symlink(path) { errors.push((path.clone(), err)); } continue; } - let canonical = match sanitize_path(path) { - Ok(c) => c, - Err(err) => { - errors.push((path.clone(), err)); - continue; - } - }; - if canonical.parent().is_none() { - errors.push(( - path.clone(), - io::Error::new( - io::ErrorKind::InvalidInput, - format!("refusing to delete filesystem root: {}", path.display()), - ), - )); - continue; - } - if is_protected_path(&canonical) && !allow_dangerous { - errors.push(( - path.clone(), - io::Error::new( - io::ErrorKind::PermissionDenied, - format!( - "refusing to delete dangerous path without extra confirmation: {}", - path.display() - ), - ), - )); - continue; - } to_recycle.push(path); } @@ -689,6 +663,7 @@ pub fn recycle_paths_validated( pub fn delete_file_roots_bulk( paths: &[std::path::PathBuf], shred: bool, + allow_dangerous: bool, bar: Option<&ProgressBar>, ) -> BulkDeleteSummary { let deleted = AtomicU64::new(0); @@ -719,12 +694,26 @@ pub fn delete_file_roots_bulk( mark_cancelled(&chunk[i..]); break; } - let result = match std::fs::symlink_metadata(path) { - Ok(meta) if meta.file_type().is_symlink() => delete_symlink(path), - Ok(_) if shred => crate::shred::shred_file(path, 3), - Ok(_) => remove_file_with_retry(path), - Err(err) => Err(err), - }; + // Revalidate at the last possible moment. Besides enforcing the + // central safety policy, this rejects a directory swapped in after + // the caller selected the bulk file fast path (TOCTOU hardening). + let result = validate_delete_target(path, allow_dangerous).and_then(|target| { + if target.metadata.file_type().is_symlink() { + delete_symlink(path) + } else if !target.metadata.is_file() { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "bulk deletion only accepts files or symlinks: {}", + path.display() + ), + )) + } else if shred { + crate::shred::shred_file(path, 3) + } else { + remove_file_with_retry(path) + } + }); match result { Ok(()) => { deleted.fetch_add(1, Ordering::Relaxed); @@ -758,26 +747,10 @@ pub fn delete_path(path: &Path, opts: DeleteOptions) -> io::Result<()> { #[cfg(windows)] if opts.recycle { - let metadata = std::fs::symlink_metadata(path)?; - if metadata.file_type().is_symlink() { + let target = validate_delete_target(path, opts.allow_dangerous)?; + if target.metadata.file_type().is_symlink() { return delete_symlink(path); } - let canonical = sanitize_path(path)?; - if canonical.parent().is_none() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("refusing to delete filesystem root: {}", path.display()), - )); - } - if is_protected_path(&canonical) && !opts.allow_dangerous { - return Err(io::Error::new( - io::ErrorKind::PermissionDenied, - format!( - "refusing to delete dangerous path without extra confirmation: {}", - path.display() - ), - )); - } return crate::recycle::recycle_path(path); } @@ -785,30 +758,13 @@ pub fn delete_path(path: &Path, opts: DeleteOptions) -> io::Result<()> { } fn delete_path_inner(path: &Path, opts: DeleteOptions) -> io::Result<()> { - let metadata = std::fs::symlink_metadata(path)?; + let target = validate_delete_target(path, opts.allow_dangerous)?; + let metadata = target.metadata; if metadata.file_type().is_symlink() { return delete_symlink(path); } - let canonical = sanitize_path(path)?; - if canonical.parent().is_none() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("refusing to delete filesystem root: {}", path.display()), - )); - } - - if is_protected_path(&canonical) && !opts.allow_dangerous { - return Err(io::Error::new( - io::ErrorKind::PermissionDenied, - format!( - "refusing to delete dangerous path without extra confirmation: {}", - path.display() - ), - )); - } - if metadata.is_dir() { if opts.only_empty { // Use `remove_dir` semantics: it only succeeds on empty @@ -1029,7 +985,7 @@ mod tests { }) .collect(); - let summary = delete_file_roots_bulk(&files, false, None); + let summary = delete_file_roots_bulk(&files, false, false, None); assert_eq!(summary.deleted, files.len() as u64); assert!(summary.errors.is_empty()); assert!(files.iter().all(|path| !path.exists())); @@ -1046,7 +1002,7 @@ mod tests { File::create(&blocked).unwrap(); set_test_remove_file_failure(Some(blocked.clone())); - let summary = delete_file_roots_bulk(&[ok.clone(), blocked.clone()], false, None); + let summary = delete_file_roots_bulk(&[ok.clone(), blocked.clone()], false, false, None); set_test_remove_file_failure(None); assert_eq!(summary.deleted, 1); @@ -1057,6 +1013,27 @@ mod tests { let _ = fs::remove_dir_all(&temp); } + #[test] + fn test_delete_file_roots_bulk_rejects_directory_target() { + let temp = create_test_dir(); + let directory = temp.join("swapped-to-directory"); + fs::create_dir(&directory).unwrap(); + File::create(directory.join("must-survive.txt")).unwrap(); + + let summary = delete_file_roots_bulk( + std::slice::from_ref(&directory), + false, + false, + None, + ); + + assert_eq!(summary.deleted, 0); + assert_eq!(summary.errors.len(), 1); + assert_eq!(summary.errors[0].0, directory); + assert!(directory.join("must-survive.txt").exists()); + let _ = fs::remove_dir_all(&temp); + } + #[test] fn test_delete_file_roots_bulk_marks_skipped_paths_cancelled_on_stop() { // A stop request must not let skipped paths look like successes: @@ -1075,7 +1052,7 @@ mod tests { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); crate::stop::request_stop(); - let summary = delete_file_roots_bulk(&files, false, None); + let summary = delete_file_roots_bulk(&files, false, false, None); crate::stop::reset(); // Every path must be accounted for: deleted + errors == total. diff --git a/src/main.rs b/src/main.rs index ae56a61..f805c0a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use indicatif::{MultiProgress, ProgressStyle}; use owo_colors::{AnsiColors, OwoColorize}; -use std::collections::HashSet; -use std::fs::{self, File}; +use std::collections::{HashMap, HashSet}; +use std::fs::{self, OpenOptions}; use std::io::{self, BufWriter, Write}; use std::path::{Path, PathBuf}; use std::time::Instant; @@ -83,14 +83,26 @@ const BATCH_INITIAL_QUIET_POLLS: usize = 10; const BATCH_FINAL_QUIET_POLLS: usize = 30; const BATCH_MAX_POLLS: usize = 300; -fn write_error_log(errors: &[(PathBuf, std::io::Error)]) { - let log_path = std::env::temp_dir().join("zap-errors.log"); - if let Ok(file) = File::create(&log_path) { - let mut w = BufWriter::new(file); - for (path, err) in errors { - let _ = writeln!(w, "{}: {}", path.display(), err); - } - } +fn write_error_log(errors: &[(PathBuf, std::io::Error)]) -> Option { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let log_path = std::env::temp_dir().join(format!( + "zap-errors-{}-{nonce}.log", + std::process::id() + )); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&log_path) + .ok()?; + let mut writer = BufWriter::new(file); + for (path, err) in errors { + writeln!(writer, "{}: {}", path.display(), err).ok()?; + } + writer.flush().ok()?; + Some(log_path) } fn elapsed_color(elapsed_secs: f32) -> AnsiColors { @@ -342,21 +354,19 @@ fn run_delete(options: &cli::CliOptions, start: Instant) -> bool { } true } else { - write_error_log(&errors); + let error_log = write_error_log(&errors); eprintln!( "\n{} {} path(s) failed out of {}", " SUMMARY ".on_color(AnsiColors::BrightRed).black(), errors.len(), options.paths.len() ); - eprintln!( - "{}", - format!( - " Errors written to {}", - std::env::temp_dir().join("zap-errors.log").display() - ) - .dimmed() - ); + if let Some(log_path) = error_log { + eprintln!( + "{}", + format!(" Errors written to {}", log_path.display()).dimmed() + ); + } false } } @@ -374,16 +384,14 @@ fn record_journal(options: &cli::CliOptions, errors: &[(PathBuf, std::io::Error) } else { journal::JournalAction::Delete }; + let error_index: HashMap<&Path, String> = errors + .iter() + .map(|(path, err)| (path.as_path(), err.to_string())) + .collect(); let outcomes: Vec = options .paths .iter() - .map(|path| { - let error = errors - .iter() - .find(|(failed, _)| failed == path) - .map(|(_, err)| err.to_string()); - (path.clone(), error) - }) + .map(|path| (path.clone(), error_index.get(path.as_path()).cloned())) .collect(); let _ = journal::record(action, &outcomes); } @@ -415,6 +423,7 @@ fn bulk_file_roots_candidate( fn run_bulk_file_roots( options: &cli::CliOptions, files: &[PathBuf], + allow_dangerous: bool, ) -> Vec<(PathBuf, std::io::Error)> { let bar = if options.silent { None @@ -430,7 +439,12 @@ fn run_bulk_file_roots( Some(bar) }; - let summary = delete::delete_file_roots_bulk(files, options.shred, bar.as_ref()); + let summary = delete::delete_file_roots_bulk( + files, + options.shred, + allow_dangerous, + bar.as_ref(), + ); if let Some(bar) = bar { if summary.errors.is_empty() { bar.finish_with_message(format!("Deleted {} files", summary.deleted)); @@ -464,7 +478,7 @@ fn run_delete_parallel( allow_dangerous: bool, ) -> Vec<(PathBuf, std::io::Error)> { if let Some(files) = bulk_file_roots_candidate(options, allow_dangerous) { - return run_bulk_file_roots(options, &files); + return run_bulk_file_roots(options, &files, allow_dangerous); } // Recycle mode: one SHFileOperationW call for the whole selection — a @@ -581,7 +595,7 @@ fn run_batch(options: &cli::CliOptions, start: Instant) -> bool { merged.paths = all_paths; merged.batch = false; - let success = run_delete(&merged, start); + let mut success = run_delete(&merged, start); // Drain any late arrivals batch::touch_lock(&mut lock); @@ -600,7 +614,7 @@ fn run_batch(options: &cli::CliOptions, start: Instant) -> bool { let mut late_merged = options.clone(); late_merged.paths = pending; late_merged.batch = false; - let _ = run_delete(&late_merged, start); + success &= run_delete(&late_merged, start); } drop(lock); diff --git a/src/protect.rs b/src/protect.rs index 85495b7..096fced 100644 --- a/src/protect.rs +++ b/src/protect.rs @@ -30,6 +30,68 @@ pub fn sanitize_path(path: &Path) -> io::Result { Ok(canonical) } +/// Metadata and canonical identity captured at the destructive API boundary. +/// Symlinks are intentionally not canonicalized: deleting a link must never +/// turn into deleting its target. +pub struct ValidatedDeleteTarget { + pub metadata: std::fs::Metadata, + pub canonical: Option, +} + +/// Validate a target immediately before a destructive operation. +/// +/// All public deletion paths use this function so callers cannot accidentally +/// bypass filesystem-root and protected-path checks. Callers must still use +/// the original path for the actual unlink, preserving symlink semantics. +pub fn validate_delete_target( + path: &Path, + allow_dangerous: bool, +) -> io::Result { + let metadata = std::fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() { + return Ok(ValidatedDeleteTarget { + metadata, + canonical: None, + }); + } + + let canonical = sanitize_path(path)?; + if canonical.parent().is_none() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("refusing to delete filesystem root: {}", path.display()), + )); + } + if is_protected_path(&canonical) && !allow_dangerous { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "refusing to delete dangerous path without extra confirmation: {}", + path.display() + ), + )); + } + + Ok(ValidatedDeleteTarget { + metadata, + canonical: Some(canonical), + }) +} + +/// Resolve whether a target needs the explicit dangerous-path confirmation. +/// Missing/unreadable targets are not classified here; destructive APIs still +/// return their concrete validation error when the operation starts. +pub fn is_dangerous_target(path: &Path) -> bool { + let Ok(metadata) = std::fs::symlink_metadata(path) else { + return false; + }; + if metadata.file_type().is_symlink() { + return false; + } + sanitize_path(path) + .is_ok_and(|canonical| is_protected_path(&canonical)) +} + #[cfg(windows)] struct ProtectedConfig { /// Prefixes (lower-cased once at init) that are fully protected: any