mur_common/worktree.rs
1//! Git worktree identity: the link between a linked worktree and the main
2//! checkout it belongs to (issue #004).
3//!
4//! Why this exists at all: entitlement checks are pure string prefix matching
5//! (`tools::fs_policy::under_any`), and a `git worktree` lives at a path that
6//! is NOT under the main checkout. So an agent granted the repo it works in
7//! was refused the moment the work moved into a worktree, and the user had to
8//! re-grant the same repo under a second name. Nothing in the runtime knew
9//! the two paths were the same project.
10//!
11//! The derivation here is deliberately read-only and one-way: it computes the
12//! relationship from git's own on-disk metadata every time it is asked. It
13//! never writes a derived path back into `profile.yaml` — a grant the user did
14//! not type must not become a permanent, user-visible entitlement they then
15//! have to audit. The derived paths exist only in the built sandbox policy and
16//! in the call-time tool gate.
17//!
18//! ## The on-disk shapes this reads
19//!
20//! Main checkout: `<main>/.git/` — a DIRECTORY
21//! Linked worktree: `<wt>/.git` — a FILE containing
22//! `gitdir: <main>/.git/worktrees/<id>`
23//! Worktree registry: `<main>/.git/worktrees/<id>/gitdir` — a file whose
24//! contents are `<wt>/.git`
25//!
26//! Both directions are needed, for two different layers:
27//!
28//! * worktree → main ([`main_checkout_of`]) for the call-time tool gate, which
29//! sees a concrete path and asks "is this reachable from something granted?"
30//! * main → worktrees ([`worktrees_of`]) for the kernel sandbox, which must
31//! enumerate every path at seal time because Landlock/SBPL cannot ask a
32//! question later.
33
34use std::path::{Path, PathBuf};
35
36/// Read a `.git` FILE's `gitdir: <path>` pointer. `None` when `p` is a
37/// directory (an ordinary checkout), missing, or not in that form.
38fn gitdir_pointer(p: &Path) -> Option<PathBuf> {
39 // A `.git` directory is the main checkout — nothing to follow.
40 if p.is_dir() {
41 return None;
42 }
43 let text = std::fs::read_to_string(p).ok()?;
44 let rest = text.trim().strip_prefix("gitdir:")?;
45 let target = PathBuf::from(rest.trim());
46 if target.as_os_str().is_empty() {
47 return None;
48 }
49 // The pointer is normally absolute, but git permits a relative one
50 // (`git worktree add --relative-paths`), resolved against the worktree.
51 if target.is_absolute() {
52 Some(target)
53 } else {
54 p.parent().map(|d| d.join(target))
55 }
56}
57
58/// The main checkout that `start` belongs to, when `start` is inside a linked
59/// git worktree. `None` for an ordinary checkout, or outside git entirely.
60///
61/// Resolution follows git's own two hops and nothing else — no `git` binary is
62/// spawned, because this runs inside the entitlement gate that decides whether
63/// spawning is allowed in the first place:
64///
65/// 1. `<wt>/.git` is a file → `gitdir: <main>/.git/worktrees/<id>`
66/// 2. `<main>/.git/worktrees/<id>/commondir` → `../..`, joined and normalized
67/// to `<main>/.git`, whose parent is the main checkout root.
68///
69/// `commondir` is read rather than assumed: it is the value git itself uses,
70/// and it stays correct for layouts where the common dir is not two levels up
71/// (a worktree of a bare or separate-gitdir repo).
72pub fn main_checkout_of(start: &Path) -> Option<PathBuf> {
73 let wt_root = worktree_root_of(start)?;
74 let gitdir = gitdir_pointer(&wt_root.join(".git"))?;
75 let commondir_file = gitdir.join("commondir");
76 let common = match std::fs::read_to_string(&commondir_file) {
77 Ok(text) => {
78 let rel = PathBuf::from(text.trim());
79 if rel.is_absolute() {
80 rel
81 } else {
82 normalize(&gitdir.join(rel))
83 }
84 }
85 // No `commondir` (very old git): fall back to the documented layout,
86 // `<common>/worktrees/<id>` → up two.
87 Err(_) => gitdir.parent()?.parent()?.to_path_buf(),
88 };
89 // `common` is the main repo's `.git`; the checkout is its parent. A bare
90 // repo has no checkout to grant, so this correctly yields `None` only if
91 // there is no parent at all.
92 let root = common.parent()?.to_path_buf();
93 if root.as_os_str().is_empty() {
94 None
95 } else {
96 Some(root)
97 }
98}
99
100/// Walk up from `start` to the nearest directory holding a `.git` entry of
101/// either shape. Mirrors `project::repo_root_of` but is kept separate because
102/// that function's contract (one project id per checkout) is deliberately
103/// worktree-blind and callers depend on that. Both share the one bounded walk
104/// in [`crate::repo_walk`], so neither can escape a scratch dir alone.
105fn worktree_root_of(start: &Path) -> Option<PathBuf> {
106 crate::repo_walk::git_root_of(start)
107}
108
109/// Every matching path in a linked worktree registered under the checkout that
110/// contains `main_path`.
111///
112/// When `main_path` is the checkout root, this returns linked worktree roots.
113/// When it is a descendant (for example `<main>/target`), the same relative
114/// path is appended to every linked worktree root (for example
115/// `<worktree>/target`). This lets every entitlement layer share one mapping
116/// rule instead of reimplementing worktree-relative paths independently.
117///
118/// Reads `<main>/.git/worktrees/*/gitdir`, each of which contains the path of
119/// the worktree's own `.git` file. Entries whose mapped path has been deleted
120/// or was never created are skipped — a dead grant is not merely useless here,
121/// it destabilizes the whole compiled sandbox profile (see
122/// `sandbox::policy::from_entitlements`, Issue 16).
123///
124/// Returns empty for a path outside a main checkout, a linked-worktree path,
125/// a checkout with no worktrees, or an unreadable registry. Never errors: an
126/// undiscoverable worktree must degrade to "not granted" (fail-closed), never
127/// to a panic inside the gate.
128pub fn worktrees_of(main_path: &Path) -> Vec<PathBuf> {
129 let Some(main_root) = worktree_root_of(main_path) else {
130 return Vec::new();
131 };
132 // A `.git` file identifies a linked worktree. Expansion is deliberately
133 // one-way from the main checkout so derived grants cannot fan out again.
134 if !main_root.join(".git").is_dir() {
135 return Vec::new();
136 }
137 let Ok(relative) = main_path.strip_prefix(&main_root) else {
138 return Vec::new();
139 };
140 let dir = main_root.join(".git").join("worktrees");
141 let Ok(entries) = std::fs::read_dir(&dir) else {
142 return Vec::new();
143 };
144 let mut out = Vec::new();
145 for entry in entries.flatten() {
146 let gitdir_file = entry.path().join("gitdir");
147 let Ok(text) = std::fs::read_to_string(&gitdir_file) else {
148 continue;
149 };
150 let dot_git = PathBuf::from(text.trim());
151 if dot_git.as_os_str().is_empty() {
152 continue;
153 }
154 let Some(root) = dot_git.parent() else {
155 continue;
156 };
157 let mapped = root.join(relative);
158 // Fail closed on a pruned/moved worktree or missing relative path.
159 if std::fs::metadata(&mapped).is_ok() {
160 out.push(mapped);
161 }
162 }
163 out.sort();
164 out.dedup();
165 out
166}
167
168/// Lexically resolve `.` / `..` without touching the filesystem.
169///
170/// `std::fs::canonicalize` is not used on purpose: it resolves symlinks, and
171/// the caller compares the result against entitlement roots the user typed by
172/// hand. Rewriting `/Users/x/repo` into `/System/Volumes/Data/Users/x/repo`
173/// (which macOS does) would make every derived grant miss.
174fn normalize(p: &Path) -> PathBuf {
175 let mut out = PathBuf::new();
176 for c in p.components() {
177 match c {
178 std::path::Component::ParentDir => {
179 out.pop();
180 }
181 std::path::Component::CurDir => {}
182 other => out.push(other.as_os_str()),
183 }
184 }
185 out
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use std::process::Command;
192
193 /// Build a real repo with a real linked worktree. The metadata layout this
194 /// module reads is git's, not ours, so a hand-faked fixture would only
195 /// prove we can read what we wrote.
196 fn repo_with_worktree() -> Option<(tempfile::TempDir, PathBuf, PathBuf)> {
197 let tmp = tempfile::tempdir().ok()?;
198 let main = tmp.path().join("main");
199 std::fs::create_dir_all(&main).ok()?;
200 let git = |args: &[&str], cwd: &Path| -> bool {
201 Command::new("git")
202 .args(args)
203 .current_dir(cwd)
204 .output()
205 .map(|o| o.status.success())
206 .unwrap_or(false)
207 };
208 if !git(&["init", "-q"], &main) {
209 return None; // no git on this machine → caller skips
210 }
211 let _ = git(&["config", "user.email", "t@example.com"], &main);
212 let _ = git(&["config", "user.name", "t"], &main);
213 std::fs::write(main.join("f.txt"), "x").ok()?;
214 let _ = git(&["add", "f.txt"], &main);
215 let _ = git(&["commit", "-qm", "init"], &main);
216 let wt = tmp.path().join("wt");
217 if !git(
218 &["worktree", "add", "-q", wt.to_str()?, "-b", "feat"],
219 &main,
220 ) {
221 return None;
222 }
223 Some((tmp, main, wt))
224 }
225
226 /// The bug in #004, stated as a property: a path inside a linked worktree
227 /// must resolve back to the main checkout the user actually granted.
228 #[test]
229 fn worktree_path_resolves_to_its_main_checkout() {
230 let Some((_tmp, main, wt)) = repo_with_worktree() else {
231 eprintln!("skipping: git unavailable or worktree creation failed");
232 return;
233 };
234 let main_c = std::fs::canonicalize(&main).unwrap();
235
236 // From the worktree root...
237 let got = main_checkout_of(&wt).expect("worktree resolves to a main checkout");
238 assert_eq!(std::fs::canonicalize(&got).unwrap(), main_c);
239
240 // ...and from a file nested deep inside it, which is what the file
241 // tools actually receive.
242 let deep = wt.join("a").join("b");
243 std::fs::create_dir_all(&deep).unwrap();
244 let got = main_checkout_of(&deep.join("c.rs")).expect("nested path resolves");
245 assert_eq!(std::fs::canonicalize(&got).unwrap(), main_c);
246 }
247
248 /// The reverse direction the kernel sandbox needs: from the granted main
249 /// checkout, enumerate the worktrees to seal in alongside it.
250 #[test]
251 fn main_checkout_enumerates_its_worktrees() {
252 let Some((_tmp, main, wt)) = repo_with_worktree() else {
253 eprintln!("skipping: git unavailable or worktree creation failed");
254 return;
255 };
256 let found = worktrees_of(&main);
257 assert_eq!(found.len(), 1, "expected exactly one worktree: {found:?}");
258 assert_eq!(
259 std::fs::canonicalize(&found[0]).unwrap(),
260 std::fs::canonicalize(&wt).unwrap()
261 );
262 }
263
264 /// A pruned worktree must NOT be returned. Issue 16: a grant naming a
265 /// nonexistent path destabilizes the compiled sandbox profile, so the
266 /// derivation has to fail closed rather than pass the stale entry through.
267 #[test]
268 fn pruned_worktree_is_not_enumerated() {
269 let Some((_tmp, main, wt)) = repo_with_worktree() else {
270 eprintln!("skipping: git unavailable or worktree creation failed");
271 return;
272 };
273 std::fs::remove_dir_all(&wt).unwrap();
274 // Registry entry still present under .git/worktrees (not pruned).
275 assert!(
276 worktrees_of(&main).is_empty(),
277 "a worktree deleted from disk must not be derived as a grant"
278 );
279 }
280
281 /// An ordinary checkout is not a worktree: it must resolve to `None` so
282 /// the gate falls through to the normal grant check unchanged.
283 #[test]
284 fn plain_checkout_and_non_repo_resolve_to_none() {
285 let Some((_tmp, main, _wt)) = repo_with_worktree() else {
286 eprintln!("skipping: git unavailable or worktree creation failed");
287 return;
288 };
289 assert!(main_checkout_of(&main).is_none());
290
291 let tmp2 = tempfile::tempdir().unwrap();
292 assert!(main_checkout_of(tmp2.path()).is_none());
293 assert!(worktrees_of(tmp2.path()).is_empty());
294 }
295
296 #[test]
297 fn normalize_resolves_dotdot_without_the_filesystem() {
298 assert_eq!(
299 normalize(Path::new("/a/b/.git/worktrees/w/../..")),
300 PathBuf::from("/a/b/.git")
301 );
302 }
303}