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:
@@ -20,7 +20,9 @@ use rdbms_playground::event::AppEvent;
|
||||
use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::{ColumnSpec, Type};
|
||||
use rdbms_playground::persistence::Persistence;
|
||||
use rdbms_playground::project::{self, Project, ProjectKind, copy_project};
|
||||
use rdbms_playground::project::{
|
||||
self, Project, ProjectKind, copy_project, safely_delete_temp_project,
|
||||
};
|
||||
|
||||
const fn key(code: KeyCode) -> AppEvent {
|
||||
AppEvent::Key(KeyEvent {
|
||||
@@ -387,6 +389,113 @@ fn named_project_is_never_unmodified_temp() {
|
||||
assert!(!opened.is_unmodified_temp());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safely_delete_removes_genuine_unmodified_temp() {
|
||||
let data = tempdir();
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
drop(project); // release lock so we can delete
|
||||
assert!(path.exists());
|
||||
safely_delete_temp_project(&path, data.path()).expect("should delete");
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safely_delete_refuses_path_outside_data_root() {
|
||||
let data = tempdir();
|
||||
let other = tempdir();
|
||||
// Construct a directory outside the data root that LOOKS
|
||||
// like a temp project (has [temp] marker + project.yaml).
|
||||
let foreign = other.path().join("20260507-[temp]-fake-fake-fake");
|
||||
fs::create_dir_all(&foreign).unwrap();
|
||||
fs::write(
|
||||
foreign.join("project.yaml"),
|
||||
"version: 1\nproject:\n created_at: x\ntables: []\nrelationships: []\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = safely_delete_temp_project(&foreign, data.path()).expect_err("must refuse");
|
||||
assert!(format!("{err}").contains("not inside"), "got: {err}");
|
||||
assert!(foreign.exists(), "foreign dir must still exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safely_delete_refuses_directory_without_temp_marker() {
|
||||
let data = tempdir();
|
||||
// Create a project directory under the data root that
|
||||
// doesn't carry the [temp] marker.
|
||||
let projects_dir = data.path().join(project::PROJECTS_SUBDIR);
|
||||
fs::create_dir_all(&projects_dir).unwrap();
|
||||
let named = projects_dir.join("MyOrders");
|
||||
fs::create_dir(&named).unwrap();
|
||||
fs::write(named.join("project.yaml"), "version: 1\n").unwrap();
|
||||
|
||||
let err = safely_delete_temp_project(&named, data.path()).expect_err("must refuse");
|
||||
assert!(format!("{err}").contains("[temp]"), "got: {err}");
|
||||
assert!(named.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safely_delete_refuses_directory_with_unexpected_file() {
|
||||
let data = tempdir();
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
// Drop a stranger file into the project dir.
|
||||
fs::write(path.join("notes.md"), "user notes\n").unwrap();
|
||||
drop(project);
|
||||
|
||||
let err = safely_delete_temp_project(&path, data.path()).expect_err("must refuse");
|
||||
assert!(format!("{err}").contains("unexpected file"), "got: {err}");
|
||||
assert!(path.exists());
|
||||
assert!(path.join("notes.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safely_delete_allows_migration_backups_and_tmp_files() {
|
||||
let data = tempdir();
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
fs::write(path.join("project.yaml.v1.bak"), "old\n").unwrap();
|
||||
fs::write(path.join("project.yaml.tmp"), "stage\n").unwrap();
|
||||
drop(project);
|
||||
|
||||
safely_delete_temp_project(&path, data.path()).expect("should delete");
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn safely_delete_refuses_symlink_top_level() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let data = tempdir();
|
||||
let real_target = tempdir();
|
||||
let projects_dir = data.path().join(project::PROJECTS_SUBDIR);
|
||||
fs::create_dir_all(&projects_dir).unwrap();
|
||||
let link = projects_dir.join("20260507-[temp]-aaa-bbb-ccc");
|
||||
symlink(real_target.path(), &link).unwrap();
|
||||
|
||||
let err = safely_delete_temp_project(&link, data.path()).expect_err("must refuse");
|
||||
assert!(format!("{err}").contains("symbolic link"), "got: {err}");
|
||||
// Real target untouched.
|
||||
assert!(real_target.path().exists());
|
||||
// Symlink itself untouched.
|
||||
assert!(link.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmodified_temp_with_residual_csv_in_data_dir_is_not_unmodified() {
|
||||
let data = tempdir();
|
||||
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||
// Hand-drop a CSV into the data dir without going through
|
||||
// the persistence layer. Schema in yaml is still empty.
|
||||
let csv = project.path().join("data").join("Stranger.csv");
|
||||
fs::write(&csv, "id\n1\n").unwrap();
|
||||
assert!(
|
||||
!project.is_unmodified_temp(),
|
||||
"non-empty data dir must disqualify the unmodified-temp check",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_projects_sorts_by_mtime() {
|
||||
let data = tempdir();
|
||||
|
||||
Reference in New Issue
Block a user