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.
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}