Skip to main content

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}