Harden temp-project cleanup with stacked safety guards

The previous remove_dir_all on a path returned by Project::path()
was too trusting: an unusual CLI argument or a hand-edited
project.yaml could in principle have steered cleanup into
deleting the wrong directory. Replace it with
safely_delete_temp_project, which refuses unless every one of
the following passes:

1. Path is not a symlink (checked before canonicalize so a
   symlink can't smuggle a different target through).
2. Path is a directory.
3. Canonical path is under <active-data-root>/projects/
   (canonical-prefix containment).
4. Directory basename contains the literal `[temp]` marker.
5. Direct children are exclusively well-known project
   artefacts (project.yaml, data/, history.log,
   playground.db, .gitignore, lock file) plus migration .bak
   files and atomic-write .tmp files. Any stranger file
   (notes.md, .git/, screenshots, etc.) makes the helper
   refuse.

is_unmodified_temp now also requires data/ to be empty, in
addition to project.yaml's tables and relationships being
empty. A hand-edited yaml that drops the schema list but
leaves CSVs in data/ no longer passes.

Failure to delete is non-fatal -- the helper returns
SafeDeleteError, the runtime logs a tracing::warn!, and the
project stays on disk. Leaving an unexpected directory alone
is always preferable to a wrong delete.

Tests: 345 passing (272 lib + 9 + 5 + 6 + 27 + 9 + 17),
0 failing, 0 skipped. 7 new tests covering each guard,
including a unix-only symlink-rejection test.
This commit is contained in:
claude@clouddev1
2026-05-08 06:59:26 +00:00
parent b7addd6161
commit 58a964da8c
3 changed files with 315 additions and 34 deletions
+24 -20
View File
@@ -34,7 +34,7 @@ use crate::dsl::Command;
use crate::event::AppEvent;
use crate::project::{
Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir,
resolve_data_root,
resolve_data_root, safely_delete_temp_project,
};
use crate::theme::Theme;
use crate::ui;
@@ -276,7 +276,9 @@ async fn run_loop(
// Auto-delete the active project on quit if it's an
// unmodified temp — same rule as on project switch (see
// perform_switch). Captures the path first, drops the
// project (releasing the lock), then removes the dir.
// project (releasing the lock), then asks
// safely_delete_temp_project to verify the directory
// before removing it.
let cleanup_on_quit: Option<std::path::PathBuf> = session
.project
.as_ref()
@@ -284,17 +286,16 @@ async fn run_loop(
let _ = session.database.take();
let _ = session.project.take();
if let Some(stale) = cleanup_on_quit {
if let Err(e) = std::fs::remove_dir_all(&stale) {
tracing::warn!(
path = %stale.display(),
error = %e,
"could not clean up unmodified temp project on quit",
);
} else {
tracing::info!(
match safely_delete_temp_project(&stale, &session.data_root) {
Ok(()) => tracing::info!(
path = %stale.display(),
"cleaned up unmodified temp project on quit",
);
),
Err(e) => tracing::warn!(
path = %stale.display(),
error = %e,
"did not clean up unmodified temp project on quit",
),
}
}
@@ -409,18 +410,21 @@ async fn perform_switch(
// The outgoing project's lock is now released; it's
// safe to remove its directory if it was unmodified.
// The safely_delete_temp_project helper layers multiple
// guards (containment under data root, [temp] marker,
// contents allowlist, no symlinks) so a bug elsewhere
// can't escalate into deleting the wrong directory.
if let Some(stale) = outgoing_cleanup_path {
if let Err(e) = std::fs::remove_dir_all(&stale) {
tracing::warn!(
path = %stale.display(),
error = %e,
"could not clean up unmodified temp project",
);
} else {
tracing::info!(
match safely_delete_temp_project(&stale, &session.data_root) {
Ok(()) => tracing::info!(
path = %stale.display(),
"cleaned up unmodified temp project on switch",
);
),
Err(e) => tracing::warn!(
path = %stale.display(),
error = %e,
"did not clean up unmodified temp project on switch",
),
}
}