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
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(¤t) {
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(¤t).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#[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#[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#[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#[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#[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#[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
1199fn 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 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 #[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 #[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}