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.
104fn worktree_root_of(start: &Path) -> Option<PathBuf> {
105 let mut dir = Some(start);
106 while let Some(d) = dir {
107 if d.join(".git").exists() {
108 return Some(d.to_path_buf());
109 }
110 dir = d.parent();
111 }
112 None
113}
114
115/// Every matching path in a linked worktree registered under the checkout that
116/// contains `main_path`.
117///
118/// When `main_path` is the checkout root, this returns linked worktree roots.
119/// When it is a descendant (for example `<main>/target`), the same relative
120/// path is appended to every linked worktree root (for example
121/// `<worktree>/target`). This lets every entitlement layer share one mapping
122/// rule instead of reimplementing worktree-relative paths independently.
123///
124/// Reads `<main>/.git/worktrees/*/gitdir`, each of which contains the path of
125/// the worktree's own `.git` file. Entries whose mapped path has been deleted
126/// or was never created are skipped — a dead grant is not merely useless here,
127/// it destabilizes the whole compiled sandbox profile (see
128/// `sandbox::policy::from_entitlements`, Issue 16).
129///
130/// Returns empty for a path outside a main checkout, a linked-worktree path,
131/// a checkout with no worktrees, or an unreadable registry. Never errors: an
132/// undiscoverable worktree must degrade to "not granted" (fail-closed), never
133/// to a panic inside the gate.
134pub fn worktrees_of(main_path: &Path) -> Vec<PathBuf> {
135 let Some(main_root) = worktree_root_of(main_path) else {
136 return Vec::new();
137 };
138 // A `.git` file identifies a linked worktree. Expansion is deliberately
139 // one-way from the main checkout so derived grants cannot fan out again.
140 if !main_root.join(".git").is_dir() {
141 return Vec::new();
142 }
143 let Ok(relative) = main_path.strip_prefix(&main_root) else {
144 return Vec::new();
145 };
146 let dir = main_root.join(".git").join("worktrees");
147 let Ok(entries) = std::fs::read_dir(&dir) else {
148 return Vec::new();
149 };
150 let mut out = Vec::new();
151 for entry in entries.flatten() {
152 let gitdir_file = entry.path().join("gitdir");
153 let Ok(text) = std::fs::read_to_string(&gitdir_file) else {
154 continue;
155 };
156 let dot_git = PathBuf::from(text.trim());
157 if dot_git.as_os_str().is_empty() {
158 continue;
159 }
160 let Some(root) = dot_git.parent() else {
161 continue;
162 };
163 let mapped = root.join(relative);
164 // Fail closed on a pruned/moved worktree or missing relative path.
165 if std::fs::metadata(&mapped).is_ok() {
166 out.push(mapped);
167 }
168 }
169 out.sort();
170 out.dedup();
171 out
172}
173
174/// Lexically resolve `.` / `..` without touching the filesystem.
175///
176/// `std::fs::canonicalize` is not used on purpose: it resolves symlinks, and
177/// the caller compares the result against entitlement roots the user typed by
178/// hand. Rewriting `/Users/x/repo` into `/System/Volumes/Data/Users/x/repo`
179/// (which macOS does) would make every derived grant miss.
180fn normalize(p: &Path) -> PathBuf {
181 let mut out = PathBuf::new();
182 for c in p.components() {
183 match c {
184 std::path::Component::ParentDir => {
185 out.pop();
186 }
187 std::path::Component::CurDir => {}
188 other => out.push(other.as_os_str()),
189 }
190 }
191 out
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use std::process::Command;
198
199 /// Build a real repo with a real linked worktree. The metadata layout this
200 /// module reads is git's, not ours, so a hand-faked fixture would only
201 /// prove we can read what we wrote.
202 fn repo_with_worktree() -> Option<(tempfile::TempDir, PathBuf, PathBuf)> {
203 let tmp = tempfile::tempdir().ok()?;
204 let main = tmp.path().join("main");
205 std::fs::create_dir_all(&main).ok()?;
206 let git = |args: &[&str], cwd: &Path| -> bool {
207 Command::new("git")
208 .args(args)
209 .current_dir(cwd)
210 .output()
211 .map(|o| o.status.success())
212 .unwrap_or(false)
213 };
214 if !git(&["init", "-q"], &main) {
215 return None; // no git on this machine → caller skips
216 }
217 let _ = git(&["config", "user.email", "t@example.com"], &main);
218 let _ = git(&["config", "user.name", "t"], &main);
219 std::fs::write(main.join("f.txt"), "x").ok()?;
220 let _ = git(&["add", "f.txt"], &main);
221 let _ = git(&["commit", "-qm", "init"], &main);
222 let wt = tmp.path().join("wt");
223 if !git(
224 &["worktree", "add", "-q", wt.to_str()?, "-b", "feat"],
225 &main,
226 ) {
227 return None;
228 }
229 Some((tmp, main, wt))
230 }
231
232 /// The bug in #004, stated as a property: a path inside a linked worktree
233 /// must resolve back to the main checkout the user actually granted.
234 #[test]
235 fn worktree_path_resolves_to_its_main_checkout() {
236 let Some((_tmp, main, wt)) = repo_with_worktree() else {
237 eprintln!("skipping: git unavailable or worktree creation failed");
238 return;
239 };
240 let main_c = std::fs::canonicalize(&main).unwrap();
241
242 // From the worktree root...
243 let got = main_checkout_of(&wt).expect("worktree resolves to a main checkout");
244 assert_eq!(std::fs::canonicalize(&got).unwrap(), main_c);
245
246 // ...and from a file nested deep inside it, which is what the file
247 // tools actually receive.
248 let deep = wt.join("a").join("b");
249 std::fs::create_dir_all(&deep).unwrap();
250 let got = main_checkout_of(&deep.join("c.rs")).expect("nested path resolves");
251 assert_eq!(std::fs::canonicalize(&got).unwrap(), main_c);
252 }
253
254 /// The reverse direction the kernel sandbox needs: from the granted main
255 /// checkout, enumerate the worktrees to seal in alongside it.
256 #[test]
257 fn main_checkout_enumerates_its_worktrees() {
258 let Some((_tmp, main, wt)) = repo_with_worktree() else {
259 eprintln!("skipping: git unavailable or worktree creation failed");
260 return;
261 };
262 let found = worktrees_of(&main);
263 assert_eq!(found.len(), 1, "expected exactly one worktree: {found:?}");
264 assert_eq!(
265 std::fs::canonicalize(&found[0]).unwrap(),
266 std::fs::canonicalize(&wt).unwrap()
267 );
268 }
269
270 /// A pruned worktree must NOT be returned. Issue 16: a grant naming a
271 /// nonexistent path destabilizes the compiled sandbox profile, so the
272 /// derivation has to fail closed rather than pass the stale entry through.
273 #[test]
274 fn pruned_worktree_is_not_enumerated() {
275 let Some((_tmp, main, wt)) = repo_with_worktree() else {
276 eprintln!("skipping: git unavailable or worktree creation failed");
277 return;
278 };
279 std::fs::remove_dir_all(&wt).unwrap();
280 // Registry entry still present under .git/worktrees (not pruned).
281 assert!(
282 worktrees_of(&main).is_empty(),
283 "a worktree deleted from disk must not be derived as a grant"
284 );
285 }
286
287 /// An ordinary checkout is not a worktree: it must resolve to `None` so
288 /// the gate falls through to the normal grant check unchanged.
289 #[test]
290 fn plain_checkout_and_non_repo_resolve_to_none() {
291 let Some((_tmp, main, _wt)) = repo_with_worktree() else {
292 eprintln!("skipping: git unavailable or worktree creation failed");
293 return;
294 };
295 assert!(main_checkout_of(&main).is_none());
296
297 let tmp2 = tempfile::tempdir().unwrap();
298 assert!(main_checkout_of(tmp2.path()).is_none());
299 assert!(worktrees_of(tmp2.path()).is_empty());
300 }
301
302 #[test]
303 fn normalize_resolves_dotdot_without_the_filesystem() {
304 assert_eq!(
305 normalize(Path::new("/a/b/.git/worktrees/w/../..")),
306 PathBuf::from("/a/b/.git")
307 );
308 }
309}