diff --git a/Cargo.toml b/Cargo.toml index e798d2d..590b5f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,11 @@ members = ["crates/deepclean-core", "crates/deepclean-app"] resolver = "2" +[workspace.package] +# Minimum supported Rust version. The binding constraint is sysinfo 0.39, +# which requires 1.95; our own code compiles on older toolchains. +rust-version = "1.95" + [workspace.dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/deepclean-app/Cargo.toml b/crates/deepclean-app/Cargo.toml index f4ce94a..3a0eddb 100644 --- a/crates/deepclean-app/Cargo.toml +++ b/crates/deepclean-app/Cargo.toml @@ -2,6 +2,7 @@ name = "deepclean-app" version = "0.1.0" edition = "2021" +rust-version.workspace = true [dependencies] deepclean-core = { workspace = true } diff --git a/crates/deepclean-core/Cargo.toml b/crates/deepclean-core/Cargo.toml index 73b0e73..8221e14 100644 --- a/crates/deepclean-core/Cargo.toml +++ b/crates/deepclean-core/Cargo.toml @@ -2,6 +2,7 @@ name = "deepclean-core" version = "0.1.0" edition = "2021" +rust-version.workspace = true [dependencies] serde = { workspace = true } diff --git a/crates/deepclean-core/src/action/mod.rs b/crates/deepclean-core/src/action/mod.rs index fe1760d..ca9a238 100644 --- a/crates/deepclean-core/src/action/mod.rs +++ b/crates/deepclean-core/src/action/mod.rs @@ -114,7 +114,7 @@ impl ActionExecutor { } ActionMethod::Command { working_dir, .. } => { if let Some(dir) = working_dir { - if !self.safety.is_path_allowed(dir) { + if !self.safety.is_workdir_allowed(dir) { return Err(ActionError::PathBlocked(dir.display().to_string())); } } @@ -354,6 +354,35 @@ mod tests { ); } + #[tokio::test] + async fn allows_command_in_project_root_holding_secrets() { + // `cargo clean` runs in the project root; a `.env` sitting there means + // "don't delete this directory", not "don't run in it". + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("trading"); + std::fs::create_dir_all(project.join("target")).unwrap(); + std::fs::write(project.join(".env"), "SECRET=x").unwrap(); + + let executor = ActionExecutor::new(SafetyChecker::new(vec![])); + let item = test_item(project.join("target")); + let action = test_action(ActionMethod::Command { + program: "true".into(), + args: vec![], + working_dir: Some(project), + }); + + let mut rx = executor.execute_batch(vec![(item, action)]); + + let event = rx.recv().await.unwrap(); + assert!(matches!(event, ActionEvent::Started { .. })); + + let event = rx.recv().await.unwrap(); + assert!( + matches!(event, ActionEvent::Completed { .. }), + "Expected Completed event, got: {event:?}" + ); + } + #[tokio::test] async fn batch_processes_multiple_items() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/deepclean-core/src/safety.rs b/crates/deepclean-core/src/safety.rs index 32de1e2..2f33f52 100644 --- a/crates/deepclean-core/src/safety.rs +++ b/crates/deepclean-core/src/safety.rs @@ -47,8 +47,21 @@ impl SafetyChecker { ] } - /// Returns `true` if the path is safe to operate on. + /// Returns `true` if the path is safe to delete. pub fn is_path_allowed(&self, path: &Path) -> bool { + self.check_path(path, true) + } + + /// Returns `true` if the path is safe to use as a command's working directory. + /// + /// Commands like `cargo clean` run *inside* a project root but never delete + /// it, so sentinel files there ("don't delete this directory") must not + /// block them. + pub fn is_workdir_allowed(&self, path: &Path) -> bool { + self.check_path(path, false) + } + + fn check_path(&self, path: &Path, check_sentinels: bool) -> bool { let canonical = match path.canonicalize() { Ok(p) => p, Err(_) => path.to_path_buf(), @@ -78,7 +91,7 @@ impl SafetyChecker { } // Check for sentinel files inside the target directory - if canonical.is_dir() && self.contains_sentinel(&canonical) { + if check_sentinels && canonical.is_dir() && self.contains_sentinel(&canonical) { return false; } @@ -162,6 +175,32 @@ mod tests { assert!(!checker.is_path_allowed(&blocked.join("target"))); } + #[test] + fn sentinel_does_not_block_command_working_dir() { + // A project root holding a `.env` must still be usable as the working + // dir for `cargo clean` — the command never deletes that directory. + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("trading"); + std::fs::create_dir_all(project.join("target")).unwrap(); + std::fs::write(project.join("Cargo.toml"), "[package]").unwrap(); + std::fs::write(project.join(".env"), "SECRET=x").unwrap(); + + let checker = SafetyChecker::new(vec![]); + assert!(checker.is_workdir_allowed(&project)); + // ...but deleting that same directory is still refused. + assert!(!checker.is_path_allowed(&project)); + } + + #[test] + fn blocked_paths_still_block_command_working_dir() { + let home = dirs::home_dir().unwrap(); + let blocked = home.join("important-project"); + let checker = SafetyChecker::new(vec![blocked.clone()]); + assert!(!checker.is_workdir_allowed(&blocked)); + assert!(!checker.is_workdir_allowed(&home.join(".ssh"))); + assert!(!checker.is_workdir_allowed(&home.join("Documents"))); + } + #[test] fn sentinel_detection() { let tmp = tempfile::tempdir().unwrap();