Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion AUDIT.md
Original file line number Diff line number Diff line change
@@ -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 (журнал операций,
Expand Down Expand Up @@ -101,7 +145,7 @@ batch-координация между процессами Explorer, акку
режиме корзины: пользователь мог думать, что в корзину уходят только
`*.log`, а перемещалось всё дерево целиком.
**Фикс:** комбинация отклоняется с явной ошибкой («recycle всегда переносит
элемент целиком»). + тест.
элемент целик��м»). + тест.

### [MEDIUM] Один нечитаемый файл прерывал всю операцию
В обоих сканерах (`scan_into_channel`, `scan_directory_plan_inner`) ошибка
Expand Down
48 changes: 34 additions & 14 deletions src/bin/zapg/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -148,7 +149,7 @@ impl ZapApp {
threads: Option<usize>,
batch_session: Option<BatchSession>,
) -> 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));
Expand All @@ -165,6 +166,7 @@ impl ZapApp {
dry_run: false,
recycle: false,
shred: false,
no_journal: false,
#[cfg(windows)]
taskbar: None,
#[cfg(windows)]
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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());
Expand All @@ -307,7 +311,12 @@ impl ZapApp {
let start = Instant::now();
let mut outcomes: Vec<PathOutcome> = 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
Expand All @@ -322,15 +331,23 @@ 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));
}
}
// 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 {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<PathBuf>,
shred: bool,
allow_dangerous: bool,
sender: Sender<WorkerEvent>,
) -> Vec<PathOutcome> {
let total = paths.len() as u64;
Expand All @@ -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();

Expand Down Expand Up @@ -771,6 +790,7 @@ pub fn run_delete_path(
dry_run: bool,
recycle: bool,
shred: bool,
allow_dangerous: bool,
sender: &Sender<WorkerEvent>,
) -> io::Result<()> {
if dry_run {
Expand All @@ -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();
}
Expand Down
28 changes: 27 additions & 1 deletion src/bin/zapg/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}),
)
Expand Down Expand Up @@ -158,17 +159,23 @@ fn try_become_coordinator(own_paths: &[PathBuf]) -> CoordinatorOutcome {
struct GuiArgs {
batch: bool,
recycle: bool,
no_journal: bool,
threads: Option<usize>,
paths: Vec<PathBuf>,
}

fn parse_args() -> GuiArgs {
parse_args_from(std::env::args_os().skip(1))
}

fn parse_args_from(args: impl IntoIterator<Item = std::ffi::OsString>) -> 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
Expand Down Expand Up @@ -283,3 +290,22 @@ fn load_app_icon() -> Option<egui::IconData> {
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")]);
}
}
14 changes: 6 additions & 8 deletions src/bin/zapw.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -176,16 +176,14 @@ fn run_silent_delete_outcomes(options: &cli::CliOptions) -> Vec<PathOutcome> {
#[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();
}

Expand Down
Loading
Loading