Skip to main content

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}