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
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/deepclean-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
name = "deepclean-app"
version = "0.1.0"
edition = "2021"
rust-version.workspace = true

[dependencies]
deepclean-core = { workspace = true }
Expand Down
1 change: 1 addition & 0 deletions crates/deepclean-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
name = "deepclean-core"
version = "0.1.0"
edition = "2021"
rust-version.workspace = true

[dependencies]
serde = { workspace = true }
Expand Down
31 changes: 30 additions & 1 deletion crates/deepclean-core/src/action/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
}
}
Expand Down Expand Up @@ -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();
Expand Down
43 changes: 41 additions & 2 deletions crates/deepclean-core/src/safety.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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();
Expand Down