khive_pack_git/cache.rs
1//! Scratch-clone cache for `git.digest`'s remote-URL mode (ADR-088
2//! Amendment 1).
3//!
4//! Clones/fetches into `~/.khive/scratch/git-digest/<cache_key>/`, keyed by
5//! canonical URL (`crate::source::cache_key`). An LRU cap evicts the
6//! least-recently-used clone (by a `.khive-last-used` marker file's mtime,
7//! touched on every successful `ensure_clone`) once the cache exceeds
8//! `digest_cache_max_repos` entries or `digest_cache_max_bytes` total size --
9//! eviction is safe because ingest cursors live in the database, not the
10//! clone (ADR-088 Amendment 1 §Remote-URL mode). Eviction only ever removes
11//! entries it can *prove* it owns (`is_owned_entry`: a 16-hex cache-key
12//! directory name containing both a `.git` dir and the `.khive-last-used`
13//! marker) -- a `KHIVE_GIT_DIGEST_SCRATCH_ROOT` override pointed at a broader
14//! or pre-existing directory must never lose unrelated operator data.
15//!
16//! A per-clone size cap (`digest_cache_clone_max_bytes`) rejects a clone/
17//! fetch that grows past its own budget *before* it ever enters the
18//! addressable cache slot: `ensure_clone` clones/fetches into a staging
19//! directory outside the cache root, measures it, and only moves it into
20//! `<root>/<cache_key>/` when it is under the cap. A too-large clone is
21//! deleted from staging and never touches `evict_lru`'s bookkeeping or the
22//! cache slot. This guarantees the cap is enforced before the clone enters
23//! the cache -- it does NOT bound the transient disk usage of the clone/
24//! fetch child process itself while it runs in staging (`git` has no
25//! reliable pre-flight or mid-transfer size check for a partial
26//! `--filter=blob:none` clone); a single oversized `git clone` can still
27//! transiently consume disk in the staging directory before this check
28//! rejects and removes it.
29//!
30//! Config is env-var driven today (`KHIVE_GIT_DIGEST_CACHE_MAX_REPOS`,
31//! `KHIVE_GIT_DIGEST_CACHE_MAX_BYTES`, `KHIVE_GIT_DIGEST_CLONE_MAX_BYTES`,
32//! `KHIVE_GIT_DIGEST_SCRATCH_ROOT`) rather than a `[git]` TOML section --
33//! see the implementation report for why.
34
35use std::path::{Path, PathBuf};
36use std::process::Command;
37use std::time::SystemTime;
38
39use uuid::Uuid;
40
41use crate::source::cache_key;
42
43pub const DEFAULT_MAX_REPOS: usize = 5;
44pub const DEFAULT_MAX_TOTAL_BYTES: u64 = 2 * 1024 * 1024 * 1024;
45pub const DEFAULT_CLONE_MAX_BYTES: u64 = 1024 * 1024 * 1024;
46
47const MARKER_FILE: &str = ".khive-last-used";
48
49#[derive(Debug)]
50pub enum CacheError {
51 Io(std::io::Error),
52 Git(String),
53 CloneTooLarge {
54 bytes: u64,
55 cap: u64,
56 },
57 /// A repair operation (refetch/reclone) would have to touch a path that
58 /// does not prove itself an owned cache slot (`is_owned_entry`) or is
59 /// not a direct child of the scratch root — refused rather than risking
60 /// deletion of unrelated operator data under an overridden
61 /// `KHIVE_GIT_DIGEST_SCRATCH_ROOT`.
62 UnsafeToReplace(PathBuf),
63}
64
65impl std::fmt::Display for CacheError {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 match self {
68 CacheError::Io(e) => write!(f, "scratch-cache I/O error: {e}"),
69 CacheError::Git(msg) => write!(f, "{msg}"),
70 CacheError::CloneTooLarge { bytes, cap } => write!(
71 f,
72 "clone exceeds the per-clone size cap ({bytes} bytes > {cap} bytes); \
73 the clone was removed. Raise KHIVE_GIT_DIGEST_CLONE_MAX_BYTES if this \
74 repository's history is legitimately this large."
75 ),
76 CacheError::UnsafeToReplace(path) => write!(
77 f,
78 "refusing to replace {} -- it does not prove itself an owned cache slot",
79 path.display()
80 ),
81 }
82 }
83}
84
85impl std::error::Error for CacheError {}
86
87impl From<std::io::Error> for CacheError {
88 fn from(e: std::io::Error) -> Self {
89 CacheError::Io(e)
90 }
91}
92
93fn scratch_root() -> PathBuf {
94 if let Ok(over) = std::env::var("KHIVE_GIT_DIGEST_SCRATCH_ROOT") {
95 if !over.is_empty() {
96 return PathBuf::from(over);
97 }
98 }
99 let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
100 PathBuf::from(home)
101 .join(".khive")
102 .join("scratch")
103 .join("git-digest")
104}
105
106fn env_u64(key: &str, default: u64) -> u64 {
107 std::env::var(key)
108 .ok()
109 .and_then(|v| v.parse().ok())
110 .unwrap_or(default)
111}
112
113fn max_repos() -> usize {
114 std::env::var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS")
115 .ok()
116 .and_then(|v| v.parse().ok())
117 .unwrap_or(DEFAULT_MAX_REPOS)
118}
119
120fn max_total_bytes() -> u64 {
121 env_u64("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", DEFAULT_MAX_TOTAL_BYTES)
122}
123
124fn clone_max_bytes() -> u64 {
125 env_u64("KHIVE_GIT_DIGEST_CLONE_MAX_BYTES", DEFAULT_CLONE_MAX_BYTES)
126}
127
128/// Ensure a local clone of `canonical_url` exists and is up to date; returns
129/// the repo's local path.
130///
131/// An existing path at the cache-key slot is only ever treated as a fetchable
132/// cache slot when it already passes `is_owned_entry` -- a `.git` directory
133/// sitting at that path without the `.khive-last-used` marker (a foreign
134/// directory that happens to collide with the cache key, or a directory a
135/// crashed prior run left in a pre-`touch` state) is refused with
136/// `CacheError::UnsafeToReplace` rather than fetched into or adopted (issue
137/// #765 review round-2 \[High\]-1). A fresh clone is written into a private
138/// staging directory first (`git clone --filter=blob:none`), measured there,
139/// marked with `.khive-last-used` there, and only *moved* into the
140/// addressable `<root>/<cache_key>/` slot once it is under the cap and
141/// already carries its ownership marker -- an oversized clone never enters
142/// the cache slot, never participates in `evict_lru`'s accounting, and is
143/// removed from staging immediately; a process interruption between the
144/// clone and the rename can never leave a live, markerless slot behind.
145///
146/// A repo that grew past the per-clone cap since it was last fetched is
147/// evicted from the cache slot on the spot, through the same ownership-
148/// guarded `remove_owned_entry` every other repair path uses, propagating
149/// any cleanup/ownership failure instead of discarding it.
150///
151/// Runs LRU eviction over the rest of the cache after a successful
152/// clone/fetch (this clone is exempt from its own eviction pass).
153pub fn ensure_clone(canonical_url: &str) -> Result<PathBuf, CacheError> {
154 let root = scratch_root();
155 std::fs::create_dir_all(&root)?;
156 let key = cache_key(canonical_url);
157 let repo_dir = root.join(&key);
158 let cap = clone_max_bytes();
159
160 if repo_dir.join(".git").exists() {
161 if !is_owned_entry(&repo_dir) {
162 return Err(CacheError::UnsafeToReplace(repo_dir));
163 }
164 fetch(&repo_dir)?;
165 // `repo_dir` was just fetched into and its ownership already
166 // confirmed above; it vanishing here is a real problem (e.g. a
167 // racing, non-serialized `ensure_clone`/`reclone` on the same key --
168 // see `refetch_clone`'s doc comment), not a maybe-absent slot, so
169 // propagate rather than swallow.
170 let size = dir_size(&repo_dir)?;
171 if size > cap {
172 remove_owned_entry(&root, &repo_dir)?;
173 return Err(CacheError::CloneTooLarge { bytes: size, cap });
174 }
175 touch(&repo_dir)?;
176 } else {
177 install_fresh_clone(canonical_url, &root, &repo_dir, cap)?;
178 }
179
180 evict_lru(&root, &repo_dir)?;
181 Ok(repo_dir)
182}
183
184/// Re-fetch a corrupt-but-present cache slot with `git fetch --refetch`
185/// (issue #765): downloads a complete fresh filtered packfile rather than
186/// trusting the existing (possibly promisor-incomplete) object store,
187/// repairing a partial/pruned clone in place. Only ever operates on an
188/// existing slot -- callers repair a slot only after a prior `ensure_clone`
189/// already produced one. Re-checks `is_owned_entry` immediately before
190/// fetching (review round-3 \[High\]-1, issue #765 follow-up PR #788): the
191/// gap between `ensure_clone`'s own ownership check and this repair running
192/// -- project resolution and GitHub ingestion happen in between -- is wide
193/// enough for the slot to go markerless or be replaced, so this function
194/// cannot rely on the caller having checked recently.
195pub(crate) fn refetch_clone(canonical_url: &str) -> Result<PathBuf, CacheError> {
196 let root = scratch_root();
197 let key = cache_key(canonical_url);
198 let repo_dir = root.join(&key);
199 if !repo_dir.join(".git").exists() {
200 return Err(CacheError::Git(format!(
201 "refetch requested for {canonical_url:?} but no cache slot exists at {}",
202 repo_dir.display()
203 )));
204 }
205 // Re-check ownership immediately before mutating the slot (review
206 // round-3 \[High\]-1, issue #765 follow-up PR #788): the caller's own
207 // ownership check (`ensure_clone`, much earlier in `handle_digest`)
208 // happens before project resolution and potentially lengthy GitHub
209 // ingestion, so the slot can go markerless -- or be replaced by a
210 // foreign directory colliding with the cache key -- in that interval.
211 // Without this re-check, `fetch_refetch` below would mutate whatever
212 // sits at `repo_dir` and `touch` would mark it owned, making it eligible
213 // for later deletion. There is no same-key serialization for cache
214 // mutation in this crate today (a concurrent `ensure_clone`/`reclone`
215 // racing this same slot is not otherwise excluded) -- this re-check
216 // narrows the adoption bug but does not close a true concurrent-writer
217 // race.
218 if !is_owned_entry(&repo_dir) {
219 return Err(CacheError::UnsafeToReplace(repo_dir));
220 }
221
222 fetch_refetch(&repo_dir)?;
223
224 let cap = clone_max_bytes();
225 // Same reasoning as `ensure_clone`: `repo_dir`'s ownership was just
226 // re-checked and it was just fetched into, so a vanish here is a real
227 // problem to surface, not a maybe-absent slot to size as `0`.
228 let size = dir_size(&repo_dir)?;
229 if size > cap {
230 // Route through the same ownership-guarded removal `reclone` uses
231 // (issue #765 remediation, review round-1 \[High\]-1) rather than a raw
232 // `remove_dir_all` -- a repair primitive must never delete a path
233 // that doesn't prove itself an owned cache slot, even on the cap-
234 // exceeded cleanup path. Propagate a cleanup/ownership failure
235 // instead of discarding it (review round-2 \[High\]-1): a refused or
236 // failed removal must surface as its own error, not be silently
237 // swallowed behind `CloneTooLarge`.
238 remove_owned_entry(&root, &repo_dir)?;
239 return Err(CacheError::CloneTooLarge { bytes: size, cap });
240 }
241
242 touch(&repo_dir)?;
243 evict_lru(&root, &repo_dir)?;
244 Ok(repo_dir)
245}
246
247/// Evict an owned cache slot (if present) and install a fresh clone in its
248/// place (issue #765's fallback when a refetch cannot repair the slot).
249/// Refuses via `CacheError::UnsafeToReplace` when the existing path does not
250/// prove itself an owned cache slot -- the same ownership guard `evict_lru`
251/// uses, so a `KHIVE_GIT_DIGEST_SCRATCH_ROOT` override pointed at a broader
252/// or pre-existing directory can never lose unrelated operator data here
253/// either.
254pub(crate) fn reclone(canonical_url: &str) -> Result<PathBuf, CacheError> {
255 let root = scratch_root();
256 std::fs::create_dir_all(&root)?;
257 let key = cache_key(canonical_url);
258 let repo_dir = root.join(&key);
259 let cap = clone_max_bytes();
260
261 remove_owned_entry(&root, &repo_dir)?;
262 install_fresh_clone(canonical_url, &root, &repo_dir, cap)?;
263
264 evict_lru(&root, &repo_dir)?;
265 Ok(repo_dir)
266}
267
268/// Shared staging-clone-then-move path for both a first-time `ensure_clone`
269/// and a `reclone` repair: clones into a private staging directory outside
270/// the cache root, measures it against the per-clone cap, writes the
271/// `.khive-last-used` ownership marker into the staging directory itself,
272/// and only then moves it into the addressable `<root>/<cache_key>/` slot --
273/// an oversized clone never enters the cache slot, and because the marker is
274/// written before the atomic rename, a process interruption between clone
275/// and rename can never leave a live, markerless slot at the cache-key path
276/// (issue #765 review round-2 \[High\]-1).
277fn install_fresh_clone(
278 canonical_url: &str,
279 root: &Path,
280 repo_dir: &Path,
281 cap: u64,
282) -> Result<(), CacheError> {
283 let staging_dir = root.join(format!(".staging-{}", Uuid::new_v4()));
284 clone(canonical_url, &staging_dir).inspect_err(|_| {
285 // `git clone` can create and partially populate the destination
286 // before failing (network drop, auth failure, bad ref) -- clean
287 // it up so a run of failures doesn't leave `.staging-*` litter
288 // under the scratch root. `evict_lru` deliberately never touches
289 // non-owned names (`is_owned_entry`), so nothing else would ever
290 // reclaim this on its own.
291 let _ = std::fs::remove_dir_all(&staging_dir);
292 })?;
293 let size = dir_size(&staging_dir).inspect_err(|_| {
294 let _ = std::fs::remove_dir_all(&staging_dir);
295 })?;
296 if size > cap {
297 let _ = std::fs::remove_dir_all(&staging_dir);
298 return Err(CacheError::CloneTooLarge { bytes: size, cap });
299 }
300 touch(&staging_dir).inspect_err(|_| {
301 let _ = std::fs::remove_dir_all(&staging_dir);
302 })?;
303 std::fs::rename(&staging_dir, repo_dir).map_err(|e| {
304 let _ = std::fs::remove_dir_all(&staging_dir);
305 CacheError::Io(e)
306 })?;
307 Ok(())
308}
309
310/// Remove `repo_dir` only when it is a direct child of `root` AND passes
311/// `is_owned_entry` -- refuses (`CacheError::UnsafeToReplace`) rather than
312/// deleting anything else, including a not-yet-existing or foreign-shaped
313/// path. A slot that does not currently exist is not an error: there is
314/// simply nothing to remove before installing a fresh clone.
315fn remove_owned_entry(root: &Path, repo_dir: &Path) -> Result<(), CacheError> {
316 if !repo_dir.exists() {
317 return Ok(());
318 }
319 if repo_dir.parent() != Some(root) || !is_owned_entry(repo_dir) {
320 return Err(CacheError::UnsafeToReplace(repo_dir.to_path_buf()));
321 }
322 remove_dir_all_retrying(repo_dir).map_err(CacheError::Io)?;
323 Ok(())
324}
325
326/// `std::fs::remove_dir_all` on a large git working tree can transiently
327/// fail with "directory not empty" when something else briefly touches the
328/// tree mid-removal (e.g. a filesystem indexer) -- retry a few times before
329/// giving up, rather than letting a one-off transient race abort a repair
330/// that would otherwise succeed.
331fn remove_dir_all_retrying(path: &Path) -> std::io::Result<()> {
332 let mut last_err = None;
333 for attempt in 0..5 {
334 match std::fs::remove_dir_all(path) {
335 Ok(()) => return Ok(()),
336 Err(e) => {
337 last_err = Some(e);
338 if attempt < 4 {
339 std::thread::sleep(std::time::Duration::from_millis(20));
340 }
341 }
342 }
343 }
344 Err(last_err.expect("loop always sets last_err before exiting"))
345}
346
347/// `-c maintenance.auto=false` on every clone/fetch into a cache slot, as
348/// defensive hardening. `git fetch` runs auto-maintenance after it
349/// finishes when `maintenance.auto` (default true) is set, and since git
350/// 2.47 that maintenance runs as a *detached background child*
351/// (`git maintenance run --auto --detach`) that can outlive the foreground
352/// command; on 2.46 and earlier it ran synchronously. The spawn is
353/// trace2-proven in both directions on the `fetch --refetch` path
354/// (`GIT_TRACE2_EVENT`, git 2.49: with default config the child forks; with
355/// `maintenance.auto=false` it does not). The same trace showed `clone`
356/// spawning no maintenance child; the flag is applied to the clone builder
357/// too purely as harmless defensive configuration, with no trace evidence
358/// claimed for that path. When one of the detached child's tasks fires it
359/// mutates the slot's `.git` tree (commit-graph writes, pack maintenance,
360/// lock files) concurrently with any `dir_size`/`evict_lru` walk of the
361/// same slot. Whether such a task actually fired in issue #842's historical
362/// macOS ENOENT failures is not proven -- in small repos the child
363/// typically finds no task to run and exits quickly -- so the load-bearing
364/// fix for that flake family is the descendant-vanish tolerance in
365/// `dir_size`; this flag removes the one background mutator git itself can
366/// fork into our cache slots. `gc.auto=0` alone does **not** suppress the
367/// child (trace2-verified); it is kept alongside because it disables
368/// `git gc --auto`'s separate opportunistic-gc check, harmless to also turn
369/// off here.
370///
371/// This does not mean a cache slot is naturally garbage-collected some other
372/// way instead: no cache-slot repo is ever gc'd or maintenance'd by us.
373/// Growth is bounded by wholesale eviction, not in-place compaction --
374/// `ensure_clone`/`refetch_clone` delete a slot outright
375/// (`remove_owned_entry`) the moment it measures over
376/// `digest_cache_clone_max_bytes` after a fetch, and `evict_lru` deletes
377/// whole least-recently-used slot directories once the cache-wide
378/// `digest_cache_max_repos`/`digest_cache_max_bytes` caps are exceeded. A
379/// slot can be fetched into repeatedly, but it can never accumulate objects
380/// past its own size cap without being deleted and re-cloned fresh, so there
381/// is nothing for git's own gc/maintenance to usefully do in a cache slot.
382fn clone(url: &str, dest: &Path) -> Result<(), CacheError> {
383 let status = Command::new("git")
384 .arg("-c")
385 .arg("core.hooksPath=/dev/null")
386 .arg("-c")
387 .arg("gc.auto=0")
388 .arg("-c")
389 .arg("maintenance.auto=false")
390 .arg("clone")
391 .arg("--filter=blob:none")
392 .arg(url)
393 .arg(dest)
394 .env("GIT_TERMINAL_PROMPT", "0")
395 .status()
396 .map_err(|e| CacheError::Git(format!("spawning git clone: {e}")))?;
397 if !status.success() {
398 return Err(CacheError::Git(format!(
399 "git clone {url} failed (exit {status})"
400 )));
401 }
402 Ok(())
403}
404
405fn fetch(repo: &Path) -> Result<(), CacheError> {
406 let status = Command::new("git")
407 .arg("-c")
408 .arg("core.hooksPath=/dev/null")
409 .arg("-c")
410 .arg("gc.auto=0")
411 .arg("-c")
412 .arg("maintenance.auto=false")
413 .arg("-C")
414 .arg(repo)
415 .arg("fetch")
416 .arg("--prune")
417 .env("GIT_TERMINAL_PROMPT", "0")
418 .status()
419 .map_err(|e| CacheError::Git(format!("spawning git fetch: {e}")))?;
420 if !status.success() {
421 return Err(CacheError::Git(format!(
422 "git fetch in {} failed (exit {status})",
423 repo.display()
424 )));
425 }
426 Ok(())
427}
428
429/// Issue #765 repair primitive: `git fetch --refetch origin` obtains a
430/// complete fresh filtered packfile instead of incrementally trusting the
431/// existing (possibly promisor-incomplete) object store -- the documented
432/// fix for a partial clone that has dropped objects it should still have.
433fn fetch_refetch(repo: &Path) -> Result<(), CacheError> {
434 let status = Command::new("git")
435 .arg("-c")
436 .arg("core.hooksPath=/dev/null")
437 .arg("-c")
438 .arg("gc.auto=0")
439 .arg("-c")
440 .arg("maintenance.auto=false")
441 .arg("-C")
442 .arg(repo)
443 .arg("fetch")
444 .arg("--refetch")
445 .arg("origin")
446 .env("GIT_TERMINAL_PROMPT", "0")
447 .status()
448 .map_err(|e| CacheError::Git(format!("spawning git fetch --refetch: {e}")))?;
449 if !status.success() {
450 return Err(CacheError::Git(format!(
451 "git fetch --refetch in {} failed (exit {status})",
452 repo.display()
453 )));
454 }
455 Ok(())
456}
457
458/// Wrap an I/O error with the operation and path it happened on -- a bare
459/// `CacheError::Io(e)` at these call sites used to surface as an opaque
460/// "No such file or directory" with no way to tell which of the many paths
461/// `dir_size`/`touch`/`evict_lru` touch actually disappeared.
462fn io_err(op: &str, path: &Path, e: std::io::Error) -> CacheError {
463 CacheError::Io(std::io::Error::new(
464 e.kind(),
465 format!("{op} {}: {e}", path.display()),
466 ))
467}
468
469fn touch(repo_dir: &Path) -> Result<(), CacheError> {
470 let marker = repo_dir.join(MARKER_FILE);
471 std::fs::write(&marker, b"").map_err(|e| io_err("touch: write marker", &marker, e))?;
472 Ok(())
473}
474
475/// Recursive directory size, following no symlinks (`symlink_metadata`
476/// throughout, so a symlink itself is sized but never traversed -- clones
477/// never legitimately contain symlinked directories pointing outside the
478/// clone, and this avoids any possibility of a symlink loop).
479///
480/// Tolerant of a *descendant* disappearing mid-walk (a vanished entry
481/// beneath an existing root contributes 0 bytes rather than aborting the
482/// whole size computation): a cache slot's `.git` tree can legitimately be
483/// mutated by something outside this function's control while it walks it
484/// -- a concurrent `evict_lru`/`ensure_clone` repair on the same slot, or a
485/// background `git maintenance` child from before `maintenance.auto=false`
486/// applied to every command this crate issues. This accounting is
487/// inherently a snapshot of a possibly-changing tree (ADR-088 Amendment 1:
488/// eviction is safe because ingest cursors live in the database, not the
489/// clone), so "a thing under the root I was about to size is already gone"
490/// is not an error here.
491///
492/// The walk **root** itself vanishing is different and is NOT tolerated --
493/// it surfaces as `CacheError::Io(NotFound)` rather than silently sizing to
494/// `0`. A caller that genuinely expects the root it's sizing to sometimes be
495/// absent (rather than an existing root racing a mid-walk mutation) must
496/// check for that error explicitly and decide its own semantics at that call
497/// site (`evict_lru` does this for a listed entry that a concurrent repair
498/// deleted between `read_dir` and this call -- see there); `dir_size` itself
499/// never launders a missing root into a bare `0`, which previously let
500/// `evict_lru` report success with a missing keep slot or count a phantom
501/// candidate and evict a valid one unnecessarily.
502fn dir_size(path: &Path) -> Result<u64, CacheError> {
503 let mut total = 0u64;
504 let mut stack = vec![path.to_path_buf()];
505 while let Some(p) = stack.pop() {
506 let is_root = p == path;
507 let md = match std::fs::symlink_metadata(&p) {
508 Ok(md) => md,
509 Err(e) if e.kind() == std::io::ErrorKind::NotFound && !is_root => continue,
510 Err(e) => return Err(io_err("dir_size: stat", &p, e)),
511 };
512 if md.is_dir() {
513 let read_dir = match std::fs::read_dir(&p) {
514 Ok(read_dir) => read_dir,
515 Err(e) if e.kind() == std::io::ErrorKind::NotFound && !is_root => continue,
516 Err(e) => return Err(io_err("dir_size: read_dir", &p, e)),
517 };
518 for entry in read_dir {
519 match entry {
520 Ok(entry) => stack.push(entry.path()),
521 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
522 Err(e) => return Err(io_err("dir_size: read_dir entry", &p, e)),
523 }
524 }
525 } else {
526 total += md.len();
527 }
528 }
529 Ok(total)
530}
531
532/// Whether `path` is a directory `ensure_clone` could plausibly have
533/// created: a 16-lowercase-hex `cache_key`-shaped directory name (never a
534/// UUID staging dir, never an arbitrary operator directory), itself a real
535/// directory rather than a symlink (a symlink placed at the cache-key path
536/// pointing at an unrelated owned-looking or foreign directory must never be
537/// treated as an owned slot), containing both a `.git` entry and the
538/// `.khive-last-used` marker written by `touch`. Eviction (and any future
539/// scratch-root cleanup) must only ever remove entries that pass this check
540/// -- a `KHIVE_GIT_DIGEST_SCRATCH_ROOT` override pointed at a broader or
541/// pre-existing directory must never lose unrelated data sitting next to the
542/// cache slots.
543fn is_owned_entry(path: &Path) -> bool {
544 let name = match path.file_name().and_then(|n| n.to_str()) {
545 Some(n) => n,
546 None => return false,
547 };
548 if name.len() != 16
549 || !name
550 .chars()
551 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
552 {
553 return false;
554 }
555 match std::fs::symlink_metadata(path) {
556 Ok(md) if md.is_dir() => {}
557 _ => return false,
558 }
559 path.join(".git").exists() && path.join(MARKER_FILE).exists()
560}
561
562/// Evict least-recently-used clones under `root` (by `.khive-last-used`
563/// mtime) until both the repo-count cap and the total-byte cap are
564/// satisfied. `keep` (the clone `ensure_clone` just touched) is never
565/// evicted. Only removes paths that are direct children of `root` AND pass
566/// `is_owned_entry` -- eviction never touches user-owned or non-cache paths.
567///
568/// `keep`'s own `dir_size` (below) is deliberately NOT tolerant of `keep`
569/// vanishing: every caller touches (or freshly installs) `keep` immediately
570/// before calling `evict_lru` in the same synchronous call chain, so `keep`
571/// disappearing out from under this call is not an expected repair race --
572/// it is either a genuine bug or an external actor deleting our slot, and
573/// silently sizing it to `0` would let eviction report success while the
574/// slot the caller asked to keep is actually gone. A listed *candidate*
575/// entry (below) is different -- another `evict_lru`/`ensure_clone`
576/// repairing the same root can legitimately delete it between the
577/// `read_dir` listing above and the `dir_size` call below, so that vanish is
578/// tolerated by skipping the entry rather than aborting the whole pass.
579fn evict_lru(root: &Path, keep: &Path) -> Result<(), CacheError> {
580 let mut entries: Vec<(PathBuf, SystemTime, u64)> = Vec::new();
581 let read_dir =
582 std::fs::read_dir(root).map_err(|e| io_err("evict_lru: read_dir root", root, e))?;
583 for entry in read_dir {
584 let entry = match entry {
585 Ok(entry) => entry,
586 // The directory listing raced a concurrent removal of one of its
587 // own entries (e.g. another `evict_lru`/`ensure_clone` repairing
588 // the same root) -- nothing to evict there anymore.
589 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
590 Err(e) => return Err(io_err("evict_lru: read_dir entry", root, e)),
591 };
592 let p = entry.path();
593 if !p.is_dir() || p == keep || !is_owned_entry(&p) {
594 continue;
595 }
596 let mtime = std::fs::metadata(p.join(MARKER_FILE))
597 .and_then(|m| m.modified())
598 .unwrap_or(SystemTime::UNIX_EPOCH);
599 let size = match dir_size(&p) {
600 Ok(size) => size,
601 // `p` was listed above but a concurrent repair on the same root
602 // has since deleted it whole -- there is no slot left to weigh
603 // in eviction accounting, not a size of `0` to record.
604 Err(CacheError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => continue,
605 Err(e) => return Err(e),
606 };
607 entries.push((p, mtime, size));
608 }
609 entries.sort_by_key(|(_, mtime, _)| *mtime);
610
611 let keep_size = dir_size(keep)?;
612 let mut total: u64 = entries.iter().map(|(_, _, s)| s).sum::<u64>() + keep_size;
613 let mut count = entries.len() + 1;
614 let cap_repos = max_repos();
615 let cap_bytes = max_total_bytes();
616
617 for (path, _, size) in entries {
618 if count <= cap_repos && total <= cap_bytes {
619 break;
620 }
621 let _ = std::fs::remove_dir_all(&path);
622 count -= 1;
623 total = total.saturating_sub(size);
624 }
625 Ok(())
626}
627
628/// `scratch_root()` reads process-global env vars; serialize any in-crate
629/// test (in this module or elsewhere, e.g. `recovery_tests.rs`) that touches
630/// it, so the whole `cargo test` binary's parallel test threads never race
631/// on `KHIVE_GIT_DIGEST_SCRATCH_ROOT`/cache-cap env vars/`PATH`. A
632/// `tokio::sync::Mutex` rather than `std::sync::Mutex` so async tests can
633/// hold the guard across `.await` points (`blocking_lock()` for this
634/// module's plain sync `#[test]`s).
635#[cfg(test)]
636pub(crate) static ENV_MUTEX: std::sync::LazyLock<tokio::sync::Mutex<()>> =
637 std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642
643 /// Build a directory shaped exactly like a real `ensure_clone` cache
644 /// slot: a 16-lowercase-hex name (a real `cache_key` output) containing
645 /// a `.git` dir and (optionally) the `.khive-last-used` marker.
646 fn make_owned_entry(root: &Path, key: &str, with_marker: bool) -> PathBuf {
647 assert_eq!(key.len(), 16, "test cache keys must be 16 hex chars");
648 let p = root.join(key);
649 std::fs::create_dir_all(p.join(".git")).unwrap();
650 if with_marker {
651 std::fs::write(p.join(MARKER_FILE), b"").unwrap();
652 }
653 p
654 }
655
656 /// ADR-088 Amendment 1 fix-round r2 Medium-1: a `git clone` failure (bad
657 /// source, no network needed -- a nonexistent local path fails
658 /// immediately) must not leave a `.staging-<uuid>` directory behind.
659 /// `evict_lru` deliberately never touches non-owned names, so a leaked
660 /// staging dir would otherwise accumulate forever across repeated
661 /// failures.
662 #[test]
663 fn ensure_clone_cleans_up_staging_dir_on_clone_failure() {
664 let _guard = ENV_MUTEX.blocking_lock();
665 let dir = tempfile::tempdir().expect("tempdir");
666 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", dir.path());
667
668 let bogus_source = dir.path().join("does-not-exist-as-a-repo");
669 let result = ensure_clone(bogus_source.to_str().expect("utf8 path"));
670 assert!(
671 result.is_err(),
672 "cloning a nonexistent local path must fail: {result:?}"
673 );
674
675 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
676 .expect("read scratch root")
677 .filter_map(|e| e.ok())
678 .map(|e| e.file_name().to_string_lossy().into_owned())
679 .filter(|name| name.starts_with(".staging-"))
680 .collect();
681 assert!(
682 leftovers.is_empty(),
683 "a failed clone must not leave .staging-* directories behind: {leftovers:?}"
684 );
685
686 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
687 }
688
689 #[test]
690 fn evict_lru_removes_oldest_past_repo_cap() {
691 let _guard = ENV_MUTEX.blocking_lock();
692 let dir = tempfile::tempdir().expect("tempdir");
693 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", dir.path());
694 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "1");
695 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "1000000000");
696
697 let root = dir.path();
698 let old = make_owned_entry(root, "1111111111111111", true);
699 // Ensure a real mtime gap.
700 std::thread::sleep(std::time::Duration::from_millis(20));
701 let new = make_owned_entry(root, "2222222222222222", true);
702
703 evict_lru(root, &new).expect("evict");
704
705 assert!(!old.exists(), "the older clone must be evicted");
706 assert!(new.exists(), "the kept clone must survive");
707
708 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
709 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
710 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
711 }
712
713 #[test]
714 fn evict_lru_only_touches_children_of_root() {
715 let _guard = ENV_MUTEX.blocking_lock();
716 let dir = tempfile::tempdir().expect("tempdir");
717 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "5");
718 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "1000000000");
719
720 let root = dir.path().join("scratch-root");
721 std::fs::create_dir_all(&root).unwrap();
722 let kept = make_owned_entry(&root, "3333333333333333", true);
723
724 evict_lru(&root, &kept).expect("evict");
725 assert!(kept.exists());
726
727 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
728 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
729 }
730
731 #[test]
732 fn evict_lru_never_removes_a_foreign_directory_under_root() {
733 let _guard = ENV_MUTEX.blocking_lock();
734 let dir = tempfile::tempdir().expect("tempdir");
735 // Cap of 0 repos: without ownership filtering this would previously
736 // have wiped out every child of root, including operator data.
737 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "0");
738 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "0");
739
740 let root = dir.path().join("scratch-root");
741 std::fs::create_dir_all(&root).unwrap();
742 let foreign = root.join("not-a-cache-entry");
743 std::fs::create_dir_all(&foreign).unwrap();
744 std::fs::write(foreign.join("important.txt"), b"do not delete me").unwrap();
745 let kept = make_owned_entry(&root, "4444444444444444", true);
746
747 evict_lru(&root, &kept).expect("evict");
748
749 assert!(
750 foreign.exists(),
751 "a directory that doesn't look like a cache slot must survive eviction"
752 );
753 assert!(
754 foreign.join("important.txt").exists(),
755 "foreign directory contents must be untouched"
756 );
757
758 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
759 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
760 }
761
762 #[test]
763 fn evict_lru_never_removes_an_owned_looking_dir_missing_the_marker() {
764 let _guard = ENV_MUTEX.blocking_lock();
765 let dir = tempfile::tempdir().expect("tempdir");
766 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "0");
767 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "0");
768
769 let root = dir.path().join("scratch-root");
770 std::fs::create_dir_all(&root).unwrap();
771 // Has a .git dir and a valid cache-key-shaped name, but no marker --
772 // e.g. a clone that failed after `clone()` but before `touch()`.
773 let no_marker = make_owned_entry(&root, "5555555555555555", false);
774 let kept = make_owned_entry(&root, "6666666666666666", true);
775
776 evict_lru(&root, &kept).expect("evict");
777
778 assert!(
779 no_marker.exists(),
780 "an owned-looking directory without the marker must survive eviction"
781 );
782
783 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
784 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
785 }
786
787 #[test]
788 fn is_owned_entry_rejects_non_cache_shapes() {
789 let dir = tempfile::tempdir().expect("tempdir");
790 let root = dir.path();
791
792 // Wrong length.
793 let short = root.join("abc123");
794 std::fs::create_dir_all(short.join(".git")).unwrap();
795 std::fs::write(short.join(MARKER_FILE), b"").unwrap();
796 assert!(!is_owned_entry(&short));
797
798 // Uppercase hex (cache_key is always lowercase).
799 let upper = root.join("ABCDEF0123456789");
800 std::fs::create_dir_all(upper.join(".git")).unwrap();
801 std::fs::write(upper.join(MARKER_FILE), b"").unwrap();
802 assert!(!is_owned_entry(&upper));
803
804 // Right shape but missing .git.
805 let no_git = root.join("7777777777777777");
806 std::fs::create_dir_all(&no_git).unwrap();
807 std::fs::write(no_git.join(MARKER_FILE), b"").unwrap();
808 assert!(!is_owned_entry(&no_git));
809
810 let owned = make_owned_entry(root, "8888888888888888", true);
811 assert!(is_owned_entry(&owned));
812 }
813
814 #[test]
815 fn dir_size_sums_file_bytes_recursively() {
816 let dir = tempfile::tempdir().expect("tempdir");
817 std::fs::write(dir.path().join("a.txt"), b"12345").unwrap();
818 std::fs::create_dir_all(dir.path().join("sub")).unwrap();
819 std::fs::write(dir.path().join("sub/b.txt"), b"1234567890").unwrap();
820 assert_eq!(dir_size(dir.path()).unwrap(), 15);
821 }
822
823 /// PR #847 round-3 Major-2: the walk root vanishing must surface as an
824 /// error, never a laundered `Ok(0)` -- distinct from a descendant
825 /// vanishing beneath a still-existing root (see the Barrier tests
826 /// below). A root that was never there to begin with is the simplest,
827 /// fully deterministic instance of "the root itself is missing".
828 #[test]
829 fn dir_size_errors_when_the_root_itself_is_missing() {
830 let dir = tempfile::tempdir().expect("tempdir");
831 let missing = dir.path().join("does-not-exist");
832 let err = dir_size(&missing).expect_err("a missing root must error, not size to 0");
833 assert!(
834 matches!(&err, CacheError::Io(e) if e.kind() == std::io::ErrorKind::NotFound),
835 "expected CacheError::Io(NotFound), got {err:?}"
836 );
837 }
838
839 /// `evict_lru`'s `keep` argument: every caller (`ensure_clone`,
840 /// `refetch_clone`, `reclone`) has just touched or freshly installed
841 /// `keep` immediately before calling `evict_lru`, so `keep` vanishing is
842 /// a real problem to surface, not a maybe-absent slot -- `evict_lru`
843 /// must propagate `dir_size(keep)`'s error rather than treat it as an
844 /// empty, evictable-looking slot.
845 #[test]
846 fn evict_lru_errors_when_keep_itself_is_missing() {
847 let _guard = ENV_MUTEX.blocking_lock();
848 let dir = tempfile::tempdir().expect("tempdir");
849 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS", "5");
850 std::env::set_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES", "1000000000");
851
852 let root = dir.path().join("scratch-root");
853 std::fs::create_dir_all(&root).unwrap();
854 let missing_keep = root.join("0000000000000000");
855
856 let err = evict_lru(&root, &missing_keep).expect_err("a missing keep root must error");
857 assert!(
858 matches!(&err, CacheError::Io(e) if e.kind() == std::io::ErrorKind::NotFound),
859 "expected CacheError::Io(NotFound), got {err:?}"
860 );
861
862 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_REPOS");
863 std::env::remove_var("KHIVE_GIT_DIGEST_CACHE_MAX_BYTES");
864 }
865
866 /// Issue #842's macOS ENOENT flake family: `dir_size` walks a tree that
867 /// can legitimately be mutated out from under it (git's own detached
868 /// background maintenance child, or a racing `evict_lru`/`ensure_clone`
869 /// repair on the same
870 /// slot) -- a subdirectory disappearing between `read_dir` listing it and
871 /// this walk descending into it must shrink the total, not abort the
872 /// whole computation with `CacheError::Io(NotFound)`.
873 ///
874 /// This is a genuine cross-thread filesystem race, not a fully
875 /// deterministic single-shot repro: a `std::sync::Barrier` releases both
876 /// threads at the same instant, a wide fan of sibling subdirectories
877 /// gives the walk many entries to still be processing when the deleter
878 /// runs, and the whole race is repeated many times so the window is
879 /// almost certain to be hit at least once across the loop. Pre-fix (see
880 /// the sabotage note on `dir_size` above), this reliably reproduces
881 /// `CacheError::Io` within a handful of iterations on this machine; it is
882 /// not a `sleep`-based synchronization, so it is not always the exact
883 /// same interleaving twice, but the failure is real and observable, not
884 /// theoretical.
885 #[test]
886 fn dir_size_tolerates_a_subdirectory_removed_mid_walk() {
887 for _ in 0..200 {
888 let dir = tempfile::tempdir().expect("tempdir");
889 let root = dir.path().to_path_buf();
890 let victim = root.join("victim");
891 std::fs::create_dir_all(&victim).unwrap();
892 for i in 0..64 {
893 std::fs::write(victim.join(format!("f{i}.txt")), b"0123456789").unwrap();
894 }
895 // A wide fan of siblings so the walk still has entries left on
896 // its stack (and is plausibly still inside `victim`) at the
897 // instant the other thread deletes it.
898 for i in 0..64 {
899 let sibling = root.join(format!("sibling{i}"));
900 std::fs::create_dir_all(&sibling).unwrap();
901 std::fs::write(sibling.join("s.txt"), b"0123456789").unwrap();
902 }
903
904 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
905 let walk_root = root.clone();
906 let walk_barrier = barrier.clone();
907 let walker = std::thread::spawn(move || {
908 walk_barrier.wait();
909 dir_size(&walk_root)
910 });
911 let delete_victim = victim.clone();
912 let deleter = std::thread::spawn(move || {
913 barrier.wait();
914 let _ = std::fs::remove_dir_all(&delete_victim);
915 });
916
917 let result = walker.join().expect("walker thread");
918 deleter.join().expect("deleter thread");
919
920 assert!(
921 result.is_ok(),
922 "dir_size must tolerate a subdirectory vanishing mid-walk, got {result:?}"
923 );
924 }
925 }
926
927 /// Companion to the test above, pinning the other half of the Major-2
928 /// contract (PR #847 round-3): when the vanishing path is the walk
929 /// **root** itself -- not a descendant beneath a still-existing root --
930 /// `dir_size` must surface an error rather than tolerate it. Same
931 /// barrier-race harness, but `root` is left empty (an empty-directory
932 /// removal is a single `rmdir` syscall, the same order of cost as the
933 /// `symlink_metadata`/`read_dir` calls `dir_size` opens with -- a
934 /// populated root, by contrast, has its own directory entry removed
935 /// *last* by `remove_dir_all` after every child, well after the
936 /// walker's first two syscalls would already have completed, which
937 /// would make the root-vanish race effectively unreachable). Pre-fix,
938 /// `dir_size` treated a disappearing root exactly like a disappearing
939 /// descendant and returned `Ok(0)`, which could let `evict_lru` report
940 /// success over a missing `keep` slot or count a phantom `0`-byte
941 /// candidate.
942 #[test]
943 fn dir_size_errors_when_the_root_is_removed_mid_walk() {
944 let mut saw_error = false;
945 for _ in 0..500 {
946 let dir = tempfile::tempdir().expect("tempdir");
947 let root = dir.path().join("slot");
948 std::fs::create_dir_all(&root).unwrap();
949
950 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
951 let walk_root = root.clone();
952 let walk_barrier = barrier.clone();
953 let walker = std::thread::spawn(move || {
954 walk_barrier.wait();
955 dir_size(&walk_root)
956 });
957 let delete_root = root.clone();
958 let deleter = std::thread::spawn(move || {
959 barrier.wait();
960 let _ = std::fs::remove_dir(&delete_root);
961 });
962
963 let result = walker.join().expect("walker thread");
964 deleter.join().expect("deleter thread");
965
966 match result {
967 Ok(_) => continue, // walker won the race this round; try again
968 Err(CacheError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
969 saw_error = true;
970 }
971 Err(e) => panic!("unexpected error kind from a vanished root: {e:?}"),
972 }
973 }
974 assert!(
975 saw_error,
976 "root-vanish-mid-walk race was never hit across 500 iterations; \
977 widen the fixture or investigate the barrier timing"
978 );
979 }
980
981 // ── issue #765: refetch/reclone repair primitives ──────────────────────
982
983 fn git(repo: &Path, args: &[&str]) {
984 let out = Command::new("git")
985 .arg("-C")
986 .arg(repo)
987 .args(args)
988 .output()
989 .expect("spawn git");
990 assert!(
991 out.status.success(),
992 "git {args:?} failed: {}",
993 String::from_utf8_lossy(&out.stderr)
994 );
995 }
996
997 /// A real local repo usable as an `ensure_clone`/`refetch_clone`/`reclone`
998 /// `canonical_url` (git accepts a plain filesystem path as a clone/fetch
999 /// source, same as the existing `ensure_clone_cleans_up_staging_dir_*`
1000 /// test does for a failure case).
1001 fn init_origin_with_one_commit(repo: &Path) {
1002 git(repo, &["init", "-q", "-b", "main"]);
1003 git(repo, &["config", "user.email", "test@example.com"]);
1004 git(repo, &["config", "user.name", "Test User"]);
1005 std::fs::write(repo.join("a.txt"), b"hello").unwrap();
1006 git(repo, &["add", "a.txt"]);
1007 git(repo, &["commit", "-q", "-m", "initial"]);
1008 }
1009
1010 fn add_commit(repo: &Path, rel: &str, contents: &str, message: &str) {
1011 std::fs::write(repo.join(rel), contents).unwrap();
1012 git(repo, &["add", rel]);
1013 git(repo, &["commit", "-q", "-m", message]);
1014 }
1015
1016 fn head_sha(repo: &Path) -> String {
1017 let out = Command::new("git")
1018 .arg("-C")
1019 .arg(repo)
1020 .args(["rev-parse", "HEAD"])
1021 .output()
1022 .expect("rev-parse");
1023 String::from_utf8_lossy(&out.stdout).trim().to_string()
1024 }
1025
1026 /// The primary #765 acceptance path: a slot already exists (via
1027 /// `ensure_clone`); `refetch_clone` must pull history the slot doesn't
1028 /// have yet (standing in for genuinely corrupt/incomplete objects, which
1029 /// `git fetch --refetch` repairs the same way -- by re-obtaining a
1030 /// complete fresh packfile from the remote).
1031 #[test]
1032 fn refetch_clone_updates_an_existing_slot_to_the_remote_tip() {
1033 let _guard = ENV_MUTEX.blocking_lock();
1034 let scratch = tempfile::tempdir().expect("tempdir");
1035 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1036
1037 let origin_dir = tempfile::tempdir().expect("tempdir");
1038 init_origin_with_one_commit(origin_dir.path());
1039 let canonical = origin_dir.path().to_str().unwrap();
1040
1041 let first = ensure_clone(canonical).expect("initial ensure_clone");
1042 let before = head_sha(&first);
1043
1044 add_commit(origin_dir.path(), "b.txt", "world", "second");
1045 let expected_tip = head_sha(origin_dir.path());
1046 assert_ne!(before, expected_tip, "origin must have moved on");
1047
1048 let repaired = refetch_clone(canonical).expect("refetch_clone");
1049 assert_eq!(repaired, first, "refetch repairs the same cache slot path");
1050 git(&repaired, &["show", &format!("{expected_tip}:b.txt")]);
1051
1052 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1053 }
1054
1055 /// Review round-1 \[High\]-1 remediation (issue #765), tightened by review
1056 /// round-2 \[High\]-1: `refetch_clone`'s over-cap cleanup must go through
1057 /// the same ownership guard `reclone` uses, not a raw `remove_dir_all`,
1058 /// AND must propagate that guard's failure rather than discarding it --
1059 /// a slot that has lost its `.khive-last-used` marker (simulating a
1060 /// directory the guard cannot prove it owns) survives over-cap cleanup,
1061 /// and the caller sees the ownership failure (`UnsafeToReplace`) that
1062 /// actually occurred, not a laundered `CloneTooLarge`. Since review
1063 /// round-3 \[High\]-1 added a pre-fetch ownership re-check, this markerless
1064 /// slot is now refused before `fetch_refetch` even runs (see
1065 /// `refetch_clone_refuses_a_markerless_slot_under_the_cap` below) rather
1066 /// than at the over-cap cleanup step this test originally targeted --
1067 /// the assertions still hold (`UnsafeToReplace`, slot survives), so this
1068 /// remains a valid regression guard for the cleanup path once a slot
1069 /// somehow reaches it un-owned.
1070 #[test]
1071 fn refetch_clone_over_cap_cleanup_never_deletes_an_unproven_slot() {
1072 let _guard = ENV_MUTEX.blocking_lock();
1073 let scratch = tempfile::tempdir().expect("tempdir");
1074 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1075
1076 let origin_dir = tempfile::tempdir().expect("tempdir");
1077 init_origin_with_one_commit(origin_dir.path());
1078 let canonical = origin_dir.path().to_str().unwrap();
1079
1080 let slot = ensure_clone(canonical).expect("initial ensure_clone");
1081 // Simulate a slot the ownership guard cannot prove it owns (e.g. a
1082 // crash between a prior clone/fetch and `touch`, or a foreign
1083 // directory occupying this exact cache-key path) by removing the
1084 // marker `touch` would normally have written.
1085 std::fs::remove_file(slot.join(".khive-last-used")).expect("remove marker");
1086
1087 std::env::set_var("KHIVE_GIT_DIGEST_CLONE_MAX_BYTES", "1");
1088 let err = refetch_clone(canonical).expect_err("refetch must report the ownership error");
1089 assert!(
1090 matches!(err, CacheError::UnsafeToReplace(_)),
1091 "expected UnsafeToReplace (the cleanup's ownership failure, propagated), got {err:?}"
1092 );
1093 assert!(
1094 slot.exists(),
1095 "a slot the ownership guard cannot prove it owns must survive over-cap cleanup"
1096 );
1097
1098 std::env::remove_var("KHIVE_GIT_DIGEST_CLONE_MAX_BYTES");
1099 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1100 }
1101
1102 /// Review round-3 \[High\]-1 remediation (issue #765 follow-up PR #788):
1103 /// `refetch_clone` must refuse a markerless slot *before* ever calling
1104 /// `fetch_refetch`, not only on the over-cap cleanup branch the previous
1105 /// test exercises. Under the default (non-cap-exceeded) cap, a
1106 /// pre-fetch ownership check is the only thing standing between a
1107 /// markerless slot and a real fetch: the origin is given fresh history
1108 /// so a fetch that ran despite the missing marker would be directly
1109 /// observable via a moved `HEAD`.
1110 #[test]
1111 fn refetch_clone_refuses_a_markerless_slot_under_the_cap() {
1112 let _guard = ENV_MUTEX.blocking_lock();
1113 let scratch = tempfile::tempdir().expect("tempdir");
1114 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1115
1116 let origin_dir = tempfile::tempdir().expect("tempdir");
1117 init_origin_with_one_commit(origin_dir.path());
1118 let canonical = origin_dir.path().to_str().unwrap();
1119
1120 let slot = ensure_clone(canonical).expect("initial ensure_clone");
1121 let sentinel_sha = head_sha(&slot);
1122 std::fs::remove_file(slot.join(MARKER_FILE)).expect("remove marker");
1123
1124 // The origin moves on -- if the ownership guard failed to fire and
1125 // a real fetch ran, the slot's HEAD would follow.
1126 add_commit(origin_dir.path(), "b.txt", "world", "second");
1127
1128 let err = refetch_clone(canonical)
1129 .expect_err("a markerless slot must be refused before any fetch runs");
1130 assert!(
1131 matches!(err, CacheError::UnsafeToReplace(_)),
1132 "expected UnsafeToReplace, got {err:?}"
1133 );
1134 assert_eq!(
1135 head_sha(&slot),
1136 sentinel_sha,
1137 "no fetch must have run against the markerless slot"
1138 );
1139 assert!(
1140 !slot.join(MARKER_FILE).exists(),
1141 "a refused refetch must never (re)write the ownership marker"
1142 );
1143
1144 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1145 }
1146
1147 #[test]
1148 fn refetch_clone_errors_when_no_slot_exists() {
1149 let _guard = ENV_MUTEX.blocking_lock();
1150 let scratch = tempfile::tempdir().expect("tempdir");
1151 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1152
1153 let err = refetch_clone("https://example.invalid/never-cloned/repo")
1154 .expect_err("no slot exists yet");
1155 assert!(
1156 matches!(err, CacheError::Git(_)),
1157 "expected CacheError::Git, got {err:?}"
1158 );
1159
1160 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1161 }
1162
1163 /// #765's fallback path: a refetch that cannot repair the slot (here,
1164 /// simulated by pointing the existing slot's `origin` remote at a
1165 /// nonexistent path so `git fetch --refetch` itself fails) is followed by
1166 /// `reclone`, which ignores the broken clone entirely and clones fresh
1167 /// from the still-good `canonical_url`.
1168 #[test]
1169 fn reclone_replaces_a_slot_whose_refetch_cannot_succeed() {
1170 let _guard = ENV_MUTEX.blocking_lock();
1171 let scratch = tempfile::tempdir().expect("tempdir");
1172 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1173
1174 let origin_dir = tempfile::tempdir().expect("tempdir");
1175 init_origin_with_one_commit(origin_dir.path());
1176 let canonical = origin_dir.path().to_str().unwrap();
1177
1178 let slot = ensure_clone(canonical).expect("initial ensure_clone");
1179 // Break the slot's own remote so `fetch --refetch origin` fails --
1180 // standing in for a corrupt slot that cannot self-repair via refetch.
1181 git(
1182 &slot,
1183 &[
1184 "remote",
1185 "set-url",
1186 "origin",
1187 "/nonexistent/path/does-not-exist",
1188 ],
1189 );
1190 assert!(matches!(refetch_clone(canonical), Err(CacheError::Git(_))));
1191
1192 let recloned = reclone(canonical).expect("reclone");
1193 assert_eq!(recloned, slot, "reclone reinstalls at the same slot path");
1194 assert_eq!(head_sha(&recloned), head_sha(origin_dir.path()));
1195 // The fresh clone's own remote points back at the canonical URL, not
1196 // the broken one the corrupt slot had.
1197 let out = Command::new("git")
1198 .arg("-C")
1199 .arg(&recloned)
1200 .args(["remote", "get-url", "origin"])
1201 .output()
1202 .expect("remote get-url");
1203 assert_eq!(
1204 String::from_utf8_lossy(&out.stdout).trim(),
1205 canonical,
1206 "reclone must re-point origin at canonical_url, not the broken remote"
1207 );
1208
1209 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1210 }
1211
1212 /// Ownership guard (ADR-088 Amendment 1 / PR #761): `reclone` must never
1213 /// delete a directory that doesn't prove itself an owned cache slot, even
1214 /// though its path is exactly where the cache key says the slot should
1215 /// be -- a `KHIVE_GIT_DIGEST_SCRATCH_ROOT` override pointed at a broader
1216 /// directory must never lose unrelated operator data to a repair.
1217 #[test]
1218 fn reclone_refuses_to_replace_a_foreign_looking_directory() {
1219 let _guard = ENV_MUTEX.blocking_lock();
1220 let scratch = tempfile::tempdir().expect("tempdir");
1221 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1222
1223 let origin_dir = tempfile::tempdir().expect("tempdir");
1224 init_origin_with_one_commit(origin_dir.path());
1225 let canonical = origin_dir.path().to_str().unwrap();
1226 let key = cache_key(canonical);
1227 let foreign = scratch.path().join(&key);
1228 std::fs::create_dir_all(&foreign).unwrap();
1229 std::fs::write(foreign.join("important.txt"), b"do not delete me").unwrap();
1230
1231 let err = reclone(canonical).expect_err("foreign directory must be refused");
1232 assert!(
1233 matches!(err, CacheError::UnsafeToReplace(_)),
1234 "expected UnsafeToReplace, got {err:?}"
1235 );
1236 assert!(
1237 foreign.join("important.txt").exists(),
1238 "foreign directory contents must survive a refused reclone"
1239 );
1240
1241 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1242 }
1243
1244 /// When no slot exists at all yet, `reclone` has nothing to remove and
1245 /// simply installs a fresh clone -- the same fallback a first-ever
1246 /// `ensure_clone` would have taken.
1247 #[test]
1248 fn reclone_installs_fresh_when_no_slot_exists_yet() {
1249 let _guard = ENV_MUTEX.blocking_lock();
1250 let scratch = tempfile::tempdir().expect("tempdir");
1251 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1252
1253 let origin_dir = tempfile::tempdir().expect("tempdir");
1254 init_origin_with_one_commit(origin_dir.path());
1255 let canonical = origin_dir.path().to_str().unwrap();
1256
1257 let recloned = reclone(canonical).expect("reclone with no prior slot");
1258 assert_eq!(head_sha(&recloned), head_sha(origin_dir.path()));
1259
1260 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1261 }
1262
1263 /// Review round-2 \[High\]-1 remediation (issue #765): `ensure_clone` must
1264 /// never adopt, fetch into, or touch a directory sitting at the
1265 /// cache-key path that does not already prove itself owned via
1266 /// `is_owned_entry`. Here the directory is a genuine Git repository (so
1267 /// the pre-fix `repo_dir.join(".git").exists()` check alone would have
1268 /// accepted it) but is missing the `.khive-last-used` marker -- standing
1269 /// in for an operator's own repository that happens to land on the same
1270 /// cache-key path under an overridden `KHIVE_GIT_DIGEST_SCRATCH_ROOT`.
1271 /// The call must refuse with `UnsafeToReplace` and the sentinel operator
1272 /// data inside must survive completely untouched.
1273 #[test]
1274 fn ensure_clone_refuses_a_markerless_git_directory_at_the_cache_key_path() {
1275 let _guard = ENV_MUTEX.blocking_lock();
1276 let scratch = tempfile::tempdir().expect("tempdir");
1277 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1278
1279 let canonical = "https://example.invalid/lookalike/repo";
1280 let key = cache_key(canonical);
1281 let lookalike = scratch.path().join(&key);
1282 std::fs::create_dir_all(&lookalike).unwrap();
1283 init_origin_with_one_commit(&lookalike);
1284 std::fs::write(lookalike.join("sentinel.txt"), b"do not delete me").unwrap();
1285 let sentinel_sha = head_sha(&lookalike);
1286
1287 let err = ensure_clone(canonical).expect_err("markerless lookalike must be refused");
1288 assert!(
1289 matches!(err, CacheError::UnsafeToReplace(_)),
1290 "expected UnsafeToReplace, got {err:?}"
1291 );
1292
1293 assert!(
1294 lookalike.join("sentinel.txt").exists(),
1295 "sentinel operator data must survive a refused ensure_clone"
1296 );
1297 assert_eq!(
1298 head_sha(&lookalike),
1299 sentinel_sha,
1300 "the lookalike repository's own history must be untouched (no fetch ran)"
1301 );
1302 assert!(
1303 !lookalike.join(MARKER_FILE).exists(),
1304 "a refused ensure_clone must never write the ownership marker either"
1305 );
1306
1307 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1308 }
1309
1310 /// Same guard, symlink variant: a symlink placed at the cache-key path
1311 /// pointing at an unrelated owned-looking directory must not be treated
1312 /// as an owned slot either -- `is_owned_entry` requires the cache-key
1313 /// path itself to be a real directory, not a symlink to one.
1314 #[cfg(unix)]
1315 #[test]
1316 fn ensure_clone_refuses_a_symlink_at_the_cache_key_path() {
1317 let _guard = ENV_MUTEX.blocking_lock();
1318 let scratch = tempfile::tempdir().expect("tempdir");
1319 std::env::set_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT", scratch.path());
1320
1321 let canonical = "https://example.invalid/symlink-lookalike/repo";
1322 let key = cache_key(canonical);
1323 let link_path = scratch.path().join(&key);
1324
1325 let target = tempfile::tempdir().expect("symlink target");
1326 make_owned_entry(target.path(), "9999999999999999", true);
1327 let real_owned = target.path().join("9999999999999999");
1328 std::fs::write(real_owned.join("sentinel.txt"), b"do not delete me").unwrap();
1329
1330 std::os::unix::fs::symlink(&real_owned, &link_path).expect("create symlink");
1331
1332 let err = ensure_clone(canonical).expect_err("symlink lookalike must be refused");
1333 assert!(
1334 matches!(err, CacheError::UnsafeToReplace(_)),
1335 "expected UnsafeToReplace, got {err:?}"
1336 );
1337 assert!(
1338 real_owned.join("sentinel.txt").exists(),
1339 "the symlink target's sentinel data must survive a refused ensure_clone"
1340 );
1341
1342 std::env::remove_var("KHIVE_GIT_DIGEST_SCRATCH_ROOT");
1343 }
1344}