1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
//! Path-containment guard for workspace deletion (#1511).
//!
//! Why: `SessionManager::decommission` previously `remove_dir_all`'d
//! `workspace_path` unconditionally, which deleted a live user repo when the
//! #1502 local-path spawn set `workspace_path` to a real on-disk directory.
//! This module provides the belt-and-suspenders containment guard that prevents
//! any path OUTSIDE the SM's managed workspace root from being deleted —
//! regardless of the `workspace_owned` flag.
//! What: [`is_safe_to_remove`] canonicalizes both paths and verifies that the
//! workspace is strictly INSIDE the managed root, rejecting: path == root, path
//! outside root, paths with too few components, and `$HOME`.
//! Test: `is_safe_to_remove_*` unit tests below.
use std::path::Path;
use tracing::warn;
/// Decide whether `workspace_path` is safe to `remove_dir_all` (#1511).
///
/// Why: even an `workspace_owned = true` record should only be deleted when the
/// path is strictly INSIDE the SM's managed workspaces root. This prevents
/// decommission from deleting a directory if `workspace_owned` were stale or if a
/// bug let a real path slip through as "owned". The guard rejects: path == root,
/// path outside root, path with too few components (filesystem / volume root), and
/// the user's home directory.
///
/// What: canonicalizes both paths and checks that `workspace_path` starts with
/// `managed_root` AND is not equal to it (strictly INSIDE). Also rejects any path
/// that is `$HOME` or has fewer than 3 components (e.g. `/`, `/tmp`) as an extra
/// safety net against catastrophic deletion. Returns `false` on any I/O error
/// during canonicalization (e.g. path does not exist) — never errors out.
///
/// Test: `is_safe_to_remove_rejects_outside_root`, `is_safe_to_remove_rejects_root_itself`,
/// `is_safe_to_remove_rejects_home`, `is_safe_to_remove_rejects_shallow_path`,
/// `is_safe_to_remove_accepts_valid_child` in this module.
pub(crate) fn is_safe_to_remove(workspace_path: &Path, managed_root: &Path) -> bool {
// Reject suspiciously shallow ABSOLUTE paths before canonicalizing.
// This counts components from the filesystem root (e.g. `/` counts 1,
// `/tmp` counts 2) as a coarse guard against catastrophic paths like `/`
// or `/tmp` — NOT as a measure of depth relative to the managed root.
// The real containment check (canonicalize + starts_with) follows below.
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;
}
// Reject $HOME outright — no managed session should ever BE the home dir.
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;
}
// Canonicalize both paths to resolve symlinks and `..` so a symlink into
// the managed root (or vice-versa) cannot trick the prefix check.
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;
}
};
// Must be strictly INSIDE the root (starts_with AND not equal to root).
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::is_safe_to_remove;
// ── is_safe_to_remove unit tests (#1511) ────────────────────────────────
/// A valid child of the managed root passes the guard.
///
/// Why: this is the happy path — an SM-provisioned workspace is always
/// nested under the managed root.
/// Test: this function IS the test.
#[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"
);
}
/// A path equal to the managed root is rejected.
///
/// Why: deleting the root itself would wipe all managed workspaces at once.
/// Test: this function IS the test.
#[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"
);
}
/// A path outside the managed root is rejected even if it exists.
///
/// Why: a stale or stale `workspace_owned` flag must not cause out-of-root
/// deletion.
/// Test: this function IS the test.
#[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(); // different 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"
);
}
/// A path with fewer than 3 components is rejected.
///
/// Why: `/`, `/tmp`, or a single-segment path is never a valid SM workspace.
/// Test: this function IS the test.
#[test]
fn is_safe_to_remove_rejects_shallow_path() {
let root = crate::test_support::hermetic_temp_dir();
// A 1-component path like "/" or a 2-component path like "/tmp" must be
// rejected before even reaching canonicalization.
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"
);
}
/// `$HOME` is rejected outright.
///
/// Why: deleting the user's home directory is catastrophic and must be
/// impossible regardless of what the managed root is set to.
/// Test: this function IS the test.
///
/// Why serial (issue #2461 sweep): this test reads `dirs::home_dir()`
/// itself AND `is_safe_to_remove` reads it again internally — two
/// separate reads of the process-wide `HOME` env var that must observe
/// the same value. Serialized against other `HOME`-redirecting tests in
/// this binary for the same reason as the `core::paths` sweep.
#[serial_test::serial]
#[test]
fn is_safe_to_remove_rejects_home() {
if let Some(home) = dirs::home_dir() {
// Use home as both path and root — even if "home is inside home" the
// home-directory check fires first and rejects.
assert!(
!is_safe_to_remove(&home, &home),
"$HOME must always be rejected"
);
}
}
/// A non-existent path returns false (canonicalize fails).
///
/// Why: a workspace that has already been deleted must not cause a panic;
/// the guard should simply return false so decommission logs and moves on.
/// Test: this function IS the test.
#[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");
// path does not exist → canonicalize fails → returns false
assert!(
!is_safe_to_remove(&nonexistent, root.path()),
"a non-existent workspace path must return false (canonicalize fails)"
);
}
}