use std::path::{Component, Path, PathBuf};
use tracing::warn;
use super::record::{ManagedSessionId, ManagedSessionState, SessionRecord};
fn canon_or_raw(path: &Path) -> PathBuf {
if let Ok(canon) = std::fs::canonicalize(path) {
return canon;
}
for ancestor in path.ancestors().skip(1) {
if let (Ok(canon_ancestor), Ok(remainder)) =
(std::fs::canonicalize(ancestor), path.strip_prefix(ancestor))
{
return normalize_lexically(&canon_ancestor.join(remainder));
}
}
normalize_lexically(path)
}
fn normalize_lexically(path: &Path) -> PathBuf {
let mut components = path.components().peekable();
let mut out = if let Some(prefix @ Component::Prefix(_)) = components.peek().copied() {
components.next();
PathBuf::from(prefix.as_os_str())
} else {
PathBuf::new()
};
for component in components {
match component {
Component::Prefix(_) => unreachable!("a Prefix can only ever be the first component"),
Component::RootDir => out.push(component.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
Component::Normal(segment) => out.push(segment),
}
}
out
}
pub(crate) fn foreign_active_claim(
workspace_path: &Path,
self_id: &ManagedSessionId,
records: &[SessionRecord],
) -> Option<ManagedSessionId> {
let canon_target = canon_or_raw(workspace_path);
records
.iter()
.find(|r| {
if r.id == *self_id || r.state != ManagedSessionState::Active {
return false;
}
let ws_matches = r
.workspace_path
.as_deref()
.is_some_and(|p| canon_or_raw(p) == canon_target || p == workspace_path);
let cwd_matches = canon_or_raw(&r.cwd) == canon_target || r.cwd == workspace_path;
ws_matches || cwd_matches
})
.map(|r| r.id)
}
pub(crate) fn is_safe_to_remove(workspace_path: &Path, managed_root: &Path) -> bool {
let component_count = workspace_path.components().count();
if component_count < 3 {
warn!(
path = %workspace_path.display(),
components = component_count,
"is_safe_to_remove: rejecting — too few path components"
);
return false;
}
if dirs::home_dir().is_some_and(|home| workspace_path == home) {
warn!(
path = %workspace_path.display(),
"is_safe_to_remove: rejecting — path is $HOME"
);
return false;
}
let canon_ws = match workspace_path.canonicalize() {
Ok(p) => p,
Err(e) => {
warn!(
path = %workspace_path.display(),
"is_safe_to_remove: cannot canonicalize workspace path ({e}); skipping deletion"
);
return false;
}
};
let canon_root = match managed_root.canonicalize() {
Ok(p) => p,
Err(e) => {
warn!(
root = %managed_root.display(),
"is_safe_to_remove: cannot canonicalize managed root ({e}); skipping deletion"
);
return false;
}
};
if canon_ws == canon_root {
warn!(
path = %workspace_path.display(),
"is_safe_to_remove: rejecting — path IS the managed root"
);
return false;
}
if !canon_ws.starts_with(&canon_root) {
warn!(
path = %workspace_path.display(),
root = %managed_root.display(),
"is_safe_to_remove: rejecting — path is outside the managed root"
);
return false;
}
true
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::{
ManagedSessionId, ManagedSessionState, SessionRecord, foreign_active_claim,
is_safe_to_remove,
};
fn record_at(
id: ManagedSessionId,
state: ManagedSessionState,
workspace_path: PathBuf,
) -> SessionRecord {
SessionRecord {
id,
tmux_name: format!("tm-foreign-claim-{id}"),
cwd: PathBuf::from("/tmp/unrelated-cwd"),
task: "task".into(),
state,
created_at: chrono::Utc::now(),
last_activity_at: None,
workspace_path: Some(workspace_path),
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause: None,
}
}
#[test]
fn foreign_active_claim_finds_a_colliding_workspace_path() {
let root = crate::test_support::hermetic_temp_dir();
let shared = root.path().join("shared-worktree");
std::fs::create_dir_all(&shared).unwrap();
let self_id = ManagedSessionId::new();
let other_id = ManagedSessionId::new();
let records = vec![record_at(
other_id,
ManagedSessionState::Active,
shared.clone(),
)];
assert_eq!(
foreign_active_claim(&shared, &self_id, &records),
Some(other_id),
"an Active sibling record naming the same path must be reported"
);
}
#[test]
fn foreign_active_claim_finds_a_colliding_cwd() {
let root = crate::test_support::hermetic_temp_dir();
let shared = root.path().join("shared-cwd");
std::fs::create_dir_all(&shared).unwrap();
let self_id = ManagedSessionId::new();
let other_id = ManagedSessionId::new();
let mut other = record_at(
other_id,
ManagedSessionState::Active,
root.path().join("elsewhere"),
);
other.cwd = shared.clone();
let records = vec![other];
assert_eq!(
foreign_active_claim(&shared, &self_id, &records),
Some(other_id),
"an Active sibling record whose cwd matches must be reported"
);
}
#[test]
fn foreign_active_claim_ignores_a_non_active_record() {
let root = crate::test_support::hermetic_temp_dir();
let shared = root.path().join("shared-terminal");
std::fs::create_dir_all(&shared).unwrap();
let self_id = ManagedSessionId::new();
let other_id = ManagedSessionId::new();
let records = vec![record_at(
other_id,
ManagedSessionState::Decommissioned,
shared.clone(),
)];
assert_eq!(
foreign_active_claim(&shared, &self_id, &records),
None,
"a terminal-state record must never block removal"
);
}
#[test]
fn foreign_active_claim_ignores_the_records_own_id() {
let root = crate::test_support::hermetic_temp_dir();
let shared = root.path().join("own-workspace");
std::fs::create_dir_all(&shared).unwrap();
let self_id = ManagedSessionId::new();
let records = vec![record_at(
self_id,
ManagedSessionState::Active,
shared.clone(),
)];
assert_eq!(
foreign_active_claim(&shared, &self_id, &records),
None,
"a record must never conflict with itself"
);
}
#[test]
fn foreign_active_claim_returns_none_when_unclaimed() {
let root = crate::test_support::hermetic_temp_dir();
let unclaimed = root.path().join("nobody-here");
std::fs::create_dir_all(&unclaimed).unwrap();
let self_id = ManagedSessionId::new();
assert_eq!(foreign_active_claim(&unclaimed, &self_id, &[]), None);
}
#[test]
fn foreign_active_claim_catches_a_nonexistent_path_spelled_with_a_trailing_slash() {
let root = crate::test_support::hermetic_temp_dir();
let parent_real = root.path().join("parent-real");
std::fs::create_dir_all(&parent_real).unwrap();
let parent_alias = root.path().join("parent-alias");
std::os::unix::fs::symlink(&parent_real, &parent_alias).unwrap();
let candidate = parent_real.join("workspace");
let phantom_spelling =
PathBuf::from(format!("{}/", parent_alias.join("workspace").display()));
let self_id = ManagedSessionId::new();
let other_id = ManagedSessionId::new();
let records = vec![record_at(
other_id,
ManagedSessionState::Active,
phantom_spelling,
)];
assert_eq!(
foreign_active_claim(&candidate, &self_id, &records),
Some(other_id),
"a phantom record reached via a symlinked alias, spelled with a \
trailing slash, must still be caught as the same directory"
);
}
#[test]
fn foreign_active_claim_catches_a_nonexistent_path_with_dot_dot_segments() {
let root = crate::test_support::hermetic_temp_dir();
let candidate = root.path().join("target");
let phantom_spelling = root.path().join("sub").join("..").join("target");
let self_id = ManagedSessionId::new();
let other_id = ManagedSessionId::new();
let records = vec![record_at(
other_id,
ManagedSessionState::Active,
phantom_spelling,
)];
assert_eq!(
foreign_active_claim(&candidate, &self_id, &records),
Some(other_id),
"a phantom record spelled with a `..` segment must still be \
caught as the same directory"
);
}
#[test]
fn foreign_active_claim_catches_a_nonexistent_path_through_a_symlinked_parent() {
let root = crate::test_support::hermetic_temp_dir();
let parent_real = root.path().join("parent-real");
std::fs::create_dir_all(&parent_real).unwrap();
let parent_alias = root.path().join("parent-alias");
std::os::unix::fs::symlink(&parent_real, &parent_alias).unwrap();
let candidate = parent_real.join("workspace");
let phantom_spelling = parent_alias.join("workspace");
let self_id = ManagedSessionId::new();
let other_id = ManagedSessionId::new();
let records = vec![record_at(
other_id,
ManagedSessionState::Active,
phantom_spelling,
)];
assert_eq!(
foreign_active_claim(&candidate, &self_id, &records),
Some(other_id),
"a phantom record reached via a symlinked parent directory must \
still be caught as the same directory"
);
}
#[test]
fn is_safe_to_remove_accepts_valid_child() {
let root = crate::test_support::hermetic_temp_dir();
let child = root.path().join("owner").join("repo").join("session-abc");
std::fs::create_dir_all(&child).unwrap();
assert!(
is_safe_to_remove(&child, root.path()),
"a path strictly inside the managed root must pass"
);
}
#[test]
fn is_safe_to_remove_rejects_root_itself() {
let root = crate::test_support::hermetic_temp_dir();
assert!(
!is_safe_to_remove(root.path(), root.path()),
"the managed root itself must be rejected"
);
}
#[test]
fn is_safe_to_remove_rejects_outside_root() {
let root = crate::test_support::hermetic_temp_dir();
let outside = crate::test_support::hermetic_temp_dir(); let outside_child = outside.path().join("some").join("path");
std::fs::create_dir_all(&outside_child).unwrap();
assert!(
!is_safe_to_remove(&outside_child, root.path()),
"a path outside the managed root must be rejected"
);
}
#[test]
fn is_safe_to_remove_rejects_shallow_path() {
let root = crate::test_support::hermetic_temp_dir();
let shallow = PathBuf::from("/tmp");
assert!(
!is_safe_to_remove(&shallow, root.path()),
"a shallow path (/tmp) must be rejected by the component-count guard"
);
}
#[serial_test::serial]
#[test]
fn is_safe_to_remove_rejects_home() {
if let Some(home) = dirs::home_dir() {
assert!(
!is_safe_to_remove(&home, &home),
"$HOME must always be rejected"
);
}
}
#[test]
fn is_safe_to_remove_returns_false_for_nonexistent_path() {
let root = crate::test_support::hermetic_temp_dir();
let nonexistent = root.path().join("ghost").join("nope").join("absent");
assert!(
!is_safe_to_remove(&nonexistent, root.path()),
"a non-existent workspace path must return false (canonicalize fails)"
);
}
}