Skip to main content

fallow_engine/
repo_refs.rs

1//! Engine-owned repository reference probes and temporary repo views.
2
3use std::fs::{self, File, OpenOptions};
4use std::io::{BufRead, BufReader, Read, Write};
5use std::path::{Component, Path, PathBuf};
6use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::time::SystemTime;
9
10use fallow_config::WorkspaceInfo;
11use fallow_types::audit_cache::{
12    AuditContextDirectoryFingerprint, AuditContextFileFingerprint, AuditContextPathState,
13    AuditMaterializedContextFingerprint,
14};
15use fallow_types::source_fingerprint::SourceFingerprint;
16use xxhash_rust::xxh3::xxh3_64;
17
18use crate::{EngineError, EngineResult};
19
20const RAW_MATERIALIZATION_MARKER: &str = "fallow-raw-materialized-v1";
21
22/// Host directories shared with detached audit base views.
23pub const AUDIT_MATERIALIZED_CONTEXT_DIRS: &[&str] = &["node_modules", ".nuxt", ".astro"];
24const AUDIT_WORKSPACE_GENERATED_CONTEXT_DIRS: &[&str] = &[".nuxt", ".astro"];
25
26const AUDIT_LOCKFILES: &[&str] = &[
27    "package-lock.json",
28    "npm-shrinkwrap.json",
29    "pnpm-lock.yaml",
30    "yarn.lock",
31    "bun.lock",
32    "bun.lockb",
33];
34
35const NODE_MODULES_MARKERS: &[&str] = &[".package-lock.json", ".modules.yaml", ".yarn-state.yml"];
36const NUXT_MARKERS: &[&str] = &[
37    "tsconfig.json",
38    "tsconfig.app.json",
39    "imports.d.ts",
40    "components.d.ts",
41    "types/nitro-routes.d.ts",
42    "types/nitro-imports.d.ts",
43];
44const ASTRO_MARKERS: &[&str] = &["types.d.ts", "content.d.ts", "env.d.ts"];
45const AUDIT_CONTEXT_FILE_MAX_BYTES: u64 = 16 * 1024 * 1024;
46const CONTEXT_SYMLINK_STATE: &str = "symlink";
47const CONTEXT_SPECIAL_FILE_STATE: &str = "not_regular_file";
48const CONTEXT_OVERSIZED_FILE_STATE: &str = "file_too_large";
49const CONTEXT_PARENT_UNAVAILABLE_STATE: &str = "parent_context_unavailable";
50const CONTEXT_CHANGED_DURING_READ_STATE: &str = "changed_during_read";
51
52#[cfg(any(target_os = "linux", target_os = "android"))]
53const UNIX_CONTEXT_OPEN_FLAGS: i32 = 0x0002_0800;
54#[cfg(any(
55    target_os = "macos",
56    target_os = "ios",
57    target_os = "freebsd",
58    target_os = "dragonfly",
59    target_os = "openbsd",
60    target_os = "netbsd"
61))]
62const UNIX_CONTEXT_OPEN_FLAGS: i32 = 0x0104;
63
64#[cfg(windows)]
65const WINDOWS_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
66#[cfg(windows)]
67const WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
68
69/// Resolved base ref for changed-code audit.
70#[derive(Debug, Clone)]
71pub struct ResolvedAuditBase {
72    /// Git ref or SHA used for comparison.
73    pub git_ref: String,
74    /// Human-readable source of the resolved ref.
75    pub description: Option<String>,
76}
77
78/// Temporary detached worktree for comparing audit results against a base ref.
79#[derive(Debug)]
80pub struct TemporaryBaseWorktree {
81    repo_root: PathBuf,
82    path: PathBuf,
83}
84
85impl TemporaryBaseWorktree {
86    /// Create a detached base worktree for `base_ref`.
87    ///
88    /// # Errors
89    ///
90    /// Returns an engine error when the temp path cannot be generated, `git`
91    /// cannot be started, or the worktree cannot be created.
92    pub fn create(repo_root: &Path, base_ref: &str) -> EngineResult<Self> {
93        let path = base_worktree_path()?;
94        create_detached_base_worktree(repo_root, &path, base_ref)?;
95        materialize_base_dependency_context(repo_root, &path);
96        Ok(Self {
97            repo_root: repo_root.to_path_buf(),
98            path,
99        })
100    }
101
102    /// Path to the detached worktree.
103    #[must_use]
104    pub fn path(&self) -> &Path {
105        &self.path
106    }
107}
108
109/// Share dependency and generated context from the host checkout with a base view.
110pub fn materialize_base_dependency_context(repo_root: &Path, worktree_path: &Path) {
111    for slot in audit_materialized_context_slots(repo_root) {
112        let Ok(source) = canonical_context_directory(&slot.source) else {
113            continue;
114        };
115
116        if validate_materialized_path(&slot.relative).is_err()
117            || create_safe_parent_directories(worktree_path, &slot.relative).is_err()
118        {
119            continue;
120        }
121        let destination = worktree_path.join(&slot.relative);
122        match fs::symlink_metadata(&destination) {
123            Ok(metadata) if metadata.file_type().is_dir() => continue,
124            Ok(metadata) if metadata.file_type().is_symlink() => {
125                if fs::remove_file(&destination).is_err() {
126                    continue;
127                }
128            }
129            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
130            Ok(_) | Err(_) => continue,
131        }
132
133        let _ = symlink_dependency_dir(&source, &destination);
134    }
135}
136
137/// Build a bounded fingerprint of the host context materialized into a base view.
138#[must_use]
139pub fn audit_materialized_context_fingerprint(root: &Path) -> AuditMaterializedContextFingerprint {
140    let lockfiles = AUDIT_LOCKFILES
141        .iter()
142        .map(|name| fingerprint_context_file(root, &root.join(name)))
143        .collect();
144    let directories = audit_materialized_context_slots(root)
145        .iter()
146        .map(fingerprint_context_directory)
147        .collect();
148    AuditMaterializedContextFingerprint {
149        lockfiles,
150        directories,
151    }
152}
153
154#[derive(Debug)]
155struct AuditMaterializedContextSlot {
156    kind: &'static str,
157    relative: PathBuf,
158    source: PathBuf,
159}
160
161fn audit_materialized_context_slots(root: &Path) -> Vec<AuditMaterializedContextSlot> {
162    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
163    let mut slots = AUDIT_MATERIALIZED_CONTEXT_DIRS
164        .iter()
165        .map(|&kind| AuditMaterializedContextSlot {
166            kind,
167            relative: PathBuf::from(kind),
168            source: canonical_root.join(kind),
169        })
170        .collect::<Vec<_>>();
171
172    for workspace in crate::discover::discover_workspace_packages(root) {
173        let Ok(canonical_workspace) = dunce::canonicalize(&workspace.root) else {
174            continue;
175        };
176        let Ok(relative_workspace) = canonical_workspace.strip_prefix(&canonical_root) else {
177            continue;
178        };
179        if relative_workspace.as_os_str().is_empty() {
180            continue;
181        }
182        for &kind in AUDIT_WORKSPACE_GENERATED_CONTEXT_DIRS {
183            slots.push(AuditMaterializedContextSlot {
184                kind,
185                relative: relative_workspace.join(kind),
186                source: canonical_workspace.join(kind),
187            });
188        }
189    }
190
191    slots.sort_by(|left, right| left.relative.cmp(&right.relative));
192    slots.dedup_by(|left, right| left.relative == right.relative);
193    slots
194}
195
196fn fingerprint_context_directory(
197    slot: &AuditMaterializedContextSlot,
198) -> AuditContextDirectoryFingerprint {
199    let path = &slot.source;
200    let (state, canonical_path, source) = match canonical_context_directory(path) {
201        Ok(canonical_path) => {
202            let source = fs::symlink_metadata(&canonical_path)
203                .ok()
204                .as_ref()
205                .map(SourceFingerprint::from_metadata);
206            (AuditContextPathState::Present, Some(canonical_path), source)
207        }
208        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
209            (AuditContextPathState::Missing, None, None)
210        }
211        Err(error) => (
212            AuditContextPathState::Unreadable(error.kind().to_string()),
213            None,
214            fs::symlink_metadata(path)
215                .ok()
216                .as_ref()
217                .map(SourceFingerprint::from_metadata),
218        ),
219    };
220    let canonical_path_display = canonical_path
221        .as_ref()
222        .map(|path| path.to_string_lossy().replace('\\', "/"));
223    let markers = context_markers(slot.kind)
224        .iter()
225        .map(|marker| {
226            let relative = slot
227                .relative
228                .join(marker)
229                .to_string_lossy()
230                .replace('\\', "/");
231            canonical_path.as_ref().map_or_else(
232                || unavailable_context_file(&relative, &state),
233                |path| fingerprint_context_file_at(&path.join(marker), &relative),
234            )
235        })
236        .collect();
237    AuditContextDirectoryFingerprint {
238        name: slot.relative.to_string_lossy().replace('\\', "/"),
239        state,
240        canonical_path: canonical_path_display,
241        source,
242        markers,
243    }
244}
245
246fn canonical_context_directory(path: &Path) -> std::io::Result<PathBuf> {
247    let canonical_path = dunce::canonicalize(path)?;
248    match fs::symlink_metadata(&canonical_path) {
249        Ok(metadata) if metadata.file_type().is_dir() => Ok(canonical_path),
250        Ok(_) => Err(std::io::Error::new(
251            std::io::ErrorKind::InvalidInput,
252            CONTEXT_SPECIAL_FILE_STATE,
253        )),
254        Err(error) => Err(error),
255    }
256}
257
258fn context_markers(name: &str) -> &'static [&'static str] {
259    match name {
260        "node_modules" => NODE_MODULES_MARKERS,
261        ".nuxt" => NUXT_MARKERS,
262        ".astro" => ASTRO_MARKERS,
263        _ => &[],
264    }
265}
266
267fn fingerprint_context_file(root: &Path, path: &Path) -> AuditContextFileFingerprint {
268    let relative = path
269        .strip_prefix(root)
270        .unwrap_or(path)
271        .to_string_lossy()
272        .replace('\\', "/");
273    fingerprint_context_file_at(path, &relative)
274}
275
276fn fingerprint_context_file_at(path: &Path, relative: &str) -> AuditContextFileFingerprint {
277    fingerprint_context_file_at_with_hooks(path, relative, || {}, || {})
278}
279
280fn fingerprint_context_file_at_with_hooks(
281    path: &Path,
282    relative: &str,
283    before_open: impl FnOnce(),
284    after_read: impl FnOnce(),
285) -> AuditContextFileFingerprint {
286    before_open();
287    let mut file = match open_context_file(path) {
288        Ok(file) => file,
289        Err(error) => return classify_unopened_context_file(path, relative, error.kind()),
290    };
291    let opened_metadata = match file.metadata() {
292        Ok(metadata) if context_handle_is_regular_file(&metadata) => metadata,
293        Ok(metadata) => {
294            return unreadable_context_file(
295                relative,
296                CONTEXT_SPECIAL_FILE_STATE,
297                Some(SourceFingerprint::from_metadata(&metadata)),
298            );
299        }
300        Err(error) => return unreadable_context_file(relative, error.kind().to_string(), None),
301    };
302    let source = Some(SourceFingerprint::from_metadata(&opened_metadata));
303    if opened_metadata.len() > AUDIT_CONTEXT_FILE_MAX_BYTES {
304        return unreadable_context_file(relative, CONTEXT_OVERSIZED_FILE_STATE, source);
305    }
306
307    let capacity = usize::try_from(opened_metadata.len()).unwrap_or(0);
308    let mut bytes = Vec::with_capacity(capacity);
309    let read_limit = AUDIT_CONTEXT_FILE_MAX_BYTES.saturating_add(1);
310    if let Err(error) = (&mut file).take(read_limit).read_to_end(&mut bytes) {
311        return unreadable_context_file(relative, error.kind().to_string(), source);
312    }
313    if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > AUDIT_CONTEXT_FILE_MAX_BYTES {
314        return unreadable_context_file(relative, CONTEXT_OVERSIZED_FILE_STATE, source);
315    }
316    after_read();
317    let final_metadata = match file.metadata() {
318        Ok(metadata) => metadata,
319        Err(error) => return unreadable_context_file(relative, error.kind().to_string(), source),
320    };
321    if SourceFingerprint::from_metadata(&opened_metadata)
322        != SourceFingerprint::from_metadata(&final_metadata)
323    {
324        return unreadable_context_file(relative, CONTEXT_CHANGED_DURING_READ_STATE, source);
325    }
326
327    AuditContextFileFingerprint {
328        path: relative.to_string(),
329        state: AuditContextPathState::Present,
330        source,
331        content_hash: Some(format!("{:016x}", xxh3_64(&bytes))),
332    }
333}
334
335#[expect(
336    clippy::filetype_is_file,
337    reason = "failed atomic opens are classified conservatively without treating arbitrary non-directories as readable files"
338)]
339fn classify_unopened_context_file(
340    path: &Path,
341    relative: &str,
342    open_error: std::io::ErrorKind,
343) -> AuditContextFileFingerprint {
344    match fs::symlink_metadata(path) {
345        Ok(metadata) if metadata.file_type().is_symlink() => unreadable_context_file(
346            relative,
347            CONTEXT_SYMLINK_STATE,
348            Some(SourceFingerprint::from_metadata(&metadata)),
349        ),
350        Ok(metadata) if !metadata.file_type().is_file() => unreadable_context_file(
351            relative,
352            CONTEXT_SPECIAL_FILE_STATE,
353            Some(SourceFingerprint::from_metadata(&metadata)),
354        ),
355        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
356            missing_context_file(relative)
357        }
358        Ok(metadata) => unreadable_context_file(
359            relative,
360            open_error.to_string(),
361            Some(SourceFingerprint::from_metadata(&metadata)),
362        ),
363        Err(_) => unreadable_context_file(relative, open_error.to_string(), None),
364    }
365}
366
367fn open_context_file(path: &Path) -> std::io::Result<File> {
368    let mut options = OpenOptions::new();
369    options.read(true);
370
371    #[cfg(any(
372        target_os = "linux",
373        target_os = "android",
374        target_os = "macos",
375        target_os = "ios",
376        target_os = "freebsd",
377        target_os = "dragonfly",
378        target_os = "openbsd",
379        target_os = "netbsd"
380    ))]
381    {
382        use std::os::unix::fs::OpenOptionsExt as _;
383
384        options.custom_flags(UNIX_CONTEXT_OPEN_FLAGS);
385    }
386    #[cfg(all(
387        unix,
388        not(any(
389            target_os = "linux",
390            target_os = "android",
391            target_os = "macos",
392            target_os = "ios",
393            target_os = "freebsd",
394            target_os = "dragonfly",
395            target_os = "openbsd",
396            target_os = "netbsd"
397        ))
398    ))]
399    {
400        return Err(std::io::Error::new(
401            std::io::ErrorKind::Unsupported,
402            "atomic no-follow context reads are unavailable on this Unix target",
403        ));
404    }
405    #[cfg(windows)]
406    {
407        use std::os::windows::fs::OpenOptionsExt as _;
408
409        options.custom_flags(WINDOWS_FILE_FLAG_OPEN_REPARSE_POINT);
410    }
411
412    options.open(path)
413}
414
415#[cfg(windows)]
416#[expect(
417    clippy::filetype_is_file,
418    reason = "security boundary intentionally accepts regular files only and rejects reparse points, directories, and special files"
419)]
420fn context_handle_is_regular_file(metadata: &fs::Metadata) -> bool {
421    use std::os::windows::fs::MetadataExt as _;
422
423    metadata.file_type().is_file()
424        && metadata.file_attributes() & WINDOWS_FILE_ATTRIBUTE_REPARSE_POINT == 0
425}
426
427#[cfg(not(windows))]
428#[expect(
429    clippy::filetype_is_file,
430    reason = "security boundary intentionally accepts regular files only and rejects directories, sockets, devices, and pipes"
431)]
432fn context_handle_is_regular_file(metadata: &fs::Metadata) -> bool {
433    metadata.file_type().is_file()
434}
435
436fn missing_context_file(relative: &str) -> AuditContextFileFingerprint {
437    AuditContextFileFingerprint {
438        path: relative.to_string(),
439        state: AuditContextPathState::Missing,
440        source: None,
441        content_hash: None,
442    }
443}
444
445fn unreadable_context_file(
446    relative: &str,
447    reason: impl Into<String>,
448    source: Option<SourceFingerprint>,
449) -> AuditContextFileFingerprint {
450    AuditContextFileFingerprint {
451        path: relative.to_string(),
452        state: AuditContextPathState::Unreadable(reason.into()),
453        source,
454        content_hash: None,
455    }
456}
457
458fn unavailable_context_file(
459    relative: &str,
460    parent_state: &AuditContextPathState,
461) -> AuditContextFileFingerprint {
462    if matches!(parent_state, AuditContextPathState::Missing) {
463        missing_context_file(relative)
464    } else {
465        unreadable_context_file(relative, CONTEXT_PARENT_UNAVAILABLE_STATE, None)
466    }
467}
468
469#[cfg(unix)]
470fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
471    std::os::unix::fs::symlink(source, destination)
472}
473
474#[cfg(windows)]
475fn symlink_dependency_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
476    std::os::windows::fs::symlink_dir(source, destination)
477}
478
479/// Register a detached worktree without checking files out, then materialize
480/// the committed tree directly from Git objects.
481///
482/// This deliberately avoids Git's checkout pipeline. No checkout hook,
483/// smudge filter, process filter, line-ending conversion, or working-tree
484/// encoding is invoked. Regular files contain the raw committed blob bytes.
485///
486/// # Errors
487///
488/// Returns an engine error when the destination is not absolute, Git cannot
489/// create the administrative worktree, the tree contains an unsafe path, or
490/// an object cannot be materialized. A failed materialization removes both
491/// the worktree registration and its partial directory.
492pub fn create_detached_base_worktree(
493    repo_root: &Path,
494    destination: &Path,
495    base_ref: &str,
496) -> EngineResult<()> {
497    if !destination.is_absolute() {
498        return Err(EngineError::new(format!(
499            "base worktree destination must be absolute: {}",
500            destination.display()
501        )));
502    }
503
504    register_no_checkout_worktree(repo_root, destination, base_ref)?;
505    let result = make_worktree_root_private(destination)
506        .and_then(|()| resolve_registered_commit(destination, base_ref))
507        .and_then(|commit| {
508            populate_worktree_index(destination, &commit)?;
509            materialize_committed_tree(repo_root, destination, &commit)
510        })
511        .and_then(|()| write_raw_materialization_marker(destination));
512    if let Err(error) = result {
513        remove_registered_worktree(repo_root, destination);
514        let _ = fs::remove_dir_all(destination);
515        return Err(error);
516    }
517    Ok(())
518}
519
520#[cfg(unix)]
521fn make_worktree_root_private(destination: &Path) -> EngineResult<()> {
522    use std::os::unix::fs::PermissionsExt as _;
523
524    fs::set_permissions(destination, fs::Permissions::from_mode(0o700)).map_err(|error| {
525        EngineError::new(format!(
526            "could not make base worktree private at `{}`: {error}",
527            destination.display()
528        ))
529    })
530}
531
532#[cfg(not(unix))]
533#[expect(
534    clippy::unnecessary_wraps,
535    reason = "shared cross-platform signature; Unix applies privacy permissions"
536)]
537fn make_worktree_root_private(_destination: &Path) -> EngineResult<()> {
538    Ok(())
539}
540
541/// Return whether a linked worktree was completely materialized by the raw
542/// object path used by [`create_detached_base_worktree`].
543///
544/// Reusable audit caches created by older versions have no marker and must be
545/// rebuilt once so smudged or checkout-generated contents are not reused.
546#[must_use]
547pub fn detached_base_worktree_is_raw_materialized(worktree_root: &Path) -> bool {
548    raw_materialization_marker_path(worktree_root).is_ok_and(|path| path.is_file())
549}
550
551fn write_raw_materialization_marker(worktree_root: &Path) -> EngineResult<()> {
552    let marker = raw_materialization_marker_path(worktree_root)?;
553    fs::write(&marker, b"raw-v1\n").map_err(|error| {
554        EngineError::new(format!(
555            "could not record raw base-worktree materialization at `{}`: {error}",
556            marker.display()
557        ))
558    })
559}
560
561fn raw_materialization_marker_path(worktree_root: &Path) -> EngineResult<PathBuf> {
562    let marker = run_git(
563        worktree_root,
564        &["rev-parse", "--git-path", RAW_MATERIALIZATION_MARKER],
565    )
566    .ok_or_else(|| EngineError::new("could not resolve base-worktree materialization marker"))?;
567    let marker = PathBuf::from(marker.trim());
568    if marker.is_absolute() {
569        Ok(marker)
570    } else {
571        Ok(worktree_root.join(marker))
572    }
573}
574
575fn register_no_checkout_worktree(
576    repo_root: &Path,
577    destination: &Path,
578    base_ref: &str,
579) -> EngineResult<()> {
580    let mut command = git_command(repo_root);
581    command.args([
582        "worktree",
583        "add",
584        "--detach",
585        "--quiet",
586        "--no-checkout",
587        "--",
588    ]);
589    command.arg(destination).arg(base_ref);
590    let output = command.output().map_err(|error| {
591        EngineError::new(format!(
592            "could not create a temporary worktree for base ref `{base_ref}`: {error}"
593        ))
594    })?;
595    if !output.status.success() {
596        return Err(EngineError::new(format!(
597            "could not create a temporary worktree for base ref `{base_ref}`: {}",
598            String::from_utf8_lossy(&output.stderr).trim()
599        )));
600    }
601    Ok(())
602}
603
604fn resolve_registered_commit(destination: &Path, base_ref: &str) -> EngineResult<String> {
605    run_git(destination, &["rev-parse", "--verify", "HEAD^{commit}"])
606        .map(|commit| commit.trim().to_owned())
607        .ok_or_else(|| {
608            EngineError::new(format!(
609                "could not resolve the commit for base ref `{base_ref}` after creating the worktree"
610            ))
611        })
612}
613
614fn populate_worktree_index(destination: &Path, commit: &str) -> EngineResult<()> {
615    let disabled_hooks_path = destination.join(".fallow-disabled-git-hooks");
616    let output = git_command(destination)
617        .env("GIT_CONFIG_COUNT", "2")
618        .env("GIT_CONFIG_KEY_0", "core.hooksPath")
619        .env("GIT_CONFIG_VALUE_0", disabled_hooks_path)
620        .env("GIT_CONFIG_KEY_1", "core.fsmonitor")
621        .env("GIT_CONFIG_VALUE_1", "false")
622        .args(["read-tree", "--reset", commit])
623        .output()
624        .map_err(|error| {
625            EngineError::new(format!("could not populate base worktree index: {error}"))
626        })?;
627    if !output.status.success() {
628        return Err(EngineError::new(format!(
629            "could not populate base worktree index: {}",
630            String::from_utf8_lossy(&output.stderr).trim()
631        )));
632    }
633    Ok(())
634}
635
636#[derive(Debug, Clone, Copy, PartialEq, Eq)]
637enum TreeEntryKind {
638    Regular,
639    Executable,
640    Symlink,
641    Gitlink,
642}
643
644#[derive(Debug)]
645struct TreeEntry {
646    kind: TreeEntryKind,
647    object_id: String,
648    path: PathBuf,
649}
650
651fn materialize_committed_tree(
652    repo_root: &Path,
653    destination: &Path,
654    commit: &str,
655) -> EngineResult<()> {
656    let entries = committed_tree_entries(repo_root, commit)?;
657    let mut blobs = BatchBlobReader::spawn(repo_root)?;
658    let mut symlinks = Vec::new();
659
660    for entry in entries {
661        create_safe_parent_directories(destination, &entry.path)?;
662        let output_path = destination.join(&entry.path);
663        match entry.kind {
664            TreeEntryKind::Regular | TreeEntryKind::Executable => {
665                let mut file = OpenOptions::new()
666                    .create_new(true)
667                    .write(true)
668                    .open(&output_path)
669                    .map_err(|error| materialization_error(&entry.path, error))?;
670                blobs.copy_blob(&entry.object_id, &entry.path, &mut file)?;
671                set_regular_file_mode(&output_path, entry.kind == TreeEntryKind::Executable)?;
672            }
673            TreeEntryKind::Symlink => {
674                let target = blobs.read_blob(&entry.object_id, &entry.path)?;
675                symlinks.push((entry.path, target));
676            }
677            TreeEntryKind::Gitlink => {
678                fs::create_dir(&output_path)
679                    .map_err(|error| materialization_error(&entry.path, error))?;
680            }
681        }
682    }
683
684    blobs.finish()?;
685    for (path, target) in symlinks {
686        create_safe_parent_directories(destination, &path)?;
687        create_materialized_symlink(&destination.join(&path), &target)
688            .map_err(|error| materialization_error(&path, error))?;
689    }
690    Ok(())
691}
692
693fn committed_tree_entries(repo_root: &Path, commit: &str) -> EngineResult<Vec<TreeEntry>> {
694    let output = git_command(repo_root)
695        .args(["ls-tree", "-r", "-z", "--full-tree", commit])
696        .output()
697        .map_err(|error| EngineError::new(format!("could not read base commit tree: {error}")))?;
698    if !output.status.success() {
699        return Err(EngineError::new(format!(
700            "could not read base commit tree: {}",
701            String::from_utf8_lossy(&output.stderr).trim()
702        )));
703    }
704
705    output
706        .stdout
707        .split(|byte| *byte == 0)
708        .filter(|record| !record.is_empty())
709        .map(parse_tree_entry)
710        .collect()
711}
712
713fn parse_tree_entry(record: &[u8]) -> EngineResult<TreeEntry> {
714    let tab = record
715        .iter()
716        .position(|byte| *byte == b'\t')
717        .ok_or_else(|| EngineError::new("could not parse base commit tree entry without a path"))?;
718    let header = std::str::from_utf8(&record[..tab])
719        .map_err(|error| EngineError::new(format!("invalid Git tree header: {error}")))?;
720    let mut fields = header.split_ascii_whitespace();
721    let mode = fields.next().unwrap_or_default();
722    let object_type = fields.next().unwrap_or_default();
723    let object_id = fields.next().unwrap_or_default();
724    if fields.next().is_some() || object_id.is_empty() {
725        return Err(EngineError::new(format!(
726            "could not parse Git tree header `{header}`"
727        )));
728    }
729    let kind = match (mode, object_type) {
730        ("100644", "blob") => TreeEntryKind::Regular,
731        ("100755", "blob") => TreeEntryKind::Executable,
732        ("120000", "blob") => TreeEntryKind::Symlink,
733        ("160000", "commit") => TreeEntryKind::Gitlink,
734        _ => {
735            return Err(EngineError::new(format!(
736                "unsupported Git tree entry mode `{mode}` and type `{object_type}`"
737            )));
738        }
739    };
740    let path = git_path_from_bytes(&record[tab + 1..])?;
741    validate_materialized_path(&path)?;
742    Ok(TreeEntry {
743        kind,
744        object_id: object_id.to_owned(),
745        path,
746    })
747}
748
749#[cfg(unix)]
750#[expect(
751    clippy::unnecessary_wraps,
752    reason = "shared cross-platform signature; non-Unix path decoding is fallible"
753)]
754fn git_path_from_bytes(bytes: &[u8]) -> EngineResult<PathBuf> {
755    use std::os::unix::ffi::OsStringExt as _;
756
757    Ok(std::ffi::OsString::from_vec(bytes.to_vec()).into())
758}
759
760#[cfg(not(unix))]
761fn git_path_from_bytes(bytes: &[u8]) -> EngineResult<PathBuf> {
762    String::from_utf8(bytes.to_vec())
763        .map(PathBuf::from)
764        .map_err(|error| EngineError::new(format!("Git tree path is not valid UTF-8: {error}")))
765}
766
767fn validate_materialized_path(path: &Path) -> EngineResult<()> {
768    if path.as_os_str().is_empty() || path.is_absolute() {
769        return Err(unsafe_tree_path(path));
770    }
771
772    let mut saw_component = false;
773    for component in path.components() {
774        let Component::Normal(segment) = component else {
775            return Err(unsafe_tree_path(path));
776        };
777        saw_component = true;
778        if segment.to_str().is_some_and(is_git_admin_alias) {
779            return Err(unsafe_tree_path(path));
780        }
781    }
782    if !saw_component {
783        return Err(unsafe_tree_path(path));
784    }
785    Ok(())
786}
787
788fn is_git_admin_alias(segment: &str) -> bool {
789    let normalized = segment.trim_end_matches([' ', '.']).to_ascii_lowercase();
790    normalized == ".git" || normalized == "git~1"
791}
792
793fn unsafe_tree_path(path: &Path) -> EngineError {
794    EngineError::new(format!(
795        "refusing to materialize unsafe Git tree path `{}`",
796        path.display()
797    ))
798}
799
800fn create_safe_parent_directories(root: &Path, relative: &Path) -> EngineResult<()> {
801    let Some(parent) = relative.parent() else {
802        return Ok(());
803    };
804    let mut current = root.to_path_buf();
805    for component in parent.components() {
806        let Component::Normal(segment) = component else {
807            return Err(unsafe_tree_path(relative));
808        };
809        current.push(segment);
810        match fs::symlink_metadata(&current) {
811            Ok(metadata) if metadata.file_type().is_dir() => {}
812            Ok(_) => {
813                return Err(EngineError::new(format!(
814                    "refusing to materialize through non-directory path `{}`",
815                    current.display()
816                )));
817            }
818            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
819                fs::create_dir(&current).map_err(|error| materialization_error(relative, error))?;
820            }
821            Err(error) => return Err(materialization_error(relative, error)),
822        }
823    }
824    Ok(())
825}
826
827fn materialization_error(path: &Path, error: impl std::fmt::Display) -> EngineError {
828    EngineError::new(format!(
829        "could not materialize base commit path `{}`: {error}",
830        path.display()
831    ))
832}
833
834#[cfg(unix)]
835fn set_regular_file_mode(path: &Path, executable: bool) -> EngineResult<()> {
836    use std::os::unix::fs::PermissionsExt as _;
837
838    let mode = if executable { 0o755 } else { 0o644 };
839    let permissions = fs::Permissions::from_mode(mode);
840    fs::set_permissions(path, permissions).map_err(|error| materialization_error(path, error))
841}
842
843#[cfg(not(unix))]
844#[expect(
845    clippy::unnecessary_wraps,
846    reason = "shared cross-platform signature; Unix permission updates are fallible"
847)]
848fn set_regular_file_mode(_path: &Path, _executable: bool) -> EngineResult<()> {
849    Ok(())
850}
851
852#[cfg(unix)]
853fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
854    use std::os::unix::ffi::OsStringExt as _;
855
856    std::os::unix::fs::symlink(std::ffi::OsString::from_vec(target.to_vec()), path)
857}
858
859#[cfg(windows)]
860fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
861    let target_path = PathBuf::from(String::from_utf8_lossy(target).into_owned());
862    let resolved_target = path
863        .parent()
864        .map_or_else(|| target_path.clone(), |parent| parent.join(&target_path));
865    let result = if resolved_target.is_dir() {
866        std::os::windows::fs::symlink_dir(&target_path, path)
867    } else {
868        std::os::windows::fs::symlink_file(&target_path, path)
869    };
870    result.or_else(|_| fs::write(path, target))
871}
872
873#[cfg(not(any(unix, windows)))]
874fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
875    fs::write(path, target)
876}
877
878struct BatchBlobReader {
879    child: Option<Child>,
880    stdin: Option<ChildStdin>,
881    stdout: BufReader<ChildStdout>,
882}
883
884impl BatchBlobReader {
885    fn spawn(repo_root: &Path) -> EngineResult<Self> {
886        let mut command = git_command(repo_root);
887        command
888            .args(["cat-file", "--batch"])
889            .stdin(Stdio::piped())
890            .stdout(Stdio::piped())
891            .stderr(Stdio::null());
892        let mut child = command.spawn().map_err(|error| {
893            EngineError::new(format!("could not start Git object reader: {error}"))
894        })?;
895        let stdin = child
896            .stdin
897            .take()
898            .ok_or_else(|| EngineError::new("Git object reader has no stdin pipe"))?;
899        let stdout = child
900            .stdout
901            .take()
902            .ok_or_else(|| EngineError::new("Git object reader has no stdout pipe"))?;
903        Ok(Self {
904            child: Some(child),
905            stdin: Some(stdin),
906            stdout: BufReader::new(stdout),
907        })
908    }
909
910    fn copy_blob(&mut self, object_id: &str, path: &Path, target: &mut File) -> EngineResult<()> {
911        let size = self.request_blob(object_id, path)?;
912        let copied = std::io::copy(&mut self.stdout.by_ref().take(size), target)
913            .map_err(|error| materialization_error(path, error))?;
914        if copied != size {
915            return Err(EngineError::new(format!(
916                "Git object reader returned {copied} of {size} bytes for `{}`",
917                path.display()
918            )));
919        }
920        self.consume_blob_terminator(path)
921    }
922
923    fn read_blob(&mut self, object_id: &str, path: &Path) -> EngineResult<Vec<u8>> {
924        let size = self.request_blob(object_id, path)?;
925        let size = usize::try_from(size).map_err(|error| materialization_error(path, error))?;
926        let mut bytes = vec![0; size];
927        self.stdout
928            .read_exact(&mut bytes)
929            .map_err(|error| materialization_error(path, error))?;
930        self.consume_blob_terminator(path)?;
931        Ok(bytes)
932    }
933
934    fn request_blob(&mut self, object_id: &str, path: &Path) -> EngineResult<u64> {
935        let stdin = self
936            .stdin
937            .as_mut()
938            .ok_or_else(|| EngineError::new("Git object reader stdin is closed"))?;
939        writeln!(stdin, "{object_id}").map_err(|error| materialization_error(path, error))?;
940        stdin
941            .flush()
942            .map_err(|error| materialization_error(path, error))?;
943
944        let mut header = Vec::new();
945        self.stdout
946            .read_until(b'\n', &mut header)
947            .map_err(|error| materialization_error(path, error))?;
948        let header = std::str::from_utf8(&header)
949            .map_err(|error| materialization_error(path, error))?
950            .trim_end();
951        let mut fields = header.split_ascii_whitespace();
952        let returned_id = fields.next().unwrap_or_default();
953        let object_type = fields.next().unwrap_or_default();
954        let size = fields.next().unwrap_or_default();
955        if returned_id != object_id || object_type != "blob" || fields.next().is_some() {
956            return Err(EngineError::new(format!(
957                "unexpected Git object response `{header}` for `{}`",
958                path.display()
959            )));
960        }
961        size.parse::<u64>()
962            .map_err(|error| materialization_error(path, error))
963    }
964
965    fn consume_blob_terminator(&mut self, path: &Path) -> EngineResult<()> {
966        let mut terminator = [0; 1];
967        self.stdout
968            .read_exact(&mut terminator)
969            .map_err(|error| materialization_error(path, error))?;
970        if terminator != *b"\n" {
971            return Err(EngineError::new(format!(
972                "Git object response for `{}` had no terminator",
973                path.display()
974            )));
975        }
976        Ok(())
977    }
978
979    fn finish(mut self) -> EngineResult<()> {
980        self.stdin.take();
981        let status = self
982            .child
983            .take()
984            .ok_or_else(|| EngineError::new("Git object reader is already closed"))?
985            .wait()
986            .map_err(|error| {
987                EngineError::new(format!("could not wait for Git object reader: {error}"))
988            })?;
989        if !status.success() {
990            return Err(EngineError::new(format!(
991                "Git object reader exited with status {status}"
992            )));
993        }
994        Ok(())
995    }
996}
997
998impl Drop for BatchBlobReader {
999    fn drop(&mut self) {
1000        self.stdin.take();
1001        if let Some(mut child) = self.child.take() {
1002            let _ = child.kill();
1003            let _ = child.wait();
1004        }
1005    }
1006}
1007
1008fn remove_registered_worktree(repo_root: &Path, destination: &Path) {
1009    let _ = git_command(repo_root)
1010        .args(["worktree", "remove", "--force"])
1011        .arg(destination)
1012        .output();
1013}
1014
1015impl Drop for TemporaryBaseWorktree {
1016    fn drop(&mut self) {
1017        let mut command = git_command(&self.repo_root);
1018        command
1019            .arg("worktree")
1020            .arg("remove")
1021            .arg("--force")
1022            .arg(&self.path);
1023        let _ = command.output();
1024        let _ = std::fs::remove_dir_all(&self.path);
1025    }
1026}
1027
1028/// Resolve the analysis root inside a detached base worktree.
1029#[must_use]
1030pub fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
1031    let Some(git_root) = git_toplevel(current_root) else {
1032        return base_worktree_root.to_path_buf();
1033    };
1034    let current_root =
1035        dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
1036    match current_root.strip_prefix(&git_root) {
1037        Ok(relative) => base_worktree_root.join(relative),
1038        Err(_) => base_worktree_root.to_path_buf(),
1039    }
1040}
1041
1042/// Auto-detect the base ref used by changed-code audit.
1043#[must_use]
1044pub fn auto_detect_audit_base_ref(root: &Path) -> Option<ResolvedAuditBase> {
1045    if let Some(upstream) = git_upstream_ref(root) {
1046        if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
1047            return Some(ResolvedAuditBase {
1048                git_ref: sha,
1049                description: Some(format!("merge-base with {upstream}")),
1050            });
1051        }
1052        return Some(ResolvedAuditBase {
1053            description: Some(format!("{upstream} (tip)")),
1054            git_ref: upstream,
1055        });
1056    }
1057
1058    if let Some(remote_ref) = detect_remote_default_ref(root) {
1059        if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
1060            return Some(ResolvedAuditBase {
1061                git_ref: sha,
1062                description: Some(format!("merge-base with {remote_ref}")),
1063            });
1064        }
1065        return Some(ResolvedAuditBase {
1066            description: Some(format!("{remote_ref} (tip)")),
1067            git_ref: remote_ref,
1068        });
1069    }
1070
1071    for candidate in ["main", "master"] {
1072        if git_ref_exists(root, candidate) {
1073            return Some(ResolvedAuditBase {
1074                git_ref: candidate.to_string(),
1075                description: Some(format!("local {candidate}")),
1076            });
1077        }
1078    }
1079
1080    None
1081}
1082
1083/// Short SHA for the current HEAD.
1084#[must_use]
1085pub fn short_head_sha(root: &Path) -> Option<String> {
1086    run_git(root, &["rev-parse", "--short", "HEAD"])
1087        .map(|value| value.trim().to_owned())
1088        .filter(|value| !value.is_empty())
1089}
1090
1091/// Resolve a concrete `--changed-workspaces` ref for project-level next steps.
1092///
1093/// Returns `None` when the project has no workspaces, is not a git repository,
1094/// or has no resolvable remote default branch.
1095#[must_use]
1096pub fn default_workspace_ref(root: &Path) -> Option<String> {
1097    let workspaces = crate::discover::discover_workspace_packages(root);
1098    default_workspace_ref_for_workspaces(root, &workspaces)
1099}
1100
1101/// Resolve a concrete `--changed-workspaces` ref using existing workspace data.
1102#[must_use]
1103pub fn default_workspace_ref_for_workspaces(
1104    root: &Path,
1105    workspaces: &[WorkspaceInfo],
1106) -> Option<String> {
1107    if workspaces.is_empty() || !crate::churn::is_git_repo(root) {
1108        return None;
1109    }
1110    if let Some(reference) = run_git(
1111        root,
1112        &[
1113            "symbolic-ref",
1114            "--quiet",
1115            "--short",
1116            "refs/remotes/origin/HEAD",
1117        ],
1118    ) {
1119        let reference = reference.trim();
1120        if !reference.is_empty() {
1121            return Some(reference.to_owned());
1122        }
1123    }
1124    ["origin/main", "origin/master"]
1125        .into_iter()
1126        .find(|candidate| git_ref_exists(root, candidate))
1127        .map(str::to_owned)
1128}
1129
1130/// Git identities for the current user in forms useful for self-routing.
1131///
1132/// Includes `user.email`, its local-part handle, a GitHub no-reply unwrapped
1133/// handle when applicable, and `user.name`. Missing config values are ignored.
1134#[must_use]
1135pub fn current_user_identities(root: &Path) -> Vec<String> {
1136    let mut ids = Vec::new();
1137    if let Some(email) = read_git_config(root, "user.email") {
1138        if let Some((local, _)) = email.split_once('@') {
1139            ids.push(local.rsplit('+').next().unwrap_or(local).to_owned());
1140        }
1141        ids.push(email);
1142    }
1143    if let Some(name) = read_git_config(root, "user.name") {
1144        ids.push(name);
1145    }
1146    ids
1147}
1148
1149fn read_git_config(root: &Path, key: &str) -> Option<String> {
1150    let value = run_git(root, &["config", "--get", key])?;
1151    let trimmed = value.trim();
1152    (!trimmed.is_empty()).then(|| trimmed.to_owned())
1153}
1154
1155fn git_ref_exists(root: &Path, reference: &str) -> bool {
1156    run_git(root, &["rev-parse", "--verify", "--quiet", reference]).is_some()
1157}
1158
1159fn git_toplevel(root: &Path) -> Option<PathBuf> {
1160    run_git(root, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
1161}
1162
1163fn git_upstream_ref(root: &Path) -> Option<String> {
1164    run_git(
1165        root,
1166        &[
1167            "rev-parse",
1168            "--abbrev-ref",
1169            "--symbolic-full-name",
1170            "@{upstream}",
1171        ],
1172    )
1173}
1174
1175fn git_merge_base(root: &Path, a: &str, b: &str) -> Option<String> {
1176    run_git(root, &["merge-base", a, b])
1177}
1178
1179fn detect_remote_default_ref(root: &Path) -> Option<String> {
1180    if let Some(full_ref) = run_git(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
1181        && let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
1182    {
1183        return Some(format!("origin/{branch}"));
1184    }
1185    ["origin/main", "origin/master"]
1186        .into_iter()
1187        .find(|candidate| git_ref_exists(root, candidate))
1188        .map(str::to_string)
1189}
1190
1191fn base_worktree_path() -> EngineResult<PathBuf> {
1192    let nanos = SystemTime::now()
1193        .duration_since(SystemTime::UNIX_EPOCH)
1194        .map_err(|err| EngineError::new(format!("system clock before unix epoch: {err}")))?
1195        .as_nanos();
1196    Ok(std::env::temp_dir().join(base_worktree_name(nanos)))
1197}
1198
1199/// Compose the directory name for a base worktree taken at clock read `nanos`.
1200///
1201/// The pid stays the FIRST `-`-separated segment so the CLI orphan sweep keeps
1202/// parsing it. A process-global monotonic counter is the final segment: `nanos`
1203/// is NOT monotonic and repeats across threads, so two audits running
1204/// concurrently in one process could otherwise compose the same name and the
1205/// loser's `git worktree add` fails with "already exists". `nanos` is a
1206/// parameter so that collision is reproducible in a test without depending on
1207/// the host clock resolution.
1208fn base_worktree_name(nanos: u128) -> String {
1209    static SEQ: AtomicU64 = AtomicU64::new(0);
1210    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
1211    format!("fallow-audit-base-{}-{nanos}-{seq}", std::process::id())
1212}
1213
1214#[expect(
1215    clippy::disallowed_methods,
1216    reason = "canonical engine-owned git spawn wrapper for repository refs"
1217)]
1218fn git_command(root: &Path) -> Command {
1219    let mut command = Command::new("git");
1220    crate::changed_files::clear_ambient_git_env(&mut command);
1221    // Repository probes never consume input and must not retain an embedder's protocol stdin.
1222    command.stdin(Stdio::null()).arg("-C").arg(root);
1223    command
1224}
1225
1226fn run_git(root: &Path, args: &[&str]) -> Option<String> {
1227    let output = git_command(root).args(args).output().ok()?;
1228    if !output.status.success() {
1229        return None;
1230    }
1231    String::from_utf8(output.stdout).ok()
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236    use std::fs;
1237    use std::path::PathBuf;
1238    use std::process::Command;
1239
1240    use super::*;
1241
1242    fn git(root: &Path, args: &[&str]) -> String {
1243        let output = Command::new("git")
1244            .args(args)
1245            .current_dir(root)
1246            .env_remove("GIT_DIR")
1247            .env_remove("GIT_WORK_TREE")
1248            .output()
1249            .expect("git command starts");
1250        assert!(
1251            output.status.success(),
1252            "git {args:?} failed: {}",
1253            String::from_utf8_lossy(&output.stderr)
1254        );
1255        String::from_utf8_lossy(&output.stdout).trim().to_owned()
1256    }
1257
1258    fn init_repo(root: &Path) {
1259        fs::create_dir_all(root).expect("create repo");
1260        git(root, &["init", "-b", "main"]);
1261        git(root, &["config", "user.name", "Test User"]);
1262        git(root, &["config", "user.email", "test@example.com"]);
1263        git(root, &["config", "commit.gpgsign", "false"]);
1264    }
1265
1266    fn commit_all(root: &Path, message: &str) {
1267        git(root, &["add", "."]);
1268        git(root, &["commit", "-m", message]);
1269    }
1270
1271    #[cfg(unix)]
1272    fn write_executable(path: &Path, source: &str) {
1273        use std::os::unix::fs::PermissionsExt as _;
1274
1275        fs::write(path, source).expect("write executable");
1276        let mut permissions = fs::metadata(path)
1277            .expect("executable metadata")
1278            .permissions();
1279        permissions.set_mode(0o755);
1280        fs::set_permissions(path, permissions).expect("set executable mode");
1281    }
1282
1283    /// Concurrent callers whose clock reads land in the same tick must still
1284    /// each get a distinct name. Before the monotonic counter they composed the
1285    /// identical name, so the second `git worktree add` failed with "already
1286    /// exists" and the audit aborted with `FALLOW_AUDIT_BASE_WORKTREE_FAILED`.
1287    ///
1288    /// The tick is pinned rather than sampled: a real `SystemTime` read is fine
1289    /// enough on most hosts that the collision would surface only as a rare
1290    /// flake, which is exactly the failure this guards.
1291    #[test]
1292    fn base_worktree_names_are_unique_when_the_clock_read_repeats() {
1293        const N: usize = 64;
1294        const SAME_TICK: u128 = 1_788_187_156_297_209_000;
1295
1296        let barrier = std::sync::Barrier::new(N);
1297        let names = std::sync::Mutex::new(Vec::with_capacity(N));
1298        std::thread::scope(|scope| {
1299            for _ in 0..N {
1300                let barrier = &barrier;
1301                let names = &names;
1302                scope.spawn(move || {
1303                    barrier.wait();
1304                    names
1305                        .lock()
1306                        .expect("names lock")
1307                        .push(base_worktree_name(SAME_TICK));
1308                });
1309            }
1310        });
1311
1312        let mut names = names.into_inner().expect("names lock");
1313        assert_eq!(names.len(), N);
1314        names.sort();
1315        names.dedup();
1316        assert_eq!(names.len(), N, "base worktree names collided");
1317    }
1318
1319    /// The pid stays the first segment so the CLI orphan sweep keeps parsing it.
1320    #[test]
1321    fn base_worktree_path_keeps_the_pid_as_the_first_segment() {
1322        let path = base_worktree_path().expect("path should build");
1323        let name = path
1324            .file_name()
1325            .and_then(|name| name.to_str())
1326            .expect("worktree name should be utf-8");
1327        let pid = name
1328            .strip_prefix("fallow-audit-base-")
1329            .and_then(|rest| rest.split('-').next())
1330            .expect("pid segment should be present");
1331        assert_eq!(pid, std::process::id().to_string());
1332    }
1333
1334    #[test]
1335    fn default_workspace_ref_skips_projects_without_workspaces() {
1336        assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[]).is_none());
1337    }
1338
1339    #[test]
1340    fn default_workspace_ref_skips_non_git_workspace_projects() {
1341        let workspace = WorkspaceInfo {
1342            root: PathBuf::from("/repo/packages/app"),
1343            name: "app".to_owned(),
1344            is_internal_dependency: false,
1345        };
1346
1347        assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[workspace]).is_none());
1348    }
1349
1350    #[test]
1351    fn current_user_identities_empty_when_git_config_is_unavailable() {
1352        assert!(current_user_identities(Path::new("/repo")).is_empty());
1353    }
1354
1355    #[test]
1356    fn short_head_sha_omits_git_line_ending() {
1357        let temp = tempfile::tempdir().expect("temp dir");
1358        let repo = temp.path().join("repo");
1359        init_repo(&repo);
1360        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1361        commit_all(&repo, "initial");
1362
1363        let sha = short_head_sha(&repo).expect("HEAD sha");
1364        assert_eq!(sha, sha.trim());
1365        assert!(!sha.is_empty());
1366    }
1367
1368    #[cfg(unix)]
1369    #[test]
1370    fn temporary_base_worktree_does_not_run_post_checkout_hook() {
1371        let temp = tempfile::tempdir().expect("temp dir");
1372        let repo = temp.path().join("repo");
1373        init_repo(&repo);
1374        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1375        commit_all(&repo, "initial");
1376
1377        let sentinel = temp.path().join("post-checkout-ran");
1378        write_executable(
1379            &repo.join(".git/hooks/post-checkout"),
1380            &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
1381        );
1382
1383        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1384            .expect("temporary worktree should be created");
1385
1386        assert_eq!(
1387            fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
1388            "committed\n"
1389        );
1390        assert!(
1391            !sentinel.exists(),
1392            "creating a base view must not execute post-checkout hooks"
1393        );
1394    }
1395
1396    #[cfg(unix)]
1397    #[test]
1398    fn temporary_base_worktree_does_not_run_post_index_change_hook() {
1399        let temp = tempfile::tempdir().expect("temp dir");
1400        let repo = temp.path().join("repo");
1401        init_repo(&repo);
1402        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1403        commit_all(&repo, "initial");
1404
1405        let sentinel = temp.path().join("post-index-change-ran");
1406        write_executable(
1407            &repo.join(".git/hooks/post-index-change"),
1408            &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
1409        );
1410
1411        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1412            .expect("temporary worktree should be created");
1413
1414        assert_eq!(
1415            fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
1416            "committed\n"
1417        );
1418        assert!(
1419            !sentinel.exists(),
1420            "creating a base view must not execute post-index-change hooks"
1421        );
1422    }
1423
1424    #[cfg(unix)]
1425    #[test]
1426    fn temporary_base_worktree_does_not_run_smudge_filter() {
1427        let temp = tempfile::tempdir().expect("temp dir");
1428        let repo = temp.path().join("repo");
1429        init_repo(&repo);
1430        fs::write(
1431            repo.join(".gitattributes"),
1432            "filtered.txt filter=sentinel\n",
1433        )
1434        .expect("write attributes");
1435        fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
1436        commit_all(&repo, "initial");
1437
1438        let sentinel = temp.path().join("smudge-ran");
1439        let filter = temp.path().join("smudge-filter.sh");
1440        write_executable(
1441            &filter,
1442            &format!(
1443                "#!/bin/sh\nprintf ran > '{}'\ncat >/dev/null\nprintf 'smudged bytes\\n'\n",
1444                sentinel.display()
1445            ),
1446        );
1447        git(
1448            &repo,
1449            &[
1450                "config",
1451                "filter.sentinel.smudge",
1452                filter.to_str().expect("filter path is UTF-8"),
1453            ],
1454        );
1455
1456        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1457            .expect("temporary worktree should be created");
1458
1459        assert_eq!(
1460            fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
1461            b"committed raw bytes\n"
1462        );
1463        assert!(
1464            !sentinel.exists(),
1465            "creating a base view must not execute smudge filters"
1466        );
1467    }
1468
1469    #[cfg(unix)]
1470    #[test]
1471    fn temporary_base_worktree_does_not_start_process_filter() {
1472        let temp = tempfile::tempdir().expect("temp dir");
1473        let repo = temp.path().join("repo");
1474        init_repo(&repo);
1475        fs::write(
1476            repo.join(".gitattributes"),
1477            "filtered.txt filter=sentinel\n",
1478        )
1479        .expect("write attributes");
1480        fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
1481        commit_all(&repo, "initial");
1482
1483        let sentinel = temp.path().join("process-filter-ran");
1484        let filter = temp.path().join("process-filter.sh");
1485        write_executable(
1486            &filter,
1487            &format!("#!/bin/sh\nprintf ran > '{}'\nexit 1\n", sentinel.display()),
1488        );
1489        git(
1490            &repo,
1491            &[
1492                "config",
1493                "filter.sentinel.process",
1494                filter.to_str().expect("filter path is UTF-8"),
1495            ],
1496        );
1497
1498        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1499            .expect("temporary worktree should be created");
1500
1501        assert_eq!(
1502            fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
1503            b"committed raw bytes\n"
1504        );
1505        assert!(
1506            !sentinel.exists(),
1507            "creating a base view must not start process filters"
1508        );
1509    }
1510
1511    #[test]
1512    fn failed_registration_does_not_remove_existing_worktree() {
1513        let temp = tempfile::tempdir().expect("temp dir");
1514        let repo = temp.path().join("repo");
1515        init_repo(&repo);
1516        fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1517        commit_all(&repo, "initial");
1518        let destination = temp.path().join("base");
1519
1520        create_detached_base_worktree(&repo, &destination, "HEAD")
1521            .expect("first worktree should be created");
1522        let second = create_detached_base_worktree(&repo, &destination, "HEAD");
1523
1524        assert!(second.is_err(), "duplicate destination must fail");
1525        assert!(
1526            destination.join("tracked.txt").is_file(),
1527            "failed registration must not remove the existing worktree"
1528        );
1529        assert_eq!(git(&destination, &["rev-parse", "HEAD"]).len(), 40);
1530
1531        remove_registered_worktree(&repo, &destination);
1532        let _ = fs::remove_dir_all(destination);
1533    }
1534
1535    #[cfg(unix)]
1536    #[test]
1537    fn temporary_base_worktree_preserves_modes_symlinks_and_gitlinks() {
1538        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
1539
1540        let temp = tempfile::tempdir().expect("temp dir");
1541        let repo = temp.path().join("repo");
1542        init_repo(&repo);
1543        fs::write(repo.join("regular.txt"), "regular\n").expect("write regular file");
1544        let executable = repo.join("run.sh");
1545        fs::write(&executable, "#!/bin/sh\nexit 0\n").expect("write executable");
1546        let mut permissions = fs::metadata(&executable)
1547            .expect("executable metadata")
1548            .permissions();
1549        permissions.set_mode(0o755);
1550        fs::set_permissions(&executable, permissions).expect("set executable mode");
1551        std::os::unix::fs::symlink("regular.txt", repo.join("regular-link"))
1552            .expect("create symlink");
1553        commit_all(&repo, "files");
1554
1555        let gitlink_commit = git(&repo, &["rev-parse", "HEAD"]);
1556        git(
1557            &repo,
1558            &[
1559                "update-index",
1560                "--add",
1561                "--cacheinfo",
1562                &format!("160000,{gitlink_commit},vendor/submodule"),
1563            ],
1564        );
1565        git(&repo, &["commit", "-m", "gitlink"]);
1566
1567        let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1568            .expect("temporary worktree should be created");
1569        let regular_mode = fs::metadata(worktree.path().join("regular.txt"))
1570            .expect("regular metadata")
1571            .mode();
1572        let executable_mode = fs::metadata(worktree.path().join("run.sh"))
1573            .expect("executable metadata")
1574            .mode();
1575
1576        assert_eq!(regular_mode & 0o111, 0);
1577        assert_ne!(executable_mode & 0o111, 0);
1578        assert_eq!(
1579            fs::read_link(worktree.path().join("regular-link")).expect("read symlink"),
1580            PathBuf::from("regular.txt")
1581        );
1582        let gitlink = worktree.path().join("vendor/submodule");
1583        assert!(gitlink.is_dir(), "gitlink must materialize as a directory");
1584        assert!(
1585            fs::read_dir(gitlink)
1586                .expect("read gitlink directory")
1587                .next()
1588                .is_none(),
1589            "an uninitialized gitlink directory must remain empty"
1590        );
1591        assert!(
1592            git(
1593                worktree.path(),
1594                &["ls-files", "--stage", "vendor/submodule"]
1595            )
1596            .starts_with(&format!("160000 {gitlink_commit} 0\t")),
1597            "the linked worktree index must retain the gitlink object id"
1598        );
1599
1600        let path = worktree.path().to_path_buf();
1601        drop(worktree);
1602        assert!(!path.exists(), "temporary worktree must clean up on drop");
1603    }
1604
1605    #[test]
1606    fn materialized_tree_paths_reject_traversal_and_git_admin_aliases() {
1607        for path in [
1608            Path::new("../escape"),
1609            Path::new("/absolute"),
1610            Path::new(".git/config"),
1611            Path::new("nested/.GIT/config"),
1612            Path::new("nested/.git. /config"),
1613            Path::new("nested/git~1/config"),
1614        ] {
1615            assert!(
1616                validate_materialized_path(path).is_err(),
1617                "unsafe path should be rejected: {}",
1618                path.display()
1619            );
1620        }
1621        assert!(validate_materialized_path(Path::new("src/.github/file.ts")).is_ok());
1622    }
1623
1624    #[cfg(unix)]
1625    #[test]
1626    fn parent_directory_creation_refuses_symlink_traversal() {
1627        let temp = tempfile::tempdir().expect("temp dir");
1628        let root = temp.path().join("root");
1629        let outside = temp.path().join("outside");
1630        fs::create_dir(&root).expect("create root");
1631        fs::create_dir(&outside).expect("create outside");
1632        std::os::unix::fs::symlink(&outside, root.join("link")).expect("create parent symlink");
1633
1634        let result = create_safe_parent_directories(&root, Path::new("link/escaped.txt"));
1635
1636        assert!(result.is_err(), "symlink parent must be rejected");
1637        assert!(!outside.join("escaped.txt").exists());
1638    }
1639
1640    #[test]
1641    fn audit_context_fingerprint_tracks_bounded_lockfiles_and_markers() {
1642        let temp = tempfile::tempdir().expect("temp dir");
1643        let root = temp.path();
1644        fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 9\n").expect("lockfile");
1645        fs::create_dir(root.join("node_modules")).expect("node_modules");
1646        fs::write(
1647            root.join("node_modules/.modules.yaml"),
1648            "layoutVersion: 5\n",
1649        )
1650        .expect("node marker");
1651
1652        let first = audit_materialized_context_fingerprint(root);
1653        let unchanged = audit_materialized_context_fingerprint(root);
1654        assert_eq!(
1655            first, unchanged,
1656            "unchanged context must preserve a warm key"
1657        );
1658
1659        fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 10\n").expect("mutate lockfile");
1660        let lock_changed = audit_materialized_context_fingerprint(root);
1661        assert_ne!(
1662            first, lock_changed,
1663            "lockfile content must invalidate the key"
1664        );
1665
1666        fs::write(
1667            root.join("node_modules/.modules.yaml"),
1668            "layoutVersion: 6\n",
1669        )
1670        .expect("mutate node marker");
1671        let marker_changed = audit_materialized_context_fingerprint(root);
1672        assert_ne!(
1673            lock_changed, marker_changed,
1674            "bounded dependency markers must invalidate the key"
1675        );
1676
1677        fs::create_dir(root.join(".nuxt")).expect("nuxt context");
1678        fs::write(root.join(".nuxt/imports.d.ts"), "export {}\n").expect("nuxt marker");
1679        assert_ne!(
1680            marker_changed,
1681            audit_materialized_context_fingerprint(root),
1682            "missing and materialized generated context must differ"
1683        );
1684    }
1685
1686    #[test]
1687    fn audit_context_fingerprint_tracks_nested_workspace_generated_roots() {
1688        let temp = tempfile::tempdir().expect("temp dir");
1689        let root = temp.path();
1690        fs::write(
1691            root.join("package.json"),
1692            r#"{"private":true,"workspaces":["packages/*"]}"#,
1693        )
1694        .expect("root package");
1695        let nuxt = root.join("packages/nuxt-app");
1696        let astro = root.join("packages/astro-app");
1697        fs::create_dir_all(nuxt.join(".nuxt")).expect("nested nuxt context");
1698        fs::create_dir_all(astro.join(".astro")).expect("nested astro context");
1699        fs::write(nuxt.join("package.json"), r#"{"name":"nuxt-app"}"#).expect("nuxt package");
1700        fs::write(astro.join("package.json"), r#"{"name":"astro-app"}"#).expect("astro package");
1701        fs::write(nuxt.join(".nuxt/imports.d.ts"), "export {};\n").expect("nuxt marker");
1702        fs::write(astro.join(".astro/types.d.ts"), "export {};\n").expect("astro marker");
1703
1704        let first = audit_materialized_context_fingerprint(root);
1705        assert!(
1706            first
1707                .directories
1708                .iter()
1709                .any(|directory| directory.name == "packages/nuxt-app/.nuxt")
1710        );
1711        assert!(
1712            first
1713                .directories
1714                .iter()
1715                .any(|directory| directory.name == "packages/astro-app/.astro")
1716        );
1717
1718        fs::write(
1719            nuxt.join(".nuxt/imports.d.ts"),
1720            "export type Changed = true;\n",
1721        )
1722        .expect("mutate nuxt marker");
1723        assert_ne!(
1724            first,
1725            audit_materialized_context_fingerprint(root),
1726            "nested workspace marker changes must invalidate the audit context"
1727        );
1728    }
1729
1730    #[cfg(unix)]
1731    #[test]
1732    fn materialize_base_context_symlinks_nested_workspace_generated_roots() {
1733        let host = tempfile::tempdir().expect("host");
1734        let worktree = tempfile::tempdir().expect("worktree");
1735        fs::write(
1736            host.path().join("package.json"),
1737            r#"{"private":true,"workspaces":["packages/*"]}"#,
1738        )
1739        .expect("root package");
1740
1741        for (workspace, generated, marker) in [
1742            ("nuxt-app", ".nuxt", "imports.d.ts"),
1743            ("astro-app", ".astro", "types.d.ts"),
1744        ] {
1745            let host_workspace = host.path().join("packages").join(workspace);
1746            let worktree_workspace = worktree.path().join("packages").join(workspace);
1747            fs::create_dir_all(host_workspace.join(generated)).expect("host generated context");
1748            fs::create_dir_all(&worktree_workspace).expect("worktree workspace");
1749            fs::write(
1750                host_workspace.join("package.json"),
1751                format!(r#"{{"name":"{workspace}"}}"#),
1752            )
1753            .expect("workspace package");
1754            fs::write(host_workspace.join(generated).join(marker), "export {};\n")
1755                .expect("generated marker");
1756        }
1757
1758        materialize_base_dependency_context(host.path(), worktree.path());
1759
1760        for (workspace, generated, marker) in [
1761            ("nuxt-app", ".nuxt", "imports.d.ts"),
1762            ("astro-app", ".astro", "types.d.ts"),
1763        ] {
1764            let mirrored = worktree
1765                .path()
1766                .join("packages")
1767                .join(workspace)
1768                .join(generated);
1769            assert!(
1770                fs::symlink_metadata(&mirrored)
1771                    .expect("mirrored generated root")
1772                    .file_type()
1773                    .is_symlink(),
1774                "{workspace}/{generated} must reuse the host generated root"
1775            );
1776            assert!(mirrored.join(marker).is_file());
1777        }
1778    }
1779
1780    #[cfg(unix)]
1781    #[test]
1782    fn materialize_base_context_resolves_symlinked_source_directories() {
1783        let host = tempfile::tempdir().expect("host");
1784        let targets = tempfile::tempdir().expect("targets");
1785        let worktree = tempfile::tempdir().expect("worktree");
1786
1787        for (kind, marker) in [
1788            ("node_modules", ".modules.yaml"),
1789            (".nuxt", "imports.d.ts"),
1790            (".astro", "types.d.ts"),
1791        ] {
1792            let target = targets.path().join(kind);
1793            fs::create_dir(&target).expect("source target");
1794            fs::write(target.join(marker), "generated context\n").expect("context marker");
1795            std::os::unix::fs::symlink(&target, host.path().join(kind))
1796                .expect("source directory symlink");
1797        }
1798
1799        materialize_base_dependency_context(host.path(), worktree.path());
1800
1801        let fingerprint = audit_materialized_context_fingerprint(host.path());
1802        for kind in AUDIT_MATERIALIZED_CONTEXT_DIRS {
1803            let target = dunce::canonicalize(targets.path().join(kind)).expect("canonical target");
1804            let mirrored = worktree.path().join(kind);
1805            assert_eq!(
1806                fs::read_link(&mirrored).expect("materialized symlink"),
1807                target,
1808                "{kind} must link directly to the validated canonical target"
1809            );
1810            let directory = fingerprint
1811                .directories
1812                .iter()
1813                .find(|directory| directory.name == *kind)
1814                .expect("fingerprinted context directory");
1815            assert_eq!(directory.state, AuditContextPathState::Present);
1816            assert!(directory.markers.iter().any(|marker| {
1817                matches!(marker.state, AuditContextPathState::Present)
1818                    && marker.content_hash.is_some()
1819            }));
1820        }
1821    }
1822
1823    #[cfg(unix)]
1824    #[test]
1825    fn materialize_base_context_refuses_symlinked_workspace_parent() {
1826        let host = tempfile::tempdir().expect("host");
1827        let worktree = tempfile::tempdir().expect("worktree");
1828        let outside = tempfile::tempdir().expect("outside");
1829        fs::write(
1830            host.path().join("package.json"),
1831            r#"{"private":true,"workspaces":["packages/*"]}"#,
1832        )
1833        .expect("root package");
1834        let host_workspace = host.path().join("packages/app");
1835        fs::create_dir_all(host_workspace.join(".nuxt")).expect("host generated context");
1836        fs::write(host_workspace.join("package.json"), r#"{"name":"app"}"#)
1837            .expect("workspace package");
1838        fs::write(host_workspace.join(".nuxt/imports.d.ts"), "export {};\n")
1839            .expect("generated marker");
1840
1841        let outside_workspace = outside.path().join("app");
1842        fs::create_dir_all(&outside_workspace).expect("outside workspace");
1843        let outside_generated = outside_workspace.join(".nuxt");
1844        std::os::unix::fs::symlink("missing-target", &outside_generated)
1845            .expect("outside sentinel symlink");
1846        std::os::unix::fs::symlink(outside.path(), worktree.path().join("packages"))
1847            .expect("hostile workspace parent symlink");
1848
1849        materialize_base_dependency_context(host.path(), worktree.path());
1850
1851        assert_eq!(
1852            fs::read_link(&outside_generated).expect("sentinel symlink must survive"),
1853            PathBuf::from("missing-target")
1854        );
1855        assert!(
1856            !outside.path().join(".nuxt").exists(),
1857            "materialization must not create generated context outside the worktree"
1858        );
1859    }
1860
1861    #[test]
1862    fn audit_context_fingerprint_rejects_oversized_files_without_reading_them() {
1863        let temp = tempfile::tempdir().expect("temp dir");
1864        let path = temp.path().join("pnpm-lock.yaml");
1865        let file = File::create(&path).expect("oversized file");
1866        file.set_len(AUDIT_CONTEXT_FILE_MAX_BYTES.saturating_add(1))
1867            .expect("set oversized length");
1868
1869        let fingerprint = fingerprint_context_file_at(&path, "pnpm-lock.yaml");
1870
1871        assert_eq!(
1872            fingerprint.state,
1873            AuditContextPathState::Unreadable(CONTEXT_OVERSIZED_FILE_STATE.to_string())
1874        );
1875        assert!(fingerprint.source.is_some());
1876        assert!(fingerprint.content_hash.is_none());
1877    }
1878
1879    #[cfg(unix)]
1880    #[test]
1881    fn audit_context_fingerprint_rejects_symlinked_files_without_following_them() {
1882        let temp = tempfile::tempdir().expect("temp dir");
1883        let target = temp.path().join("target-lock.yaml");
1884        let link = temp.path().join("pnpm-lock.yaml");
1885        fs::write(&target, "secret target contents\n").expect("target file");
1886        std::os::unix::fs::symlink(&target, &link).expect("lockfile symlink");
1887
1888        let fingerprint = fingerprint_context_file_at(&link, "pnpm-lock.yaml");
1889
1890        assert_eq!(
1891            fingerprint.state,
1892            AuditContextPathState::Unreadable(CONTEXT_SYMLINK_STATE.to_string())
1893        );
1894        assert!(fingerprint.content_hash.is_none());
1895    }
1896
1897    #[cfg(unix)]
1898    #[test]
1899    fn audit_context_fingerprint_does_not_follow_symlink_swapped_before_open() {
1900        let temp = tempfile::tempdir().expect("temp dir");
1901        let path = temp.path().join("pnpm-lock.yaml");
1902        let target = temp.path().join("target-lock.yaml");
1903        fs::write(&path, "original contents\n").expect("original file");
1904        fs::write(&target, "secret target contents\n").expect("target file");
1905
1906        let fingerprint = fingerprint_context_file_at_with_hooks(
1907            &path,
1908            "pnpm-lock.yaml",
1909            || {
1910                fs::remove_file(&path).expect("remove original");
1911                std::os::unix::fs::symlink(&target, &path).expect("replacement symlink");
1912            },
1913            || {},
1914        );
1915
1916        assert_eq!(
1917            fingerprint.state,
1918            AuditContextPathState::Unreadable(CONTEXT_SYMLINK_STATE.to_string())
1919        );
1920        assert!(fingerprint.content_hash.is_none());
1921    }
1922
1923    #[test]
1924    fn audit_context_fingerprint_rejects_file_changed_during_read() {
1925        let temp = tempfile::tempdir().expect("temp dir");
1926        let path = temp.path().join("pnpm-lock.yaml");
1927        fs::write(&path, "original contents\n").expect("original file");
1928
1929        let fingerprint = fingerprint_context_file_at_with_hooks(
1930            &path,
1931            "pnpm-lock.yaml",
1932            || {},
1933            || {
1934                OpenOptions::new()
1935                    .write(true)
1936                    .open(&path)
1937                    .expect("open replacement")
1938                    .set_len(1)
1939                    .expect("truncate replacement");
1940            },
1941        );
1942
1943        assert_eq!(
1944            fingerprint.state,
1945            AuditContextPathState::Unreadable(CONTEXT_CHANGED_DURING_READ_STATE.to_string())
1946        );
1947        assert!(fingerprint.content_hash.is_none());
1948    }
1949
1950    #[cfg(unix)]
1951    #[test]
1952    fn unix_context_open_does_not_block_on_fifo() {
1953        let temp = tempfile::tempdir().expect("temp dir");
1954        let fifo = temp.path().join("pnpm-lock.yaml");
1955        let status = Command::new("mkfifo")
1956            .arg(&fifo)
1957            .status()
1958            .expect("run mkfifo");
1959        assert!(status.success(), "mkfifo must create the test pipe");
1960
1961        let fallback_fifo = fifo.clone();
1962        let fallback_writer = std::thread::spawn(move || {
1963            std::thread::sleep(std::time::Duration::from_secs(1));
1964            OpenOptions::new()
1965                .read(true)
1966                .write(true)
1967                .open(fallback_fifo)
1968                .expect("open fallback FIFO writer")
1969        });
1970        let started = std::time::Instant::now();
1971        let fingerprint = fingerprint_context_file_at(&fifo, "pnpm-lock.yaml");
1972        let elapsed = started.elapsed();
1973
1974        assert!(
1975            elapsed < std::time::Duration::from_millis(500),
1976            "nonblocking FIFO open took {elapsed:?}"
1977        );
1978        assert_eq!(
1979            fingerprint.state,
1980            AuditContextPathState::Unreadable(CONTEXT_SPECIAL_FILE_STATE.to_string())
1981        );
1982        assert!(fingerprint.content_hash.is_none());
1983        drop(fallback_writer.join().expect("fallback writer"));
1984    }
1985
1986    #[cfg(unix)]
1987    #[test]
1988    fn audit_context_fingerprint_rejects_special_files_without_opening_them() {
1989        use std::os::unix::net::UnixListener;
1990
1991        let temp = tempfile::tempdir().expect("temp dir");
1992        let socket = temp.path().join("pnpm-lock.yaml");
1993        let _listener = UnixListener::bind(&socket).expect("unix socket");
1994
1995        let fingerprint = fingerprint_context_file_at(&socket, "pnpm-lock.yaml");
1996
1997        assert_eq!(
1998            fingerprint.state,
1999            AuditContextPathState::Unreadable(CONTEXT_SPECIAL_FILE_STATE.to_string())
2000        );
2001        assert!(fingerprint.content_hash.is_none());
2002    }
2003}