1use std::path::{Path, PathBuf};
15use std::process::Command;
16use std::time::SystemTime;
17
18use uuid::Uuid;
19
20use crate::source::cache_key;
21
22pub const DEFAULT_MAX_REPOS: usize = 5;
23pub const DEFAULT_MAX_TOTAL_BYTES: u64 = 2 * 1024 * 1024 * 1024;
24pub const DEFAULT_CLONE_MAX_BYTES: u64 = 1024 * 1024 * 1024;
25
26const MARKER_FILE: &str = ".khive-last-used";
27
28#[derive(Debug)]
29pub enum CacheError {
30 Io(std::io::Error),
31 Git(String),
32 CloneTooLarge {
33 bytes: u64,
34 cap: u64,
35 },
36 UnsafeToReplace(PathBuf),
40}
41
42impl std::fmt::Display for CacheError {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 CacheError::Io(e) => write!(f, "scratch-cache I/O error: {e}"),
46 CacheError::Git(msg) => write!(f, "{msg}"),
47 CacheError::CloneTooLarge { bytes, cap } => write!(
48 f,
49 "clone exceeds the per-clone size cap ({bytes} bytes > {cap} bytes); \
50 the clone was removed. Raise KHIVE_GIT_DIGEST_CLONE_MAX_BYTES if this \
51 repository's history is legitimately this large."
52 ),
53 CacheError::UnsafeToReplace(path) => write!(
54 f,
55 "refusing to replace {} -- it does not prove itself an owned cache slot",
56 path.display()
57 ),
58 }
59 }
60}
61
62impl std::error::Error for CacheError {}
63
64impl From<std::io::Error> for CacheError {
65 fn from(e: std::io::Error) -> Self {
66 CacheError::Io(e)
67 }
68}
69
70fn scratch_root() -> PathBuf {
71 if let Ok(over) = std::env::var("KHIVE_GIT_DIGEST_SCRATCH_ROOT") {
72 if !over.is_empty() {
73 return PathBuf::from(over);
74 }
75 }
76 let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
77 PathBuf::from(home)
78 .join(".khive")
79 .join("scratch")
80 .join("git-digest")
81}
82
83fn env_u64(key: &str, default: u64) -> u64 {
84 std::env::var(key)
85 .ok()
86 .and_then(|v| v.parse().ok())
87 .unwrap_or(default)
88}
89
90fn max_repos() -> usize {
91 std::env::var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS")
92 .ok()
93 .and_then(|v| v.parse().ok())
94 .unwrap_or(DEFAULT_MAX_REPOS)
95}
96
97fn max_total_bytes() -> u64 {
98 env_u64("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", DEFAULT_MAX_TOTAL_BYTES)
99}
100
101fn clone_max_bytes() -> u64 {
102 env_u64("KHIVE_GIT_DIGEST_CLONE_MAX_BYTES", DEFAULT_CLONE_MAX_BYTES)
103}
104
105pub fn ensure_clone(canonical_url: &str) -> Result<PathBuf, CacheError> {
119 let root = scratch_root();
120 std::fs::create_dir_all(&root)?;
121 let key = cache_key(canonical_url);
122 let repo_dir = root.join(&key);
123 let cap = clone_max_bytes();
124
125 if repo_dir.join(".git").exists() {
126 if !is_owned_entry(&repo_dir) {
127 return Err(CacheError::UnsafeToReplace(repo_dir));
128 }
129 fetch(&repo_dir)?;
130 let size = dir_size(&repo_dir)?;
136 if size > cap {
137 remove_owned_entry(&root, &repo_dir)?;
138 return Err(CacheError::CloneTooLarge { bytes: size, cap });
139 }
140 touch(&repo_dir)?;
141 } else {
142 install_fresh_clone(canonical_url, &root, &repo_dir, cap)?;
143 }
144
145 evict_lru(&root, &repo_dir)?;
146 Ok(repo_dir)
147}
148
149pub(crate) fn refetch_clone(canonical_url: &str) -> Result<PathBuf, CacheError> {
153 let root = scratch_root();
154 let key = cache_key(canonical_url);
155 let repo_dir = root.join(&key);
156 if !repo_dir.join(".git").exists() {
157 return Err(CacheError::Git(format!(
158 "refetch requested for {canonical_url:?} but no cache slot exists at {}",
159 repo_dir.display()
160 )));
161 }
162 if !is_owned_entry(&repo_dir) {
165 return Err(CacheError::UnsafeToReplace(repo_dir));
166 }
167
168 fetch_refetch(&repo_dir)?;
169
170 let cap = clone_max_bytes();
171 let size = dir_size(&repo_dir)?;
172 if size > cap {
173 remove_owned_entry(&root, &repo_dir)?;
176 return Err(CacheError::CloneTooLarge { bytes: size, cap });
177 }
178
179 touch(&repo_dir)?;
180 evict_lru(&root, &repo_dir)?;
181 Ok(repo_dir)
182}
183
184pub(crate) fn reclone(canonical_url: &str) -> Result<PathBuf, CacheError> {
188 let root = scratch_root();
189 std::fs::create_dir_all(&root)?;
190 let key = cache_key(canonical_url);
191 let repo_dir = root.join(&key);
192 let cap = clone_max_bytes();
193
194 remove_owned_entry(&root, &repo_dir)?;
195 install_fresh_clone(canonical_url, &root, &repo_dir, cap)?;
196
197 evict_lru(&root, &repo_dir)?;
198 Ok(repo_dir)
199}
200
201fn install_fresh_clone(
205 canonical_url: &str,
206 root: &Path,
207 repo_dir: &Path,
208 cap: u64,
209) -> Result<(), CacheError> {
210 let staging_dir = root.join(format!(".staging-{}", Uuid::new_v4()));
211 clone(canonical_url, &staging_dir).inspect_err(|_| {
212 let _ = std::fs::remove_dir_all(&staging_dir);
219 })?;
220 let size = dir_size(&staging_dir).inspect_err(|_| {
221 let _ = std::fs::remove_dir_all(&staging_dir);
222 })?;
223 if size > cap {
224 let _ = std::fs::remove_dir_all(&staging_dir);
225 return Err(CacheError::CloneTooLarge { bytes: size, cap });
226 }
227 touch(&staging_dir).inspect_err(|_| {
228 let _ = std::fs::remove_dir_all(&staging_dir);
229 })?;
230 std::fs::rename(&staging_dir, repo_dir).map_err(|e| {
231 let _ = std::fs::remove_dir_all(&staging_dir);
232 CacheError::Io(e)
233 })?;
234 Ok(())
235}
236
237fn remove_owned_entry(root: &Path, repo_dir: &Path) -> Result<(), CacheError> {
240 if !repo_dir.exists() {
241 return Ok(());
242 }
243 if repo_dir.parent() != Some(root) || !is_owned_entry(repo_dir) {
244 return Err(CacheError::UnsafeToReplace(repo_dir.to_path_buf()));
245 }
246 remove_dir_all_retrying(repo_dir).map_err(CacheError::Io)?;
247 Ok(())
248}
249
250fn remove_dir_all_retrying(path: &Path) -> std::io::Result<()> {
253 let mut last_err = None;
254 for attempt in 0..5 {
255 match std::fs::remove_dir_all(path) {
256 Ok(()) => return Ok(()),
257 Err(e) => {
258 last_err = Some(e);
259 if attempt < 4 {
260 std::thread::sleep(std::time::Duration::from_millis(20));
261 }
262 }
263 }
264 }
265 Err(last_err.expect("loop always sets last_err before exiting"))
266}
267
268fn clone(url: &str, dest: &Path) -> Result<(), CacheError> {
274 let status = Command::new("git")
275 .arg("-c")
276 .arg("core.hooksPath=/dev/null")
277 .arg("-c")
278 .arg("gc.auto=0")
279 .arg("-c")
280 .arg("maintenance.auto=false")
281 .arg("clone")
282 .arg("--filter=blob:none")
283 .arg(url)
284 .arg(dest)
285 .env("GIT_TERMINAL_PROMPT", "0")
286 .status()
287 .map_err(|e| CacheError::Git(format!("spawning git clone: {e}")))?;
288 if !status.success() {
289 return Err(CacheError::Git(format!(
290 "git clone {url} failed (exit {status})"
291 )));
292 }
293 Ok(())
294}
295
296fn fetch(repo: &Path) -> Result<(), CacheError> {
297 let status = Command::new("git")
298 .arg("-c")
299 .arg("core.hooksPath=/dev/null")
300 .arg("-c")
301 .arg("gc.auto=0")
302 .arg("-c")
303 .arg("maintenance.auto=false")
304 .arg("-C")
305 .arg(repo)
306 .arg("fetch")
307 .arg("--prune")
308 .env("GIT_TERMINAL_PROMPT", "0")
309 .status()
310 .map_err(|e| CacheError::Git(format!("spawning git fetch: {e}")))?;
311 if !status.success() {
312 return Err(CacheError::Git(format!(
313 "git fetch in {} failed (exit {status})",
314 repo.display()
315 )));
316 }
317 Ok(())
318}
319
320fn fetch_refetch(repo: &Path) -> Result<(), CacheError> {
324 let status = Command::new("git")
325 .arg("-c")
326 .arg("core.hooksPath=/dev/null")
327 .arg("-c")
328 .arg("gc.auto=0")
329 .arg("-c")
330 .arg("maintenance.auto=false")
331 .arg("-C")
332 .arg(repo)
333 .arg("fetch")
334 .arg("--refetch")
335 .arg("origin")
336 .env("GIT_TERMINAL_PROMPT", "0")
337 .status()
338 .map_err(|e| CacheError::Git(format!("spawning git fetch --refetch: {e}")))?;
339 if !status.success() {
340 return Err(CacheError::Git(format!(
341 "git fetch --refetch in {} failed (exit {status})",
342 repo.display()
343 )));
344 }
345 Ok(())
346}
347
348fn io_err(op: &str, path: &Path, e: std::io::Error) -> CacheError {
350 CacheError::Io(std::io::Error::new(
351 e.kind(),
352 format!("{op} {}: {e}", path.display()),
353 ))
354}
355
356fn touch(repo_dir: &Path) -> Result<(), CacheError> {
357 let marker = repo_dir.join(MARKER_FILE);
358 std::fs::write(&marker, b"").map_err(|e| io_err("touch: write marker", &marker, e))?;
359 Ok(())
360}
361
362fn dir_size(path: &Path) -> Result<u64, CacheError> {
368 let mut total = 0u64;
369 let mut stack = vec![path.to_path_buf()];
370 while let Some(p) = stack.pop() {
371 let is_root = p == path;
372 let md = match std::fs::symlink_metadata(&p) {
373 Ok(md) => md,
374 Err(e) if e.kind() == std::io::ErrorKind::NotFound && !is_root => continue,
375 Err(e) => return Err(io_err("dir_size: stat", &p, e)),
376 };
377 if md.is_dir() {
378 let read_dir = match std::fs::read_dir(&p) {
379 Ok(read_dir) => read_dir,
380 Err(e) if e.kind() == std::io::ErrorKind::NotFound && !is_root => continue,
381 Err(e) => return Err(io_err("dir_size: read_dir", &p, e)),
382 };
383 for entry in read_dir {
384 match entry {
385 Ok(entry) => stack.push(entry.path()),
386 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
387 Err(e) => return Err(io_err("dir_size: read_dir entry", &p, e)),
388 }
389 }
390 } else {
391 total += md.len();
392 }
393 }
394 Ok(total)
395}
396
397fn is_owned_entry(path: &Path) -> bool {
402 let name = match path.file_name().and_then(|n| n.to_str()) {
403 Some(n) => n,
404 None => return false,
405 };
406 if name.len() != 16
407 || !name
408 .chars()
409 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
410 {
411 return false;
412 }
413 match std::fs::symlink_metadata(path) {
414 Ok(md) if md.is_dir() => {}
415 _ => return false,
416 }
417 path.join(".git").exists() && path.join(MARKER_FILE).exists()
418}
419
420fn evict_lru(root: &Path, keep: &Path) -> Result<(), CacheError> {
426 let mut entries: Vec<(PathBuf, SystemTime, u64)> = Vec::new();
427 let read_dir =
428 std::fs::read_dir(root).map_err(|e| io_err("evict_lru: read_dir root", root, e))?;
429 for entry in read_dir {
430 let entry = match entry {
431 Ok(entry) => entry,
432 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
436 Err(e) => return Err(io_err("evict_lru: read_dir entry", root, e)),
437 };
438 let p = entry.path();
439 if !p.is_dir() || p == keep || !is_owned_entry(&p) {
440 continue;
441 }
442 let mtime = std::fs::metadata(p.join(MARKER_FILE))
443 .and_then(|m| m.modified())
444 .unwrap_or(SystemTime::UNIX_EPOCH);
445 let size = match dir_size(&p) {
446 Ok(size) => size,
447 Err(CacheError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => continue,
451 Err(e) => return Err(e),
452 };
453 entries.push((p, mtime, size));
454 }
455 entries.sort_by_key(|(_, mtime, _)| *mtime);
456
457 let keep_size = dir_size(keep)?;
458 let mut total: u64 = entries.iter().map(|(_, _, s)| s).sum::<u64>() + keep_size;
459 let mut count = entries.len() + 1;
460 let cap_repos = max_repos();
461 let cap_bytes = max_total_bytes();
462
463 for (path, _, size) in entries {
464 if count <= cap_repos && total <= cap_bytes {
465 break;
466 }
467 let _ = std::fs::remove_dir_all(&path);
468 count -= 1;
469 total = total.saturating_sub(size);
470 }
471 Ok(())
472}
473
474#[cfg(test)]
477pub(crate) static ENV_MUTEX: std::sync::LazyLock<tokio::sync::Mutex<()>> =
478 std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 fn make_owned_entry(root: &Path, key: &str, with_marker: bool) -> PathBuf {
486 assert_eq!(key.len(), 16, "test cache keys must be 16 hex chars");
487 let p = root.join(key);
488 std::fs::create_dir_all(p.join(".git")).unwrap();
489 if with_marker {
490 std::fs::write(p.join(MARKER_FILE), b"").unwrap();
491 }
492 p
493 }
494
495 #[test]
497 fn ensure_clone_cleans_up_staging_dir_on_clone_failure() {
498 let _guard = ENV_MUTEX.blocking_lock();
499 let dir = tempfile::tempdir().expect("tempdir");
500 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", dir.path());
501
502 let bogus_source = dir.path().join("does-not-exist-as-a-repo");
503 let result = ensure_clone(bogus_source.to_str().expect("utf8 path"));
504 assert!(
505 result.is_err(),
506 "cloning a nonexistent local path must fail: {result:?}"
507 );
508
509 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
510 .expect("read scratch root")
511 .filter_map(|e| e.ok())
512 .map(|e| e.file_name().to_string_lossy().into_owned())
513 .filter(|name| name.starts_with(".staging-"))
514 .collect();
515 assert!(
516 leftovers.is_empty(),
517 "a failed clone must not leave .staging-* directories behind: {leftovers:?}"
518 );
519
520 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
521 }
522
523 #[test]
524 fn evict_lru_removes_oldest_past_repo_cap() {
525 let _guard = ENV_MUTEX.blocking_lock();
526 let dir = tempfile::tempdir().expect("tempdir");
527 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", dir.path());
528 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "1");
529 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "1000000000");
530
531 let root = dir.path();
532 let old = make_owned_entry(root, "1111111111111111", true);
533 std::thread::sleep(std::time::Duration::from_millis(20));
535 let new = make_owned_entry(root, "2222222222222222", true);
536
537 evict_lru(root, &new).expect("evict");
538
539 assert!(!old.exists(), "the older clone must be evicted");
540 assert!(new.exists(), "the kept clone must survive");
541
542 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
543 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
544 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
545 }
546
547 #[test]
548 fn evict_lru_only_touches_children_of_root() {
549 let _guard = ENV_MUTEX.blocking_lock();
550 let dir = tempfile::tempdir().expect("tempdir");
551 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "5");
552 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "1000000000");
553
554 let root = dir.path().join("scratch-root");
555 std::fs::create_dir_all(&root).unwrap();
556 let kept = make_owned_entry(&root, "3333333333333333", true);
557
558 evict_lru(&root, &kept).expect("evict");
559 assert!(kept.exists());
560
561 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
562 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
563 }
564
565 #[test]
566 fn evict_lru_never_removes_a_foreign_directory_under_root() {
567 let _guard = ENV_MUTEX.blocking_lock();
568 let dir = tempfile::tempdir().expect("tempdir");
569 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "0");
572 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "0");
573
574 let root = dir.path().join("scratch-root");
575 std::fs::create_dir_all(&root).unwrap();
576 let foreign = root.join("not-a-cache-entry");
577 std::fs::create_dir_all(&foreign).unwrap();
578 std::fs::write(foreign.join("important.txt"), b"do not delete me").unwrap();
579 let kept = make_owned_entry(&root, "4444444444444444", true);
580
581 evict_lru(&root, &kept).expect("evict");
582
583 assert!(
584 foreign.exists(),
585 "a directory that doesn't look like a cache slot must survive eviction"
586 );
587 assert!(
588 foreign.join("important.txt").exists(),
589 "foreign directory contents must be untouched"
590 );
591
592 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
593 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
594 }
595
596 #[test]
597 fn evict_lru_never_removes_an_owned_looking_dir_missing_the_marker() {
598 let _guard = ENV_MUTEX.blocking_lock();
599 let dir = tempfile::tempdir().expect("tempdir");
600 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "0");
601 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "0");
602
603 let root = dir.path().join("scratch-root");
604 std::fs::create_dir_all(&root).unwrap();
605 let no_marker = make_owned_entry(&root, "5555555555555555", false);
608 let kept = make_owned_entry(&root, "6666666666666666", true);
609
610 evict_lru(&root, &kept).expect("evict");
611
612 assert!(
613 no_marker.exists(),
614 "an owned-looking directory without the marker must survive eviction"
615 );
616
617 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
618 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
619 }
620
621 #[test]
622 fn is_owned_entry_rejects_non_cache_shapes() {
623 let dir = tempfile::tempdir().expect("tempdir");
624 let root = dir.path();
625
626 let short = root.join("abc123");
628 std::fs::create_dir_all(short.join(".git")).unwrap();
629 std::fs::write(short.join(MARKER_FILE), b"").unwrap();
630 assert!(!is_owned_entry(&short));
631
632 let upper = root.join("ABCDEF0123456789");
634 std::fs::create_dir_all(upper.join(".git")).unwrap();
635 std::fs::write(upper.join(MARKER_FILE), b"").unwrap();
636 assert!(!is_owned_entry(&upper));
637
638 let no_git = root.join("7777777777777777");
640 std::fs::create_dir_all(&no_git).unwrap();
641 std::fs::write(no_git.join(MARKER_FILE), b"").unwrap();
642 assert!(!is_owned_entry(&no_git));
643
644 let owned = make_owned_entry(root, "8888888888888888", true);
645 assert!(is_owned_entry(&owned));
646 }
647
648 #[test]
649 fn dir_size_sums_file_bytes_recursively() {
650 let dir = tempfile::tempdir().expect("tempdir");
651 std::fs::write(dir.path().join("a.txt"), b"12345").unwrap();
652 std::fs::create_dir_all(dir.path().join("sub")).unwrap();
653 std::fs::write(dir.path().join("sub/b.txt"), b"1234567890").unwrap();
654 assert_eq!(dir_size(dir.path()).unwrap(), 15);
655 }
656
657 #[test]
659 fn dir_size_errors_when_the_root_itself_is_missing() {
660 let dir = tempfile::tempdir().expect("tempdir");
661 let missing = dir.path().join("does-not-exist");
662 let err = dir_size(&missing).expect_err("a missing root must error, not size to 0");
663 assert!(
664 matches!(&err, CacheError::Io(e) if e.kind() == std::io::ErrorKind::NotFound),
665 "expected CacheError::Io(NotFound), got {err:?}"
666 );
667 }
668
669 #[test]
671 fn evict_lru_errors_when_keep_itself_is_missing() {
672 let _guard = ENV_MUTEX.blocking_lock();
673 let dir = tempfile::tempdir().expect("tempdir");
674 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "5");
675 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "1000000000");
676
677 let root = dir.path().join("scratch-root");
678 std::fs::create_dir_all(&root).unwrap();
679 let missing_keep = root.join("0000000000000000");
680
681 let err = evict_lru(&root, &missing_keep).expect_err("a missing keep root must error");
682 assert!(
683 matches!(&err, CacheError::Io(e) if e.kind() == std::io::ErrorKind::NotFound),
684 "expected CacheError::Io(NotFound), got {err:?}"
685 );
686
687 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
688 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
689 }
690
691 #[test]
695 fn dir_size_tolerates_a_subdirectory_removed_mid_walk() {
696 for _ in 0..200 {
697 let dir = tempfile::tempdir().expect("tempdir");
698 let root = dir.path().to_path_buf();
699 let victim = root.join("victim");
700 std::fs::create_dir_all(&victim).unwrap();
701 for i in 0..64 {
702 std::fs::write(victim.join(format!("f{i}.txt")), b"0123456789").unwrap();
703 }
704 for i in 0..64 {
708 let sibling = root.join(format!("sibling{i}"));
709 std::fs::create_dir_all(&sibling).unwrap();
710 std::fs::write(sibling.join("s.txt"), b"0123456789").unwrap();
711 }
712
713 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
714 let walk_root = root.clone();
715 let walk_barrier = barrier.clone();
716 let walker = std::thread::spawn(move || {
717 walk_barrier.wait();
718 dir_size(&walk_root)
719 });
720 let delete_victim = victim.clone();
721 let deleter = std::thread::spawn(move || {
722 barrier.wait();
723 let _ = std::fs::remove_dir_all(&delete_victim);
724 });
725
726 let result = walker.join().expect("walker thread");
727 deleter.join().expect("deleter thread");
728
729 assert!(
730 result.is_ok(),
731 "dir_size must tolerate a subdirectory vanishing mid-walk, got {result:?}"
732 );
733 }
734 }
735
736 #[test]
740 fn dir_size_errors_when_the_root_is_removed_mid_walk() {
741 let mut saw_error = false;
742 for _ in 0..500 {
743 let dir = tempfile::tempdir().expect("tempdir");
744 let root = dir.path().join("slot");
745 std::fs::create_dir_all(&root).unwrap();
746
747 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
748 let walk_root = root.clone();
749 let walk_barrier = barrier.clone();
750 let walker = std::thread::spawn(move || {
751 walk_barrier.wait();
752 dir_size(&walk_root)
753 });
754 let delete_root = root.clone();
755 let deleter = std::thread::spawn(move || {
756 barrier.wait();
757 let _ = std::fs::remove_dir(&delete_root);
758 });
759
760 let result = walker.join().expect("walker thread");
761 deleter.join().expect("deleter thread");
762
763 match result {
764 Ok(_) => continue, Err(CacheError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
766 saw_error = true;
767 }
768 Err(e) => panic!("unexpected error kind from a vanished root: {e:?}"),
769 }
770 }
771 assert!(
772 saw_error,
773 "root-vanish-mid-walk race was never hit across 500 iterations; \
774 widen the fixture or investigate the barrier timing"
775 );
776 }
777
778 fn git(repo: &Path, args: &[&str]) {
781 let out = Command::new("git")
782 .arg("-C")
783 .arg(repo)
784 .args(args)
785 .output()
786 .expect("spawn git");
787 assert!(
788 out.status.success(),
789 "git {args:?} failed: {}",
790 String::from_utf8_lossy(&out.stderr)
791 );
792 }
793
794 fn init_origin_with_one_commit(repo: &Path) {
797 git(repo, &["init", "-q", "-b", "main"]);
798 git(repo, &["config", "user.email", "test@example.com"]);
799 git(repo, &["config", "user.name", "Test User"]);
800 std::fs::write(repo.join("a.txt"), b"hello").unwrap();
801 git(repo, &["add", "a.txt"]);
802 git(repo, &["commit", "-q", "-m", "initial"]);
803 }
804
805 fn add_commit(repo: &Path, rel: &str, contents: &str, message: &str) {
806 std::fs::write(repo.join(rel), contents).unwrap();
807 git(repo, &["add", rel]);
808 git(repo, &["commit", "-q", "-m", message]);
809 }
810
811 fn head_sha(repo: &Path) -> String {
812 let out = Command::new("git")
813 .arg("-C")
814 .arg(repo)
815 .args(["rev-parse", "HEAD"])
816 .output()
817 .expect("rev-parse");
818 String::from_utf8_lossy(&out.stdout).trim().to_string()
819 }
820
821 #[test]
824 fn refetch_clone_updates_an_existing_slot_to_the_remote_tip() {
825 let _guard = ENV_MUTEX.blocking_lock();
826 let scratch = tempfile::tempdir().expect("tempdir");
827 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
828
829 let origin_dir = tempfile::tempdir().expect("tempdir");
830 init_origin_with_one_commit(origin_dir.path());
831 let canonical = origin_dir.path().to_str().unwrap();
832
833 let first = ensure_clone(canonical).expect("initial ensure_clone");
834 let before = head_sha(&first);
835
836 add_commit(origin_dir.path(), "b.txt", "world", "second");
837 let expected_tip = head_sha(origin_dir.path());
838 assert_ne!(before, expected_tip, "origin must have moved on");
839
840 let repaired = refetch_clone(canonical).expect("refetch_clone");
841 assert_eq!(repaired, first, "refetch repairs the same cache slot path");
842 git(&repaired, &["show", &format!("{expected_tip}:b.txt")]);
843
844 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
845 }
846
847 #[test]
850 fn refetch_clone_over_cap_cleanup_never_deletes_an_unproven_slot() {
851 let _guard = ENV_MUTEX.blocking_lock();
852 let scratch = tempfile::tempdir().expect("tempdir");
853 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
854
855 let origin_dir = tempfile::tempdir().expect("tempdir");
856 init_origin_with_one_commit(origin_dir.path());
857 let canonical = origin_dir.path().to_str().unwrap();
858
859 let slot = ensure_clone(canonical).expect("initial ensure_clone");
860 std::fs::remove_file(slot.join(".khive-last-used")).expect("remove marker");
865
866 std::env::set_var("KHIVE_GIT_DIGEST_CLONE_MAX_BYTES", "1");
867 let err = refetch_clone(canonical).expect_err("refetch must report the ownership error");
868 assert!(
869 matches!(err, CacheError::UnsafeToReplace(_)),
870 "expected UnsafeToReplace (the cleanup's ownership failure, propagated), got {err:?}"
871 );
872 assert!(
873 slot.exists(),
874 "a slot the ownership guard cannot prove it owns must survive over-cap cleanup"
875 );
876
877 std::env::remove_var("KHIVE_GIT_DIGEST_CLONE_MAX_BYTES");
878 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
879 }
880
881 #[test]
884 fn refetch_clone_refuses_a_markerless_slot_under_the_cap() {
885 let _guard = ENV_MUTEX.blocking_lock();
886 let scratch = tempfile::tempdir().expect("tempdir");
887 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
888
889 let origin_dir = tempfile::tempdir().expect("tempdir");
890 init_origin_with_one_commit(origin_dir.path());
891 let canonical = origin_dir.path().to_str().unwrap();
892
893 let slot = ensure_clone(canonical).expect("initial ensure_clone");
894 let sentinel_sha = head_sha(&slot);
895 std::fs::remove_file(slot.join(MARKER_FILE)).expect("remove marker");
896
897 add_commit(origin_dir.path(), "b.txt", "world", "second");
900
901 let err = refetch_clone(canonical)
902 .expect_err("a markerless slot must be refused before any fetch runs");
903 assert!(
904 matches!(err, CacheError::UnsafeToReplace(_)),
905 "expected UnsafeToReplace, got {err:?}"
906 );
907 assert_eq!(
908 head_sha(&slot),
909 sentinel_sha,
910 "no fetch must have run against the markerless slot"
911 );
912 assert!(
913 !slot.join(MARKER_FILE).exists(),
914 "a refused refetch must never (re)write the ownership marker"
915 );
916
917 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
918 }
919
920 #[test]
921 fn refetch_clone_errors_when_no_slot_exists() {
922 let _guard = ENV_MUTEX.blocking_lock();
923 let scratch = tempfile::tempdir().expect("tempdir");
924 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
925
926 let err = refetch_clone("https://example.invalid/never-cloned/repo")
927 .expect_err("no slot exists yet");
928 assert!(
929 matches!(err, CacheError::Git(_)),
930 "expected CacheError::Git, got {err:?}"
931 );
932
933 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
934 }
935
936 #[test]
939 fn reclone_replaces_a_slot_whose_refetch_cannot_succeed() {
940 let _guard = ENV_MUTEX.blocking_lock();
941 let scratch = tempfile::tempdir().expect("tempdir");
942 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
943
944 let origin_dir = tempfile::tempdir().expect("tempdir");
945 init_origin_with_one_commit(origin_dir.path());
946 let canonical = origin_dir.path().to_str().unwrap();
947
948 let slot = ensure_clone(canonical).expect("initial ensure_clone");
949 git(
952 &slot,
953 &[
954 "remote",
955 "set-url",
956 "origin",
957 "/nonexistent/path/does-not-exist",
958 ],
959 );
960 assert!(matches!(refetch_clone(canonical), Err(CacheError::Git(_))));
961
962 let recloned = reclone(canonical).expect("reclone");
963 assert_eq!(recloned, slot, "reclone reinstalls at the same slot path");
964 assert_eq!(head_sha(&recloned), head_sha(origin_dir.path()));
965 let out = Command::new("git")
968 .arg("-C")
969 .arg(&recloned)
970 .args(["remote", "get-url", "origin"])
971 .output()
972 .expect("remote get-url");
973 assert_eq!(
974 String::from_utf8_lossy(&out.stdout).trim(),
975 canonical,
976 "reclone must re-point origin at canonical_url, not the broken remote"
977 );
978
979 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
980 }
981
982 #[test]
985 fn reclone_refuses_to_replace_a_foreign_looking_directory() {
986 let _guard = ENV_MUTEX.blocking_lock();
987 let scratch = tempfile::tempdir().expect("tempdir");
988 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
989
990 let origin_dir = tempfile::tempdir().expect("tempdir");
991 init_origin_with_one_commit(origin_dir.path());
992 let canonical = origin_dir.path().to_str().unwrap();
993 let key = cache_key(canonical);
994 let foreign = scratch.path().join(&key);
995 std::fs::create_dir_all(&foreign).unwrap();
996 std::fs::write(foreign.join("important.txt"), b"do not delete me").unwrap();
997
998 let err = reclone(canonical).expect_err("foreign directory must be refused");
999 assert!(
1000 matches!(err, CacheError::UnsafeToReplace(_)),
1001 "expected UnsafeToReplace, got {err:?}"
1002 );
1003 assert!(
1004 foreign.join("important.txt").exists(),
1005 "foreign directory contents must survive a refused reclone"
1006 );
1007
1008 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1009 }
1010
1011 #[test]
1013 fn reclone_installs_fresh_when_no_slot_exists_yet() {
1014 let _guard = ENV_MUTEX.blocking_lock();
1015 let scratch = tempfile::tempdir().expect("tempdir");
1016 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1017
1018 let origin_dir = tempfile::tempdir().expect("tempdir");
1019 init_origin_with_one_commit(origin_dir.path());
1020 let canonical = origin_dir.path().to_str().unwrap();
1021
1022 let recloned = reclone(canonical).expect("reclone with no prior slot");
1023 assert_eq!(head_sha(&recloned), head_sha(origin_dir.path()));
1024
1025 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1026 }
1027
1028 #[test]
1031 fn ensure_clone_refuses_a_markerless_git_directory_at_the_cache_key_path() {
1032 let _guard = ENV_MUTEX.blocking_lock();
1033 let scratch = tempfile::tempdir().expect("tempdir");
1034 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1035
1036 let canonical = "https://example.invalid/lookalike/repo";
1037 let key = cache_key(canonical);
1038 let lookalike = scratch.path().join(&key);
1039 std::fs::create_dir_all(&lookalike).unwrap();
1040 init_origin_with_one_commit(&lookalike);
1041 std::fs::write(lookalike.join("sentinel.txt"), b"do not delete me").unwrap();
1042 let sentinel_sha = head_sha(&lookalike);
1043
1044 let err = ensure_clone(canonical).expect_err("markerless lookalike must be refused");
1045 assert!(
1046 matches!(err, CacheError::UnsafeToReplace(_)),
1047 "expected UnsafeToReplace, got {err:?}"
1048 );
1049
1050 assert!(
1051 lookalike.join("sentinel.txt").exists(),
1052 "sentinel operator data must survive a refused ensure_clone"
1053 );
1054 assert_eq!(
1055 head_sha(&lookalike),
1056 sentinel_sha,
1057 "the lookalike repository's own history must be untouched (no fetch ran)"
1058 );
1059 assert!(
1060 !lookalike.join(MARKER_FILE).exists(),
1061 "a refused ensure_clone must never write the ownership marker either"
1062 );
1063
1064 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1065 }
1066
1067 #[cfg(unix)]
1069 #[test]
1070 fn ensure_clone_refuses_a_symlink_at_the_cache_key_path() {
1071 let _guard = ENV_MUTEX.blocking_lock();
1072 let scratch = tempfile::tempdir().expect("tempdir");
1073 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1074
1075 let canonical = "https://example.invalid/symlink-lookalike/repo";
1076 let key = cache_key(canonical);
1077 let link_path = scratch.path().join(&key);
1078
1079 let target = tempfile::tempdir().expect("symlink target");
1080 make_owned_entry(target.path(), "9999999999999999", true);
1081 let real_owned = target.path().join("9999999999999999");
1082 std::fs::write(real_owned.join("sentinel.txt"), b"do not delete me").unwrap();
1083
1084 std::os::unix::fs::symlink(&real_owned, &link_path).expect("create symlink");
1085
1086 let err = ensure_clone(canonical).expect_err("symlink lookalike must be refused");
1087 assert!(
1088 matches!(err, CacheError::UnsafeToReplace(_)),
1089 "expected UnsafeToReplace, got {err:?}"
1090 );
1091 assert!(
1092 real_owned.join("sentinel.txt").exists(),
1093 "the symlink target's sentinel data must survive a refused ensure_clone"
1094 );
1095
1096 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1097 }
1098}