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