1use 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
22pub 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#[derive(Debug, Clone)]
71pub struct ResolvedAuditBase {
72 pub git_ref: String,
74 pub description: Option<String>,
76}
77
78#[derive(Debug)]
80pub struct TemporaryBaseWorktree {
81 repo_root: PathBuf,
82 path: PathBuf,
83}
84
85impl TemporaryBaseWorktree {
86 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 #[must_use]
104 pub fn path(&self) -> &Path {
105 &self.path
106 }
107}
108
109pub 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#[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
479pub 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#[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
651struct MaterializationScope {
675 subdir_prefix: Option<String>,
678 sparse_dirs: Option<Vec<String>>,
681}
682
683impl MaterializationScope {
684 fn should_materialize(&self, path: &Path) -> bool {
685 let Some(relative) = forward_slash_path(path) else {
686 return true;
687 };
688 if let Some(prefix) = self.subdir_prefix.as_deref() {
689 if relative == prefix || relative.starts_with(&format!("{prefix}/")) {
690 return true;
691 }
692 if !relative.contains('/') {
697 return true;
698 }
699 return is_ancestor_ignore_file(&relative, prefix);
700 }
701 if let Some(dirs) = self.sparse_dirs.as_deref() {
702 if !relative.contains('/') {
703 return true;
704 }
705 return dirs
706 .iter()
707 .any(|dir| relative == *dir || relative.starts_with(&format!("{dir}/")));
708 }
709 true
710 }
711}
712
713fn forward_slash_path(path: &Path) -> Option<String> {
717 let raw = path.to_str()?;
718 Some(raw.replace('\\', "/"))
719}
720
721fn is_ancestor_ignore_file(relative: &str, prefix: &str) -> bool {
725 const IGNORE_FILES: &[&str] = &[".gitignore", ".gitattributes"];
726 let Some(file_name) = relative.rsplit('/').next() else {
727 return false;
728 };
729 if !IGNORE_FILES.contains(&file_name) {
730 return false;
731 }
732 let parent = relative.rsplit_once('/').map_or("", |(parent, _)| parent);
733 parent.is_empty() || prefix == parent || prefix.starts_with(&format!("{parent}/"))
734}
735
736fn materialization_scope(repo_root: &Path) -> MaterializationScope {
737 MaterializationScope {
738 subdir_prefix: analysis_subdir_prefix(repo_root),
739 sparse_dirs: sparse_cone_dirs(repo_root),
740 }
741}
742
743fn analysis_subdir_prefix(repo_root: &Path) -> Option<String> {
747 let toplevel = run_git(repo_root, &["rev-parse", "--show-toplevel"])?;
748 let toplevel = PathBuf::from(toplevel.trim());
749 let canonical_toplevel = dunce::canonicalize(&toplevel).unwrap_or(toplevel);
750 let canonical_root = dunce::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
751 let relative = canonical_root.strip_prefix(&canonical_toplevel).ok()?;
752 if relative.as_os_str().is_empty() {
753 return None;
754 }
755 let prefix = forward_slash_path(relative)?;
756 if prefix.is_empty() {
757 return None;
758 }
759 Some(prefix)
760}
761
762fn sparse_cone_dirs(repo_root: &Path) -> Option<Vec<String>> {
769 if run_git(repo_root, &["config", "--get", "core.sparseCheckout"])?.trim() != "true" {
770 return None;
771 }
772 if run_git(repo_root, &["config", "--get", "core.sparseCheckoutCone"])?.trim() != "true" {
773 return None;
774 }
775 let output = git_command(repo_root)
776 .args(["sparse-checkout", "list"])
777 .output()
778 .ok()?;
779 if !output.status.success() {
780 return None;
781 }
782 let list = String::from_utf8(output.stdout).ok()?;
783 let mut dirs = Vec::new();
784 for line in list.lines() {
785 let pattern = line.trim().trim_matches('/');
786 if pattern.is_empty() {
787 continue;
788 }
789 if pattern.contains(['*', '?', '[', '!']) {
792 return None;
793 }
794 dirs.push(pattern.replace('\\', "/"));
795 }
796 Some(dirs)
797}
798
799fn materialize_committed_tree(
800 repo_root: &Path,
801 destination: &Path,
802 commit: &str,
803) -> EngineResult<()> {
804 let entries = committed_tree_entries(repo_root, commit)?;
805 let scope = materialization_scope(repo_root);
806 let entries: Vec<TreeEntry> = entries
807 .into_iter()
808 .filter(|entry| scope.should_materialize(&entry.path))
809 .collect();
810 let mut blobs = BatchBlobReader::spawn(repo_root)?;
811 let mut symlinks = Vec::new();
812
813 for entry in entries {
814 create_safe_parent_directories(destination, &entry.path)?;
815 let output_path = destination.join(&entry.path);
816 match entry.kind {
817 TreeEntryKind::Regular | TreeEntryKind::Executable => {
818 let mut file = OpenOptions::new()
819 .create_new(true)
820 .write(true)
821 .open(&output_path)
822 .map_err(|error| materialization_error(&entry.path, error))?;
823 blobs.copy_blob(&entry.object_id, &entry.path, &mut file)?;
824 set_regular_file_mode(&output_path, entry.kind == TreeEntryKind::Executable)?;
825 }
826 TreeEntryKind::Symlink => {
827 let target = blobs.read_blob(&entry.object_id, &entry.path)?;
828 symlinks.push((entry.path, target));
829 }
830 TreeEntryKind::Gitlink => {
831 fs::create_dir(&output_path)
832 .map_err(|error| materialization_error(&entry.path, error))?;
833 }
834 }
835 }
836
837 blobs.finish()?;
838 for (path, target) in symlinks {
839 create_safe_parent_directories(destination, &path)?;
840 create_materialized_symlink(&destination.join(&path), &target)
841 .map_err(|error| materialization_error(&path, error))?;
842 }
843 Ok(())
844}
845
846fn committed_tree_entries(repo_root: &Path, commit: &str) -> EngineResult<Vec<TreeEntry>> {
847 let output = git_command(repo_root)
848 .args(["ls-tree", "-r", "-z", "--full-tree", commit])
849 .output()
850 .map_err(|error| EngineError::new(format!("could not read base commit tree: {error}")))?;
851 if !output.status.success() {
852 return Err(EngineError::new(format!(
853 "could not read base commit tree: {}",
854 String::from_utf8_lossy(&output.stderr).trim()
855 )));
856 }
857
858 output
859 .stdout
860 .split(|byte| *byte == 0)
861 .filter(|record| !record.is_empty())
862 .map(parse_tree_entry)
863 .collect()
864}
865
866fn parse_tree_entry(record: &[u8]) -> EngineResult<TreeEntry> {
867 let tab = record
868 .iter()
869 .position(|byte| *byte == b'\t')
870 .ok_or_else(|| EngineError::new("could not parse base commit tree entry without a path"))?;
871 let header = std::str::from_utf8(&record[..tab])
872 .map_err(|error| EngineError::new(format!("invalid Git tree header: {error}")))?;
873 let mut fields = header.split_ascii_whitespace();
874 let mode = fields.next().unwrap_or_default();
875 let object_type = fields.next().unwrap_or_default();
876 let object_id = fields.next().unwrap_or_default();
877 if fields.next().is_some() || object_id.is_empty() {
878 return Err(EngineError::new(format!(
879 "could not parse Git tree header `{header}`"
880 )));
881 }
882 let kind = match (mode, object_type) {
883 ("100644", "blob") => TreeEntryKind::Regular,
884 ("100755", "blob") => TreeEntryKind::Executable,
885 ("120000", "blob") => TreeEntryKind::Symlink,
886 ("160000", "commit") => TreeEntryKind::Gitlink,
887 _ => {
888 return Err(EngineError::new(format!(
889 "unsupported Git tree entry mode `{mode}` and type `{object_type}`"
890 )));
891 }
892 };
893 let path = git_path_from_bytes(&record[tab + 1..])?;
894 validate_materialized_path(&path)?;
895 Ok(TreeEntry {
896 kind,
897 object_id: object_id.to_owned(),
898 path,
899 })
900}
901
902#[cfg(unix)]
903#[expect(
904 clippy::unnecessary_wraps,
905 reason = "shared cross-platform signature; non-Unix path decoding is fallible"
906)]
907fn git_path_from_bytes(bytes: &[u8]) -> EngineResult<PathBuf> {
908 use std::os::unix::ffi::OsStringExt as _;
909
910 Ok(std::ffi::OsString::from_vec(bytes.to_vec()).into())
911}
912
913#[cfg(not(unix))]
914fn git_path_from_bytes(bytes: &[u8]) -> EngineResult<PathBuf> {
915 String::from_utf8(bytes.to_vec())
916 .map(PathBuf::from)
917 .map_err(|error| EngineError::new(format!("Git tree path is not valid UTF-8: {error}")))
918}
919
920fn validate_materialized_path(path: &Path) -> EngineResult<()> {
921 if path.as_os_str().is_empty() || path.is_absolute() {
922 return Err(unsafe_tree_path(path));
923 }
924
925 let mut saw_component = false;
926 for component in path.components() {
927 let Component::Normal(segment) = component else {
928 return Err(unsafe_tree_path(path));
929 };
930 saw_component = true;
931 if segment.to_str().is_some_and(is_git_admin_alias) {
932 return Err(unsafe_tree_path(path));
933 }
934 }
935 if !saw_component {
936 return Err(unsafe_tree_path(path));
937 }
938 Ok(())
939}
940
941fn is_git_admin_alias(segment: &str) -> bool {
942 let normalized = segment.trim_end_matches([' ', '.']).to_ascii_lowercase();
943 normalized == ".git" || normalized == "git~1"
944}
945
946fn unsafe_tree_path(path: &Path) -> EngineError {
947 EngineError::new(format!(
948 "refusing to materialize unsafe Git tree path `{}`",
949 path.display()
950 ))
951}
952
953fn create_safe_parent_directories(root: &Path, relative: &Path) -> EngineResult<()> {
954 let Some(parent) = relative.parent() else {
955 return Ok(());
956 };
957 let mut current = root.to_path_buf();
958 for component in parent.components() {
959 let Component::Normal(segment) = component else {
960 return Err(unsafe_tree_path(relative));
961 };
962 current.push(segment);
963 match fs::symlink_metadata(¤t) {
964 Ok(metadata) if metadata.file_type().is_dir() => {}
965 Ok(_) => {
966 return Err(EngineError::new(format!(
967 "refusing to materialize through non-directory path `{}`",
968 current.display()
969 )));
970 }
971 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
972 fs::create_dir(¤t).map_err(|error| materialization_error(relative, error))?;
973 }
974 Err(error) => return Err(materialization_error(relative, error)),
975 }
976 }
977 Ok(())
978}
979
980fn materialization_error(path: &Path, error: impl std::fmt::Display) -> EngineError {
981 EngineError::new(format!(
982 "could not materialize base commit path `{}`: {error}",
983 path.display()
984 ))
985}
986
987#[cfg(unix)]
988fn set_regular_file_mode(path: &Path, executable: bool) -> EngineResult<()> {
989 use std::os::unix::fs::PermissionsExt as _;
990
991 let mode = if executable { 0o755 } else { 0o644 };
992 let permissions = fs::Permissions::from_mode(mode);
993 fs::set_permissions(path, permissions).map_err(|error| materialization_error(path, error))
994}
995
996#[cfg(not(unix))]
997#[expect(
998 clippy::unnecessary_wraps,
999 reason = "shared cross-platform signature; Unix permission updates are fallible"
1000)]
1001fn set_regular_file_mode(_path: &Path, _executable: bool) -> EngineResult<()> {
1002 Ok(())
1003}
1004
1005#[cfg(unix)]
1006fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
1007 use std::os::unix::ffi::OsStringExt as _;
1008
1009 std::os::unix::fs::symlink(std::ffi::OsString::from_vec(target.to_vec()), path)
1010}
1011
1012#[cfg(windows)]
1013fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
1014 let target_path = PathBuf::from(String::from_utf8_lossy(target).into_owned());
1015 let resolved_target = path
1016 .parent()
1017 .map_or_else(|| target_path.clone(), |parent| parent.join(&target_path));
1018 let result = if resolved_target.is_dir() {
1019 std::os::windows::fs::symlink_dir(&target_path, path)
1020 } else {
1021 std::os::windows::fs::symlink_file(&target_path, path)
1022 };
1023 result.or_else(|_| fs::write(path, target))
1024}
1025
1026#[cfg(not(any(unix, windows)))]
1027fn create_materialized_symlink(path: &Path, target: &[u8]) -> std::io::Result<()> {
1028 fs::write(path, target)
1029}
1030
1031struct BatchBlobReader {
1032 child: Option<Child>,
1033 stdin: Option<ChildStdin>,
1034 stdout: BufReader<ChildStdout>,
1035}
1036
1037impl BatchBlobReader {
1038 fn spawn(repo_root: &Path) -> EngineResult<Self> {
1039 let mut command = git_command(repo_root);
1040 command
1041 .args(["cat-file", "--batch"])
1042 .stdin(Stdio::piped())
1043 .stdout(Stdio::piped())
1044 .stderr(Stdio::null());
1045 let mut child = command.spawn().map_err(|error| {
1046 EngineError::new(format!("could not start Git object reader: {error}"))
1047 })?;
1048 let stdin = child
1049 .stdin
1050 .take()
1051 .ok_or_else(|| EngineError::new("Git object reader has no stdin pipe"))?;
1052 let stdout = child
1053 .stdout
1054 .take()
1055 .ok_or_else(|| EngineError::new("Git object reader has no stdout pipe"))?;
1056 Ok(Self {
1057 child: Some(child),
1058 stdin: Some(stdin),
1059 stdout: BufReader::new(stdout),
1060 })
1061 }
1062
1063 fn copy_blob(&mut self, object_id: &str, path: &Path, target: &mut File) -> EngineResult<()> {
1064 let size = self.request_blob(object_id, path)?;
1065 let copied = std::io::copy(&mut self.stdout.by_ref().take(size), target)
1066 .map_err(|error| materialization_error(path, error))?;
1067 if copied != size {
1068 return Err(EngineError::new(format!(
1069 "Git object reader returned {copied} of {size} bytes for `{}`",
1070 path.display()
1071 )));
1072 }
1073 self.consume_blob_terminator(path)
1074 }
1075
1076 fn read_blob(&mut self, object_id: &str, path: &Path) -> EngineResult<Vec<u8>> {
1077 let size = self.request_blob(object_id, path)?;
1078 let size = usize::try_from(size).map_err(|error| materialization_error(path, error))?;
1079 let mut bytes = vec![0; size];
1080 self.stdout
1081 .read_exact(&mut bytes)
1082 .map_err(|error| materialization_error(path, error))?;
1083 self.consume_blob_terminator(path)?;
1084 Ok(bytes)
1085 }
1086
1087 fn request_blob(&mut self, object_id: &str, path: &Path) -> EngineResult<u64> {
1088 let stdin = self
1089 .stdin
1090 .as_mut()
1091 .ok_or_else(|| EngineError::new("Git object reader stdin is closed"))?;
1092 writeln!(stdin, "{object_id}").map_err(|error| materialization_error(path, error))?;
1093 stdin
1094 .flush()
1095 .map_err(|error| materialization_error(path, error))?;
1096
1097 let mut header = Vec::new();
1098 self.stdout
1099 .read_until(b'\n', &mut header)
1100 .map_err(|error| materialization_error(path, error))?;
1101 let header = std::str::from_utf8(&header)
1102 .map_err(|error| materialization_error(path, error))?
1103 .trim_end();
1104 let mut fields = header.split_ascii_whitespace();
1105 let returned_id = fields.next().unwrap_or_default();
1106 let object_type = fields.next().unwrap_or_default();
1107 let size = fields.next().unwrap_or_default();
1108 if returned_id != object_id || object_type != "blob" || fields.next().is_some() {
1109 return Err(EngineError::new(format!(
1110 "unexpected Git object response `{header}` for `{}`",
1111 path.display()
1112 )));
1113 }
1114 size.parse::<u64>()
1115 .map_err(|error| materialization_error(path, error))
1116 }
1117
1118 fn consume_blob_terminator(&mut self, path: &Path) -> EngineResult<()> {
1119 let mut terminator = [0; 1];
1120 self.stdout
1121 .read_exact(&mut terminator)
1122 .map_err(|error| materialization_error(path, error))?;
1123 if terminator != *b"\n" {
1124 return Err(EngineError::new(format!(
1125 "Git object response for `{}` had no terminator",
1126 path.display()
1127 )));
1128 }
1129 Ok(())
1130 }
1131
1132 fn finish(mut self) -> EngineResult<()> {
1133 self.stdin.take();
1134 let status = self
1135 .child
1136 .take()
1137 .ok_or_else(|| EngineError::new("Git object reader is already closed"))?
1138 .wait()
1139 .map_err(|error| {
1140 EngineError::new(format!("could not wait for Git object reader: {error}"))
1141 })?;
1142 if !status.success() {
1143 return Err(EngineError::new(format!(
1144 "Git object reader exited with status {status}"
1145 )));
1146 }
1147 Ok(())
1148 }
1149}
1150
1151impl Drop for BatchBlobReader {
1152 fn drop(&mut self) {
1153 self.stdin.take();
1154 if let Some(mut child) = self.child.take() {
1155 let _ = child.kill();
1156 let _ = child.wait();
1157 }
1158 }
1159}
1160
1161fn remove_registered_worktree(repo_root: &Path, destination: &Path) {
1162 let _ = git_command(repo_root)
1163 .args(["worktree", "remove", "--force"])
1164 .arg(destination)
1165 .output();
1166}
1167
1168impl Drop for TemporaryBaseWorktree {
1169 fn drop(&mut self) {
1170 let mut command = git_command(&self.repo_root);
1171 command
1172 .arg("worktree")
1173 .arg("remove")
1174 .arg("--force")
1175 .arg(&self.path);
1176 let _ = command.output();
1177 let _ = std::fs::remove_dir_all(&self.path);
1178 }
1179}
1180
1181#[must_use]
1183pub fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf {
1184 let Some(git_root) = git_toplevel(current_root) else {
1185 return base_worktree_root.to_path_buf();
1186 };
1187 let current_root =
1188 dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf());
1189 match current_root.strip_prefix(&git_root) {
1190 Ok(relative) => base_worktree_root.join(relative),
1191 Err(_) => base_worktree_root.to_path_buf(),
1192 }
1193}
1194
1195#[must_use]
1197pub fn auto_detect_audit_base_ref(root: &Path) -> Option<ResolvedAuditBase> {
1198 if let Some(upstream) = git_upstream_ref(root) {
1199 if let Some(sha) = git_merge_base(root, &upstream, "HEAD") {
1200 return Some(ResolvedAuditBase {
1201 git_ref: sha,
1202 description: Some(format!("merge-base with {upstream}")),
1203 });
1204 }
1205 return Some(ResolvedAuditBase {
1206 description: Some(format!("{upstream} (tip)")),
1207 git_ref: upstream,
1208 });
1209 }
1210
1211 if let Some(remote_ref) = detect_remote_default_ref(root) {
1212 if let Some(sha) = git_merge_base(root, &remote_ref, "HEAD") {
1213 return Some(ResolvedAuditBase {
1214 git_ref: sha,
1215 description: Some(format!("merge-base with {remote_ref}")),
1216 });
1217 }
1218 return Some(ResolvedAuditBase {
1219 description: Some(format!("{remote_ref} (tip)")),
1220 git_ref: remote_ref,
1221 });
1222 }
1223
1224 for candidate in ["main", "master"] {
1225 if git_ref_exists(root, candidate) {
1226 return Some(ResolvedAuditBase {
1227 git_ref: candidate.to_string(),
1228 description: Some(format!("local {candidate}")),
1229 });
1230 }
1231 }
1232
1233 None
1234}
1235
1236#[must_use]
1238pub fn short_head_sha(root: &Path) -> Option<String> {
1239 run_git(root, &["rev-parse", "--short", "HEAD"])
1240 .map(|value| value.trim().to_owned())
1241 .filter(|value| !value.is_empty())
1242}
1243
1244#[must_use]
1249pub fn default_workspace_ref(root: &Path) -> Option<String> {
1250 let workspaces = crate::discover::discover_workspace_packages(root);
1251 default_workspace_ref_for_workspaces(root, &workspaces)
1252}
1253
1254#[must_use]
1256pub fn default_workspace_ref_for_workspaces(
1257 root: &Path,
1258 workspaces: &[WorkspaceInfo],
1259) -> Option<String> {
1260 if workspaces.is_empty() || !crate::churn::is_git_repo(root) {
1261 return None;
1262 }
1263 if let Some(reference) = run_git(
1264 root,
1265 &[
1266 "symbolic-ref",
1267 "--quiet",
1268 "--short",
1269 "refs/remotes/origin/HEAD",
1270 ],
1271 ) {
1272 let reference = reference.trim();
1273 if !reference.is_empty() {
1274 return Some(reference.to_owned());
1275 }
1276 }
1277 ["origin/main", "origin/master"]
1278 .into_iter()
1279 .find(|candidate| git_ref_exists(root, candidate))
1280 .map(str::to_owned)
1281}
1282
1283#[must_use]
1288pub fn current_user_identities(root: &Path) -> Vec<String> {
1289 let mut ids = Vec::new();
1290 if let Some(email) = read_git_config(root, "user.email") {
1291 if let Some((local, _)) = email.split_once('@') {
1292 ids.push(local.rsplit('+').next().unwrap_or(local).to_owned());
1293 }
1294 ids.push(email);
1295 }
1296 if let Some(name) = read_git_config(root, "user.name") {
1297 ids.push(name);
1298 }
1299 ids
1300}
1301
1302fn read_git_config(root: &Path, key: &str) -> Option<String> {
1303 let value = run_git(root, &["config", "--get", key])?;
1304 let trimmed = value.trim();
1305 (!trimmed.is_empty()).then(|| trimmed.to_owned())
1306}
1307
1308fn git_ref_exists(root: &Path, reference: &str) -> bool {
1309 run_git(root, &["rev-parse", "--verify", "--quiet", reference]).is_some()
1310}
1311
1312fn git_toplevel(root: &Path) -> Option<PathBuf> {
1313 run_git(root, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
1314}
1315
1316fn git_upstream_ref(root: &Path) -> Option<String> {
1317 run_git(
1318 root,
1319 &[
1320 "rev-parse",
1321 "--abbrev-ref",
1322 "--symbolic-full-name",
1323 "@{upstream}",
1324 ],
1325 )
1326}
1327
1328fn git_merge_base(root: &Path, a: &str, b: &str) -> Option<String> {
1329 run_git(root, &["merge-base", a, b])
1330}
1331
1332fn detect_remote_default_ref(root: &Path) -> Option<String> {
1333 if let Some(full_ref) = run_git(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
1334 && let Some(branch) = full_ref.strip_prefix("refs/remotes/origin/")
1335 {
1336 return Some(format!("origin/{branch}"));
1337 }
1338 ["origin/main", "origin/master"]
1339 .into_iter()
1340 .find(|candidate| git_ref_exists(root, candidate))
1341 .map(str::to_string)
1342}
1343
1344fn base_worktree_path() -> EngineResult<PathBuf> {
1345 let nanos = SystemTime::now()
1346 .duration_since(SystemTime::UNIX_EPOCH)
1347 .map_err(|err| EngineError::new(format!("system clock before unix epoch: {err}")))?
1348 .as_nanos();
1349 Ok(std::env::temp_dir().join(base_worktree_name(nanos)))
1350}
1351
1352fn base_worktree_name(nanos: u128) -> String {
1362 static SEQ: AtomicU64 = AtomicU64::new(0);
1363 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
1364 format!("fallow-audit-base-{}-{nanos}-{seq}", std::process::id())
1365}
1366
1367#[expect(
1368 clippy::disallowed_methods,
1369 reason = "canonical engine-owned git spawn wrapper for repository refs"
1370)]
1371fn git_command(root: &Path) -> Command {
1372 let mut command = Command::new("git");
1373 crate::changed_files::clear_ambient_git_env(&mut command);
1374 command.stdin(Stdio::null()).arg("-C").arg(root);
1376 command
1377}
1378
1379fn run_git(root: &Path, args: &[&str]) -> Option<String> {
1380 let output = git_command(root).args(args).output().ok()?;
1381 if !output.status.success() {
1382 return None;
1383 }
1384 String::from_utf8(output.stdout).ok()
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389 use std::fs;
1390 use std::path::PathBuf;
1391 use std::process::Command;
1392
1393 use super::*;
1394
1395 fn git(root: &Path, args: &[&str]) -> String {
1396 let output = Command::new("git")
1397 .args(args)
1398 .current_dir(root)
1399 .env_remove("GIT_DIR")
1400 .env_remove("GIT_WORK_TREE")
1401 .output()
1402 .expect("git command starts");
1403 assert!(
1404 output.status.success(),
1405 "git {args:?} failed: {}",
1406 String::from_utf8_lossy(&output.stderr)
1407 );
1408 String::from_utf8_lossy(&output.stdout).trim().to_owned()
1409 }
1410
1411 fn init_repo(root: &Path) {
1412 fs::create_dir_all(root).expect("create repo");
1413 git(root, &["init", "-b", "main"]);
1414 git(root, &["config", "user.name", "Test User"]);
1415 git(root, &["config", "user.email", "test@example.com"]);
1416 git(root, &["config", "commit.gpgsign", "false"]);
1417 }
1418
1419 fn commit_all(root: &Path, message: &str) {
1420 git(root, &["add", "."]);
1421 git(root, &["commit", "-m", message]);
1422 }
1423
1424 #[cfg(unix)]
1425 fn write_executable(path: &Path, source: &str) {
1426 use std::os::unix::fs::PermissionsExt as _;
1427
1428 fs::write(path, source).expect("write executable");
1429 let mut permissions = fs::metadata(path)
1430 .expect("executable metadata")
1431 .permissions();
1432 permissions.set_mode(0o755);
1433 fs::set_permissions(path, permissions).expect("set executable mode");
1434 }
1435
1436 #[test]
1445 fn base_worktree_names_are_unique_when_the_clock_read_repeats() {
1446 const N: usize = 64;
1447 const SAME_TICK: u128 = 1_788_187_156_297_209_000;
1448
1449 let barrier = std::sync::Barrier::new(N);
1450 let names = std::sync::Mutex::new(Vec::with_capacity(N));
1451 std::thread::scope(|scope| {
1452 for _ in 0..N {
1453 let barrier = &barrier;
1454 let names = &names;
1455 scope.spawn(move || {
1456 barrier.wait();
1457 names
1458 .lock()
1459 .expect("names lock")
1460 .push(base_worktree_name(SAME_TICK));
1461 });
1462 }
1463 });
1464
1465 let mut names = names.into_inner().expect("names lock");
1466 assert_eq!(names.len(), N);
1467 names.sort();
1468 names.dedup();
1469 assert_eq!(names.len(), N, "base worktree names collided");
1470 }
1471
1472 #[test]
1474 fn base_worktree_path_keeps_the_pid_as_the_first_segment() {
1475 let path = base_worktree_path().expect("path should build");
1476 let name = path
1477 .file_name()
1478 .and_then(|name| name.to_str())
1479 .expect("worktree name should be utf-8");
1480 let pid = name
1481 .strip_prefix("fallow-audit-base-")
1482 .and_then(|rest| rest.split('-').next())
1483 .expect("pid segment should be present");
1484 assert_eq!(pid, std::process::id().to_string());
1485 }
1486
1487 #[test]
1493 fn detached_worktree_from_a_subdir_skips_sibling_subtrees() {
1494 let temp = tempfile::tempdir().expect("temp dir");
1495 let repo = temp.path().join("repo");
1496 init_repo(&repo);
1497 fs::create_dir_all(repo.join("sub")).expect("create sub dir");
1498 fs::create_dir_all(repo.join("big")).expect("create big dir");
1499 fs::write(repo.join("sub/a.ts"), "export const a = 1;\n").expect("write sub file");
1500 fs::write(repo.join("big/b.ts"), "export const b = 1;\n").expect("write big file");
1501 fs::write(repo.join("top.ts"), "export const top = 1;\n").expect("write top file");
1502 commit_all(&repo, "initial");
1503
1504 let destination = temp.path().join("base");
1505 create_detached_base_worktree(&repo.join("sub"), &destination, "HEAD")
1506 .expect("base worktree should be created");
1507
1508 assert!(
1509 destination.join("sub/a.ts").is_file(),
1510 "the requested subtree must be materialized"
1511 );
1512 assert!(
1513 destination.join("top.ts").is_file(),
1514 "top-level files shape subdir discovery and stay materialized"
1515 );
1516 assert!(
1517 !destination.join("big/b.ts").exists(),
1518 "sibling subtrees must not be materialized: {}",
1519 destination.join("big/b.ts").display()
1520 );
1521
1522 remove_registered_worktree(&repo, &destination);
1523 let _ = fs::remove_dir_all(&destination);
1524 }
1525
1526 #[test]
1531 fn detached_worktree_at_the_root_respects_the_sparse_cone() {
1532 let temp = tempfile::tempdir().expect("temp dir");
1533 let repo = temp.path().join("repo");
1534 init_repo(&repo);
1535 fs::create_dir_all(repo.join("sub")).expect("create sub dir");
1536 fs::create_dir_all(repo.join("big")).expect("create big dir");
1537 fs::write(repo.join("sub/a.ts"), "export const a = 1;\n").expect("write sub file");
1538 fs::write(repo.join("big/b.ts"), "export const b = 1;\n").expect("write big file");
1539 commit_all(&repo, "initial");
1540 git(&repo, &["sparse-checkout", "init", "--cone"]);
1541 git(&repo, &["sparse-checkout", "set", "sub"]);
1542
1543 let destination = temp.path().join("base");
1544 create_detached_base_worktree(&repo, &destination, "HEAD")
1545 .expect("base worktree should be created");
1546
1547 assert!(
1548 destination.join("sub/a.ts").is_file(),
1549 "the sparse cone must be materialized"
1550 );
1551 assert!(
1552 !destination.join("big/b.ts").exists(),
1553 "paths outside the sparse cone must not be materialized: {}",
1554 destination.join("big/b.ts").display()
1555 );
1556
1557 remove_registered_worktree(&repo, &destination);
1558 let _ = fs::remove_dir_all(&destination);
1559 }
1560
1561 #[test]
1565 fn materialization_scope_filters_to_the_needed_working_set() {
1566 let subdir = MaterializationScope {
1567 subdir_prefix: Some("apps/web".to_string()),
1568 sparse_dirs: None,
1569 };
1570 assert!(subdir.should_materialize(Path::new("apps/web/a.ts")));
1571 assert!(subdir.should_materialize(Path::new("top.ts")));
1572 assert!(subdir.should_materialize(Path::new(".gitignore")));
1573 assert!(subdir.should_materialize(Path::new("apps/.gitignore")));
1574 assert!(!subdir.should_materialize(Path::new("apps/other/b.ts")));
1575 assert!(!subdir.should_materialize(Path::new("apps/.gitignore.bak")));
1576
1577 let sparse = MaterializationScope {
1578 subdir_prefix: None,
1579 sparse_dirs: Some(vec!["apps/web".to_string()]),
1580 };
1581 assert!(sparse.should_materialize(Path::new("apps/web/a.ts")));
1582 assert!(sparse.should_materialize(Path::new("top.ts")));
1583 assert!(!sparse.should_materialize(Path::new("apps/other/b.ts")));
1584
1585 let full = MaterializationScope {
1586 subdir_prefix: None,
1587 sparse_dirs: None,
1588 };
1589 assert!(full.should_materialize(Path::new("apps/other/b.ts")));
1590 }
1591
1592 #[test]
1593 fn default_workspace_ref_skips_projects_without_workspaces() {
1594 assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[]).is_none());
1595 }
1596
1597 #[test]
1598 fn default_workspace_ref_skips_non_git_workspace_projects() {
1599 let workspace = WorkspaceInfo {
1600 root: PathBuf::from("/repo/packages/app"),
1601 name: "app".to_owned(),
1602 is_internal_dependency: false,
1603 };
1604
1605 assert!(default_workspace_ref_for_workspaces(Path::new("/repo"), &[workspace]).is_none());
1606 }
1607
1608 #[test]
1609 fn current_user_identities_empty_when_git_config_is_unavailable() {
1610 assert!(current_user_identities(Path::new("/repo")).is_empty());
1611 }
1612
1613 #[test]
1614 fn short_head_sha_omits_git_line_ending() {
1615 let temp = tempfile::tempdir().expect("temp dir");
1616 let repo = temp.path().join("repo");
1617 init_repo(&repo);
1618 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1619 commit_all(&repo, "initial");
1620
1621 let sha = short_head_sha(&repo).expect("HEAD sha");
1622 assert_eq!(sha, sha.trim());
1623 assert!(!sha.is_empty());
1624 }
1625
1626 #[cfg(unix)]
1627 #[test]
1628 fn temporary_base_worktree_does_not_run_post_checkout_hook() {
1629 let temp = tempfile::tempdir().expect("temp dir");
1630 let repo = temp.path().join("repo");
1631 init_repo(&repo);
1632 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1633 commit_all(&repo, "initial");
1634
1635 let sentinel = temp.path().join("post-checkout-ran");
1636 write_executable(
1637 &repo.join(".git/hooks/post-checkout"),
1638 &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
1639 );
1640
1641 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1642 .expect("temporary worktree should be created");
1643
1644 assert_eq!(
1645 fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
1646 "committed\n"
1647 );
1648 assert!(
1649 !sentinel.exists(),
1650 "creating a base view must not execute post-checkout hooks"
1651 );
1652 }
1653
1654 #[cfg(unix)]
1655 #[test]
1656 fn temporary_base_worktree_does_not_run_post_index_change_hook() {
1657 let temp = tempfile::tempdir().expect("temp dir");
1658 let repo = temp.path().join("repo");
1659 init_repo(&repo);
1660 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1661 commit_all(&repo, "initial");
1662
1663 let sentinel = temp.path().join("post-index-change-ran");
1664 write_executable(
1665 &repo.join(".git/hooks/post-index-change"),
1666 &format!("#!/bin/sh\nprintf ran > '{}'\n", sentinel.display()),
1667 );
1668
1669 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1670 .expect("temporary worktree should be created");
1671
1672 assert_eq!(
1673 fs::read_to_string(worktree.path().join("tracked.txt")).expect("read tracked file"),
1674 "committed\n"
1675 );
1676 assert!(
1677 !sentinel.exists(),
1678 "creating a base view must not execute post-index-change hooks"
1679 );
1680 }
1681
1682 #[cfg(unix)]
1683 #[test]
1684 fn temporary_base_worktree_does_not_run_smudge_filter() {
1685 let temp = tempfile::tempdir().expect("temp dir");
1686 let repo = temp.path().join("repo");
1687 init_repo(&repo);
1688 fs::write(
1689 repo.join(".gitattributes"),
1690 "filtered.txt filter=sentinel\n",
1691 )
1692 .expect("write attributes");
1693 fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
1694 commit_all(&repo, "initial");
1695
1696 let sentinel = temp.path().join("smudge-ran");
1697 let filter = temp.path().join("smudge-filter.sh");
1698 write_executable(
1699 &filter,
1700 &format!(
1701 "#!/bin/sh\nprintf ran > '{}'\ncat >/dev/null\nprintf 'smudged bytes\\n'\n",
1702 sentinel.display()
1703 ),
1704 );
1705 git(
1706 &repo,
1707 &[
1708 "config",
1709 "filter.sentinel.smudge",
1710 filter.to_str().expect("filter path is UTF-8"),
1711 ],
1712 );
1713
1714 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1715 .expect("temporary worktree should be created");
1716
1717 assert_eq!(
1718 fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
1719 b"committed raw bytes\n"
1720 );
1721 assert!(
1722 !sentinel.exists(),
1723 "creating a base view must not execute smudge filters"
1724 );
1725 }
1726
1727 #[cfg(unix)]
1728 #[test]
1729 fn temporary_base_worktree_does_not_start_process_filter() {
1730 let temp = tempfile::tempdir().expect("temp dir");
1731 let repo = temp.path().join("repo");
1732 init_repo(&repo);
1733 fs::write(
1734 repo.join(".gitattributes"),
1735 "filtered.txt filter=sentinel\n",
1736 )
1737 .expect("write attributes");
1738 fs::write(repo.join("filtered.txt"), "committed raw bytes\n").expect("write filtered file");
1739 commit_all(&repo, "initial");
1740
1741 let sentinel = temp.path().join("process-filter-ran");
1742 let filter = temp.path().join("process-filter.sh");
1743 write_executable(
1744 &filter,
1745 &format!("#!/bin/sh\nprintf ran > '{}'\nexit 1\n", sentinel.display()),
1746 );
1747 git(
1748 &repo,
1749 &[
1750 "config",
1751 "filter.sentinel.process",
1752 filter.to_str().expect("filter path is UTF-8"),
1753 ],
1754 );
1755
1756 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1757 .expect("temporary worktree should be created");
1758
1759 assert_eq!(
1760 fs::read(worktree.path().join("filtered.txt")).expect("read filtered file"),
1761 b"committed raw bytes\n"
1762 );
1763 assert!(
1764 !sentinel.exists(),
1765 "creating a base view must not start process filters"
1766 );
1767 }
1768
1769 #[test]
1770 fn failed_registration_does_not_remove_existing_worktree() {
1771 let temp = tempfile::tempdir().expect("temp dir");
1772 let repo = temp.path().join("repo");
1773 init_repo(&repo);
1774 fs::write(repo.join("tracked.txt"), "committed\n").expect("write tracked file");
1775 commit_all(&repo, "initial");
1776 let destination = temp.path().join("base");
1777
1778 create_detached_base_worktree(&repo, &destination, "HEAD")
1779 .expect("first worktree should be created");
1780 let second = create_detached_base_worktree(&repo, &destination, "HEAD");
1781
1782 assert!(second.is_err(), "duplicate destination must fail");
1783 assert!(
1784 destination.join("tracked.txt").is_file(),
1785 "failed registration must not remove the existing worktree"
1786 );
1787 assert_eq!(git(&destination, &["rev-parse", "HEAD"]).len(), 40);
1788
1789 remove_registered_worktree(&repo, &destination);
1790 let _ = fs::remove_dir_all(destination);
1791 }
1792
1793 #[cfg(unix)]
1794 #[test]
1795 fn temporary_base_worktree_preserves_modes_symlinks_and_gitlinks() {
1796 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
1797
1798 let temp = tempfile::tempdir().expect("temp dir");
1799 let repo = temp.path().join("repo");
1800 init_repo(&repo);
1801 fs::write(repo.join("regular.txt"), "regular\n").expect("write regular file");
1802 let executable = repo.join("run.sh");
1803 fs::write(&executable, "#!/bin/sh\nexit 0\n").expect("write executable");
1804 let mut permissions = fs::metadata(&executable)
1805 .expect("executable metadata")
1806 .permissions();
1807 permissions.set_mode(0o755);
1808 fs::set_permissions(&executable, permissions).expect("set executable mode");
1809 std::os::unix::fs::symlink("regular.txt", repo.join("regular-link"))
1810 .expect("create symlink");
1811 commit_all(&repo, "files");
1812
1813 let gitlink_commit = git(&repo, &["rev-parse", "HEAD"]);
1814 git(
1815 &repo,
1816 &[
1817 "update-index",
1818 "--add",
1819 "--cacheinfo",
1820 &format!("160000,{gitlink_commit},vendor/submodule"),
1821 ],
1822 );
1823 git(&repo, &["commit", "-m", "gitlink"]);
1824
1825 let worktree = TemporaryBaseWorktree::create(&repo, "HEAD")
1826 .expect("temporary worktree should be created");
1827 let regular_mode = fs::metadata(worktree.path().join("regular.txt"))
1828 .expect("regular metadata")
1829 .mode();
1830 let executable_mode = fs::metadata(worktree.path().join("run.sh"))
1831 .expect("executable metadata")
1832 .mode();
1833
1834 assert_eq!(regular_mode & 0o111, 0);
1835 assert_ne!(executable_mode & 0o111, 0);
1836 assert_eq!(
1837 fs::read_link(worktree.path().join("regular-link")).expect("read symlink"),
1838 PathBuf::from("regular.txt")
1839 );
1840 let gitlink = worktree.path().join("vendor/submodule");
1841 assert!(gitlink.is_dir(), "gitlink must materialize as a directory");
1842 assert!(
1843 fs::read_dir(gitlink)
1844 .expect("read gitlink directory")
1845 .next()
1846 .is_none(),
1847 "an uninitialized gitlink directory must remain empty"
1848 );
1849 assert!(
1850 git(
1851 worktree.path(),
1852 &["ls-files", "--stage", "vendor/submodule"]
1853 )
1854 .starts_with(&format!("160000 {gitlink_commit} 0\t")),
1855 "the linked worktree index must retain the gitlink object id"
1856 );
1857
1858 let path = worktree.path().to_path_buf();
1859 drop(worktree);
1860 assert!(!path.exists(), "temporary worktree must clean up on drop");
1861 }
1862
1863 #[test]
1864 fn materialized_tree_paths_reject_traversal_and_git_admin_aliases() {
1865 for path in [
1866 Path::new("../escape"),
1867 Path::new("/absolute"),
1868 Path::new(".git/config"),
1869 Path::new("nested/.GIT/config"),
1870 Path::new("nested/.git. /config"),
1871 Path::new("nested/git~1/config"),
1872 ] {
1873 assert!(
1874 validate_materialized_path(path).is_err(),
1875 "unsafe path should be rejected: {}",
1876 path.display()
1877 );
1878 }
1879 assert!(validate_materialized_path(Path::new("src/.github/file.ts")).is_ok());
1880 }
1881
1882 #[cfg(unix)]
1883 #[test]
1884 fn parent_directory_creation_refuses_symlink_traversal() {
1885 let temp = tempfile::tempdir().expect("temp dir");
1886 let root = temp.path().join("root");
1887 let outside = temp.path().join("outside");
1888 fs::create_dir(&root).expect("create root");
1889 fs::create_dir(&outside).expect("create outside");
1890 std::os::unix::fs::symlink(&outside, root.join("link")).expect("create parent symlink");
1891
1892 let result = create_safe_parent_directories(&root, Path::new("link/escaped.txt"));
1893
1894 assert!(result.is_err(), "symlink parent must be rejected");
1895 assert!(!outside.join("escaped.txt").exists());
1896 }
1897
1898 #[test]
1899 fn audit_context_fingerprint_tracks_bounded_lockfiles_and_markers() {
1900 let temp = tempfile::tempdir().expect("temp dir");
1901 let root = temp.path();
1902 fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 9\n").expect("lockfile");
1903 fs::create_dir(root.join("node_modules")).expect("node_modules");
1904 fs::write(
1905 root.join("node_modules/.modules.yaml"),
1906 "layoutVersion: 5\n",
1907 )
1908 .expect("node marker");
1909
1910 let first = audit_materialized_context_fingerprint(root);
1911 let unchanged = audit_materialized_context_fingerprint(root);
1912 assert_eq!(
1913 first, unchanged,
1914 "unchanged context must preserve a warm key"
1915 );
1916
1917 fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 10\n").expect("mutate lockfile");
1918 let lock_changed = audit_materialized_context_fingerprint(root);
1919 assert_ne!(
1920 first, lock_changed,
1921 "lockfile content must invalidate the key"
1922 );
1923
1924 fs::write(
1925 root.join("node_modules/.modules.yaml"),
1926 "layoutVersion: 6\n",
1927 )
1928 .expect("mutate node marker");
1929 let marker_changed = audit_materialized_context_fingerprint(root);
1930 assert_ne!(
1931 lock_changed, marker_changed,
1932 "bounded dependency markers must invalidate the key"
1933 );
1934
1935 fs::create_dir(root.join(".nuxt")).expect("nuxt context");
1936 fs::write(root.join(".nuxt/imports.d.ts"), "export {}\n").expect("nuxt marker");
1937 assert_ne!(
1938 marker_changed,
1939 audit_materialized_context_fingerprint(root),
1940 "missing and materialized generated context must differ"
1941 );
1942 }
1943
1944 #[test]
1945 fn audit_context_fingerprint_tracks_nested_workspace_generated_roots() {
1946 let temp = tempfile::tempdir().expect("temp dir");
1947 let root = temp.path();
1948 fs::write(
1949 root.join("package.json"),
1950 r#"{"private":true,"workspaces":["packages/*"]}"#,
1951 )
1952 .expect("root package");
1953 let nuxt = root.join("packages/nuxt-app");
1954 let astro = root.join("packages/astro-app");
1955 fs::create_dir_all(nuxt.join(".nuxt")).expect("nested nuxt context");
1956 fs::create_dir_all(astro.join(".astro")).expect("nested astro context");
1957 fs::write(nuxt.join("package.json"), r#"{"name":"nuxt-app"}"#).expect("nuxt package");
1958 fs::write(astro.join("package.json"), r#"{"name":"astro-app"}"#).expect("astro package");
1959 fs::write(nuxt.join(".nuxt/imports.d.ts"), "export {};\n").expect("nuxt marker");
1960 fs::write(astro.join(".astro/types.d.ts"), "export {};\n").expect("astro marker");
1961
1962 let first = audit_materialized_context_fingerprint(root);
1963 assert!(
1964 first
1965 .directories
1966 .iter()
1967 .any(|directory| directory.name == "packages/nuxt-app/.nuxt")
1968 );
1969 assert!(
1970 first
1971 .directories
1972 .iter()
1973 .any(|directory| directory.name == "packages/astro-app/.astro")
1974 );
1975
1976 fs::write(
1977 nuxt.join(".nuxt/imports.d.ts"),
1978 "export type Changed = true;\n",
1979 )
1980 .expect("mutate nuxt marker");
1981 assert_ne!(
1982 first,
1983 audit_materialized_context_fingerprint(root),
1984 "nested workspace marker changes must invalidate the audit context"
1985 );
1986 }
1987
1988 #[cfg(unix)]
1989 #[test]
1990 fn materialize_base_context_symlinks_nested_workspace_generated_roots() {
1991 let host = tempfile::tempdir().expect("host");
1992 let worktree = tempfile::tempdir().expect("worktree");
1993 fs::write(
1994 host.path().join("package.json"),
1995 r#"{"private":true,"workspaces":["packages/*"]}"#,
1996 )
1997 .expect("root package");
1998
1999 for (workspace, generated, marker) in [
2000 ("nuxt-app", ".nuxt", "imports.d.ts"),
2001 ("astro-app", ".astro", "types.d.ts"),
2002 ] {
2003 let host_workspace = host.path().join("packages").join(workspace);
2004 let worktree_workspace = worktree.path().join("packages").join(workspace);
2005 fs::create_dir_all(host_workspace.join(generated)).expect("host generated context");
2006 fs::create_dir_all(&worktree_workspace).expect("worktree workspace");
2007 fs::write(
2008 host_workspace.join("package.json"),
2009 format!(r#"{{"name":"{workspace}"}}"#),
2010 )
2011 .expect("workspace package");
2012 fs::write(host_workspace.join(generated).join(marker), "export {};\n")
2013 .expect("generated marker");
2014 }
2015
2016 materialize_base_dependency_context(host.path(), worktree.path());
2017
2018 for (workspace, generated, marker) in [
2019 ("nuxt-app", ".nuxt", "imports.d.ts"),
2020 ("astro-app", ".astro", "types.d.ts"),
2021 ] {
2022 let mirrored = worktree
2023 .path()
2024 .join("packages")
2025 .join(workspace)
2026 .join(generated);
2027 assert!(
2028 fs::symlink_metadata(&mirrored)
2029 .expect("mirrored generated root")
2030 .file_type()
2031 .is_symlink(),
2032 "{workspace}/{generated} must reuse the host generated root"
2033 );
2034 assert!(mirrored.join(marker).is_file());
2035 }
2036 }
2037
2038 #[cfg(unix)]
2039 #[test]
2040 fn materialize_base_context_resolves_symlinked_source_directories() {
2041 let host = tempfile::tempdir().expect("host");
2042 let targets = tempfile::tempdir().expect("targets");
2043 let worktree = tempfile::tempdir().expect("worktree");
2044
2045 for (kind, marker) in [
2046 ("node_modules", ".modules.yaml"),
2047 (".nuxt", "imports.d.ts"),
2048 (".astro", "types.d.ts"),
2049 ] {
2050 let target = targets.path().join(kind);
2051 fs::create_dir(&target).expect("source target");
2052 fs::write(target.join(marker), "generated context\n").expect("context marker");
2053 std::os::unix::fs::symlink(&target, host.path().join(kind))
2054 .expect("source directory symlink");
2055 }
2056
2057 materialize_base_dependency_context(host.path(), worktree.path());
2058
2059 let fingerprint = audit_materialized_context_fingerprint(host.path());
2060 for kind in AUDIT_MATERIALIZED_CONTEXT_DIRS {
2061 let target = dunce::canonicalize(targets.path().join(kind)).expect("canonical target");
2062 let mirrored = worktree.path().join(kind);
2063 assert_eq!(
2064 fs::read_link(&mirrored).expect("materialized symlink"),
2065 target,
2066 "{kind} must link directly to the validated canonical target"
2067 );
2068 let directory = fingerprint
2069 .directories
2070 .iter()
2071 .find(|directory| directory.name == *kind)
2072 .expect("fingerprinted context directory");
2073 assert_eq!(directory.state, AuditContextPathState::Present);
2074 assert!(directory.markers.iter().any(|marker| {
2075 matches!(marker.state, AuditContextPathState::Present)
2076 && marker.content_hash.is_some()
2077 }));
2078 }
2079 }
2080
2081 #[cfg(unix)]
2082 #[test]
2083 fn materialize_base_context_refuses_symlinked_workspace_parent() {
2084 let host = tempfile::tempdir().expect("host");
2085 let worktree = tempfile::tempdir().expect("worktree");
2086 let outside = tempfile::tempdir().expect("outside");
2087 fs::write(
2088 host.path().join("package.json"),
2089 r#"{"private":true,"workspaces":["packages/*"]}"#,
2090 )
2091 .expect("root package");
2092 let host_workspace = host.path().join("packages/app");
2093 fs::create_dir_all(host_workspace.join(".nuxt")).expect("host generated context");
2094 fs::write(host_workspace.join("package.json"), r#"{"name":"app"}"#)
2095 .expect("workspace package");
2096 fs::write(host_workspace.join(".nuxt/imports.d.ts"), "export {};\n")
2097 .expect("generated marker");
2098
2099 let outside_workspace = outside.path().join("app");
2100 fs::create_dir_all(&outside_workspace).expect("outside workspace");
2101 let outside_generated = outside_workspace.join(".nuxt");
2102 std::os::unix::fs::symlink("missing-target", &outside_generated)
2103 .expect("outside sentinel symlink");
2104 std::os::unix::fs::symlink(outside.path(), worktree.path().join("packages"))
2105 .expect("hostile workspace parent symlink");
2106
2107 materialize_base_dependency_context(host.path(), worktree.path());
2108
2109 assert_eq!(
2110 fs::read_link(&outside_generated).expect("sentinel symlink must survive"),
2111 PathBuf::from("missing-target")
2112 );
2113 assert!(
2114 !outside.path().join(".nuxt").exists(),
2115 "materialization must not create generated context outside the worktree"
2116 );
2117 }
2118
2119 #[test]
2120 fn audit_context_fingerprint_rejects_oversized_files_without_reading_them() {
2121 let temp = tempfile::tempdir().expect("temp dir");
2122 let path = temp.path().join("pnpm-lock.yaml");
2123 let file = File::create(&path).expect("oversized file");
2124 file.set_len(AUDIT_CONTEXT_FILE_MAX_BYTES.saturating_add(1))
2125 .expect("set oversized length");
2126
2127 let fingerprint = fingerprint_context_file_at(&path, "pnpm-lock.yaml");
2128
2129 assert_eq!(
2130 fingerprint.state,
2131 AuditContextPathState::Unreadable(CONTEXT_OVERSIZED_FILE_STATE.to_string())
2132 );
2133 assert!(fingerprint.source.is_some());
2134 assert!(fingerprint.content_hash.is_none());
2135 }
2136
2137 #[cfg(unix)]
2138 #[test]
2139 fn audit_context_fingerprint_rejects_symlinked_files_without_following_them() {
2140 let temp = tempfile::tempdir().expect("temp dir");
2141 let target = temp.path().join("target-lock.yaml");
2142 let link = temp.path().join("pnpm-lock.yaml");
2143 fs::write(&target, "secret target contents\n").expect("target file");
2144 std::os::unix::fs::symlink(&target, &link).expect("lockfile symlink");
2145
2146 let fingerprint = fingerprint_context_file_at(&link, "pnpm-lock.yaml");
2147
2148 assert_eq!(
2149 fingerprint.state,
2150 AuditContextPathState::Unreadable(CONTEXT_SYMLINK_STATE.to_string())
2151 );
2152 assert!(fingerprint.content_hash.is_none());
2153 }
2154
2155 #[cfg(unix)]
2156 #[test]
2157 fn audit_context_fingerprint_does_not_follow_symlink_swapped_before_open() {
2158 let temp = tempfile::tempdir().expect("temp dir");
2159 let path = temp.path().join("pnpm-lock.yaml");
2160 let target = temp.path().join("target-lock.yaml");
2161 fs::write(&path, "original contents\n").expect("original file");
2162 fs::write(&target, "secret target contents\n").expect("target file");
2163
2164 let fingerprint = fingerprint_context_file_at_with_hooks(
2165 &path,
2166 "pnpm-lock.yaml",
2167 || {
2168 fs::remove_file(&path).expect("remove original");
2169 std::os::unix::fs::symlink(&target, &path).expect("replacement symlink");
2170 },
2171 || {},
2172 );
2173
2174 assert_eq!(
2175 fingerprint.state,
2176 AuditContextPathState::Unreadable(CONTEXT_SYMLINK_STATE.to_string())
2177 );
2178 assert!(fingerprint.content_hash.is_none());
2179 }
2180
2181 #[test]
2182 fn audit_context_fingerprint_rejects_file_changed_during_read() {
2183 let temp = tempfile::tempdir().expect("temp dir");
2184 let path = temp.path().join("pnpm-lock.yaml");
2185 fs::write(&path, "original contents\n").expect("original file");
2186
2187 let fingerprint = fingerprint_context_file_at_with_hooks(
2188 &path,
2189 "pnpm-lock.yaml",
2190 || {},
2191 || {
2192 OpenOptions::new()
2193 .write(true)
2194 .open(&path)
2195 .expect("open replacement")
2196 .set_len(1)
2197 .expect("truncate replacement");
2198 },
2199 );
2200
2201 assert_eq!(
2202 fingerprint.state,
2203 AuditContextPathState::Unreadable(CONTEXT_CHANGED_DURING_READ_STATE.to_string())
2204 );
2205 assert!(fingerprint.content_hash.is_none());
2206 }
2207
2208 #[cfg(unix)]
2209 #[test]
2210 fn unix_context_open_does_not_block_on_fifo() {
2211 let temp = tempfile::tempdir().expect("temp dir");
2212 let fifo = temp.path().join("pnpm-lock.yaml");
2213 let status = Command::new("mkfifo")
2214 .arg(&fifo)
2215 .status()
2216 .expect("run mkfifo");
2217 assert!(status.success(), "mkfifo must create the test pipe");
2218
2219 let fallback_fifo = fifo.clone();
2220 let fallback_writer = std::thread::spawn(move || {
2221 std::thread::sleep(std::time::Duration::from_secs(1));
2222 OpenOptions::new()
2223 .read(true)
2224 .write(true)
2225 .open(fallback_fifo)
2226 .expect("open fallback FIFO writer")
2227 });
2228 let started = std::time::Instant::now();
2229 let fingerprint = fingerprint_context_file_at(&fifo, "pnpm-lock.yaml");
2230 let elapsed = started.elapsed();
2231
2232 assert!(
2233 elapsed < std::time::Duration::from_millis(500),
2234 "nonblocking FIFO open took {elapsed:?}"
2235 );
2236 assert_eq!(
2237 fingerprint.state,
2238 AuditContextPathState::Unreadable(CONTEXT_SPECIAL_FILE_STATE.to_string())
2239 );
2240 assert!(fingerprint.content_hash.is_none());
2241 drop(fallback_writer.join().expect("fallback writer"));
2242 }
2243
2244 #[cfg(unix)]
2245 #[test]
2246 fn audit_context_fingerprint_rejects_special_files_without_opening_them() {
2247 use std::os::unix::net::UnixListener;
2248
2249 let temp = tempfile::tempdir().expect("temp dir");
2250 let socket = temp.path().join("pnpm-lock.yaml");
2251 let _listener = UnixListener::bind(&socket).expect("unix socket");
2252
2253 let fingerprint = fingerprint_context_file_at(&socket, "pnpm-lock.yaml");
2254
2255 assert_eq!(
2256 fingerprint.state,
2257 AuditContextPathState::Unreadable(CONTEXT_SPECIAL_FILE_STATE.to_string())
2258 );
2259 assert!(fingerprint.content_hash.is_none());
2260 }
2261}