cordance_doctrine/lib.rs
1//! Doctrine consumer. Reads `0ryant/engineering-doctrine` and indexes by
2//! directory. **Never re-classifies prose** — only filenames and paths.
3//!
4//! Source-of-truth: `engineering-doctrine/doctrine/SEMANTIC_INDEX.md`.
5//!
6//! # Golden path
7//!
8//! ```no_run
9//! use camino::Utf8PathBuf;
10//!
11//! let root = Utf8PathBuf::from("../engineering-doctrine");
12//! let index = cordance_doctrine::load_doctrine(&root).expect("load doctrine");
13//!
14//! println!("doctrine pinned at: {:?}", index.commit);
15//! for entry in &index.principles {
16//! println!("principle: {} ({})", entry.topic, entry.path);
17//! }
18//! ```
19//!
20//! With network fallback (when the sibling clone is absent, performs a
21//! hardened shallow HTTPS clone into an operator-trusted cache —
22//! `dirs::cache_dir()/cordance/doctrine/<hash>/<head-sha>/`, never inside
23//! the target repo):
24//!
25//! ```no_run
26//! # use camino::Utf8PathBuf;
27//! let root = Utf8PathBuf::from("../engineering-doctrine");
28//! let index = cordance_doctrine::load_doctrine_with_fallback(
29//! &root,
30//! "https://github.com/0ryant/engineering-doctrine",
31//! None,
32//! Some("auto"),
33//! ).expect("doctrine loaded (sibling or fallback clone)");
34//! # let _ = index;
35//! ```
36
37#![forbid(unsafe_code)]
38#![deny(clippy::unwrap_used, clippy::expect_used)]
39#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
40
41use std::num::NonZeroU32;
42
43use camino::Utf8PathBuf;
44use serde::{Deserialize, Serialize};
45use sha2::Digest;
46use tracing::{debug, info, warn};
47use walkdir::WalkDir;
48
49#[derive(Clone, Debug, Serialize, Deserialize)]
50pub struct DoctrineIndex {
51 pub schema: String,
52 pub repo: String,
53 pub commit: Option<String>,
54 pub root: Utf8PathBuf,
55 pub principles: Vec<DoctrineEntry>,
56 pub patterns: Vec<DoctrineEntry>,
57 pub checklists: Vec<DoctrineEntry>,
58 pub tooling: Vec<DoctrineEntry>,
59 pub glossary_path: Option<Utf8PathBuf>,
60 pub semantic_index_path: Option<Utf8PathBuf>,
61}
62
63impl DoctrineIndex {
64 /// Build an empty index rooted at `root`. Used when doctrine cannot be
65 /// loaded and the caller prefers graceful degradation.
66 #[must_use]
67 pub fn empty(root: Utf8PathBuf) -> Self {
68 Self {
69 schema: "cordance-doctrine-index.v1".into(),
70 repo: "0ryant/engineering-doctrine".into(),
71 commit: None,
72 root,
73 principles: vec![],
74 patterns: vec![],
75 checklists: vec![],
76 tooling: vec![],
77 glossary_path: None,
78 semantic_index_path: None,
79 }
80 }
81}
82
83#[derive(Clone, Debug, Serialize, Deserialize)]
84pub struct DoctrineEntry {
85 /// Filename stem — e.g. `"audit-logging"` from `audit-logging.md`.
86 pub topic: String,
87 /// Repo-relative path with forward slashes.
88 pub path: Utf8PathBuf,
89 pub sha256: String,
90 pub bytes: u64,
91}
92
93#[derive(Debug, thiserror::Error)]
94pub enum DoctrineError {
95 #[error("doctrine root not found: {0}")]
96 NotFound(Utf8PathBuf),
97 #[error("io error: {0}")]
98 Io(#[from] std::io::Error),
99 #[error("git clone failed: {0}")]
100 GitClone(String),
101 #[error("doctrine pin mismatch: expected {expected}, got {actual}")]
102 PinMismatch { expected: String, actual: String },
103 #[error("doctrine fallback URL rejected: {0}")]
104 InvalidFallbackUrl(String),
105}
106
107/// Soft cap on the size of any single doctrine markdown file. Anything above
108/// this is silently skipped (the entry is omitted from the index) rather than
109/// loaded into memory. Doctrine prose is human-authored — a multi-megabyte
110/// file is almost certainly a mistake or an attack.
111const MAX_DOCTRINE_FILE_BYTES: u64 = 16 * 1024 * 1024;
112
113/// Load and index the engineering-doctrine repository at `root`.
114///
115/// Walks fixed subdirectories under `root/doctrine/`, hashes every `.md`
116/// file, and attempts a best-effort git HEAD read. Returns a sorted,
117/// fully-populated [`DoctrineIndex`] on success.
118#[allow(clippy::missing_errors_doc)]
119pub fn load_doctrine(root: &Utf8PathBuf) -> Result<DoctrineIndex, DoctrineError> {
120 if !root.exists() {
121 return Err(DoctrineError::NotFound(root.clone()));
122 }
123
124 let doctrine_dir = root.join("doctrine");
125
126 let mut principles = collect_flat_md(&doctrine_dir.join("principles"))?;
127 let mut patterns = collect_flat_md(&doctrine_dir.join("patterns"))?;
128 let mut checklists = collect_flat_md(&doctrine_dir.join("checklists"))?;
129 let mut tooling = collect_recursive_md(&doctrine_dir.join("tooling"))?;
130
131 principles.sort_by(|a, b| a.topic.cmp(&b.topic));
132 patterns.sort_by(|a, b| a.topic.cmp(&b.topic));
133 checklists.sort_by(|a, b| a.topic.cmp(&b.topic));
134 tooling.sort_by(|a, b| a.topic.cmp(&b.topic));
135
136 let glossary_path = {
137 let p = doctrine_dir.join("glossary.md");
138 if p.exists() {
139 Some(p)
140 } else {
141 None
142 }
143 };
144
145 let semantic_index_path = {
146 let p = doctrine_dir.join("SEMANTIC_INDEX.md");
147 if p.exists() {
148 Some(p)
149 } else {
150 None
151 }
152 };
153
154 let commit = resolve_head_commit(root.as_std_path());
155
156 debug!(
157 root = %root,
158 principles = principles.len(),
159 patterns = patterns.len(),
160 checklists = checklists.len(),
161 tooling = tooling.len(),
162 commit = ?commit,
163 "doctrine index built"
164 );
165
166 Ok(DoctrineIndex {
167 schema: "cordance-doctrine-index.v1".into(),
168 repo: "0ryant/engineering-doctrine".into(),
169 commit,
170 root: root.clone(),
171 principles,
172 patterns,
173 checklists,
174 tooling,
175 glossary_path,
176 semantic_index_path,
177 })
178}
179
180/// Load doctrine, returning an empty index on any error rather than propagating.
181///
182/// Useful when doctrine is optional and callers prefer graceful degradation.
183#[must_use]
184pub fn load_doctrine_or_default(root: &Utf8PathBuf) -> DoctrineIndex {
185 match load_doctrine(root) {
186 Ok(idx) => idx,
187 Err(err) => {
188 warn!(root = %root, error = %err, "doctrine load failed; using empty index");
189 DoctrineIndex::empty(root.clone())
190 }
191 }
192}
193
194/// Load doctrine from `root` on disk. If `root` does not exist, clone
195/// `fallback_repo` (shallow, depth=1) into `cache_dir` and load from there.
196///
197/// When `cache_dir` is `None`, the loader defaults to an *operator-trusted*
198/// location derived from [`cordance_core::paths::doctrine_cache_dir_for_url`]
199/// (i.e. `dirs::cache_dir()/cordance/doctrine/<url-hash>/`). The default
200/// previously pointed inside the target tree, which round-4 redteam #1
201/// identified as a doctrine-cache-injection vector: a hostile target can
202/// pre-populate `<target>/.cordance/cache/doctrine/<self-consistent-sha>/`
203/// with a real-but-attacker-crafted git repo whose dirname matches HEAD,
204/// satisfying the warm-cache `"auto"`-pin path and slipping attacker prose
205/// into the index.
206///
207/// Within `cache_dir`, the clone is stored at `<base>/<full-sha>/` so
208/// repeat invocations reuse the same checkout when the remote HEAD is
209/// unchanged. The full 40-character SHA is used to defeat the redteam
210/// "doctrine cache uses 8-hex short SHA, collision-prone" finding: an
211/// attacker who briefly controls the network would otherwise have many
212/// tries to land at a colliding short prefix.
213///
214/// `pin_commit` is an optional full-SHA assertion: when `Some(expected)` and
215/// `expected != "auto"`, the cloned HEAD must match `expected` byte-for-byte
216/// or [`DoctrineError::PinMismatch`] is returned. This defeats the redteam
217/// "doctrine clone hijack" finding by binding the on-disk doctrine to a
218/// known-good commit declared in `cordance.toml`.
219///
220/// `fallback_repo` is also validated: only `https://` URLs without path-
221/// traversal segments are accepted, defeating the `file://` escape that gix
222/// otherwise permits.
223///
224/// # Errors
225///
226/// - [`DoctrineError::InvalidFallbackUrl`] if `fallback_repo` is not `https://`
227/// or contains `..` segments.
228/// - [`DoctrineError::GitClone`] if the network clone fails (offline, DNS
229/// failure, TLS error, remote unreachable).
230/// - [`DoctrineError::PinMismatch`] if `pin_commit` is set and HEAD doesn't
231/// match.
232/// - [`DoctrineError::Io`] if the cache directory cannot be created or read.
233pub fn load_doctrine_with_fallback(
234 root: &Utf8PathBuf,
235 fallback_repo: &str,
236 cache_dir: Option<&Utf8PathBuf>,
237 pin_commit: Option<&str>,
238) -> Result<DoctrineIndex, DoctrineError> {
239 match load_doctrine(root) {
240 Ok(idx) => Ok(idx),
241 Err(DoctrineError::NotFound(_)) => {
242 debug!(
243 root = %root,
244 fallback_repo,
245 "doctrine sibling missing; falling back to HTTPS clone"
246 );
247 load_from_fallback_clone(fallback_repo, cache_dir, pin_commit)
248 }
249 Err(other) => Err(other),
250 }
251}
252
253/// Like [`load_doctrine_with_fallback`] but returns an empty index instead of
254/// propagating any error. Logs a warning when the fallback fails.
255///
256/// This entry point does not enforce pin verification. Callers that need pin
257/// enforcement must use [`load_doctrine_with_fallback`] and surface the error.
258#[must_use]
259pub fn load_doctrine_or_fallback(root: &Utf8PathBuf, fallback_repo: &str) -> DoctrineIndex {
260 match load_doctrine_with_fallback(root, fallback_repo, None, None) {
261 Ok(idx) => idx,
262 Err(err) => {
263 warn!(
264 root = %root,
265 fallback_repo,
266 error = %err,
267 "doctrine fallback failed; using empty index"
268 );
269 DoctrineIndex::empty(root.clone())
270 }
271 }
272}
273
274/// Validate the `fallback_repo` URL supplied to [`load_doctrine_with_fallback`].
275///
276/// Earlier rounds used substring checks (`starts_with("https://")` plus a
277/// `..` contains-check). Those are defeated by:
278/// - URL-encoded path traversal (`%2e%2e`)
279/// - `userinfo` segments (`https://user:pass@evil.com/`) that some git
280/// transports honour as credentials
281/// - IP-literal hosts (loopback / RFC1918 bypass)
282/// - IDNA homograph spoofing of the scheme prefix
283///
284/// This stricter validator parses the URL with the `url` crate and enforces:
285/// - scheme must be exactly `https`
286/// - no userinfo (no username, no password)
287/// - host must be present
288/// - host must be a domain name, not a bare IPv4 or IPv6 literal
289/// - the raw URL must not contain a literal `..` segment in its path
290/// position, nor a percent-encoded equivalent (`%2e%2e`)
291fn validate_fallback_url(fallback_repo: &str) -> Result<(), DoctrineError> {
292 // RFC 3986 §5.2.4 normalises `..` segments at parse time, so we must
293 // inspect the *raw* string before url::Url::parse silently resolves
294 // them. We reject the literal `..` token and its percent-encoded
295 // variant `%2e%2e` (case-insensitive). Doing this on the raw bytes
296 // also defeats payloads that would otherwise resolve into a
297 // different on-wire path than the operator intended.
298 let raw_lower = fallback_repo.to_ascii_lowercase();
299 if raw_lower.contains("/..") || raw_lower.contains("/%2e%2e") || raw_lower.contains("%2e%2e/") {
300 return Err(DoctrineError::InvalidFallbackUrl(
301 "fallback URL must not contain '..' segments".into(),
302 ));
303 }
304
305 let parsed = url::Url::parse(fallback_repo)
306 .map_err(|e| DoctrineError::InvalidFallbackUrl(format!("not a valid URL: {e}")))?;
307
308 if parsed.scheme() != "https" {
309 return Err(DoctrineError::InvalidFallbackUrl(
310 "only https:// schemes are accepted for doctrine fallback".into(),
311 ));
312 }
313
314 if !parsed.username().is_empty() {
315 return Err(DoctrineError::InvalidFallbackUrl(
316 "fallback URL must not include userinfo".into(),
317 ));
318 }
319 if parsed.password().is_some() {
320 return Err(DoctrineError::InvalidFallbackUrl(
321 "fallback URL must not include a password".into(),
322 ));
323 }
324
325 // Use `Host` directly: `host_str()` surfaces IPv6 literals as
326 // bracketed `[::1]`, which `IpAddr::from_str` will not parse —
327 // matching on the enum is unambiguous.
328 match parsed.host() {
329 Some(url::Host::Domain(_)) => {}
330 Some(url::Host::Ipv4(_) | url::Host::Ipv6(_)) => {
331 return Err(DoctrineError::InvalidFallbackUrl(
332 "fallback URL host must be a domain name, not an IP literal".into(),
333 ));
334 }
335 None => {
336 return Err(DoctrineError::InvalidFallbackUrl(
337 "fallback URL must include a host".into(),
338 ));
339 }
340 }
341
342 // Defense in depth: even though `..` is rejected on the raw string above,
343 // also reject any `..` segment that somehow survives parsing. Covers
344 // future url-crate changes that might preserve them.
345 for seg in parsed.path().split('/') {
346 if seg == ".." {
347 return Err(DoctrineError::InvalidFallbackUrl(
348 "fallback URL path must not contain '..' segments".into(),
349 ));
350 }
351 }
352
353 Ok(())
354}
355
356// ---------------------------------------------------------------------------
357// Internal helpers
358// ---------------------------------------------------------------------------
359
360/// Perform the HTTPS clone fallback and load the resulting checkout.
361///
362/// When `pin_commit` is `Some(expected)` (and not the sentinel `"auto"`), the
363/// cloned HEAD must match `expected` exactly or the function returns
364/// [`DoctrineError::PinMismatch`]. The same check applies to the cache-hit
365/// path: a `cache_base/<full-sha>` that doesn't agree with the supplied pin
366/// is rejected before any prose is loaded.
367///
368/// Cache directories are named with the full 40-character HEAD SHA, not an
369/// 8-character truncation. Short prefixes are collision-prone (~10k entries
370/// hit non-trivial birthday probabilities) and trivially gameable by an
371/// attacker who briefly controls the network — the longer name slams that
372/// door shut.
373///
374/// `clippy::too_many_lines` is allowed: this function orchestrates URL
375/// validation, two warm-cache code paths (explicit pin vs. auto), the
376/// scratch-clone -> rename-into-place promotion, and the cross-volume copy
377/// fallback. Splitting along those seams would create helpers that only
378/// make sense in this one call chain and obscure the cache-hit / clone-fresh
379/// / lost-race state machine.
380#[allow(clippy::too_many_lines)]
381fn load_from_fallback_clone(
382 fallback_repo: &str,
383 cache_dir: Option<&Utf8PathBuf>,
384 pin_commit: Option<&str>,
385) -> Result<DoctrineIndex, DoctrineError> {
386 validate_fallback_url(fallback_repo)?;
387
388 // When no explicit cache_dir is supplied, route the cache to an
389 // operator-trusted location keyed by the fallback-URL hash. Round-4
390 // redteam #1: the prior default (`<target>/.cordance/cache/doctrine`)
391 // lived inside the target tree, which is hostile by threat model — an
392 // attacker who controls the target could pre-populate a self-consistent
393 // fake git repo and bypass pin verification.
394 let cache_base = cache_dir
395 .cloned()
396 .unwrap_or_else(|| cordance_core::paths::doctrine_cache_dir_for_url(fallback_repo));
397
398 if !cache_base.exists() {
399 std::fs::create_dir_all(cache_base.as_std_path())?;
400 }
401
402 // Warm-cache fast path. We have two scenarios:
403 //
404 // (a) Explicit pin: the operator supplied a 40-hex commit SHA. If
405 // `cache_base/<pin>` exists, verify HEAD matches the pin (which
406 // *also* implicitly verifies HEAD matches the dirname, since both
407 // are equal in this branch).
408 //
409 // (b) `"auto"` pin (or no pin): we don't know the expected commit yet,
410 // but every populated cache directory is named after the HEAD it
411 // was cloned at. Bughunt #4 round-3: if the cache dir's HEAD has
412 // drifted from its NAME, someone has tampered with the on-disk
413 // cache (e.g. swapped SEMANTIC_INDEX.md while keeping the dir
414 // name). Cross-check every cache directory we'd load from against
415 // its own name to catch that.
416 // Two warm-cache shapes to handle:
417 // - Explicit, non-"auto" pin: `cache_base/<pin>` is the only candidate;
418 // verify HEAD matches the pin and load directly.
419 // - "auto" pin OR no pin at all: probe `cache_base` for any
420 // self-consistent (name == HEAD) entry and reuse it. A directory
421 // whose name disagrees with its HEAD is treated as tampered and
422 // rejected up-front (round-3 bughunt #4).
423 let explicit_pin: Option<&str> = match pin_commit {
424 Some(p) if p != "auto" => Some(p),
425 _ => None,
426 };
427 if let Some(expected) = explicit_pin {
428 let final_dir = cache_base.join(expected);
429 if final_dir.exists() {
430 let actual = head_full_sha_from_path(final_dir.as_std_path()).ok_or_else(|| {
431 DoctrineError::GitClone("cache directory exists but HEAD could not be read".into())
432 })?;
433 if actual != expected {
434 return Err(DoctrineError::PinMismatch {
435 expected: expected.to_string(),
436 actual,
437 });
438 }
439 debug!(
440 pin = expected,
441 cache_dir = %final_dir,
442 "doctrine fallback warm-cache hit; pin verified"
443 );
444 return load_doctrine(&final_dir);
445 }
446 } else if let Some(reused) = find_self_consistent_cache_entry(&cache_base)? {
447 debug!(
448 cache_dir = %reused,
449 "doctrine fallback warm-cache hit (auto pin); self-consistency verified"
450 );
451 return load_doctrine(&reused);
452 }
453
454 // We need to discover the remote HEAD commit to compute the SHA for
455 // the cache directory name. Clone into a scratch directory first, then
456 // either drop it (cache already populated by a concurrent run) or
457 // promote it into the final cache location via `std::fs::rename`.
458 let tmp = tempfile::Builder::new()
459 .prefix("cordance-doctrine-clone-")
460 .tempdir_in(cache_base.as_std_path())
461 .map_err(|e| DoctrineError::GitClone(format!("create scratch tempdir: {e}")))?;
462 let scratch_path = tmp.path().to_path_buf();
463
464 let repo = run_shallow_clone(fallback_repo, &scratch_path)?;
465 let full = head_full_sha_from_repo(&repo)
466 .ok_or_else(|| DoctrineError::GitClone("clone succeeded but HEAD is unresolved".into()))?;
467
468 if let Some(expected) = pin_commit {
469 if expected != "auto" && expected != full {
470 return Err(DoctrineError::PinMismatch {
471 expected: expected.to_string(),
472 actual: full,
473 });
474 }
475 }
476
477 // Full 40-character SHA in the directory name — no truncation.
478 let final_dir = cache_base.join(&full);
479 if final_dir.exists() {
480 // Cache hit: another process — or a prior run — already placed this
481 // commit on disk. Verify the cached HEAD agrees with what we just
482 // cloned and (transitively) with the pin, then load from the cache.
483 let cached = head_full_sha_from_path(final_dir.as_std_path()).ok_or_else(|| {
484 DoctrineError::GitClone("cache directory exists but HEAD could not be read".into())
485 })?;
486 if cached != full {
487 return Err(DoctrineError::PinMismatch {
488 expected: full,
489 actual: cached,
490 });
491 }
492 debug!(
493 full_sha = %full,
494 cache_dir = %final_dir,
495 "doctrine fallback cache hit; reusing existing clone"
496 );
497 drop(tmp);
498 return load_doctrine(&final_dir);
499 }
500
501 // Promote scratch -> final_dir. Use rename when on the same volume
502 // (tempdir is created inside cache_base, so this is the common case).
503 // tempfile's TempDir would try to delete the path on drop, so we must
504 // disarm it by calling `keep` before renaming.
505 let scratch_owned = tmp.keep();
506 if let Err(rename_err) = std::fs::rename(&scratch_owned, final_dir.as_std_path()) {
507 // Cross-device or already-exists race: fall back to a recursive copy
508 // and best-effort cleanup of the scratch directory.
509 if final_dir.exists() {
510 // Lost the race; clean up our scratch and load from the winner,
511 // but verify the winner's HEAD before trusting it.
512 let _ = std::fs::remove_dir_all(&scratch_owned);
513 let cached = head_full_sha_from_path(final_dir.as_std_path()).ok_or_else(|| {
514 DoctrineError::GitClone("cache directory exists but HEAD could not be read".into())
515 })?;
516 if cached != full {
517 return Err(DoctrineError::PinMismatch {
518 expected: full,
519 actual: cached,
520 });
521 }
522 return load_doctrine(&final_dir);
523 }
524 warn!(
525 error = %rename_err,
526 "rename failed; falling back to recursive copy"
527 );
528 // Round-4 bughunt #6: a mid-copy failure (disk full, I/O fault, or a
529 // permission error on a `.git/pack` shard) used to leave `final_dir`
530 // partially populated AND `scratch_owned` still on disk. On the next
531 // invocation, the partial cache could satisfy `verify_cache_dir_self_
532 // consistent` (the dirname matches HEAD if `.git/` happened to copy
533 // first) but be missing the `doctrine/principles/*.md` prose,
534 // yielding a silently empty doctrine index. Inspect the copy result
535 // explicitly so we can clean up BOTH directories before returning
536 // the error — the next invocation starts from a clean slate.
537 let copy_result = copy_dir_all(&scratch_owned, final_dir.as_std_path());
538 if let Err(copy_err) = copy_result {
539 let _ = std::fs::remove_dir_all(final_dir.as_std_path());
540 let _ = std::fs::remove_dir_all(&scratch_owned);
541 return Err(DoctrineError::Io(copy_err));
542 }
543 let _ = std::fs::remove_dir_all(&scratch_owned);
544 }
545
546 info!(
547 full_sha = %full,
548 cache_dir = %final_dir,
549 "doctrine fallback clone complete"
550 );
551 load_doctrine(&final_dir)
552}
553
554/// Run a shallow (depth=1) clone of `url` into `dest`, returning the opened
555/// repository on success.
556fn run_shallow_clone(url: &str, dest: &std::path::Path) -> Result<gix::Repository, DoctrineError> {
557 // Safe: 1 is non-zero and known at compile time.
558 let depth = NonZeroU32::new(1).ok_or_else(|| {
559 DoctrineError::GitClone("internal: NonZeroU32::new(1) returned None".into())
560 })?;
561
562 let parsed_url = gix::url::parse(url.as_bytes().into())
563 .map_err(|e| DoctrineError::GitClone(format!("parse url {url:?}: {e}")))?;
564
565 let mut prepare = gix::prepare_clone(parsed_url, dest)
566 .map_err(|e| DoctrineError::GitClone(format!("prepare_clone: {e}")))?
567 .with_shallow(gix::remote::fetch::Shallow::DepthAtRemote(depth));
568
569 let (mut prepare_checkout, _) = prepare
570 .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
571 .map_err(|e| DoctrineError::GitClone(format!("fetch: {e}")))?;
572
573 let (repo, _) = prepare_checkout
574 .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
575 .map_err(|e| DoctrineError::GitClone(format!("checkout: {e}")))?;
576
577 Ok(repo)
578}
579
580/// Return HEAD's full 40-character commit SHA from an open `gix::Repository`,
581/// or `None` if HEAD cannot be resolved.
582///
583/// The full SHA is required for pin verification — taking only the first 8
584/// characters would allow trivial collisions in adversarial inputs.
585fn head_full_sha_from_repo(repo: &gix::Repository) -> Option<String> {
586 let mut head = repo.head().ok()?;
587 let id = head.try_peel_to_id_in_place().ok()??;
588 Some(id.to_string())
589}
590
591/// Return HEAD's full 40-character commit SHA for the repo rooted at `path`,
592/// or `None` if `path` is not a valid git repository or HEAD cannot be
593/// resolved.
594///
595/// Used by [`load_from_fallback_clone`] to verify a pre-populated cache
596/// directory against the supplied `pin_commit` before any prose is read.
597fn head_full_sha_from_path(path: &std::path::Path) -> Option<String> {
598 let repo = gix::open(path).ok()?;
599 head_full_sha_from_repo(&repo)
600}
601
602/// Scan `cache_base` for the first immediate child directory whose name agrees
603/// with its current git HEAD. Used by the `"auto"` (no explicit pin) warm-cache
604/// path so a previously-cloned commit is reused without a network call.
605///
606/// Returns:
607/// - `Ok(Some(dir))` for the lexicographically-first self-consistent cache
608/// entry. Sorting is deterministic across filesystems (round-4 bughunt #7).
609/// - `Ok(None)` if `cache_base` is missing, empty, contains only entries
610/// that are not git directories, OR every git directory failed the
611/// self-consistency cross-check WITH A TAMPER-SHAPED ERROR (name ≠ HEAD).
612/// In the all-tampered case a single `tracing::warn!` summarises the skip
613/// count so the caller falls through to the network-clone path instead
614/// of surfacing a function-level Err.
615/// - `Err(DoctrineError::Io)` for disk I/O failures on `cache_base`, OR for
616/// any per-entry transient I/O failure (Windows `STATUS_SHARING_VIOLATION`
617/// against a sibling-process `cordance pack`, antivirus holding `HEAD`
618/// open, EACCES on a read-restricted entry, SMB share latency, …).
619/// Round-7 bughunt #2 (R7-bughunt-2): the previous shape collapsed every
620/// non-validating error into "tampered" and returned `Ok(None)`, which
621/// turned a flaky cache into a silent forced network clone. The
622/// operator's audit log claimed tamper-skip when the real cause was a
623/// transient race. Surfacing transient I/O lets the caller decide
624/// (retry, back-off, or fail loudly) instead of paying clone latency.
625///
626/// Round-5 bughunt #3 (R5-bughunt-3): the previous shape returned
627/// `Err(DoctrineError::PinMismatch)` on the *first* tampered candidate, which
628/// aborted the entire scan even when a fresh sibling sorted later. The
629/// round-5 fix made per-entry skipping work but still surfaced `Err` when
630/// EVERY entry was tampered.
631///
632/// Round-6 bughunt #3 (R6-bughunt-3): the all-tampered `Err` was caught by
633/// the caller via `?` and short-circuited the function before reaching the
634/// network-clone fallback at `load_from_fallback_clone`'s clone path. The
635/// operator-visible result was a silent empty-doctrine pack on tampered-only
636/// caches even though a fresh clone would have succeeded. The fix: also
637/// return `Ok(None)` when every candidate is tampered, log a single warn
638/// summarising the skip count, and let the caller proceed to the
639/// network-clone path. Individual per-entry tamper details remain in the
640/// per-iteration `warn!` for operator audit.
641fn find_self_consistent_cache_entry(
642 cache_base: &Utf8PathBuf,
643) -> Result<Option<Utf8PathBuf>, DoctrineError> {
644 if !cache_base.exists() {
645 return Ok(None);
646 }
647 let read_dir = match std::fs::read_dir(cache_base.as_std_path()) {
648 Ok(rd) => rd,
649 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
650 Err(e) => return Err(DoctrineError::Io(e)),
651 };
652
653 // Round-4 bughunt #7: `std::fs::read_dir` returns entries in
654 // unspecified, filesystem-dependent order. NTFS typically returns
655 // alphabetical, ext4 returns inode-order, HFS+ returns alphabetical.
656 // When two self-consistent cache entries exist (operator pulled an
657 // updated doctrine HEAD without pruning the old clone), the "first"
658 // entry we encountered would differ between operators on different
659 // OSes — and the chosen entry's commit propagates into
660 // `pack.doctrine_pins[0].commit` and into every emitted artifact.
661 // Collect candidates first, then sort by name, so the choice is
662 // deterministic across platforms.
663 let mut candidates: Vec<Utf8PathBuf> = Vec::new();
664 for entry in read_dir {
665 let entry = entry.map_err(DoctrineError::Io)?;
666 let file_type = entry.file_type().map_err(DoctrineError::Io)?;
667 if !file_type.is_dir() {
668 continue;
669 }
670 // Skip scratch tempdirs created mid-clone (tempfile prefixes them
671 // with `cordance-doctrine-clone-`). Only consider real cache entries.
672 let name = entry.file_name();
673 let name_str = name.to_string_lossy();
674 if name_str.starts_with("cordance-doctrine-clone-") {
675 continue;
676 }
677 let path = entry.path();
678 let Ok(utf8_path) = Utf8PathBuf::from_path_buf(path) else {
679 continue;
680 };
681 candidates.push(utf8_path);
682 }
683
684 // Sort by full path string. Cache directory names are 40-char hex SHAs,
685 // so lexicographic order is well-defined and platform-independent.
686 candidates.sort();
687
688 // Round-5 bughunt #3 (R5-bughunt-3): the previous shape used
689 // `verify_cache_dir_self_consistent(...)?` and aborted the whole search
690 // on the first tampered entry. That locked the operator out of any
691 // fresh sibling that happened to sort later — a single stale-named
692 // directory poisoned the cache. The new shape collects tampered entries
693 // counts, keeps walking, and falls through cleanly when none validate.
694 //
695 // Round-6 bughunt #3 (R6-bughunt-3): the round-5 shape still returned
696 // `Err(PinMismatch)` when EVERY candidate failed self-consistency,
697 // which propagated through the caller's `?` and skipped the
698 // network-clone fallback. The new shape returns `Ok(None)` in the
699 // all-tampered case too, with a single summary `warn!` carrying the
700 // skip count. The per-entry `warn!` inside the loop preserves
701 // per-candidate audit detail; this outer one tells the operator the
702 // loader is falling through to a fresh clone instead of silently
703 // emitting an empty doctrine pack.
704 //
705 // Round-7 bughunt #2 (R7-bughunt-2): tampered_count alone conflated
706 // legitimate name≠HEAD entries with transient I/O failures (Windows
707 // `STATUS_SHARING_VIOLATION` against a sibling `cordance pack`, antivirus
708 // open handle, EACCES on a permission-stripped entry). The previous
709 // shape silently fell through to a network clone on every transient
710 // race, paying clone latency for what was a 50ms cache contention.
711 // The new shape buckets transients separately and surfaces the FIRST
712 // such error via `Err(DoctrineError::Io(...))` so the caller can decide
713 // (retry, back-off, fail loudly) instead of paying an unnecessary clone.
714 let mut tampered_count: usize = 0;
715 let mut transient_count: usize = 0;
716 let mut first_transient: Option<std::io::Error> = None;
717 for utf8_path in candidates {
718 let status = classify_cache_entry(&utf8_path);
719 match status {
720 EntryStatusInternal::Validated(path) => return Ok(Some(path)),
721 EntryStatusInternal::NotAGitRepo => {
722 // Silent skip — a directory sitting in `cache_base` without
723 // `.git/` is "not a doctrine cache entry", matching round-5
724 // behaviour.
725 }
726 EntryStatusInternal::Tampered => {
727 warn!(
728 cache_dir = %utf8_path,
729 "doctrine cache entry self-consistency check failed (name != HEAD); skipping"
730 );
731 tampered_count += 1;
732 }
733 EntryStatusInternal::Transient(io_err) => {
734 // Round-7 bughunt #2: log under a distinct category so the
735 // operator's audit log doesn't falsely claim tamper. Capture
736 // the first transient I/O error so we can return it after
737 // the loop — without aborting the loop early, since a fresh
738 // sibling that sorts later should still win.
739 warn!(
740 cache_dir = %utf8_path,
741 error = %io_err,
742 "doctrine cache entry I/O error (transient or locked); skipping"
743 );
744 transient_count += 1;
745 if first_transient.is_none() {
746 first_transient = Some(io_err);
747 }
748 }
749 }
750 }
751 // No self-consistent candidate found. The summary warn helps the
752 // operator correlate per-entry warns with a single "what next" line.
753 //
754 // Round-7 bughunt #2 (R7-bughunt-2): if ANY entry hit transient I/O,
755 // return `Err(DoctrineError::Io(...))` so the caller sees the real
756 // failure instead of silently force-cloning. If only tamper-shaped
757 // errors occurred, preserve the round-6 `Ok(None)` fallthrough so the
758 // caller's network-clone path runs.
759 if let Some(io_err) = first_transient {
760 warn!(
761 transient_count,
762 tampered_count,
763 cache_base = %cache_base,
764 "doctrine cache scan encountered transient I/O errors; surfacing instead of falling through to network clone"
765 );
766 return Err(DoctrineError::Io(io_err));
767 }
768 if tampered_count > 0 {
769 warn!(
770 tampered_count,
771 cache_base = %cache_base,
772 "every doctrine cache entry was tampered; falling through to network clone"
773 );
774 }
775 Ok(None)
776}
777
778/// Classify a single cache-entry candidate into one of four outcomes.
779///
780/// Round-7 bughunt #2 (R7-bughunt-2) helper. Disentangles three previously
781/// conflated cases:
782/// * `NotAGitRepo` — `<path>/.git` is genuinely absent. Silent skip.
783/// * `Tampered` — `<path>/.git` exists AND HEAD is readable AND HEAD's
784/// full SHA does not equal the directory's last-component name.
785/// * `Transient(io_err)` — anything else: `.git` metadata read failed with
786/// a non-NotFound error (EACCES, sharing violation, …) OR `gix::open`
787/// erred OR HEAD could not be resolved despite `.git` being present.
788/// * `Validated(path)` — directory name == HEAD's full SHA.
789///
790/// This function is the single point where the previous shape's "every
791/// failure looks like tamper" mistake is corrected.
792fn classify_cache_entry(utf8_path: &Utf8PathBuf) -> EntryStatusInternal {
793 // 1. Look for `<path>/.git`. If it's missing we treat the directory as
794 // "not a cache entry" exactly as round-5 did. Any OTHER error from
795 // `metadata` (EACCES, sharing violation on Windows, …) is a transient
796 // signal — not tamper.
797 let git_dir_marker = utf8_path.as_std_path().join(".git");
798 match std::fs::symlink_metadata(&git_dir_marker) {
799 Ok(_) => {}
800 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
801 return EntryStatusInternal::NotAGitRepo;
802 }
803 Err(e) => return EntryStatusInternal::Transient(e),
804 }
805
806 // 2. `.git` is present; try to read HEAD. A failure here means the
807 // entry IS shaped like a git repo but the read couldn't complete:
808 // that's transient, not tamper. (A genuinely tampered entry that
809 // rewrote git internals into garbage would land here too — and the
810 // correct response is still "surface the I/O error", not "silently
811 // force a clone".)
812 let Some(actual_head) = head_full_sha_from_path(utf8_path.as_std_path()) else {
813 return EntryStatusInternal::Transient(std::io::Error::other(format!(
814 "cache directory {utf8_path} has .git/ but HEAD could not be read"
815 )));
816 };
817
818 // 3. Compare HEAD to dir name. The only remaining error class IS tamper
819 // (operator overwrote SEMANTIC_INDEX.md without rewriting history,
820 // or a stale-named dir from an interrupted update).
821 let Some(name) = utf8_path.file_name() else {
822 return EntryStatusInternal::Transient(std::io::Error::other(
823 "cache directory candidate has no file_name component",
824 ));
825 };
826 if actual_head == name {
827 EntryStatusInternal::Validated(utf8_path.clone())
828 } else {
829 EntryStatusInternal::Tampered
830 }
831}
832
833/// Internal classification result for [`classify_cache_entry`]. Kept as a
834/// free enum (not nested in the function) so unit tests can import it.
835#[derive(Debug)]
836enum EntryStatusInternal {
837 Validated(Utf8PathBuf),
838 NotAGitRepo,
839 Tampered,
840 Transient(std::io::Error),
841}
842
843/// Recursive directory copy. Used as a fallback when `std::fs::rename` can't
844/// cross volumes.
845fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
846 std::fs::create_dir_all(dst)?;
847 for entry in std::fs::read_dir(src)? {
848 let entry = entry?;
849 let ft = entry.file_type()?;
850 let src_path = entry.path();
851 let dst_path = dst.join(entry.file_name());
852 if ft.is_dir() {
853 copy_dir_all(&src_path, &dst_path)?;
854 } else if ft.is_file() {
855 std::fs::copy(&src_path, &dst_path)?;
856 }
857 // Symlinks inside a freshly cloned git repo are rare; skip silently
858 // rather than fail the fallback over them.
859 }
860 Ok(())
861}
862
863/// Collect `.md` files in a single flat directory (non-recursive).
864fn collect_flat_md(dir: &Utf8PathBuf) -> Result<Vec<DoctrineEntry>, DoctrineError> {
865 if !dir.exists() {
866 return Ok(vec![]);
867 }
868
869 let mut entries = Vec::new();
870 for entry in WalkDir::new(dir.as_std_path())
871 .min_depth(1)
872 .max_depth(1)
873 .follow_links(false)
874 .sort_by_file_name()
875 {
876 let entry =
877 entry.map_err(|e| std::io::Error::other(format!("walkdir error in {dir}: {e}")))?;
878
879 if !entry.file_type().is_file() {
880 continue;
881 }
882
883 let path = entry.path();
884 if path.extension().and_then(|s| s.to_str()) != Some("md") {
885 continue;
886 }
887
888 if let Some(doctrine_entry) = build_entry(path, dir.as_std_path())? {
889 entries.push(doctrine_entry);
890 }
891 }
892
893 Ok(entries)
894}
895
896/// Collect `.md` files recursively under a directory tree.
897fn collect_recursive_md(dir: &Utf8PathBuf) -> Result<Vec<DoctrineEntry>, DoctrineError> {
898 if !dir.exists() {
899 return Ok(vec![]);
900 }
901
902 let mut entries = Vec::new();
903 for entry in WalkDir::new(dir.as_std_path())
904 .min_depth(1)
905 .follow_links(false)
906 .sort_by_file_name()
907 {
908 let entry =
909 entry.map_err(|e| std::io::Error::other(format!("walkdir error in {dir}: {e}")))?;
910
911 if !entry.file_type().is_file() {
912 continue;
913 }
914
915 let path = entry.path();
916 if path.extension().and_then(|s| s.to_str()) != Some("md") {
917 continue;
918 }
919
920 if let Some(doctrine_entry) = build_entry(path, dir.as_std_path())? {
921 entries.push(doctrine_entry);
922 }
923 }
924
925 Ok(entries)
926}
927
928/// Hash a file and build a [`DoctrineEntry`].
929///
930/// Returns `None` if the stem cannot be extracted (shouldn't happen for `.md`)
931/// or if the file is larger than [`MAX_DOCTRINE_FILE_BYTES`]. Oversize files
932/// are skipped silently with a `warn!` line; doctrine is human-authored prose
933/// and a multi-megabyte entry is almost certainly a mistake or an attack.
934fn build_entry(
935 abs_path: &std::path::Path,
936 base_dir: &std::path::Path,
937) -> Result<Option<DoctrineEntry>, DoctrineError> {
938 let stem = match abs_path.file_stem().and_then(|s| s.to_str()) {
939 Some(s) => s.to_owned(),
940 None => return Ok(None),
941 };
942
943 let metadata = std::fs::metadata(abs_path)?;
944 if metadata.len() > MAX_DOCTRINE_FILE_BYTES {
945 warn!(
946 path = %abs_path.display(),
947 bytes = metadata.len(),
948 cap = MAX_DOCTRINE_FILE_BYTES,
949 "doctrine entry exceeds size cap; skipping"
950 );
951 return Ok(None);
952 }
953
954 let bytes_content = std::fs::read(abs_path)?;
955 let file_bytes = bytes_content.len() as u64;
956
957 let mut hasher = sha2::Sha256::new();
958 hasher.update(&bytes_content);
959 let sha256 = hex::encode(hasher.finalize());
960
961 // Repo-relative path: strip the doctrine base dir prefix, use forward slashes.
962 let rel = abs_path
963 .strip_prefix(base_dir)
964 .unwrap_or(abs_path)
965 .to_string_lossy()
966 .replace('\\', "/");
967
968 let path = Utf8PathBuf::from(rel);
969
970 Ok(Some(DoctrineEntry {
971 topic: stem,
972 path,
973 sha256,
974 bytes: file_bytes,
975 }))
976}
977
978/// Attempt to read the HEAD commit SHA from the git repo at `root`.
979///
980/// Any failure — missing repo, unborn HEAD, pack-file issues — returns `None`
981/// rather than propagating, since the git commit is informational.
982fn resolve_head_commit(root: &std::path::Path) -> Option<String> {
983 let repo = gix::open(root).ok()?;
984 let mut head = repo.head().ok()?;
985 let id = head.try_peel_to_id_in_place().ok()??;
986 Some(id.to_string())
987}
988
989// ---------------------------------------------------------------------------
990// Tests (unit)
991// ---------------------------------------------------------------------------
992
993#[cfg(test)]
994mod tests {
995 use super::*;
996
997 #[test]
998 fn stub_load_returns_index() {
999 // Backward-compat: loading "." (cwd) won't find "doctrine/" so
1000 // entries will be empty, but if the root exists we get an Ok.
1001 // The cwd during tests is the crate directory which exists.
1002 let idx = load_doctrine(&Utf8PathBuf::from(".")).expect("load");
1003 // The crate dir has no doctrine/ sub-dir, so all vecs empty.
1004 assert!(idx.principles.is_empty());
1005 }
1006
1007 #[test]
1008 fn empty_index_has_no_entries() {
1009 let idx = DoctrineIndex::empty(Utf8PathBuf::from("/nowhere"));
1010 assert!(idx.principles.is_empty());
1011 assert!(idx.patterns.is_empty());
1012 assert!(idx.checklists.is_empty());
1013 assert!(idx.tooling.is_empty());
1014 assert!(idx.glossary_path.is_none());
1015 assert!(idx.semantic_index_path.is_none());
1016 assert!(idx.commit.is_none());
1017 assert_eq!(idx.schema, "cordance-doctrine-index.v1");
1018 }
1019
1020 #[test]
1021 fn doctrine_cache_root_is_operator_trusted() {
1022 // Round-4 redteam #1: the default cache root must live on
1023 // operator-controlled filesystem, not inside the (hostile) target.
1024 // When the env override is unset, the result comes from
1025 // `dirs::cache_dir()` (or the home-dir fallback) — both of which
1026 // are by definition operator-trusted. The strongest cross-platform
1027 // check we can make without coupling to a specific OS layout is:
1028 // the resolved path is non-empty and (in any realistic test
1029 // environment) absolute. The deeper "is outside the target" property
1030 // is structurally guaranteed: `doctrine_cache_root` derives from
1031 // `dirs::*`, which never returns paths under a project tree, so the
1032 // unit test in cordance-core's `paths::tests` covers the absolute-
1033 // path property and this test pins the cross-crate wire-up.
1034 let prev = std::env::var("CORDANCE_DOCTRINE_CACHE_DIR").ok();
1035 std::env::remove_var("CORDANCE_DOCTRINE_CACHE_DIR");
1036 let resolved = cordance_core::paths::doctrine_cache_root();
1037 if let Some(v) = prev {
1038 std::env::set_var("CORDANCE_DOCTRINE_CACHE_DIR", v);
1039 }
1040 assert!(
1041 !resolved.as_str().is_empty(),
1042 "doctrine_cache_root must not be empty; got {resolved}"
1043 );
1044 }
1045
1046 #[test]
1047 fn copy_dir_all_failure_cleans_up_partial_cache() {
1048 // Round-4 bughunt #6: mid-copy failure used to leave both
1049 // `final_dir` and `scratch_owned` populated on disk. Force a
1050 // failure by passing a destination that already exists as a *file*
1051 // — `create_dir_all` returns Err("not a directory"), so the entire
1052 // copy operation fails.
1053 let tmp = tempfile::tempdir().expect("tempdir");
1054 let src = tmp.path().join("src");
1055 std::fs::create_dir_all(&src).expect("mkdir src");
1056 std::fs::write(src.join("file.txt"), b"hello").expect("write src file");
1057
1058 // Create a file at the destination path so `copy_dir_all`'s
1059 // `create_dir_all(dst)` fails.
1060 let dst = tmp.path().join("dst-is-a-file");
1061 std::fs::write(&dst, b"i am a file, not a dir").expect("write dst file");
1062
1063 let result = copy_dir_all(&src, &dst);
1064 assert!(
1065 result.is_err(),
1066 "copy_dir_all must fail when dst is a file; got {result:?}"
1067 );
1068 // The original dst file is untouched (we didn't try to remove it as
1069 // part of the cleanup path here; this test exercises copy_dir_all
1070 // itself rather than the cleanup wrapper. The cleanup wrapper is
1071 // tested integration-style via load_from_fallback_clone, which is
1072 // exercised by the existing fallback test suite with network access.)
1073 assert!(
1074 dst.exists(),
1075 "test invariant: dst sentinel file must still exist"
1076 );
1077 }
1078
1079 #[test]
1080 fn find_self_consistent_cache_entry_returns_sorted_order() {
1081 // Round-4 bughunt #7: with two valid cache entries, the loader
1082 // must pick the lexicographically smallest entry to keep the
1083 // choice deterministic across operating systems.
1084 //
1085 // We can't easily construct two REAL self-consistent cache entries
1086 // without git (the helper requires real HEAD SHAs that match dir
1087 // names). Instead we assert the behaviour negatively: a directory
1088 // tree of only-non-git subdirs returns Ok(None), exercising the
1089 // collect-and-sort path without an early break. The positive
1090 // (cross-platform determinism) test lives in `tests/loader.rs`
1091 // where it can shell out to `git init`.
1092 let tmp = tempfile::tempdir().expect("tempdir");
1093 let cache_base: Utf8PathBuf = tmp.path().to_path_buf().try_into().expect("utf8");
1094
1095 // Two empty directories, neither of which is a git repo.
1096 std::fs::create_dir_all(cache_base.join("aaaa").as_std_path()).expect("mkdir aaaa");
1097 std::fs::create_dir_all(cache_base.join("bbbb").as_std_path()).expect("mkdir bbbb");
1098
1099 let result = find_self_consistent_cache_entry(&cache_base).expect("find ok");
1100 assert!(
1101 result.is_none(),
1102 "two non-git directories must return Ok(None), not pick one arbitrarily"
1103 );
1104 }
1105
1106 /// Initialise a tiny on-disk git repo at `path` and return its HEAD SHA.
1107 /// Mirrors `tests/loader.rs::init_fake_cache_repo` so the unit-level test
1108 /// below can plant real git directories without depending on the
1109 /// integration-test helper. Requires `git` in PATH (present on the
1110 /// project's CI runners and developer machines per `BUILD_SPEC` §1).
1111 fn init_fake_cache_repo(path: &std::path::Path) -> String {
1112 use std::process::Command;
1113
1114 std::fs::create_dir_all(path).expect("mkdir cache repo");
1115 let run = |args: &[&str]| {
1116 let out = Command::new("git")
1117 .args(args)
1118 .current_dir(path)
1119 .output()
1120 .expect("invoke git");
1121 assert!(
1122 out.status.success(),
1123 "git {:?} failed: stdout={} stderr={}",
1124 args,
1125 String::from_utf8_lossy(&out.stdout),
1126 String::from_utf8_lossy(&out.stderr),
1127 );
1128 out
1129 };
1130
1131 run(&["init", "-q", "-b", "main"]);
1132 run(&["config", "user.email", "cordance-test@example.invalid"]);
1133 run(&["config", "user.name", "cordance-test"]);
1134 std::fs::write(path.join("README"), "fixture\n").expect("write README");
1135 run(&["add", "README"]);
1136 run(&["commit", "-q", "--allow-empty-message", "-m", ""]);
1137 let out = run(&["rev-parse", "HEAD"]);
1138 String::from_utf8(out.stdout)
1139 .expect("rev-parse output is utf8")
1140 .trim()
1141 .to_string()
1142 }
1143
1144 /// Round-5 bughunt #3 (R5-bughunt-3): the round-4 sort fix made the cache
1145 /// scan deterministic but kept the "first tampered entry aborts the whole
1146 /// search" semantics. A single stale-named directory (operator did a
1147 /// `git reset --hard` inside the cache, or an interrupted update left
1148 /// HEAD pointing somewhere other than the dir name) used to lock the
1149 /// operator out of every fresh sibling. The fix: log and skip a tampered
1150 /// entry, keep searching, only fail when no candidate self-validates.
1151 /// This test plants one tampered (wrong-name) entry and one fresh
1152 /// (name == HEAD) entry in the same `cache_base` and asserts the fresh
1153 /// entry is returned.
1154 #[test]
1155 fn find_self_consistent_skips_tampered_and_returns_fresh() {
1156 let tmp = tempfile::tempdir().expect("tempdir");
1157 let cache_base: Utf8PathBuf = tmp.path().to_path_buf().try_into().expect("utf8");
1158
1159 // 1. Tampered entry — staged under a "0…0" name that won't match its
1160 // real HEAD (`init_fake_cache_repo` creates a deterministic commit
1161 // over the fixture README; its SHA is some random 40-hex value,
1162 // not all-zeros). Sorts FIRST (`0` < real hex chars), so the
1163 // pre-round-5 code would have aborted here before reaching the
1164 // fresh entry.
1165 let tampered_name = "0".repeat(40);
1166 let tampered_path = cache_base.as_std_path().join(&tampered_name);
1167 let tampered_head = init_fake_cache_repo(&tampered_path);
1168 assert_ne!(
1169 tampered_head, tampered_name,
1170 "test invariant: tampered name must not coincidentally equal HEAD"
1171 );
1172
1173 // 2. Fresh entry — stage at a temp dir, learn its HEAD, then rename
1174 // so the dir name equals HEAD. This is the well-formed shape the
1175 // loader should reuse.
1176 let staging = cache_base.as_std_path().join("staging");
1177 let fresh_head = init_fake_cache_repo(&staging);
1178 let fresh_path = cache_base.as_std_path().join(&fresh_head);
1179 std::fs::rename(&staging, &fresh_path).expect("rename staging to head");
1180 assert!(
1181 fresh_path.exists(),
1182 "test invariant: fresh entry must exist at HEAD-named path"
1183 );
1184
1185 // Hand both entries to `find_self_consistent_cache_entry`. Pre-round-5
1186 // semantics: returns `Err(PinMismatch)` on the tampered entry (which
1187 // sorts first). Round-5 semantics: skips it with a warn-log and
1188 // returns the fresh sibling.
1189 let result = find_self_consistent_cache_entry(&cache_base).expect("find must not error");
1190 let Some(returned) = result else {
1191 panic!("expected Ok(Some(fresh)) — got Ok(None); cache_base={cache_base}");
1192 };
1193 assert_eq!(
1194 returned.file_name().expect("returned has file name"),
1195 fresh_head.as_str(),
1196 "must return the fresh entry, not the tampered one"
1197 );
1198 }
1199
1200 /// Round-6 bughunt #3 (R6-bughunt-3): when every git directory in the
1201 /// cache fails the self-consistency cross-check, the function must
1202 /// return `Ok(None)` (NOT `Err(PinMismatch)`) so the caller falls
1203 /// through to the network-clone fallback path. Prior to this fix the
1204 /// caller's `?` short-circuited the function and a tampered cache
1205 /// silently emitted an empty doctrine pack even when a fresh clone
1206 /// would have succeeded.
1207 ///
1208 /// We assert the return value is `Ok(None)`. (An earlier revision also
1209 /// captured the `tracing` log via a custom `MakeWriter` to confirm the
1210 /// summary warn fired with the correct skip count, but the capture
1211 /// proved flaky in parallel test runs and the load-bearing contract
1212 /// here is the return value — the warn is informational.)
1213 #[test]
1214 fn find_self_consistent_all_tampered_returns_ok_none_with_summary_warn() {
1215 let tmp = tempfile::tempdir().expect("tempdir");
1216 let cache_base: Utf8PathBuf = tmp.path().to_path_buf().try_into().expect("utf8");
1217
1218 // Plant ONE tampered entry: a git repo at a name that won't equal
1219 // its HEAD. No fresh entries elsewhere in cache_base.
1220 let tampered_name = "f".repeat(40);
1221 let tampered_path = cache_base.as_std_path().join(&tampered_name);
1222 let tampered_head = init_fake_cache_repo(&tampered_path);
1223 assert_ne!(
1224 tampered_head, tampered_name,
1225 "test invariant: tampered name must not coincidentally equal HEAD"
1226 );
1227
1228 // The load-bearing assertion: the function returns `Ok(None)` so the
1229 // caller falls through to the network-clone path. The warn-log shape
1230 // is informational and was asserted via a `tracing-subscriber` buffer
1231 // capture in an earlier revision, but that capture proved flaky when
1232 // tests run in parallel (subscriber registration is a global-ish
1233 // operation in `tracing` and multiple test threads can collide on
1234 // the writer). We keep the behavioural assertion and drop the log
1235 // assertion — the warn is still emitted (the `warn!` site at the
1236 // `if tampered_count > 0` branch is unconditional given a tampered
1237 // entry), but its CONTENT is no longer tested here.
1238 let result = find_self_consistent_cache_entry(&cache_base).expect("must not error");
1239
1240 assert!(
1241 result.is_none(),
1242 "all-tampered must return Ok(None) so caller falls through to network clone; got {result:?}"
1243 );
1244 }
1245
1246 /// Round-7 bughunt #2 (R7-bughunt-2): differentiate "tampered" from
1247 /// "transient I/O / locked" in the cache loader.
1248 ///
1249 /// Pre-round-7 shape: every per-entry failure — tamper, sharing
1250 /// violation against a sibling `cordance pack`, antivirus open handle,
1251 /// EACCES on a permission-stripped entry — collapsed into the same
1252 /// `tampered_count` bucket and the function returned `Ok(None)`,
1253 /// silently forcing a network clone.
1254 ///
1255 /// Round-7 shape: a per-entry transient I/O failure must surface as
1256 /// `Err(DoctrineError::Io(...))` so the caller sees the real failure
1257 /// instead of paying clone latency for what was a 50ms cache
1258 /// contention. We simulate "locked / transient" by planting `.git`
1259 /// as a REGULAR FILE (instead of a directory). The classifier sees
1260 /// `.git` present (so it isn't "not a git repo") but `gix::open`
1261 /// fails (since `.git` isn't a directory), which is exactly the
1262 /// `Transient` bucket.
1263 #[test]
1264 fn find_self_consistent_transient_io_returns_err_not_ok_none() {
1265 let tmp = tempfile::tempdir().expect("tempdir");
1266 let cache_base: Utf8PathBuf = tmp.path().to_path_buf().try_into().expect("utf8");
1267
1268 // Plant ONE "locked / transient" entry. The cache dirname is a
1269 // 40-hex SHA shape so it passes the `cordance-doctrine-clone-`
1270 // filter. We make `.git` a regular file so:
1271 // 1. `symlink_metadata(.git)` succeeds (entry is NOT classified
1272 // `NotAGitRepo` — the directory shape looks like a cache
1273 // entry).
1274 // 2. `head_full_sha_from_path` fails because gix can't open a
1275 // file as a repo. The classifier maps that to `Transient`.
1276 let locked_name = "a".repeat(40);
1277 let locked_path = cache_base.as_std_path().join(&locked_name);
1278 std::fs::create_dir_all(&locked_path).expect("mkdir locked entry");
1279 std::fs::write(locked_path.join(".git"), b"not actually a git dir")
1280 .expect("plant fake .git file");
1281
1282 let result = find_self_consistent_cache_entry(&cache_base);
1283 assert!(
1284 matches!(result, Err(DoctrineError::Io(_))),
1285 "transient I/O must surface as Err(DoctrineError::Io), not Ok(None); got {result:?}"
1286 );
1287 }
1288
1289 /// Round-7 bughunt #2 (R7-bughunt-2): when one entry is tampered AND a
1290 /// fresh self-consistent sibling is also present, the loader must
1291 /// still return the fresh sibling. This pins that the new
1292 /// classification path does not regress the round-5 "skip tampered
1293 /// and return fresh" behaviour: tampered entries must NOT be
1294 /// promoted into the new `Transient` bucket.
1295 #[test]
1296 fn find_self_consistent_tampered_alone_still_returns_ok_none() {
1297 // Same shape as `find_self_consistent_all_tampered_returns_ok_none_with_summary_warn`,
1298 // but we additionally assert the function does NOT return
1299 // `Err(Io)` — i.e. tamper is a strictly different bucket from
1300 // transient I/O.
1301 let tmp = tempfile::tempdir().expect("tempdir");
1302 let cache_base: Utf8PathBuf = tmp.path().to_path_buf().try_into().expect("utf8");
1303
1304 let tampered_name = "f".repeat(40);
1305 let tampered_path = cache_base.as_std_path().join(&tampered_name);
1306 let tampered_head = init_fake_cache_repo(&tampered_path);
1307 assert_ne!(
1308 tampered_head, tampered_name,
1309 "test invariant: tampered name must not coincidentally equal HEAD"
1310 );
1311
1312 let result = find_self_consistent_cache_entry(&cache_base);
1313 match result {
1314 Ok(None) => {} // expected
1315 Ok(Some(p)) => panic!(
1316 "tampered-only must return Ok(None) (fall through to clone); got Some({p})"
1317 ),
1318 Err(e) => panic!(
1319 "tampered-only must NOT surface as Err — that's reserved for transient I/O; got Err({e:?})"
1320 ),
1321 }
1322 }
1323}