Skip to main content

fallow_engine/
base_worktree_registration.rs

1//! Git registration of detached base snapshots.
2//!
3//! A base snapshot is a linked worktree only while it is created. Fallow then
4//! removes its Git admin entry and keeps the directory, so Git-aware tools do
5//! not see the snapshot while the base analysis reads it, and a process that
6//! dies before cleanup leaves no registered worktree behind (issues #1815 and
7//! #3885). The CLI audit cache and the programmatic base snapshot share this
8//! lifecycle.
9
10use std::fs;
11use std::io::Write as _;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15/// Last segment of the gitdir that the stub of a deregistered snapshot names.
16const UNREGISTERED_GITDIR_NAME: &str = "fallow-audit-unregistered";
17
18/// Stub of older versions. Its gitdir is relative, so Git resolves it inside
19/// the snapshot, where the untrusted base commit can supply a directory of
20/// that name with its own config. It is recognized only to replace it.
21const LEGACY_UNREGISTERED_GITDIR_STUB: &str = "gitdir: fallow-audit-unregistered\n";
22
23/// Prefix of every admin entry and snapshot directory that Fallow creates.
24const FALLOW_BASE_WORKTREE_PREFIX: &str = "fallow-audit-base-";
25
26/// Gitfile contents written into `<snapshot>/.git` after Fallow deregisters
27/// a base snapshot.
28///
29/// The `.git` file is REPLACED, never deleted: both discovery walkers use the
30/// `ignore` crate with `require_git` on, whose gitignore handling is gated on
31/// `<root>/.git` existing. Deleting the gitfile would silently stop the base
32/// pass from honoring `.gitignore`, inflating base findings and skewing
33/// audit's introduced-vs-inherited split.
34///
35/// The stub names the absolute path `<snapshot>/.git/fallow-audit-unregistered`.
36/// That path can never exist, because `<snapshot>/.git` is this regular file,
37/// and the committed tree cannot supply a `.git` entry. So a `git` command in
38/// the snapshot fails and never reads a config from the untrusted base commit.
39#[must_use]
40pub fn unregistered_gitdir_stub(snapshot: &Path) -> String {
41    let gitdir = snapshot.join(".git").join(UNREGISTERED_GITDIR_NAME);
42    format!("gitdir: {}\n", gitdir.display())
43}
44
45/// Return whether `contents` is the stub of a deregistered snapshot at
46/// `snapshot`, in the current or the legacy form.
47#[must_use]
48pub fn is_unregistered_gitdir_stub(snapshot: &Path, contents: &str) -> bool {
49    if contents == LEGACY_UNREGISTERED_GITDIR_STUB {
50        return true;
51    }
52    let Some(gitdir) = parse_worktree_gitdir(contents) else {
53        return false;
54    };
55    let Some(gitfile) = gitdir
56        .file_name()
57        .is_some_and(|name| name == UNREGISTERED_GITDIR_NAME)
58        .then(|| gitdir.parent())
59        .flatten()
60    else {
61        return false;
62    };
63    if gitfile.file_name().is_none_or(|name| name != ".git") {
64        return false;
65    }
66    gitfile
67        .parent()
68        .is_some_and(|owner| owner == snapshot || same_existing_path(owner, snapshot))
69}
70
71fn same_existing_path(left: &Path, right: &Path) -> bool {
72    match (dunce::canonicalize(left), dunce::canonicalize(right)) {
73        (Ok(left), Ok(right)) => left == right,
74        _ => false,
75    }
76}
77
78/// Return the deregistered snapshot that contains `dir`, if any.
79///
80/// The walk stops at the first `.git` entry. A Fallow stub marks a snapshot.
81/// Any other `.git` entry is an ordinary repository boundary.
82#[must_use]
83pub fn enclosing_unregistered_snapshot(dir: &Path) -> Option<PathBuf> {
84    let dir = std::path::absolute(dir).ok()?;
85    for candidate in dir.ancestors() {
86        let gitfile = candidate.join(".git");
87        let Ok(metadata) = fs::symlink_metadata(&gitfile) else {
88            continue;
89        };
90        if !is_regular_file(&metadata) {
91            return None;
92        }
93        let contents = fs::read_to_string(&gitfile).ok()?;
94        return is_unregistered_gitdir_stub(candidate, &contents).then(|| candidate.to_path_buf());
95    }
96    None
97}
98
99/// Keep a `git` command that runs in `cwd` from discovering a repository
100/// inside a deregistered base snapshot.
101///
102/// The snapshot holds the untrusted base commit. It can contain a directory
103/// that looks like a bare repository, and Git would read its config. When
104/// `cwd` is inside a snapshot, the command gets a `GIT_DIR` that does not
105/// exist and a `GIT_CEILING_DIRECTORIES` at the snapshot parent, so Git stops
106/// with "not a git repository". Outside a snapshot, the command is unchanged.
107pub fn confine_git_to_host(command: &mut Command, cwd: &Path) {
108    let Some(snapshot) = enclosing_unregistered_snapshot(cwd) else {
109        return;
110    };
111    command.env(
112        "GIT_DIR",
113        snapshot.join(".git").join(UNREGISTERED_GITDIR_NAME),
114    );
115    if let Some(parent) = snapshot.parent() {
116        command.env("GIT_CEILING_DIRECTORIES", parent);
117    }
118}
119
120/// Deregister a Fallow base snapshot from Git while KEEPING its directory.
121///
122/// Targets the single admin dir this worktree owns via its `.git` gitfile
123/// pointer, rather than a global `git worktree prune`, so a user's unrelated
124/// prunable worktrees are never collaterally deregistered and Git's
125/// name-collision admin suffixing (`<name>1`) is handled for free. The `.git`
126/// gitfile is REPLACED with [`unregistered_gitdir_stub`], never deleted.
127/// A missing directory and an already deregistered snapshot are success. A
128/// legacy stub is replaced with the current one.
129///
130/// # Errors
131///
132/// Returns an error when the gitfile is not a regular file, when its admin
133/// entry is not a Fallow entry of `repo_root` that links back to `path`, or
134/// when the admin entry or the gitfile cannot be changed.
135pub fn deregister_base_worktree(repo_root: &Path, path: &Path) -> std::io::Result<()> {
136    if !path.exists() {
137        return Ok(());
138    }
139    let gitfile = path.join(".git");
140    let metadata = fs::symlink_metadata(&gitfile)?;
141    if !is_regular_file(&metadata) {
142        return Err(std::io::Error::new(
143            std::io::ErrorKind::InvalidData,
144            "refusing to deregister through a non-file audit worktree .git entry",
145        ));
146    }
147    let contents = fs::read_to_string(&gitfile)?;
148    if is_unregistered_gitdir_stub(path, &contents) {
149        if contents == unregistered_gitdir_stub(path) {
150            return Ok(());
151        }
152        return write_unregistered_gitdir_stub(&gitfile);
153    }
154    let Some(admin_dir) = trusted_base_worktree_admin_dir(repo_root, path) else {
155        return Err(std::io::Error::new(
156            std::io::ErrorKind::InvalidData,
157            "refusing to deregister an unverified audit worktree admin entry",
158        ));
159    };
160    match fs::remove_dir_all(&admin_dir) {
161        Ok(()) => {}
162        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
163        Err(err) => return Err(err),
164    }
165    write_unregistered_gitdir_stub(&gitfile)
166}
167
168/// Write the [`unregistered_gitdir_stub`] of the snapshot that owns `gitfile`
169/// into `gitfile`, creating it when it is missing.
170///
171/// # Errors
172///
173/// Returns an error when `gitfile` exists but is not a regular file, or when
174/// the write fails.
175pub fn write_unregistered_gitdir_stub(gitfile: &Path) -> std::io::Result<()> {
176    let mut options = fs::OpenOptions::new();
177    options.write(true).truncate(true);
178    match fs::symlink_metadata(gitfile) {
179        Ok(metadata) if is_regular_file(&metadata) => {}
180        Ok(_) => {
181            return Err(std::io::Error::new(
182                std::io::ErrorKind::InvalidData,
183                "refusing to replace non-file audit worktree .git entry",
184            ));
185        }
186        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
187            options.create_new(true);
188        }
189        Err(error) => return Err(error),
190    }
191    let snapshot = gitfile.parent().ok_or_else(|| {
192        std::io::Error::new(
193            std::io::ErrorKind::InvalidInput,
194            "audit worktree .git entry has no parent directory",
195        )
196    })?;
197    let stub = unregistered_gitdir_stub(snapshot);
198    let mut file = options.open(gitfile)?;
199    file.write_all(stub.as_bytes())?;
200    file.sync_all()
201}
202
203/// Return the Git admin entry of the snapshot at `path` when it is a Fallow
204/// entry of `repo_root` whose backlink points at `path`.
205#[must_use]
206pub fn trusted_base_worktree_admin_dir(repo_root: &Path, path: &Path) -> Option<PathBuf> {
207    let gitfile = path.join(".git");
208    let metadata = fs::symlink_metadata(&gitfile).ok()?;
209    if !is_regular_file(&metadata) {
210        return None;
211    }
212    let contents = fs::read_to_string(&gitfile).ok()?;
213    let admin_dir = parse_worktree_gitdir(&contents)?;
214    if !is_fallow_admin_dir(&admin_dir) {
215        return None;
216    }
217    let common_dir = crate::changed_files::resolve_git_common_dir(repo_root).ok()?;
218    let worktrees_dir = dunce::canonicalize(common_dir.join("worktrees")).ok()?;
219    let admin_parent = dunce::canonicalize(admin_dir.parent()?).ok()?;
220    if admin_parent != worktrees_dir {
221        return None;
222    }
223    let backlink = fs::read_to_string(admin_dir.join("gitdir")).ok()?;
224    let expected_gitfile = dunce::canonicalize(&gitfile).ok()?;
225    let actual_gitfile = dunce::canonicalize(Path::new(backlink.trim())).ok()?;
226    (actual_gitfile == expected_gitfile).then_some(admin_dir)
227}
228
229/// Parse the `gitdir: <path>` pointer from a linked-worktree `.git` gitfile.
230#[must_use]
231pub fn parse_worktree_gitdir(contents: &str) -> Option<PathBuf> {
232    contents
233        .lines()
234        .find_map(|line| line.trim().strip_prefix("gitdir:"))
235        .map(|rest| PathBuf::from(rest.trim()))
236}
237
238#[expect(
239    clippy::filetype_is_file,
240    reason = "a gitfile must be a regular file, not an arbitrary non-directory"
241)]
242fn is_regular_file(metadata: &fs::Metadata) -> bool {
243    metadata.file_type().is_file()
244}
245
246/// True when an admin dir basename is one Fallow itself created, used as a
247/// safety belt before removing it.
248fn is_fallow_admin_dir(admin_dir: &Path) -> bool {
249    admin_dir
250        .file_name()
251        .and_then(|name| name.to_str())
252        .is_some_and(|name| name.starts_with(FALLOW_BASE_WORKTREE_PREFIX))
253}
254
255#[cfg(test)]
256mod tests {
257    use std::process::Command;
258
259    use super::*;
260    use crate::repo_refs::{TemporaryBaseWorktree, create_detached_base_worktree};
261
262    fn git(root: &Path, args: &[&str]) -> String {
263        let output = crate::changed_files::clear_ambient_git_env(&mut Command::new("git"))
264            .arg("-C")
265            .arg(root)
266            .args(args)
267            .output()
268            .expect("git command starts");
269        assert!(
270            output.status.success(),
271            "git {args:?} failed: {}",
272            String::from_utf8_lossy(&output.stderr)
273        );
274        String::from_utf8_lossy(&output.stdout).trim().to_owned()
275    }
276
277    fn committed_repo(parent: &Path) -> PathBuf {
278        let repo = parent.join("repo");
279        fs::create_dir_all(&repo).expect("create repo");
280        git(&repo, &["init", "-b", "main"]);
281        git(&repo, &["config", "user.name", "Test User"]);
282        git(&repo, &["config", "user.email", "test@example.com"]);
283        git(&repo, &["config", "commit.gpgsign", "false"]);
284        fs::write(repo.join(".gitignore"), "ignored/\n").expect("write gitignore");
285        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
286        git(&repo, &["add", "."]);
287        git(&repo, &["commit", "-m", "initial"]);
288        repo
289    }
290
291    fn registered_worktrees(repo: &Path) -> Vec<String> {
292        git(repo, &["worktree", "list", "--porcelain"])
293            .lines()
294            .filter_map(|line| line.strip_prefix("worktree "))
295            .map(str::to_owned)
296            .collect()
297    }
298
299    /// Issue #3885: the programmatic base snapshot must not stay registered
300    /// while the base analysis reads it, so a host process that stops before
301    /// `Drop` leaves no Git worktree entry behind.
302    #[test]
303    fn temporary_base_worktree_is_unregistered_while_it_is_in_use() {
304        let temp = tempfile::tempdir().expect("temp dir");
305        let repo = committed_repo(temp.path());
306        let before = registered_worktrees(&repo);
307
308        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD", &[])
309            .expect("temporary worktree should be created");
310
311        assert_eq!(
312            registered_worktrees(&repo),
313            before,
314            "the base snapshot must be deregistered before analysis reads it"
315        );
316        assert_eq!(
317            fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
318            "committed\n"
319        );
320        assert_eq!(
321            fs::read_to_string(worktree.path().join(".git")).expect("read gitfile"),
322            unregistered_gitdir_stub(worktree.path()),
323            "the gitfile stays as a stub so gitignore handling still applies"
324        );
325
326        let path = worktree.path().to_path_buf();
327        drop(worktree);
328        assert!(!path.exists(), "temporary worktree must clean up on drop");
329        assert_eq!(registered_worktrees(&repo), before);
330    }
331
332    #[test]
333    fn registered_snapshot_index_keeps_gitlinks_until_deregistration() {
334        let temp = tempfile::tempdir().expect("temp dir");
335        let repo = committed_repo(temp.path());
336        let gitlink_commit = git(&repo, &["rev-parse", "HEAD"]);
337        let cacheinfo = format!("160000,{gitlink_commit},vendor/submodule");
338        git(&repo, &["update-index", "--add", "--cacheinfo", &cacheinfo]);
339        git(&repo, &["commit", "-m", "gitlink"]);
340        let destination = temp.path().join("fallow-audit-base-index-probe");
341
342        create_detached_base_worktree(&repo, &destination, "HEAD")
343            .expect("registered worktree should be created");
344        assert!(
345            git(&destination, &["ls-files", "--stage", "vendor/submodule"])
346                .starts_with(&format!("160000 {gitlink_commit} 0\t")),
347            "the linked worktree index must retain the gitlink object id"
348        );
349
350        deregister_base_worktree(&repo, &destination).expect("snapshot deregisters");
351        assert_eq!(registered_worktrees(&repo).len(), 1);
352        assert!(destination.join("tracked.txt").is_file());
353    }
354
355    /// Write a directory that Git accepts as a repository, whose config runs
356    /// `marker` as the fsmonitor hook.
357    #[cfg(unix)]
358    fn write_crafted_gitdir(dir: &Path, marker: &Path) {
359        fs::create_dir_all(dir.join("objects")).expect("create objects");
360        fs::create_dir_all(dir.join("refs")).expect("create refs");
361        fs::write(dir.join("objects/.keep"), "").expect("write objects keep");
362        fs::write(dir.join("refs/.keep"), "").expect("write refs keep");
363        fs::write(dir.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
364        fs::write(
365            dir.join("config"),
366            format!(
367                "[core]\n\trepositoryformatversion = 0\n\tfsmonitor = \"touch '{}'; false\"\n",
368                marker.display()
369            ),
370        )
371        .expect("write config");
372    }
373
374    #[cfg(unix)]
375    fn raw_git_status(cwd: &Path, confine: bool) -> bool {
376        let mut command = Command::new("git");
377        crate::changed_files::clear_ambient_git_env(&mut command);
378        if confine {
379            confine_git_to_host(&mut command, cwd);
380        }
381        command
382            .args(["status", "--porcelain"])
383            .current_dir(cwd)
384            .output()
385            .expect("git command starts")
386            .status
387            .success()
388    }
389
390    /// The base commit is untrusted. A top-level `fallow-audit-unregistered/`
391    /// or a nested directory shaped as a bare repository must never become
392    /// the repository of a `git` command inside the snapshot.
393    #[cfg(unix)]
394    #[test]
395    fn crafted_gitdirs_in_the_base_commit_are_never_read() {
396        let temp = tempfile::tempdir().expect("temp dir");
397        let repo = committed_repo(temp.path());
398        let marker = temp.path().join("fsmonitor-ran");
399        write_crafted_gitdir(&repo.join(UNREGISTERED_GITDIR_NAME), &marker);
400        write_crafted_gitdir(&repo.join("pkg"), &marker);
401        git(&repo, &["add", "-f", "."]);
402        git(&repo, &["commit", "-m", "crafted"]);
403
404        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD", &[])
405            .expect("temporary worktree should be created");
406        let snapshot = worktree.path();
407        assert!(
408            snapshot
409                .join(UNREGISTERED_GITDIR_NAME)
410                .join("config")
411                .is_file()
412        );
413
414        assert!(
415            !raw_git_status(snapshot, false),
416            "Git at the snapshot root must not find a repository"
417        );
418        assert!(!raw_git_status(&snapshot.join("pkg"), true));
419        assert!(!crate::churn::is_git_repo(snapshot));
420        assert!(!crate::churn::is_git_repo(&snapshot.join("pkg")));
421        assert!(
422            crate::changed_files::resolve_git_toplevel(&snapshot.join("pkg")).is_err(),
423            "changed-file probes must not find the nested bare repository"
424        );
425        for dir in [snapshot.to_path_buf(), snapshot.join("pkg")] {
426            let status = crate::git_env::git_command(&dir)
427                .args(["status", "--porcelain"])
428                .output()
429                .expect("git command starts")
430                .status;
431            assert!(
432                !status.success(),
433                "the shared Git wrapper found a repository"
434            );
435        }
436        assert!(
437            !marker.exists(),
438            "a config from the base commit ran a command"
439        );
440    }
441
442    /// The shared Git wrapper also refuses a bare repository that Git would
443    /// find on its own outside a snapshot, and keeps ordinary checkouts.
444    #[cfg(unix)]
445    #[test]
446    fn shared_git_wrapper_refuses_implicit_bare_repositories() {
447        let temp = tempfile::tempdir().expect("temp dir");
448        let repo = committed_repo(temp.path());
449        let marker = temp.path().join("fsmonitor-ran");
450        let bare = temp.path().join("bare");
451        write_crafted_gitdir(&bare, &marker);
452        fs::create_dir_all(repo.join("sub")).expect("create subdirectory");
453
454        let run = |dir: &Path| {
455            crate::git_env::git_command(dir)
456                .args(["status", "--porcelain"])
457                .output()
458                .expect("git command starts")
459                .status
460                .success()
461        };
462
463        assert!(!run(&bare), "an implicit bare repository must be refused");
464        assert!(!marker.exists());
465        assert!(run(&repo), "an ordinary checkout must keep working");
466        assert!(
467            run(&repo.join("sub")),
468            "a checkout subdirectory must keep working"
469        );
470    }
471
472    /// A project whose `.git` is a gitfile into a bare clone (the bare-clone
473    /// worktree layout) keeps working with `safe.bareRepository=explicit`,
474    /// for repository probes and for a base snapshot.
475    #[test]
476    fn bare_clone_worktree_layout_keeps_working() {
477        let temp = tempfile::tempdir().expect("temp dir");
478        let source = committed_repo(temp.path());
479        let bare = temp.path().join("bare.git");
480        let linked = temp.path().join("linked");
481        let parent = temp.path();
482        git(
483            parent,
484            &[
485                "clone",
486                "--quiet",
487                "--bare",
488                source.to_str().expect("UTF-8"),
489                "bare.git",
490            ],
491        );
492        git(
493            &bare,
494            &[
495                "worktree",
496                "add",
497                "--quiet",
498                linked.to_str().expect("UTF-8"),
499                "main",
500            ],
501        );
502        assert!(
503            fs::symlink_metadata(linked.join(".git"))
504                .expect("gitfile")
505                .is_file()
506        );
507
508        let toplevel = crate::git_env::git_command(&linked)
509            .args(["rev-parse", "--show-toplevel"])
510            .output()
511            .expect("git command starts");
512        assert!(toplevel.status.success(), "{toplevel:?}");
513        assert!(crate::churn::is_git_repo(&linked));
514        assert!(crate::changed_files::resolve_git_toplevel(&linked).is_ok());
515
516        let worktree = TemporaryBaseWorktree::create(&linked, "HEAD", &[])
517            .expect("base snapshot from a bare-clone worktree");
518        assert!(worktree.path().join("tracked.txt").is_file());
519        assert_eq!(
520            registered_worktrees(&bare).len(),
521            2,
522            "only the bare clone and its linked worktree stay registered"
523        );
524    }
525
526    /// The stub keeps `.gitignore` handling of the base pass, which needs a
527    /// `.git` entry at the walk root.
528    #[test]
529    fn unregistered_snapshot_keeps_gitignore_handling() {
530        let temp = tempfile::tempdir().expect("temp dir");
531        let repo = committed_repo(temp.path());
532        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD", &[])
533            .expect("temporary worktree should be created");
534        fs::create_dir_all(worktree.path().join("ignored")).expect("create ignored dir");
535        fs::write(worktree.path().join("ignored/skip.ts"), "").expect("write ignored file");
536
537        let walked: Vec<PathBuf> = ignore::WalkBuilder::new(worktree.path())
538            .build()
539            .filter_map(Result::ok)
540            .map(|entry| entry.into_path())
541            .collect();
542
543        assert!(walked.iter().any(|path| path.ends_with("tracked.txt")));
544        assert!(
545            !walked.iter().any(|path| path.ends_with("ignored/skip.ts")),
546            "the base walk must honor .gitignore: {walked:?}"
547        );
548        assert_eq!(
549            enclosing_unregistered_snapshot(&worktree.path().join("ignored")).as_deref(),
550            Some(worktree.path())
551        );
552    }
553}