Skip to main content

mif_rh/
vendor.rs

1//! On-demand ontology vendoring (rht ADR-0012).
2//!
3//! Fetches domain ontology packs from the canonical registry, sha256-verifies
4//! and pins them, catalogs them, discovers new registry entries, and checks
5//! for local drift.
6//!
7//! Ports rht's `scripts/fetch-ontology.sh`, the ontology-catalog section of
8//! `scripts/sync-packs.sh`, `scripts/check-ontology-lock.sh`, and
9//! `scripts/sync-registry-ontologies.sh` to the compiled engine (Epic
10//! research-harness-template#276, Story #277).
11
12use std::collections::{BTreeMap, BTreeSet, HashSet};
13use std::path::Path;
14
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17
18use crate::error::MifRhError;
19use crate::ontology_pack::parse_pack;
20
21/// The canonical ontology registry's default base URL.
22pub const DEFAULT_REGISTRY_SOURCE: &str = "https://mif-spec.dev/ontologies";
23
24/// Ontology ids that are always committed base layers, never vendored, and
25/// implicitly core when cataloged (mirrors `sync-packs.sh`'s `CORE_IDS`).
26const CORE_IDS: [&str; 3] = ["mif-base", "mif-generic", "shared-traits"];
27
28/// One registry index entry: a domain ontology's pinned version, sha256,
29/// vendored file name, and `extends` ancestry.
30#[derive(Debug, Clone, Deserialize)]
31pub struct IndexEntry {
32    /// The ontology's canonical version.
33    pub version: String,
34    /// The expected sha256 of the vendored file.
35    pub sha256: String,
36    /// The bare filename to fetch (must not contain a path separator).
37    pub file: String,
38    /// Ontology ids this ontology directly extends.
39    #[serde(default)]
40    pub extends: Vec<String>,
41}
42
43/// The canonical registry's `index.json`: every known domain ontology,
44/// keyed by id.
45#[derive(Debug, Clone, Deserialize)]
46pub struct RegistryIndex {
47    /// The indexed ontologies.
48    pub ontologies: BTreeMap<String, IndexEntry>,
49}
50
51/// One pinned lock entry: a vendored ontology's version and verified
52/// sha256.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct LockEntry {
55    /// The vendored version.
56    pub version: String,
57    /// The verified sha256 of the vendored file.
58    pub sha256: String,
59}
60
61/// `ontologies.lock.json`: the registry source, its trust-pinned index
62/// sha256 (trust-on-first-use), and every vendored ontology's pin.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct LockFile {
65    /// The lock file's schema tag.
66    #[serde(default = "lock_schema")]
67    pub schema: String,
68    /// The registry source this lock was populated from.
69    #[serde(default)]
70    pub source: String,
71    /// The pinned registry index sha256, once one has been fetched.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub index_sha256: Option<String>,
74    /// Every vendored ontology's pin, keyed by id.
75    #[serde(default)]
76    pub ontologies: BTreeMap<String, LockEntry>,
77}
78
79fn lock_schema() -> String {
80    "mif-ontology-lock/v1".to_string()
81}
82
83impl Default for LockFile {
84    fn default() -> Self {
85        Self {
86            schema: lock_schema(),
87            source: String::new(),
88            index_sha256: None,
89            ontologies: BTreeMap::new(),
90        }
91    }
92}
93
94impl LockFile {
95    /// Loads `ontologies.lock.json` at `path`, or an empty default lock if
96    /// the file does not exist yet (on-demand vendoring not yet adopted).
97    ///
98    /// # Errors
99    ///
100    /// Returns [`MifRhError::Io`] if `path` exists but cannot be read, or
101    /// [`MifRhError::Json`] if it is not valid JSON.
102    pub fn load_or_default(path: &Path) -> Result<Self, MifRhError> {
103        if !path.exists() {
104            return Ok(Self::default());
105        }
106        let contents = std::fs::read_to_string(path).map_err(|source| MifRhError::Io {
107            path: path.display().to_string(),
108            source,
109        })?;
110        serde_json::from_str(&contents).map_err(|source| MifRhError::Json {
111            path: path.display().to_string(),
112            source,
113        })
114    }
115}
116
117/// Resolves the ontology registry source.
118///
119/// Mirrors rht's own precedence: an explicit override (e.g. the
120/// `MIF_ONTOLOGY_SOURCE` env var, read by the CLI layer), then a
121/// `.ontologies.source` marker file in `root`, then the canonical default.
122#[must_use]
123pub fn resolve_source(root: &Path, source_override: Option<&str>) -> String {
124    if let Some(src) = source_override
125        && !src.is_empty()
126    {
127        return trim_trailing_slash(src);
128    }
129    if let Ok(contents) = std::fs::read_to_string(root.join(".ontologies.source"))
130        && let Some(first_line) = contents.lines().next()
131        && !first_line.is_empty()
132    {
133        return trim_trailing_slash(first_line);
134    }
135    DEFAULT_REGISTRY_SOURCE.to_string()
136}
137
138fn trim_trailing_slash(source: &str) -> String {
139    source.strip_suffix('/').unwrap_or(source).to_string()
140}
141
142fn is_http(source: &str) -> bool {
143    source.starts_with("http://") || source.starts_with("https://")
144}
145
146/// Fetches one relative path from `source` (a local directory, `file://`
147/// URL, or an http(s) base URL), returning its raw bytes.
148fn fetch_raw(source: &str, relpath: &str) -> Result<Vec<u8>, MifRhError> {
149    if is_http(source) {
150        let url = format!("{source}/{relpath}");
151        let mut response = ureq::get(&url)
152            .call()
153            .map_err(|err| MifRhError::RegistryFetch {
154                registry_source: source.to_string(),
155                detail: err.to_string(),
156            })?;
157        response
158            .body_mut()
159            .read_to_vec()
160            .map_err(|err| MifRhError::RegistryFetch {
161                registry_source: source.to_string(),
162                detail: err.to_string(),
163            })
164    } else {
165        let base = source.strip_prefix("file://").unwrap_or(source);
166        let path = Path::new(base).join(relpath);
167        std::fs::read(&path).map_err(|err| MifRhError::RegistryFetch {
168            registry_source: source.to_string(),
169            detail: format!("cannot read {}: {err}", path.display()),
170        })
171    }
172}
173
174/// Hex-encodes `bytes`' sha256 digest, lowercase, matching `sha256sum`'s
175/// output format.
176#[must_use]
177fn sha256_hex(bytes: &[u8]) -> String {
178    use std::fmt::Write as _;
179
180    let mut hasher = Sha256::new();
181    hasher.update(bytes);
182    hasher
183        .finalize()
184        .iter()
185        .fold(String::new(), |mut hex, byte| {
186            let _ = write!(hex, "{byte:02x}");
187            hex
188        })
189}
190
191/// Whether `id` is already satisfied by a committed base layer under
192/// `root/schemas/ontologies/<id>/` — never fetched, regardless of the
193/// registry.
194fn is_committed_base(root: &Path, id: &str) -> bool {
195    root.join("schemas/ontologies").join(id).is_dir()
196}
197
198/// A bare, lowercase slug (matches `harness.config.schema.json`'s own
199/// ontology id pattern) — rejects anything that could escape
200/// `packs/ontologies/<id>/` once vendored.
201fn is_wellformed_id(id: &str) -> bool {
202    let mut chars = id.chars();
203    let Some(first) = chars.next() else {
204        return false;
205    };
206    first.is_ascii_lowercase()
207        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
208}
209
210/// Whether `file` (an index entry's attacker-controlled `file` field) is
211/// safe to join onto a filesystem path: exactly one normal path component
212/// (a bare filename). Checking only for `/` and `..` would still let a
213/// Windows-style separator (`..\..\etc`) or an absolute path (`C:\...`)
214/// through unchecked.
215fn is_bare_filename(file: &str) -> bool {
216    // `Path::components()` alone is insufficient: a trailing slash (e.g.
217    // "foo/") still yields a single `Normal` component with nothing after
218    // it, so it would otherwise pass as if it were the bare filename "foo".
219    // Rejecting any '/' directly closes that gap without relying on
220    // component parsing to catch it.
221    let mut components = std::path::Path::new(file).components();
222    matches!(components.next(), Some(std::path::Component::Normal(_)))
223        && components.next().is_none()
224        && !file.contains('\\')
225        && !file.contains('/')
226}
227
228/// Resolves `requested`'s `extends` closure against `index` (breadth-first,
229/// skipping committed base layers), returning the domain layers that must
230/// actually be fetched, in discovery order.
231///
232/// # Errors
233///
234/// Returns [`MifRhError::OntologyNotInRegistry`] if a requested or ancestor
235/// id has no index entry.
236fn resolve_fetch_set(
237    root: &Path,
238    index: &RegistryIndex,
239    requested: &[String],
240) -> Result<Vec<String>, MifRhError> {
241    let mut seen: HashSet<String> = HashSet::new();
242    let mut queue: Vec<String> = Vec::new();
243    for id in requested {
244        if seen.insert(id.clone()) {
245            queue.push(id.clone());
246        }
247    }
248    let mut fetch_list: Vec<String> = Vec::new();
249    let mut cursor = 0;
250    while cursor < queue.len() {
251        let id = queue[cursor].clone();
252        cursor += 1;
253        // Every id reaching this point — whether directly requested or
254        // discovered via a registry entry's `extends` — ends up joined onto
255        // a filesystem path in `fetch()` (`packs_dir.join(id)`). A
256        // compromised registry could otherwise smuggle a path-traversal id
257        // (e.g. `../../../etc`) through `extends`, which is fully
258        // attacker-controlled index content, the same threat model as the
259        // `entry.file` bare-filename check below.
260        if !is_wellformed_id(&id) {
261            return Err(MifRhError::MalformedOntologyId { id });
262        }
263        if is_committed_base(root, &id) {
264            continue;
265        }
266        let entry = index
267            .ontologies
268            .get(&id)
269            .ok_or_else(|| MifRhError::OntologyNotInRegistry { id: id.clone() })?;
270        if !fetch_list.contains(&id) {
271            fetch_list.push(id.clone());
272        }
273        for ancestor in &entry.extends {
274            if seen.insert(ancestor.clone()) {
275                queue.push(ancestor.clone());
276            }
277        }
278    }
279    Ok(fetch_list)
280}
281
282/// One ontology vendored by [`fetch`].
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct VendoredOntology {
285    /// The vendored ontology's id.
286    pub id: String,
287    /// The vendored version.
288    pub version: String,
289}
290
291/// One ontology [`fetch`] left untouched at its pinned version.
292///
293/// Its `ontologies.lock.json` entry differs from the registry's current
294/// version, and `refresh` was not set (research-harness-template#270: a
295/// pinned corpus must not silently advance underneath an already-stamped
296/// finding set).
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct PinnedSkipped {
299    /// The ontology's id.
300    pub id: String,
301    /// The version currently pinned in `ontologies.lock.json`.
302    pub locked_version: String,
303    /// The version the registry currently offers.
304    pub registry_version: String,
305}
306
307/// The outcome of a [`fetch`] call.
308#[derive(Debug, Clone, Default)]
309pub struct FetchReport {
310    /// Every ontology newly vendored or refreshed by this call
311    /// (already-vendored, unchanged ontologies are not re-listed).
312    pub vendored: Vec<VendoredOntology>,
313    /// Every ontology left at its pinned version because the registry has
314    /// moved past it and `refresh` was not set. Never populated on a first
315    /// fetch of an id (nothing pinned yet to hold).
316    pub pinned_skipped: Vec<PinnedSkipped>,
317}
318
319/// Whether `id` is already pinned in `lock` at a version different from
320/// `entry`'s (the registry's current one) and `refresh` was not set — the
321/// rht#270 drift `fetch` must hold rather than silently advance past.
322fn pinned_below_registry(
323    refresh: bool,
324    lock: &LockFile,
325    id: &str,
326    entry: &IndexEntry,
327) -> Option<PinnedSkipped> {
328    if refresh {
329        return None;
330    }
331    let locked = lock.ontologies.get(id)?;
332    if locked.version == entry.version {
333        return None;
334    }
335    Some(PinnedSkipped {
336        id: id.to_string(),
337        locked_version: locked.version.clone(),
338        registry_version: entry.version.clone(),
339    })
340}
341
342/// Vendors `ids` and their `extends` closure from the registry.
343///
344/// Fetches from `source`'s registry index into
345/// `root/packs/ontologies/<id>/`, sha256-verifying every fetched file
346/// against the index (fail-closed) and pinning the result in
347/// `root/ontologies.lock.json`. A committed base layer under
348/// `root/schemas/ontologies/<id>/` is always satisfied locally and never
349/// fetched.
350///
351/// Trust-on-first-use, then pin: the index's own sha256 is recorded in the
352/// lock on first fetch from a given `source`. A later fetch from the SAME
353/// source whose index sha256 has changed is refused — the trust root
354/// moved — unless the lock's `index_sha256` is cleared to re-pin
355/// deliberately.
356///
357/// An id already present in `ontologies.lock.json` whose pinned version
358/// differs from the registry's current one is left untouched unless
359/// `refresh` is set — the lock IS the version pin (rht#270): a corpus must
360/// not have an ontology's schema silently advance underneath its
361/// already-stamped findings just because the registry published a newer
362/// version. Skipped ids are reported in
363/// [`FetchReport::pinned_skipped`], never silently dropped. An id with no
364/// existing lock entry (a first-time fetch) is unaffected by `refresh` and
365/// always vendors at the registry's current version.
366///
367/// # Errors
368///
369/// Returns [`MifRhError::RegistryFetch`] if the index or a file cannot be
370/// read, [`MifRhError::RegistryIndexInvalid`] if the index is not valid
371/// JSON, [`MifRhError::OntologyNotInRegistry`] if a requested or ancestor id
372/// has no index entry, [`MifRhError::IndexPinMismatch`] if the source's
373/// index sha256 no longer matches the lock's pinned value,
374/// [`MifRhError::UnsafeIndexPath`] if the index names an unsafe file path,
375/// [`MifRhError::ChecksumMismatch`] if a fetched file's sha256 does not
376/// match the index, or [`MifRhError::Io`] if a vendored file cannot be
377/// written or the lock cannot be saved.
378pub fn fetch(
379    root: &Path,
380    source: &str,
381    ids: &[String],
382    refresh: bool,
383) -> Result<FetchReport, MifRhError> {
384    let index_bytes = fetch_raw(source, "index.json")?;
385    let index_sha256 = sha256_hex(&index_bytes);
386    let index: RegistryIndex =
387        serde_json::from_slice(&index_bytes).map_err(|err| MifRhError::RegistryIndexInvalid {
388            registry_source: source.to_string(),
389            detail: err.to_string(),
390        })?;
391
392    let lock_path = root.join("ontologies.lock.json");
393    let mut lock = LockFile::load_or_default(&lock_path)?;
394    if let Some(pinned) = &lock.index_sha256
395        && lock.source == source
396        && *pinned != index_sha256
397    {
398        return Err(MifRhError::IndexPinMismatch {
399            registry_source: source.to_string(),
400            pinned: pinned.clone(),
401            got: index_sha256,
402        });
403    }
404
405    let fetch_list = resolve_fetch_set(root, &index, ids)?;
406    let mut vendored = Vec::new();
407    let mut pinned_skipped = Vec::new();
408    let packs_dir = root.join("packs/ontologies");
409    // Set before the loop (not after) and persisted incrementally per id
410    // below: if a later id in this same request fails
411    // (checksum/unsafe-path/IO), the ontologies successfully vendored
412    // earlier in the loop must still end up pinned in the lock — otherwise
413    // they sit on disk, sha256-verified once, permanently invisible to
414    // `lock_check` (which only walks `lock.ontologies`, never the packs
415    // directory).
416    lock.index_sha256 = Some(index_sha256);
417    lock.source = source.to_string();
418    for id in &fetch_list {
419        // Present in `index` by construction: `resolve_fetch_set` already
420        // resolved every entry in `fetch_list` through a successful lookup.
421        let Some(entry) = index.ontologies.get(id) else {
422            continue;
423        };
424        if let Some(skip) = pinned_below_registry(refresh, &lock, id, entry) {
425            pinned_skipped.push(skip);
426            continue;
427        }
428        // `entry.file` is later joined onto a filesystem path, so it must
429        // be a bare filename, never a path that could escape the intended
430        // directory.
431        if !is_bare_filename(&entry.file) {
432            return Err(MifRhError::UnsafeIndexPath {
433                id: id.clone(),
434                file: entry.file.clone(),
435            });
436        }
437        let bytes = fetch_raw(source, &entry.file)?;
438        let got = sha256_hex(&bytes);
439        if got != entry.sha256 {
440            return Err(MifRhError::ChecksumMismatch {
441                id: id.clone(),
442                file: entry.file.clone(),
443                expected: entry.sha256.clone(),
444                got,
445            });
446        }
447        let dest_dir = packs_dir.join(id);
448        let out_yaml = dest_dir.join(format!("{id}.ontology.yaml"));
449        // Parse BEFORE writing to disk: a parse failure must never leave a
450        // malformed/wrong-shape YAML behind under packs/ontologies/ (that
451        // would violate the fail-closed "no partial vendoring on error"
452        // contract and confuse a later `lock_check`/`load_packs_via_catalog`
453        // scan that finds the file but doesn't know it was never vendored).
454        let pack = parse_pack(
455            &String::from_utf8_lossy(&bytes),
456            &out_yaml.display().to_string(),
457        )?;
458        std::fs::create_dir_all(&dest_dir).map_err(|source| MifRhError::Io {
459            path: dest_dir.display().to_string(),
460            source,
461        })?;
462        std::fs::write(&out_yaml, &bytes).map_err(|source| MifRhError::Io {
463            path: out_yaml.display().to_string(),
464            source,
465        })?;
466        let sidecar = serde_json::json!({
467            "name": id,
468            "version": entry.version,
469            "kind": "ontology",
470            "description": pack.description.as_deref().unwrap_or(""),
471            "provides": {"ontologies": [id]},
472        });
473        crate::write_json_atomic(&dest_dir.join("ontology.pack.json"), &sidecar)?;
474
475        lock.ontologies.insert(
476            id.clone(),
477            LockEntry {
478                version: entry.version.clone(),
479                sha256: entry.sha256.clone(),
480            },
481        );
482        crate::write_json_atomic(&lock_path, &lock)?;
483        vendored.push(VendoredOntology {
484            id: id.clone(),
485            version: entry.version.clone(),
486        });
487    }
488    if vendored.is_empty() {
489        // No per-id write happened above to persist the mutated
490        // index_sha256/source: either fetch_list was empty (every requested
491        // id was a committed base layer), or every entry in it was pinned
492        // and skipped. Either way the pin/trust-root mutations made before
493        // the loop still need to land on disk.
494        crate::write_json_atomic(&lock_path, &lock)?;
495    }
496
497    Ok(FetchReport {
498        vendored,
499        pinned_skipped,
500    })
501}
502
503/// Reads `harness.config.json`'s `.ontologies[]` (JSON, since `sync_registry`
504/// must round-trip every OTHER top-level field of that file untouched).
505fn load_config_json(path: &Path) -> Result<serde_json::Value, MifRhError> {
506    if !path.exists() {
507        return Err(MifRhError::ConfigMissing {
508            path: path.display().to_string(),
509        });
510    }
511    let contents = std::fs::read_to_string(path).map_err(|source| MifRhError::Io {
512        path: path.display().to_string(),
513        source,
514    })?;
515    serde_json::from_str(&contents).map_err(|source| MifRhError::Json {
516        path: path.display().to_string(),
517        source,
518    })
519}
520
521fn ontologies_array(config: &serde_json::Value) -> &[serde_json::Value] {
522    config
523        .get("ontologies")
524        .and_then(serde_json::Value::as_array)
525        .map_or(&[][..], Vec::as_slice)
526}
527
528fn enabled_ontology_ids(config: &serde_json::Value) -> Vec<String> {
529    ontologies_array(config)
530        .iter()
531        .filter(|entry| {
532            entry
533                .get("enabled")
534                .and_then(serde_json::Value::as_bool)
535                .unwrap_or(false)
536        })
537        .filter_map(|entry| entry.get("id").and_then(serde_json::Value::as_str))
538        .map(str::to_string)
539        .collect()
540}
541
542fn known_ontology_ids(config: &serde_json::Value) -> HashSet<String> {
543    ontologies_array(config)
544        .iter()
545        .filter_map(|entry| entry.get("id").and_then(serde_json::Value::as_str))
546        .map(str::to_string)
547        .collect()
548}
549
550/// One cataloged ontology entry written by [`sync_catalog`], matching
551/// `sync-packs.sh`'s own `{id, version, source, core}` shape.
552#[derive(Debug, Clone, Serialize)]
553struct CatalogOntologyEntry {
554    id: String,
555    version: String,
556    source: String,
557    core: bool,
558}
559
560/// The outcome of a [`sync_catalog`] call.
561#[derive(Debug, Clone, Default)]
562pub struct CatalogSyncReport {
563    /// How many ontologies (core + enabled) were cataloged.
564    pub cataloged: usize,
565}
566
567/// Rebuilds the ontology-catalog section of rht's catalog sidecar.
568///
569/// Writes `.claude/enabled-packs.json`'s `.ontologies` key: every committed
570/// base layer under `root/schemas/ontologies/` (core, if its id is one of
571/// `mif-base`/`mif-generic`/`shared-traits`) plus every ontology enabled in
572/// `harness.config.json` that is vendored under
573/// `root/packs/ontologies/<id>/`.
574///
575/// Merges into whatever else already lives in `sidecar_path` (the general
576/// pack/plugin-enablement half of `sync-packs.sh` writes other keys to the
577/// same file) rather than overwriting it.
578///
579/// # Errors
580///
581/// Returns [`MifRhError::ConfigMissing`]/[`MifRhError::Json`] if
582/// `config_path` cannot be read, [`MifRhError::Io`] if `root`'s ontology
583/// directories cannot be read, [`MifRhError::OntologyPackYaml`] if a pack
584/// fails to parse, and [`MifRhError::Io`]/[`MifRhError::JsonSerialize`] if
585/// `sidecar_path` cannot be written.
586pub fn sync_catalog(
587    root: &Path,
588    config_path: &Path,
589    sidecar_path: &Path,
590) -> Result<CatalogSyncReport, MifRhError> {
591    let config = load_config_json(config_path)?;
592    let mut entries = Vec::new();
593
594    let core_dir = root.join("schemas/ontologies");
595    if core_dir.is_dir() {
596        let dir_entries = std::fs::read_dir(&core_dir).map_err(|source| MifRhError::Io {
597            path: core_dir.display().to_string(),
598            source,
599        })?;
600        let mut subdirs: Vec<_> = dir_entries
601            .filter_map(Result::ok)
602            .map(|entry| entry.path())
603            .filter(|path| path.is_dir())
604            .collect();
605        subdirs.sort();
606        for subdir in subdirs {
607            for pack_path in yaml_files_in(&subdir)? {
608                let display = pack_path.display().to_string();
609                let contents =
610                    std::fs::read_to_string(&pack_path).map_err(|source| MifRhError::Io {
611                        path: display.clone(),
612                        source,
613                    })?;
614                let pack = parse_pack(&contents, &display)?;
615                let core = CORE_IDS.contains(&pack.id.as_str());
616                entries.push(CatalogOntologyEntry {
617                    id: pack.id,
618                    version: pack.version,
619                    source: repo_relative(root, &pack_path),
620                    core,
621                });
622            }
623        }
624    }
625
626    let mut enabled: Vec<String> = enabled_ontology_ids(&config);
627    enabled.sort();
628    for id in enabled {
629        let pack_path = root
630            .join("packs/ontologies")
631            .join(&id)
632            .join(format!("{id}.ontology.yaml"));
633        if !pack_path.is_file() {
634            continue;
635        }
636        let display = pack_path.display().to_string();
637        let contents = std::fs::read_to_string(&pack_path).map_err(|source| MifRhError::Io {
638            path: display.clone(),
639            source,
640        })?;
641        let pack = parse_pack(&contents, &display)?;
642        entries.push(CatalogOntologyEntry {
643            id: pack.id,
644            version: pack.version,
645            source: repo_relative(root, &pack_path),
646            core: false,
647        });
648    }
649
650    let mut sidecar = if sidecar_path.exists() {
651        load_config_json(sidecar_path)?
652    } else {
653        serde_json::json!({})
654    };
655    let cataloged = entries.len();
656    if let Some(object) = sidecar.as_object_mut() {
657        object.insert(
658            "ontologies".to_string(),
659            serde_json::to_value(&entries).map_err(|source| MifRhError::JsonSerialize {
660                path: sidecar_path.display().to_string(),
661                source,
662            })?,
663        );
664    }
665    if let Some(parent) = sidecar_path.parent()
666        && !parent.as_os_str().is_empty()
667    {
668        std::fs::create_dir_all(parent).map_err(|source| MifRhError::Io {
669            path: parent.display().to_string(),
670            source,
671        })?;
672    }
673    crate::write_json_atomic(sidecar_path, &sidecar)?;
674
675    Ok(CatalogSyncReport { cataloged })
676}
677
678fn yaml_files_in(dir: &Path) -> Result<Vec<std::path::PathBuf>, MifRhError> {
679    let entries = std::fs::read_dir(dir).map_err(|source| MifRhError::Io {
680        path: dir.display().to_string(),
681        source,
682    })?;
683    let mut files: Vec<_> = entries
684        .filter_map(Result::ok)
685        .map(|entry| entry.path())
686        .filter(|path| {
687            path.extension()
688                .and_then(|ext| ext.to_str())
689                .is_some_and(|ext| ext == "yaml" || ext == "yml")
690        })
691        .collect();
692    files.sort();
693    Ok(files)
694}
695
696fn repo_relative(root: &Path, path: &Path) -> String {
697    path.strip_prefix(root)
698        .unwrap_or(path)
699        .display()
700        .to_string()
701}
702
703/// The outcome of a [`sync_registry`] call.
704#[derive(Debug, Clone, Default)]
705pub struct RegistrySyncReport {
706    /// Registry ontology ids discovered and newly added (enabled by
707    /// default) to `harness.config.json`.
708    pub discovered: Vec<String>,
709    /// The subsequent vendor pass over every now-enabled ontology.
710    pub fetch: FetchReport,
711}
712
713/// Discovers domain ontologies newly published to the registry.
714///
715/// Finds ontologies in `source`'s registry that `config_path` has never
716/// heard of (not merely disabled — absent from `.ontologies[]` entirely),
717/// adds each with `enabled: true` (this harness's default posture), then
718/// vendors and catalogs everything currently enabled.
719///
720/// # Errors
721///
722/// Returns [`MifRhError::ConfigMissing`]/[`MifRhError::Json`] if
723/// `config_path` cannot be read, [`MifRhError::RegistryFetch`]/
724/// [`MifRhError::RegistryIndexInvalid`] if the registry index cannot be
725/// read or parsed, [`MifRhError::MalformedOntologyId`] if the index
726/// declares a malformed id, [`MifRhError::ConfigMalformed`] if
727/// `config_path`'s `.ontologies` exists but is not an array, or any error
728/// [`fetch`]/[`sync_catalog`] can return.
729pub fn sync_registry(
730    root: &Path,
731    config_path: &Path,
732    sidecar_path: &Path,
733    source: &str,
734) -> Result<RegistrySyncReport, MifRhError> {
735    // Validate the config's shape BEFORE any network/source activity — a
736    // malformed config must fail fast and offline, matching
737    // `sync-registry-ontologies.sh`'s own original `jq -e '.ontologies |
738    // type == "array"'` guard, which ran before it ever touched the
739    // registry.
740    let mut config = load_config_json(config_path)?;
741    if !config
742        .get("ontologies")
743        .is_some_and(serde_json::Value::is_array)
744    {
745        return Err(MifRhError::ConfigMalformed {
746            path: config_path.display().to_string(),
747            detail: ".ontologies is missing or not an array".to_string(),
748        });
749    }
750
751    let index_bytes = fetch_raw(source, "index.json")?;
752    let index: RegistryIndex =
753        serde_json::from_slice(&index_bytes).map_err(|err| MifRhError::RegistryIndexInvalid {
754            registry_source: source.to_string(),
755            detail: err.to_string(),
756        })?;
757    // Check the trust-on-first-use pin BEFORE mutating harness.config.json
758    // below: `fetch()` (called later in this function) re-fetches and
759    // re-checks the same index independently, but if that later check
760    // fails, config.json must not have already been durably rewritten to
761    // enable ontologies discovered from an index whose trust root moved.
762    let lock = LockFile::load_or_default(&root.join("ontologies.lock.json"))?;
763    if let Some(pinned) = &lock.index_sha256
764        && lock.source == source
765        && *pinned != sha256_hex(&index_bytes)
766    {
767        return Err(MifRhError::IndexPinMismatch {
768            registry_source: source.to_string(),
769            pinned: pinned.clone(),
770            got: sha256_hex(&index_bytes),
771        });
772    }
773
774    let known = known_ontology_ids(&config);
775    let mut discovered: Vec<String> = index
776        .ontologies
777        .keys()
778        .filter(|id| !is_committed_base(root, id) && !known.contains(*id))
779        .cloned()
780        .collect();
781    discovered.sort();
782    for id in &discovered {
783        if !is_wellformed_id(id) {
784            return Err(MifRhError::MalformedOntologyId { id: id.clone() });
785        }
786    }
787
788    if !discovered.is_empty() {
789        let object = config
790            .as_object_mut()
791            .ok_or_else(|| MifRhError::ConfigMalformed {
792                path: config_path.display().to_string(),
793                detail: "top-level document is not a JSON object".to_string(),
794            })?;
795        let array = object
796            .entry("ontologies")
797            .or_insert_with(|| serde_json::Value::Array(Vec::new()))
798            .as_array_mut()
799            .ok_or_else(|| MifRhError::ConfigMalformed {
800                path: config_path.display().to_string(),
801                detail: ".ontologies exists but is not an array".to_string(),
802            })?;
803        for id in &discovered {
804            array.push(serde_json::json!({"id": id, "enabled": true}));
805        }
806        crate::write_json_atomic(config_path, &config)?;
807    }
808
809    let enabled = enabled_ontology_ids(&config);
810    // `refresh: false` — sync-registry's job is discovering NEWLY published
811    // ids and enabling them, not silently advancing ids already pinned in
812    // ontologies.lock.json (rht#270). A newly-discovered id has no lock
813    // entry yet, so it always vendors regardless of this flag.
814    let fetch_report = fetch(root, source, &enabled, false)?;
815    sync_catalog(root, config_path, sidecar_path)?;
816
817    Ok(RegistrySyncReport {
818        discovered,
819        fetch: fetch_report,
820    })
821}
822
823/// One drifted vendored ontology: its id, its pinned sha256, and the
824/// sha256 actually found on disk.
825#[derive(Debug, Clone)]
826pub struct DriftEntry {
827    /// The drifted ontology's id.
828    pub id: String,
829    /// The pinned (expected) sha256.
830    pub pinned: String,
831    /// The sha256 actually computed from the file on disk.
832    pub got: String,
833}
834
835/// The outcome of a [`lock_check`] call.
836#[derive(Debug, Clone, Default)]
837pub struct LockCheckReport {
838    /// Enabled ontology ids with no pin in `ontologies.lock.json`.
839    pub missing_pins: Vec<String>,
840    /// Enabled, pinned ontology ids with no vendored file on disk.
841    pub not_vendored: Vec<String>,
842    /// Pinned, vendored ontologies whose on-disk sha256 no longer matches
843    /// the pin.
844    pub drift: Vec<DriftEntry>,
845    /// How many vendored ontologies were checked and matched their pin.
846    pub checked: usize,
847}
848
849impl LockCheckReport {
850    /// Whether every check passed: no missing pins, no un-vendored enabled
851    /// ontologies, and no drift.
852    #[must_use]
853    pub const fn ok(&self) -> bool {
854        self.missing_pins.is_empty() && self.not_vendored.is_empty() && self.drift.is_empty()
855    }
856}
857
858/// Proves vendored domain ontologies match `root/ontologies.lock.json`.
859///
860/// Checks coverage (every enabled domain ontology is pinned and vendored)
861/// and integrity (every vendored ontology present on disk hashes to its
862/// pinned sha256). A missing lock file is not an error — on-demand
863/// vendoring has not been adopted in the clone, so there is nothing to
864/// verify.
865///
866/// # Errors
867///
868/// Returns [`MifRhError::ConfigMissing`]/[`MifRhError::Json`] if
869/// `config_path` cannot be read, or [`MifRhError::Io`]/[`MifRhError::Json`]
870/// if the lock file exists but cannot be read/parsed.
871pub fn lock_check(root: &Path, config_path: &Path) -> Result<LockCheckReport, MifRhError> {
872    let lock_path = root.join("ontologies.lock.json");
873    if !lock_path.exists() {
874        return Ok(LockCheckReport::default());
875    }
876    let lock = LockFile::load_or_default(&lock_path)?;
877    let config = load_config_json(config_path)?;
878
879    let mut report = LockCheckReport::default();
880    // A BTreeSet, not a HashSet: `missing_pins` is built by iterating this
881    // set, and its order should stay deterministic rather than depend on
882    // hash iteration order.
883    let enabled: BTreeSet<String> = enabled_ontology_ids(&config).into_iter().collect();
884
885    for id in &enabled {
886        if is_committed_base(root, id) {
887            continue;
888        }
889        if !lock.ontologies.contains_key(id) {
890            report.missing_pins.push(id.clone());
891        }
892    }
893
894    for (id, entry) in &lock.ontologies {
895        let yaml = root
896            .join("packs/ontologies")
897            .join(id)
898            .join(format!("{id}.ontology.yaml"));
899        if !yaml.is_file() {
900            if enabled.contains(id) {
901                report.not_vendored.push(id.clone());
902            }
903            continue;
904        }
905        let bytes = std::fs::read(&yaml).map_err(|source| MifRhError::Io {
906            path: yaml.display().to_string(),
907            source,
908        })?;
909        let got = sha256_hex(&bytes);
910        if got == entry.sha256 {
911            report.checked += 1;
912        } else {
913            report.drift.push(DriftEntry {
914                id: id.clone(),
915                pinned: entry.sha256.clone(),
916                got,
917            });
918        }
919    }
920
921    Ok(report)
922}
923
924/// One `required` field the registry's advanced schema newly demands for an
925/// entity type both the vendored and registry packs declare.
926#[derive(Debug, Clone, PartialEq, Eq)]
927pub struct NewlyRequiredField {
928    /// The entity type the field was added to.
929    pub entity_type: String,
930    /// The newly required field's name.
931    pub field: String,
932}
933
934/// A stamped finding missing a newly required field.
935///
936/// The breaking-change warning [`check_pin_safety`] exists to surface,
937/// distinct from `fetch`'s plain version-drift warning.
938#[derive(Debug, Clone, PartialEq, Eq)]
939pub struct PinSafetyGap {
940    /// The topic the finding belongs to.
941    pub topic: String,
942    /// The finding's id.
943    pub finding_id: String,
944    /// The finding's stamped entity type.
945    pub entity_type: String,
946    /// The newly required field the finding's `entity` payload lacks.
947    pub field: String,
948}
949
950/// One pinned, drifted ontology's pin-safety analysis.
951#[derive(Debug, Clone, Default)]
952pub struct PinSafetyReport {
953    /// The ontology's id.
954    pub id: String,
955    /// The version currently pinned in `ontologies.lock.json`.
956    pub locked_version: String,
957    /// The version the registry currently offers.
958    pub registry_version: String,
959    /// Whether the diff was actually performed. `false` when the vendored
960    /// pack could not be read from disk to diff against — in that case
961    /// `newly_required`/`gaps` are empty because nothing was analyzed, NOT
962    /// because the drift was found safe. Callers must check this before
963    /// treating an empty `gaps` as a clean bill of health.
964    pub analyzed: bool,
965    /// Fields the registry's advanced schema newly requires, relative to
966    /// the vendored version. Always empty when `analyzed` is `false`.
967    pub newly_required: Vec<NewlyRequiredField>,
968    /// Stamped findings missing a newly required field. Always empty when
969    /// `newly_required` is empty.
970    pub gaps: Vec<PinSafetyGap>,
971}
972
973/// The `required` array of an entity type's `schema` object, as strings.
974///
975/// # Errors
976///
977/// Returns [`MifRhError::EntityTypeSchemaInvalid`] if `required` is present
978/// but is not an array of strings. A missing `required` key is not an
979/// error — it means the entity type declares no required fields — but a
980/// PRESENT, malformed one must fail closed rather than silently default to
981/// an empty list: for a pin-safety check, treating a malformed schema as
982/// "nothing required" would produce a false "safe" verdict, the
983/// worst-case failure mode for this specific check.
984fn required_fields(
985    entity_type: &str,
986    schema: &serde_json::Value,
987) -> Result<Vec<String>, MifRhError> {
988    let Some(required) = schema.get("required") else {
989        return Ok(Vec::new());
990    };
991    let Some(values) = required.as_array() else {
992        return Err(MifRhError::EntityTypeSchemaInvalid {
993            entity_type: entity_type.to_string(),
994            detail: "schema.required is present but is not an array".to_string(),
995        });
996    };
997    values
998        .iter()
999        .map(|value| {
1000            value
1001                .as_str()
1002                .map(str::to_string)
1003                .ok_or_else(|| MifRhError::EntityTypeSchemaInvalid {
1004                    entity_type: entity_type.to_string(),
1005                    detail: format!("schema.required contains a non-string element: {value}"),
1006                })
1007        })
1008        .collect()
1009}
1010
1011/// The `required` fields `new`'s schema adds for an entity type present in
1012/// both `old` and `new`. An entity type absent from `old` is skipped
1013/// entirely: it is wholly new, so no existing stamped finding could have
1014/// been typed with it, and nothing about it can be "newly" required.
1015///
1016/// # Errors
1017///
1018/// Returns [`MifRhError::EntityTypeSchemaInvalid`] if either pack's
1019/// `schema.required` is malformed; see [`required_fields`].
1020fn diff_newly_required(
1021    old: &crate::ontology_pack::OntologyPack,
1022    new: &crate::ontology_pack::OntologyPack,
1023) -> Result<Vec<NewlyRequiredField>, MifRhError> {
1024    let mut out = Vec::new();
1025    for new_type in &new.entity_types {
1026        let Some(old_type) = old.entity_types.iter().find(|t| t.name == new_type.name) else {
1027            continue;
1028        };
1029        let old_required: HashSet<String> = required_fields(&old_type.name, &old_type.schema)?
1030            .into_iter()
1031            .collect();
1032        let mut seen_new = HashSet::new();
1033        for field in required_fields(&new_type.name, &new_type.schema)? {
1034            if !old_required.contains(&field) && seen_new.insert(field.clone()) {
1035                out.push(NewlyRequiredField {
1036                    entity_type: new_type.name.clone(),
1037                    field,
1038                });
1039            }
1040        }
1041    }
1042    Ok(out)
1043}
1044
1045/// Cross-references `newly_required` against every stamped finding (basis
1046/// declared/resolved and valid — the same predicate `collect_topic_samples`
1047/// uses) across `topics` whose `resolved_ontology` names `ontology_id`,
1048/// reporting one [`PinSafetyGap`] per finding whose `entity` payload lacks a
1049/// newly required field.
1050fn find_pin_safety_gaps(
1051    reports_dir: &Path,
1052    topics: &[String],
1053    ontology_id: &str,
1054    newly_required: &[NewlyRequiredField],
1055) -> Result<Vec<PinSafetyGap>, MifRhError> {
1056    let mut gaps = Vec::new();
1057    for topic in topics {
1058        let map_path = reports_dir.join(topic).join("ontology-map.json");
1059        let contents = match std::fs::read_to_string(&map_path) {
1060            Ok(contents) => contents,
1061            Err(source) if source.kind() == std::io::ErrorKind::NotFound => continue,
1062            Err(source) => {
1063                return Err(MifRhError::Io {
1064                    path: map_path.display().to_string(),
1065                    source,
1066                });
1067            },
1068        };
1069        let records: Vec<crate::resolve::MapRecord> =
1070            serde_json::from_str(&contents).map_err(|source| MifRhError::Json {
1071                path: map_path.display().to_string(),
1072                source,
1073            })?;
1074        // Indexed once per topic rather than linearly scanned per finding
1075        // file below (O(records) once vs. O(records) per finding).
1076        let records_by_finding_id: std::collections::HashMap<&str, &crate::resolve::MapRecord> =
1077            records.iter().map(|r| (r.finding_id.as_str(), r)).collect();
1078
1079        let findings_dir = reports_dir.join(topic).join("findings");
1080        if !findings_dir.is_dir() {
1081            continue;
1082        }
1083        for file in crate::review::list_finding_files(&findings_dir)? {
1084            let Ok(finding) = crate::finding::Finding::load(&file) else {
1085                continue; // gap findings are review's concern, not this one's
1086            };
1087            let Some(record) = records_by_finding_id.get(finding.id.as_str()).copied() else {
1088                continue;
1089            };
1090            let stamped = record.valid
1091                && matches!(
1092                    record.basis,
1093                    crate::resolve::Basis::Declared | crate::resolve::Basis::Resolved
1094                );
1095            if !stamped {
1096                continue;
1097            }
1098            let bound_to_this_ontology = record
1099                .resolved_ontology
1100                .as_deref()
1101                .and_then(|resolved| resolved.split('@').next())
1102                == Some(ontology_id);
1103            if !bound_to_this_ontology {
1104                continue;
1105            }
1106            let Some(entity_type) = record.entity_type.as_deref() else {
1107                continue;
1108            };
1109            gaps.extend(
1110                newly_required
1111                    .iter()
1112                    .filter(|nrf| nrf.entity_type == entity_type)
1113                    .filter(|nrf| {
1114                        finding
1115                            .entity
1116                            .as_ref()
1117                            .and_then(|entity| entity.get(&nrf.field))
1118                            .is_none_or(serde_json::Value::is_null)
1119                    })
1120                    .map(|nrf| PinSafetyGap {
1121                        topic: topic.clone(),
1122                        finding_id: finding.id.clone(),
1123                        entity_type: entity_type.to_string(),
1124                        field: nrf.field.clone(),
1125                    }),
1126            );
1127        }
1128    }
1129    Ok(gaps)
1130}
1131
1132/// Guards against trusting `lock`'s per-id version pins when it was
1133/// established against a different registry source than `source`.
1134///
1135/// Unlike `fetch`, [`check_pin_safety`] never re-pins the lock — it is
1136/// read-only — so it must never treat `lock.ontologies`' per-id pins as
1137/// meaningful against a source other than the one the lock itself trusts.
1138/// `fetch` legitimately adopts a new source on first use (writing
1139/// `lock.source = source`); a read-only check has no such adoption step,
1140/// so a mismatch here can only mean the caller is checking pin safety
1141/// against the wrong registry.
1142///
1143/// # Errors
1144///
1145/// Returns [`MifRhError::LockSourceMismatch`] if `lock.source` is set and
1146/// differs from `source`, or if `lock.source` is empty while
1147/// `lock.ontologies` already holds pins (a legacy or hand-edited lock file
1148/// with pins but no recorded trust root — `LockFile`'s `#[serde(default)]`
1149/// on `source` would otherwise let it through as if empty meant "nothing
1150/// pinned yet").
1151fn check_lock_source(lock: &LockFile, source: &str) -> Result<(), MifRhError> {
1152    if lock.source.is_empty() {
1153        if lock.ontologies.is_empty() {
1154            return Ok(());
1155        }
1156        return Err(MifRhError::LockSourceMismatch {
1157            lock_source: "<unset>".to_string(),
1158            requested_source: source.to_string(),
1159        });
1160    }
1161    if lock.source != source {
1162        return Err(MifRhError::LockSourceMismatch {
1163            lock_source: lock.source.clone(),
1164            requested_source: source.to_string(),
1165        });
1166    }
1167    Ok(())
1168}
1169
1170/// Fetches a drifted id's registry file and parses it into an
1171/// [`OntologyPack`], applying the same integrity checks `fetch` enforces on
1172/// this exact attacker-controlled path: `entry.file` must be a bare
1173/// filename (never a path that could escape the intended directory), the
1174/// downloaded bytes must match the index's own pinned sha256, and the bytes
1175/// must be valid UTF-8 (fail closed rather than `from_utf8_lossy`, since the
1176/// schema diffed by the caller must be exactly the checksum-verified bytes,
1177/// not a lossy substitution).
1178///
1179/// # Errors
1180///
1181/// Returns [`MifRhError::UnsafeIndexPath`] if `entry.file` is not a bare
1182/// filename, [`MifRhError::RegistryFetch`] if the file cannot be fetched,
1183/// [`MifRhError::ChecksumMismatch`] if its sha256 does not match `entry`,
1184/// [`MifRhError::OntologyPackNotUtf8`] if the bytes are not valid UTF-8, or
1185/// [`MifRhError::OntologyPackYaml`] if the pack YAML is malformed.
1186fn fetch_and_parse_registry_pack(
1187    source: &str,
1188    id: &str,
1189    entry: &IndexEntry,
1190) -> Result<crate::ontology_pack::OntologyPack, MifRhError> {
1191    if !is_bare_filename(&entry.file) {
1192        return Err(MifRhError::UnsafeIndexPath {
1193            id: id.to_string(),
1194            file: entry.file.clone(),
1195        });
1196    }
1197    let new_bytes = fetch_raw(source, &entry.file)?;
1198    let got = sha256_hex(&new_bytes);
1199    if got != entry.sha256 {
1200        return Err(MifRhError::ChecksumMismatch {
1201            id: id.to_string(),
1202            file: entry.file.clone(),
1203            expected: entry.sha256.clone(),
1204            got,
1205        });
1206    }
1207    let new_text = String::from_utf8(new_bytes).map_err(|_| MifRhError::OntologyPackNotUtf8 {
1208        id: id.to_string(),
1209        file: entry.file.clone(),
1210    })?;
1211    parse_pack(&new_text, &format!("{source}/{}", entry.file))
1212}
1213
1214/// Validates and dedupes `ids` (preserving first-seen order), then filters
1215/// to only those actually pinned in `lock.ontologies` — the ids
1216/// [`check_pin_safety`] has anything to say about.
1217///
1218/// # Errors
1219///
1220/// Returns [`MifRhError::MalformedOntologyId`] if a requested id is not a
1221/// bare, lowercase slug.
1222fn pinned_requested_ids(lock: &LockFile, ids: &[String]) -> Result<Vec<String>, MifRhError> {
1223    let mut seen = HashSet::new();
1224    let mut pinned = Vec::new();
1225    for id in ids {
1226        // `id` is joined onto a filesystem path later (`old_path`), so it
1227        // must be validated the same way `resolve_fetch_set` validates
1228        // every id (including directly-requested ones) before any use —
1229        // a caller-supplied id is just as untrusted a path component as
1230        // one discovered via the registry's `extends` chain.
1231        if !is_wellformed_id(id) {
1232            return Err(MifRhError::MalformedOntologyId { id: id.clone() });
1233        }
1234        if seen.insert(id.as_str()) && lock.ontologies.contains_key(id) {
1235            pinned.push(id.clone());
1236        }
1237    }
1238    Ok(pinned)
1239}
1240
1241/// Warns only when a pinned ontology's advanced schema actually breaks an
1242/// already-stamped finding.
1243///
1244/// For each of `ids` currently pinned in `root/ontologies.lock.json` whose
1245/// locked version differs from the registry's current one, diffs the
1246/// vendored (old) and registry (new) schema's `entity_types[].schema.required`
1247/// lists, and cross-references any newly required field against `topics`'
1248/// already-stamped findings — the narrower, smarter follow-up to `fetch`'s
1249/// plain version-drift warning (research-harness-template#270's proposed
1250/// fix #2; tracked as mif-rs#61).
1251///
1252/// An id with no lock entry, or already at the registry's current version,
1253/// produces no report entry — nothing pinned, or nothing drifted, to
1254/// analyze. An id whose vendored pack cannot be read from disk (e.g.
1255/// deleted since the pin was recorded) produces a report entry with empty
1256/// `newly_required`/`gaps`: the version drift is still real, but nothing
1257/// can be diffed against.
1258///
1259/// Read-only: never touches `ontologies.lock.json`, never vendors, never
1260/// advances a pin. Downloads the registry's current file for a drifted id
1261/// purely to diff it — unlike `fetch`, which never re-fetches a
1262/// pinned-skipped id's file at all — applying the same integrity checks
1263/// `fetch` applies to that same attacker-controlled path (index-pin
1264/// verification, bare-filename validation, sha256 verification against the
1265/// index) before trusting any fetched byte.
1266///
1267/// # Errors
1268///
1269/// Returns [`MifRhError::MalformedOntologyId`] if a requested id is not a
1270/// bare, lowercase slug, [`MifRhError::LockSourceMismatch`] if
1271/// `ontologies.lock.json` is pinned to a different source than `source`,
1272/// [`MifRhError::RegistryFetch`] if the registry index or a drifted id's
1273/// file cannot be fetched, [`MifRhError::RegistryIndexInvalid`] if the
1274/// index is not valid JSON, [`MifRhError::IndexPinMismatch`] if the
1275/// source's index sha256 no longer matches the lock's pinned value,
1276/// [`MifRhError::OntologyNotInRegistry`] if a requested id has no index
1277/// entry, [`MifRhError::UnsafeIndexPath`] if the index names an unsafe
1278/// file path, [`MifRhError::ChecksumMismatch`] if a fetched file's sha256
1279/// does not match the index, [`MifRhError::OntologyPackNotUtf8`] if a
1280/// checksum-verified fetched file is not valid UTF-8,
1281/// [`MifRhError::OntologyPackYaml`] if the vendored or registry pack YAML
1282/// is malformed, or [`MifRhError::Json`] if a topic's `ontology-map.json`
1283/// exists but fails to parse. A topic whose `ontology-map.json` does not
1284/// exist yet (e.g. review has never run for it) contributes no gaps for
1285/// that topic rather than erroring — the same treatment an individual
1286/// finding file that cannot be read or parsed gets (the same treatment
1287/// `collect_topic_samples` gives an unreadable finding) — a gap-analysis
1288/// finding is review's concern, not this function's.
1289pub fn check_pin_safety(
1290    root: &Path,
1291    source: &str,
1292    reports_dir: &Path,
1293    topics: &[String],
1294    ids: &[String],
1295) -> Result<Vec<PinSafetyReport>, MifRhError> {
1296    let lock = LockFile::load_or_default(&root.join("ontologies.lock.json"))?;
1297    // Validated and filtered to actually-pinned ids before any network/lock-source
1298    // work: nothing to analyze (and nothing to fail closed about) if none are pinned.
1299    let pinned_ids = pinned_requested_ids(&lock, ids)?;
1300    if pinned_ids.is_empty() {
1301        return Ok(Vec::new());
1302    }
1303    check_lock_source(&lock, source)?;
1304    let index_bytes = fetch_raw(source, "index.json")?;
1305    let index_sha256 = sha256_hex(&index_bytes);
1306    let index: RegistryIndex =
1307        serde_json::from_slice(&index_bytes).map_err(|err| MifRhError::RegistryIndexInvalid {
1308            registry_source: source.to_string(),
1309            detail: err.to_string(),
1310        })?;
1311    // Same trust-on-first-use check `fetch` enforces before trusting index
1312    // content (`vendor.rs`'s module doc: the index is "fully
1313    // attacker-controlled"). Read-only here — never re-pins, only refuses
1314    // to proceed on a mismatch.
1315    if let Some(pinned) = &lock.index_sha256
1316        && lock.source == source
1317        && *pinned != index_sha256
1318    {
1319        return Err(MifRhError::IndexPinMismatch {
1320            registry_source: source.to_string(),
1321            pinned: pinned.clone(),
1322            got: index_sha256,
1323        });
1324    }
1325
1326    let mut reports = Vec::new();
1327    for id in &pinned_ids {
1328        // Guaranteed present: pinned_requested_ids already filtered to ids in lock.ontologies.
1329        let locked = &lock.ontologies[id];
1330        let entry = index
1331            .ontologies
1332            .get(id)
1333            .ok_or_else(|| MifRhError::OntologyNotInRegistry { id: id.clone() })?;
1334        if locked.version == entry.version {
1335            continue; // not drifted, nothing to check
1336        }
1337
1338        let old_path = root
1339            .join("packs/ontologies")
1340            .join(id)
1341            .join(format!("{id}.ontology.yaml"));
1342        let old_pack = match std::fs::read_to_string(&old_path) {
1343            Ok(yaml) => Some(parse_pack(&yaml, &old_path.display().to_string())?),
1344            Err(source) if source.kind() == std::io::ErrorKind::NotFound => None,
1345            Err(source) => {
1346                return Err(MifRhError::Io {
1347                    path: old_path.display().to_string(),
1348                    source,
1349                });
1350            },
1351        };
1352        let Some(old_pack) = old_pack else {
1353            reports.push(PinSafetyReport {
1354                id: id.clone(),
1355                locked_version: locked.version.clone(),
1356                registry_version: entry.version.clone(),
1357                analyzed: false,
1358                newly_required: Vec::new(),
1359                gaps: Vec::new(),
1360            });
1361            continue;
1362        };
1363
1364        let new_pack = fetch_and_parse_registry_pack(source, id, entry)?;
1365
1366        let newly_required = diff_newly_required(&old_pack, &new_pack)?;
1367        let gaps = if newly_required.is_empty() {
1368            Vec::new()
1369        } else {
1370            find_pin_safety_gaps(reports_dir, topics, id, &newly_required)?
1371        };
1372
1373        reports.push(PinSafetyReport {
1374            id: id.clone(),
1375            locked_version: locked.version.clone(),
1376            registry_version: entry.version.clone(),
1377            analyzed: true,
1378            newly_required,
1379            gaps,
1380        });
1381    }
1382    Ok(reports)
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387    use std::fs;
1388
1389    use super::{
1390        DEFAULT_REGISTRY_SOURCE, diff_newly_required, fetch, is_bare_filename, is_wellformed_id,
1391        lock_check, resolve_source, sync_catalog, sync_registry,
1392    };
1393    use crate::ontology_pack::parse_pack;
1394
1395    const EDU_INDEX: &str = r#"{
1396        "ontologies": {
1397            "edu-fixture": {
1398                "version": "0.1.0",
1399                "sha256": "REPLACED",
1400                "file": "edu-fixture.ontology.yaml",
1401                "extends": ["mif-base"]
1402            }
1403        }
1404    }"#;
1405
1406    const EDU_YAML: &str = "ontology:\n  id: edu-fixture\n  version: \"0.1.0\"\n  description: \"An edu fixture\"\n  extends: [mif-base]\nentity_types: []\n";
1407
1408    fn sha256_of(bytes: &[u8]) -> String {
1409        super::sha256_hex(bytes)
1410    }
1411
1412    /// Writes a local-directory registry source (index.json + one ontology
1413    /// file) under `dir/registry/`, with the index's sha256 filled in for
1414    /// real, and returns the source path as a string.
1415    fn write_local_registry(dir: &std::path::Path) -> String {
1416        let registry = dir.join("registry");
1417        fs::create_dir_all(&registry).unwrap();
1418        let sha = sha256_of(EDU_YAML.as_bytes());
1419        let index = EDU_INDEX.replace("REPLACED", &sha);
1420        fs::write(registry.join("index.json"), index).unwrap();
1421        fs::write(registry.join("edu-fixture.ontology.yaml"), EDU_YAML).unwrap();
1422        registry.display().to_string()
1423    }
1424
1425    fn write_base_layer(root: &std::path::Path) {
1426        fs::create_dir_all(root.join("schemas/ontologies/mif-base")).unwrap();
1427        fs::write(
1428            root.join("schemas/ontologies/mif-base/mif-base.ontology.yaml"),
1429            "ontology:\n  id: mif-base\n  version: \"1.0.0\"\nentity_types: []\n",
1430        )
1431        .unwrap();
1432    }
1433
1434    #[test]
1435    fn resolve_source_defaults_to_the_canonical_registry() {
1436        let dir = tempfile::tempdir().unwrap();
1437        assert_eq!(resolve_source(dir.path(), None), DEFAULT_REGISTRY_SOURCE);
1438    }
1439
1440    #[test]
1441    fn resolve_source_prefers_an_explicit_override() {
1442        let dir = tempfile::tempdir().unwrap();
1443        fs::write(dir.path().join(".ontologies.source"), "/marker/path\n").unwrap();
1444        assert_eq!(resolve_source(dir.path(), Some("/override/")), "/override");
1445    }
1446
1447    #[test]
1448    fn resolve_source_falls_back_to_the_marker_file() {
1449        let dir = tempfile::tempdir().unwrap();
1450        fs::write(dir.path().join(".ontologies.source"), "/marker/path/\n").unwrap();
1451        assert_eq!(resolve_source(dir.path(), None), "/marker/path");
1452    }
1453
1454    #[test]
1455    fn is_wellformed_id_accepts_bare_lowercase_slugs_only() {
1456        assert!(is_wellformed_id("clinical-trials"));
1457        assert!(is_wellformed_id("edu2"));
1458        assert!(!is_wellformed_id(""));
1459        assert!(!is_wellformed_id("../etc"));
1460        assert!(!is_wellformed_id("Clinical"));
1461        assert!(!is_wellformed_id("has space"));
1462    }
1463
1464    #[test]
1465    fn is_bare_filename_rejects_a_trailing_slash() {
1466        // Path::components() alone treats "foo/" as a single Normal("foo")
1467        // component with nothing after it, so it would otherwise pass as
1468        // if it were the bare filename "foo" — this must be rejected.
1469        assert!(!is_bare_filename("foo/"));
1470        assert!(!is_bare_filename("foo/bar"));
1471        assert!(!is_bare_filename("/foo"));
1472        assert!(!is_bare_filename("foo\\bar"));
1473        assert!(is_bare_filename("edu-fixture.ontology.yaml"));
1474    }
1475
1476    #[test]
1477    fn fetch_vendors_a_verified_ontology_and_skips_committed_bases() {
1478        let dir = tempfile::tempdir().unwrap();
1479        write_base_layer(dir.path());
1480        let source = write_local_registry(dir.path());
1481
1482        let report = fetch(dir.path(), &source, &["edu-fixture".to_string()], false).unwrap();
1483
1484        assert_eq!(report.vendored.len(), 1);
1485        assert_eq!(report.vendored[0].id, "edu-fixture");
1486        let vendored_yaml = dir
1487            .path()
1488            .join("packs/ontologies/edu-fixture/edu-fixture.ontology.yaml");
1489        assert!(vendored_yaml.is_file());
1490        let lock: super::LockFile = serde_json::from_str(
1491            &fs::read_to_string(dir.path().join("ontologies.lock.json")).unwrap(),
1492        )
1493        .unwrap();
1494        assert!(lock.ontologies.contains_key("edu-fixture"));
1495        assert!(lock.index_sha256.is_some());
1496        // mif-base is a committed base layer: it must never be vendored,
1497        // even though edu-fixture's extends names it.
1498        assert!(!dir.path().join("packs/ontologies/mif-base").exists());
1499    }
1500
1501    /// Writes a registry at `version` for `edu-fixture` (distinct content
1502    /// from `write_local_registry`'s 0.1.0, so its sha256 genuinely
1503    /// differs), then seeds `ontologies.lock.json` claiming `edu-fixture`
1504    /// is already pinned at `locked_version` — an already-index-trusted
1505    /// registry (the index sha256 in the lock matches the one on disk) that
1506    /// has moved past a per-id pin, the exact drift `fetch`'s pin-respecting
1507    /// check (rht#270) must catch. Returns the registry source path.
1508    fn seed_registry_ahead_of_a_pinned_lock(
1509        dir: &std::path::Path,
1510        registry_version: &str,
1511        locked_version: &str,
1512    ) -> String {
1513        let registry = dir.join("registry");
1514        fs::create_dir_all(&registry).unwrap();
1515        let yaml = format!(
1516            "ontology:\n  id: edu-fixture\n  version: \"{registry_version}\"\n  description: \
1517             \"An edu fixture at {registry_version}\"\n  extends: [mif-base]\nentity_types: \
1518             []\n"
1519        );
1520        let sha = sha256_of(yaml.as_bytes());
1521        let index = format!(
1522            r#"{{"ontologies":{{"edu-fixture":{{"version":"{registry_version}","sha256":"{sha}","file":"edu-fixture.ontology.yaml","extends":["mif-base"]}}}}}}"#
1523        );
1524        fs::write(registry.join("index.json"), &index).unwrap();
1525        fs::write(registry.join("edu-fixture.ontology.yaml"), yaml).unwrap();
1526
1527        let source = registry.display().to_string();
1528        let lock = serde_json::json!({
1529            "schema": "mif-ontology-lock/v1",
1530            "source": source,
1531            "index_sha256": sha256_of(index.as_bytes()),
1532            "ontologies": {
1533                "edu-fixture": {"version": locked_version, "sha256": "irrelevant-for-this-test"}
1534            }
1535        });
1536        fs::write(
1537            dir.join("ontologies.lock.json"),
1538            serde_json::to_string(&lock).unwrap(),
1539        )
1540        .unwrap();
1541        source
1542    }
1543
1544    #[test]
1545    fn fetch_leaves_a_pinned_ontology_untouched_when_the_registry_advances_without_refresh() {
1546        let dir = tempfile::tempdir().unwrap();
1547        write_base_layer(dir.path());
1548        let source = seed_registry_ahead_of_a_pinned_lock(dir.path(), "0.2.0", "0.1.0");
1549
1550        let report = fetch(dir.path(), &source, &["edu-fixture".to_string()], false).unwrap();
1551
1552        assert!(report.vendored.is_empty());
1553        assert_eq!(report.pinned_skipped.len(), 1);
1554        assert_eq!(report.pinned_skipped[0].id, "edu-fixture");
1555        assert_eq!(report.pinned_skipped[0].locked_version, "0.1.0");
1556        assert_eq!(report.pinned_skipped[0].registry_version, "0.2.0");
1557
1558        let lock: super::LockFile = serde_json::from_str(
1559            &fs::read_to_string(dir.path().join("ontologies.lock.json")).unwrap(),
1560        )
1561        .unwrap();
1562        assert_eq!(lock.ontologies["edu-fixture"].version, "0.1.0");
1563        assert!(
1564            !dir.path()
1565                .join("packs/ontologies/edu-fixture/edu-fixture.ontology.yaml")
1566                .exists(),
1567            "a pinned-and-skipped id must never be written to disk"
1568        );
1569    }
1570
1571    #[test]
1572    fn fetch_refresh_advances_a_pinned_ontology_to_the_registry_version() {
1573        let dir = tempfile::tempdir().unwrap();
1574        write_base_layer(dir.path());
1575        let source = seed_registry_ahead_of_a_pinned_lock(dir.path(), "0.2.0", "0.1.0");
1576
1577        let report = fetch(dir.path(), &source, &["edu-fixture".to_string()], true).unwrap();
1578
1579        assert!(report.pinned_skipped.is_empty());
1580        assert_eq!(report.vendored.len(), 1);
1581        assert_eq!(report.vendored[0].version, "0.2.0");
1582
1583        let lock: super::LockFile = serde_json::from_str(
1584            &fs::read_to_string(dir.path().join("ontologies.lock.json")).unwrap(),
1585        )
1586        .unwrap();
1587        assert_eq!(lock.ontologies["edu-fixture"].version, "0.2.0");
1588        assert!(
1589            dir.path()
1590                .join("packs/ontologies/edu-fixture/edu-fixture.ontology.yaml")
1591                .exists()
1592        );
1593    }
1594
1595    #[test]
1596    fn fetch_persists_a_cleared_index_pin_even_when_every_id_is_pinned_skipped() {
1597        // Regression: when every requested id is pinned-and-skipped, no
1598        // per-id write happens in the loop, so the index_sha256/source
1599        // mutated before the loop must still be persisted by the
1600        // fallback write after it — otherwise a deliberate re-pin (clear
1601        // index_sha256, re-fetch) silently fails to record the new trust
1602        // root whenever it also happens to hit only already-pinned ids.
1603        let dir = tempfile::tempdir().unwrap();
1604        write_base_layer(dir.path());
1605        let source = seed_registry_ahead_of_a_pinned_lock(dir.path(), "0.2.0", "0.1.0");
1606
1607        // Clear index_sha256, simulating a deliberate re-pin in progress.
1608        let mut lock: serde_json::Value = serde_json::from_str(
1609            &fs::read_to_string(dir.path().join("ontologies.lock.json")).unwrap(),
1610        )
1611        .unwrap();
1612        lock.as_object_mut().unwrap().remove("index_sha256");
1613        fs::write(
1614            dir.path().join("ontologies.lock.json"),
1615            serde_json::to_string(&lock).unwrap(),
1616        )
1617        .unwrap();
1618
1619        let report = fetch(dir.path(), &source, &["edu-fixture".to_string()], false).unwrap();
1620        assert!(report.vendored.is_empty());
1621        assert_eq!(report.pinned_skipped.len(), 1);
1622
1623        let after: super::LockFile = serde_json::from_str(
1624            &fs::read_to_string(dir.path().join("ontologies.lock.json")).unwrap(),
1625        )
1626        .unwrap();
1627        assert!(
1628            after.index_sha256.is_some(),
1629            "the re-pinned index_sha256 must be persisted even though every \
1630             requested id was pinned-skipped"
1631        );
1632    }
1633
1634    #[test]
1635    fn fetch_fails_closed_on_a_checksum_mismatch() {
1636        let dir = tempfile::tempdir().unwrap();
1637        write_base_layer(dir.path());
1638        let registry = dir.path().join("registry");
1639        fs::create_dir_all(&registry).unwrap();
1640        let index = EDU_INDEX.replace(
1641            "REPLACED",
1642            "0000000000000000000000000000000000000000000000000000000000000000",
1643        );
1644        fs::write(registry.join("index.json"), index).unwrap();
1645        fs::write(registry.join("edu-fixture.ontology.yaml"), EDU_YAML).unwrap();
1646
1647        let error = fetch(
1648            dir.path(),
1649            &registry.display().to_string(),
1650            &["edu-fixture".to_string()],
1651            false,
1652        )
1653        .unwrap_err();
1654        assert!(matches!(error, super::MifRhError::ChecksumMismatch { .. }));
1655        assert!(!dir.path().join("packs/ontologies/edu-fixture").exists());
1656    }
1657
1658    #[test]
1659    fn fetch_rejects_a_windows_style_or_absolute_index_file_path() {
1660        let dir = tempfile::tempdir().unwrap();
1661        write_base_layer(dir.path());
1662        let registry = dir.path().join("registry");
1663        fs::create_dir_all(&registry).unwrap();
1664        for unsafe_file in [
1665            "..\\..\\etc\\passwd",
1666            "C:\\Windows\\evil.yaml",
1667            "/etc/passwd",
1668        ] {
1669            // Substitute via a JSON-escaped literal (not a raw string replace)
1670            // so a literal backslash in `unsafe_file` doesn't itself produce
1671            // invalid JSON.
1672            let index = EDU_INDEX
1673                .replace("REPLACED", &sha256_of(EDU_YAML.as_bytes()))
1674                .replace(
1675                    "\"edu-fixture.ontology.yaml\"",
1676                    &serde_json::to_string(unsafe_file).unwrap(),
1677                );
1678            fs::write(registry.join("index.json"), index).unwrap();
1679
1680            let error = fetch(
1681                dir.path(),
1682                &registry.display().to_string(),
1683                &["edu-fixture".to_string()],
1684                false,
1685            )
1686            .unwrap_err();
1687            assert!(
1688                matches!(error, super::MifRhError::UnsafeIndexPath { .. }),
1689                "expected UnsafeIndexPath for {unsafe_file:?}, got {error:?}"
1690            );
1691        }
1692    }
1693
1694    #[test]
1695    fn fetch_leaves_no_file_behind_when_the_vendored_yaml_fails_to_parse() {
1696        let dir = tempfile::tempdir().unwrap();
1697        write_base_layer(dir.path());
1698        let registry = dir.path().join("registry");
1699        fs::create_dir_all(&registry).unwrap();
1700        let malformed = b"not: [valid, yaml, ontology shape";
1701        let index = EDU_INDEX.replace("REPLACED", &sha256_of(malformed));
1702        fs::write(registry.join("index.json"), index).unwrap();
1703        fs::write(registry.join("edu-fixture.ontology.yaml"), malformed).unwrap();
1704
1705        let error = fetch(
1706            dir.path(),
1707            &registry.display().to_string(),
1708            &["edu-fixture".to_string()],
1709            false,
1710        )
1711        .unwrap_err();
1712
1713        assert!(
1714            matches!(error, super::MifRhError::OntologyPackYaml { .. }),
1715            "{error:?}"
1716        );
1717        assert!(!dir.path().join("packs/ontologies/edu-fixture").exists());
1718    }
1719
1720    #[test]
1721    fn fetch_refuses_a_moved_trust_root_for_the_same_source() {
1722        let dir = tempfile::tempdir().unwrap();
1723        write_base_layer(dir.path());
1724        let source = write_local_registry(dir.path());
1725        fetch(dir.path(), &source, &["edu-fixture".to_string()], false).unwrap();
1726
1727        // The same source's index.json now hashes differently.
1728        fs::write(
1729            std::path::Path::new(&source).join("index.json"),
1730            EDU_INDEX
1731                .replace("REPLACED", &sha256_of(EDU_YAML.as_bytes()))
1732                .replace('0', "1"),
1733        )
1734        .unwrap();
1735
1736        let error = fetch(dir.path(), &source, &["edu-fixture".to_string()], false).unwrap_err();
1737        assert!(matches!(error, super::MifRhError::IndexPinMismatch { .. }));
1738    }
1739
1740    #[test]
1741    fn fetch_reports_an_id_absent_from_the_registry() {
1742        let dir = tempfile::tempdir().unwrap();
1743        let source = write_local_registry(dir.path());
1744
1745        let error = fetch(dir.path(), &source, &["nonexistent-id".to_string()], false).unwrap_err();
1746        assert!(matches!(
1747            error,
1748            super::MifRhError::OntologyNotInRegistry { .. }
1749        ));
1750    }
1751
1752    #[test]
1753    fn fetch_rejects_a_path_traversal_id_reached_via_an_extends_ancestor() {
1754        // A compromised registry can name anything in an `extends` array —
1755        // it is fully attacker-controlled index content, just like a
1756        // requested id. A malformed ancestor id must be rejected before it
1757        // is ever joined onto a filesystem path, not just at discovery time
1758        // in `sync_registry`.
1759        let dir = tempfile::tempdir().unwrap();
1760        let registry = dir.path().join("registry");
1761        fs::create_dir_all(&registry).unwrap();
1762        let sha = sha256_of(EDU_YAML.as_bytes());
1763        let malicious_index = format!(
1764            r#"{{"ontologies":{{"edu-fixture":{{"version":"0.1.0","sha256":"{sha}","file":"edu-fixture.ontology.yaml","extends":["../../../etc"]}}}}}}"#
1765        );
1766        fs::write(registry.join("index.json"), malicious_index).unwrap();
1767        fs::write(registry.join("edu-fixture.ontology.yaml"), EDU_YAML).unwrap();
1768
1769        let error = fetch(
1770            dir.path(),
1771            &registry.display().to_string(),
1772            &["edu-fixture".to_string()],
1773            false,
1774        )
1775        .unwrap_err();
1776        assert!(matches!(
1777            error,
1778            super::MifRhError::MalformedOntologyId { .. }
1779        ));
1780        assert!(!dir.path().join("packs/ontologies/edu-fixture").exists());
1781    }
1782
1783    #[test]
1784    fn a_failure_partway_through_a_batch_still_pins_the_ontologies_already_vendored() {
1785        let dir = tempfile::tempdir().unwrap();
1786        let registry = dir.path().join("registry");
1787        fs::create_dir_all(&registry).unwrap();
1788        let good_sha = sha256_of(EDU_YAML.as_bytes());
1789        let index = format!(
1790            r#"{{"ontologies":{{
1791                "edu-fixture":{{"version":"0.1.0","sha256":"{good_sha}","file":"edu-fixture.ontology.yaml","extends":[]}},
1792                "bad-fixture":{{"version":"0.1.0","sha256":"0000000000000000000000000000000000000000000000000000000000000000","file":"edu-fixture.ontology.yaml","extends":[]}}
1793            }}}}"#
1794        );
1795        fs::write(registry.join("index.json"), index).unwrap();
1796        fs::write(registry.join("edu-fixture.ontology.yaml"), EDU_YAML).unwrap();
1797
1798        let source = registry.display().to_string();
1799        let error = fetch(
1800            dir.path(),
1801            &source,
1802            &["edu-fixture".to_string(), "bad-fixture".to_string()],
1803            false,
1804        )
1805        .unwrap_err();
1806        assert!(matches!(error, super::MifRhError::ChecksumMismatch { .. }));
1807
1808        // edu-fixture was vendored successfully before bad-fixture failed;
1809        // it must be pinned in the lock, not left as an untracked,
1810        // unverifiable file on disk.
1811        let lock: super::LockFile = serde_json::from_str(
1812            &fs::read_to_string(dir.path().join("ontologies.lock.json")).unwrap(),
1813        )
1814        .unwrap();
1815        assert!(lock.ontologies.contains_key("edu-fixture"));
1816        assert!(dir.path().join("packs/ontologies/edu-fixture").is_dir());
1817    }
1818
1819    #[test]
1820    fn lock_check_passes_cleanly_with_no_lock_file() {
1821        let dir = tempfile::tempdir().unwrap();
1822        fs::write(
1823            dir.path().join("harness.config.json"),
1824            r#"{"ontologies":[]}"#,
1825        )
1826        .unwrap();
1827        let report = lock_check(dir.path(), &dir.path().join("harness.config.json")).unwrap();
1828        assert!(report.ok());
1829    }
1830
1831    #[test]
1832    fn lock_check_flags_a_missing_pin_for_an_enabled_ontology() {
1833        let dir = tempfile::tempdir().unwrap();
1834        let config_path = dir.path().join("harness.config.json");
1835        fs::write(
1836            &config_path,
1837            r#"{"ontologies":[{"id":"edu-fixture","enabled":true}]}"#,
1838        )
1839        .unwrap();
1840        fs::write(
1841            dir.path().join("ontologies.lock.json"),
1842            r#"{"schema":"mif-ontology-lock/v1","source":"x","ontologies":{}}"#,
1843        )
1844        .unwrap();
1845
1846        let report = lock_check(dir.path(), &config_path).unwrap();
1847        assert!(!report.ok());
1848        assert_eq!(report.missing_pins, ["edu-fixture"]);
1849    }
1850
1851    #[test]
1852    fn lock_check_flags_drift_when_a_vendored_file_no_longer_matches_its_pin() {
1853        let dir = tempfile::tempdir().unwrap();
1854        let config_path = dir.path().join("harness.config.json");
1855        fs::write(&config_path, r#"{"ontologies":[]}"#).unwrap();
1856        fs::create_dir_all(dir.path().join("packs/ontologies/edu-fixture")).unwrap();
1857        fs::write(
1858            dir.path()
1859                .join("packs/ontologies/edu-fixture/edu-fixture.ontology.yaml"),
1860            "edited locally",
1861        )
1862        .unwrap();
1863        fs::write(
1864            dir.path().join("ontologies.lock.json"),
1865            r#"{"schema":"mif-ontology-lock/v1","source":"x","ontologies":{"edu-fixture":{"version":"0.1.0","sha256":"deadbeef"}}}"#,
1866        )
1867        .unwrap();
1868
1869        let report = lock_check(dir.path(), &config_path).unwrap();
1870        assert!(!report.ok());
1871        assert_eq!(report.drift.len(), 1);
1872        assert_eq!(report.drift[0].id, "edu-fixture");
1873    }
1874
1875    #[test]
1876    fn sync_catalog_catalogs_core_and_enabled_ontologies() {
1877        let dir = tempfile::tempdir().unwrap();
1878        write_base_layer(dir.path());
1879        let config_path = dir.path().join("harness.config.json");
1880        fs::write(
1881            &config_path,
1882            r#"{"ontologies":[{"id":"edu-fixture","enabled":true}]}"#,
1883        )
1884        .unwrap();
1885        fs::create_dir_all(dir.path().join("packs/ontologies/edu-fixture")).unwrap();
1886        fs::write(
1887            dir.path()
1888                .join("packs/ontologies/edu-fixture/edu-fixture.ontology.yaml"),
1889            EDU_YAML,
1890        )
1891        .unwrap();
1892        let sidecar_path = dir.path().join("enabled-packs.json");
1893        fs::write(&sidecar_path, r#"{"enabledPlugins":["x"]}"#).unwrap();
1894
1895        let report = sync_catalog(dir.path(), &config_path, &sidecar_path).unwrap();
1896        assert_eq!(report.cataloged, 2);
1897
1898        let sidecar: serde_json::Value =
1899            serde_json::from_str(&fs::read_to_string(&sidecar_path).unwrap()).unwrap();
1900        // The unrelated key the general pack-sync half owns must survive.
1901        assert_eq!(sidecar["enabledPlugins"], serde_json::json!(["x"]));
1902        let ontologies = sidecar["ontologies"].as_array().unwrap();
1903        assert!(
1904            ontologies
1905                .iter()
1906                .any(|e| e["id"] == "mif-base" && e["core"] == true)
1907        );
1908        assert!(
1909            ontologies
1910                .iter()
1911                .any(|e| e["id"] == "edu-fixture" && e["core"] == false)
1912        );
1913    }
1914
1915    #[test]
1916    fn sync_catalog_creates_the_sidecars_parent_directory_when_missing() {
1917        let dir = tempfile::tempdir().unwrap();
1918        let config_path = dir.path().join("harness.config.json");
1919        fs::write(&config_path, r#"{"ontologies":[]}"#).unwrap();
1920        // No `.claude/` directory exists yet — a fresh clone before any
1921        // sync has ever run.
1922        let sidecar_path = dir.path().join(".claude/enabled-packs.json");
1923
1924        let report = sync_catalog(dir.path(), &config_path, &sidecar_path).unwrap();
1925        assert_eq!(report.cataloged, 0);
1926        assert!(sidecar_path.is_file());
1927    }
1928
1929    #[test]
1930    fn sync_registry_discovers_and_enables_a_new_registry_ontology_and_preserves_other_config_fields()
1931     {
1932        let dir = tempfile::tempdir().unwrap();
1933        write_base_layer(dir.path());
1934        let source = write_local_registry(dir.path());
1935        let config_path = dir.path().join("harness.config.json");
1936        fs::write(
1937            &config_path,
1938            r#"{"version":"1.2.3","topics":[{"id":"t","ontologies":[]}],"ontologies":[]}"#,
1939        )
1940        .unwrap();
1941        let sidecar_path = dir.path().join("enabled-packs.json");
1942
1943        let report = sync_registry(dir.path(), &config_path, &sidecar_path, &source).unwrap();
1944
1945        assert_eq!(report.discovered, ["edu-fixture"]);
1946        assert_eq!(report.fetch.vendored.len(), 1);
1947
1948        let config: serde_json::Value =
1949            serde_json::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
1950        // Untouched top-level fields must survive the read-modify-write.
1951        assert_eq!(config["version"], "1.2.3");
1952        assert_eq!(config["topics"][0]["id"], "t");
1953        assert_eq!(config["ontologies"][0]["id"], "edu-fixture");
1954        assert_eq!(config["ontologies"][0]["enabled"], true);
1955    }
1956
1957    #[test]
1958    fn sync_registry_is_idempotent_when_nothing_new_is_published() {
1959        let dir = tempfile::tempdir().unwrap();
1960        write_base_layer(dir.path());
1961        let source = write_local_registry(dir.path());
1962        let config_path = dir.path().join("harness.config.json");
1963        fs::write(
1964            &config_path,
1965            r#"{"ontologies":[{"id":"edu-fixture","enabled":true}]}"#,
1966        )
1967        .unwrap();
1968        let sidecar_path = dir.path().join("enabled-packs.json");
1969
1970        let report = sync_registry(dir.path(), &config_path, &sidecar_path, &source).unwrap();
1971        assert!(report.discovered.is_empty());
1972    }
1973
1974    #[test]
1975    fn sync_registry_rejects_a_non_array_ontologies_field_without_touching_the_network() {
1976        // A deliberately unreachable source: if the config-shape check ran
1977        // AFTER fetching the index (as it once did), this would hang or
1978        // fail on a network error instead of failing closed immediately on
1979        // the malformed config, matching
1980        // `sync-registry-ontologies.sh`'s own original ordering (config
1981        // validated before any registry access).
1982        let dir = tempfile::tempdir().unwrap();
1983        let config_path = dir.path().join("harness.config.json");
1984        fs::write(&config_path, r#"{"ontologies":"not-an-array"}"#).unwrap();
1985        let sidecar_path = dir.path().join("enabled-packs.json");
1986
1987        let error = sync_registry(
1988            dir.path(),
1989            &config_path,
1990            &sidecar_path,
1991            "http://127.0.0.1.invalid/unreachable",
1992        )
1993        .unwrap_err();
1994        assert!(matches!(error, super::MifRhError::ConfigMalformed { .. }));
1995    }
1996
1997    #[test]
1998    fn sync_registry_rejects_invalid_json_config_without_touching_the_network() {
1999        let dir = tempfile::tempdir().unwrap();
2000        let config_path = dir.path().join("harness.config.json");
2001        fs::write(&config_path, "not json at all").unwrap();
2002        let sidecar_path = dir.path().join("enabled-packs.json");
2003
2004        let error = sync_registry(
2005            dir.path(),
2006            &config_path,
2007            &sidecar_path,
2008            "http://127.0.0.1.invalid/unreachable",
2009        )
2010        .unwrap_err();
2011        assert!(matches!(error, super::MifRhError::Json { .. }));
2012    }
2013
2014    /// Seeds a full pin-safety fixture: a vendored (old) pack on disk at
2015    /// `locked_version`, a registry (new) pack at `registry_version`
2016    /// (`title` entity type's `schema.required` growing from `[name]` to
2017    /// `[name, isbn]`), and a lock pinned at `locked_version`. Returns the
2018    /// registry source path.
2019    fn seed_pin_safety_fixture(
2020        dir: &std::path::Path,
2021        locked_version: &str,
2022        registry_version: &str,
2023    ) -> String {
2024        let old_yaml = format!(
2025            "ontology:\n  id: edu-fixture\n  version: \"{locked_version}\"\n  extends: \
2026             [mif-base]\nentity_types:\n  - name: title\n    schema:\n      required: \
2027             [name]\n      properties:\n        name: {{type: string}}\n"
2028        );
2029        fs::create_dir_all(dir.join("packs/ontologies/edu-fixture")).unwrap();
2030        fs::write(
2031            dir.join("packs/ontologies/edu-fixture/edu-fixture.ontology.yaml"),
2032            &old_yaml,
2033        )
2034        .unwrap();
2035
2036        let registry = dir.join("registry");
2037        fs::create_dir_all(&registry).unwrap();
2038        let new_yaml = format!(
2039            "ontology:\n  id: edu-fixture\n  version: \"{registry_version}\"\n  extends: \
2040             [mif-base]\nentity_types:\n  - name: title\n    schema:\n      required: [name, \
2041             isbn]\n      properties:\n        name: {{type: string}}\n        isbn: {{type: \
2042             string}}\n"
2043        );
2044        let sha = sha256_of(new_yaml.as_bytes());
2045        let index = format!(
2046            r#"{{"ontologies":{{"edu-fixture":{{"version":"{registry_version}","sha256":"{sha}","file":"edu-fixture.ontology.yaml","extends":["mif-base"]}}}}}}"#
2047        );
2048        fs::write(registry.join("index.json"), &index).unwrap();
2049        fs::write(registry.join("edu-fixture.ontology.yaml"), &new_yaml).unwrap();
2050
2051        let source = registry.display().to_string();
2052        let lock = serde_json::json!({
2053            "schema": "mif-ontology-lock/v1",
2054            "source": source,
2055            "index_sha256": sha256_of(index.as_bytes()),
2056            "ontologies": {
2057                "edu-fixture": {"version": locked_version, "sha256": "irrelevant-for-this-test"}
2058            }
2059        });
2060        fs::write(
2061            dir.join("ontologies.lock.json"),
2062            serde_json::to_string(&lock).unwrap(),
2063        )
2064        .unwrap();
2065        source
2066    }
2067
2068    /// Writes one topic's `ontology-map.json` (one stamped `title` record
2069    /// resolved against `edu-fixture@{locked_version}`) and a matching
2070    /// finding file whose `entity` payload is `entity_json`.
2071    fn write_stamped_finding(
2072        reports_dir: &std::path::Path,
2073        topic: &str,
2074        locked_version: &str,
2075        entity_json: &str,
2076    ) {
2077        let topic_dir = reports_dir.join(topic);
2078        fs::create_dir_all(topic_dir.join("findings")).unwrap();
2079        fs::write(
2080            topic_dir.join("ontology-map.json"),
2081            format!(
2082                r#"[{{"finding_id":"f-1","entity_type":"title","resolved_ontology":"edu-fixture@{locked_version}","basis":"resolved","valid":true}}]"#
2083            ),
2084        )
2085        .unwrap();
2086        fs::write(
2087            topic_dir.join("findings/f-1.json"),
2088            format!(r#"{{"@id":"f-1","entity":{entity_json}}}"#),
2089        )
2090        .unwrap();
2091    }
2092
2093    #[test]
2094    fn diff_newly_required_dedupes_a_field_repeated_in_the_new_schema() {
2095        let old_yaml = "ontology:\n  id: edu-fixture\n  version: \"0.1.0\"\n  extends: \
2096                        [mif-base]\nentity_types:\n  - name: title\n    schema:\n      required: \
2097                        [name]\n      properties:\n        name: {type: string}\n";
2098        let new_yaml = "ontology:\n  id: edu-fixture\n  version: \"0.2.0\"\n  extends: \
2099                        [mif-base]\nentity_types:\n  - name: title\n    schema:\n      required: \
2100                        [name, isbn, isbn]\n      properties:\n        name: {type: string}\n        \
2101                        isbn: {type: string}\n";
2102        let old_pack = parse_pack(old_yaml, "old").unwrap();
2103        let new_pack = parse_pack(new_yaml, "new").unwrap();
2104
2105        let newly_required = diff_newly_required(&old_pack, &new_pack).unwrap();
2106
2107        assert_eq!(
2108            newly_required.len(),
2109            1,
2110            "a required field repeated in the new schema must be reported once, not once per repetition"
2111        );
2112        assert_eq!(newly_required[0].entity_type, "title");
2113        assert_eq!(newly_required[0].field, "isbn");
2114    }
2115
2116    #[test]
2117    fn check_pin_safety_reports_nothing_for_an_id_with_no_lock_entry() {
2118        let dir = tempfile::tempdir().unwrap();
2119        let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
2120        fs::remove_file(dir.path().join("ontologies.lock.json")).unwrap();
2121
2122        let reports = super::check_pin_safety(
2123            dir.path(),
2124            &source,
2125            &dir.path().join("reports"),
2126            &[],
2127            &["edu-fixture".to_string()],
2128        )
2129        .unwrap();
2130        assert!(reports.is_empty());
2131    }
2132
2133    #[test]
2134    fn check_pin_safety_reports_nothing_when_not_drifted() {
2135        let dir = tempfile::tempdir().unwrap();
2136        let source = seed_pin_safety_fixture(dir.path(), "0.2.0", "0.2.0");
2137
2138        let reports = super::check_pin_safety(
2139            dir.path(),
2140            &source,
2141            &dir.path().join("reports"),
2142            &[],
2143            &["edu-fixture".to_string()],
2144        )
2145        .unwrap();
2146        assert!(reports.is_empty());
2147    }
2148
2149    #[test]
2150    fn check_pin_safety_dedupes_a_repeated_id() {
2151        let dir = tempfile::tempdir().unwrap();
2152        let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
2153
2154        let reports = super::check_pin_safety(
2155            dir.path(),
2156            &source,
2157            &dir.path().join("reports"),
2158            &[],
2159            &["edu-fixture".to_string(), "edu-fixture".to_string()],
2160        )
2161        .unwrap();
2162
2163        assert_eq!(
2164            reports.len(),
2165            1,
2166            "a duplicate id in the request must be analyzed once, not once per repetition"
2167        );
2168    }
2169
2170    #[test]
2171    fn check_pin_safety_flags_a_newly_required_field_missing_from_a_stamped_finding() {
2172        let dir = tempfile::tempdir().unwrap();
2173        let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
2174        let reports_dir = dir.path().join("reports");
2175        write_stamped_finding(&reports_dir, "edu", "0.1.0", r#"{"name":"Algebra I"}"#);
2176
2177        let reports = super::check_pin_safety(
2178            dir.path(),
2179            &source,
2180            &reports_dir,
2181            &["edu".to_string()],
2182            &["edu-fixture".to_string()],
2183        )
2184        .unwrap();
2185
2186        assert_eq!(reports.len(), 1);
2187        let report = &reports[0];
2188        assert_eq!(report.locked_version, "0.1.0");
2189        assert_eq!(report.registry_version, "0.2.0");
2190        assert_eq!(report.newly_required.len(), 1);
2191        assert_eq!(report.newly_required[0].entity_type, "title");
2192        assert_eq!(report.newly_required[0].field, "isbn");
2193        assert_eq!(report.gaps.len(), 1);
2194        assert_eq!(report.gaps[0].finding_id, "f-1");
2195        assert_eq!(report.gaps[0].topic, "edu");
2196        assert_eq!(report.gaps[0].field, "isbn");
2197    }
2198
2199    #[test]
2200    fn check_pin_safety_reports_no_gap_when_the_stamped_finding_already_has_the_field() {
2201        let dir = tempfile::tempdir().unwrap();
2202        let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
2203        let reports_dir = dir.path().join("reports");
2204        write_stamped_finding(
2205            &reports_dir,
2206            "edu",
2207            "0.1.0",
2208            r#"{"name":"Algebra I","isbn":"978-0-13-000000-0"}"#,
2209        );
2210
2211        let reports = super::check_pin_safety(
2212            dir.path(),
2213            &source,
2214            &reports_dir,
2215            &["edu".to_string()],
2216            &["edu-fixture".to_string()],
2217        )
2218        .unwrap();
2219
2220        assert_eq!(reports.len(), 1);
2221        assert_eq!(reports[0].newly_required.len(), 1);
2222        assert!(
2223            reports[0].gaps.is_empty(),
2224            "a finding that already carries the newly required field must not be flagged"
2225        );
2226    }
2227
2228    #[test]
2229    fn check_pin_safety_reports_no_extra_warning_when_nothing_newly_required() {
2230        let dir = tempfile::tempdir().unwrap();
2231        // Same required list on both sides: no newly required field.
2232        let old_yaml = "ontology:\n  id: edu-fixture\n  version: \"0.1.0\"\n  extends: \
2233                         [mif-base]\nentity_types:\n  - name: title\n    schema:\n      \
2234                         required: [name]\n";
2235        fs::create_dir_all(dir.path().join("packs/ontologies/edu-fixture")).unwrap();
2236        fs::write(
2237            dir.path()
2238                .join("packs/ontologies/edu-fixture/edu-fixture.ontology.yaml"),
2239            old_yaml,
2240        )
2241        .unwrap();
2242        let registry = dir.path().join("registry");
2243        fs::create_dir_all(&registry).unwrap();
2244        let new_yaml = "ontology:\n  id: edu-fixture\n  version: \"0.2.0\"\n  extends: \
2245                         [mif-base]\nentity_types:\n  - name: title\n    schema:\n      \
2246                         required: [name]\n";
2247        let sha = sha256_of(new_yaml.as_bytes());
2248        let index = format!(
2249            r#"{{"ontologies":{{"edu-fixture":{{"version":"0.2.0","sha256":"{sha}","file":"edu-fixture.ontology.yaml","extends":["mif-base"]}}}}}}"#
2250        );
2251        fs::write(registry.join("index.json"), &index).unwrap();
2252        fs::write(registry.join("edu-fixture.ontology.yaml"), new_yaml).unwrap();
2253        let source = registry.display().to_string();
2254        let lock = serde_json::json!({
2255            "schema": "mif-ontology-lock/v1",
2256            "source": source,
2257            "index_sha256": sha256_of(index.as_bytes()),
2258            "ontologies": {"edu-fixture": {"version": "0.1.0", "sha256": "irrelevant"}}
2259        });
2260        fs::write(
2261            dir.path().join("ontologies.lock.json"),
2262            serde_json::to_string(&lock).unwrap(),
2263        )
2264        .unwrap();
2265
2266        let reports = super::check_pin_safety(
2267            dir.path(),
2268            &source,
2269            &dir.path().join("reports"),
2270            &[],
2271            &["edu-fixture".to_string()],
2272        )
2273        .unwrap();
2274
2275        assert_eq!(reports.len(), 1);
2276        assert!(reports[0].newly_required.is_empty());
2277        assert!(reports[0].gaps.is_empty());
2278    }
2279
2280    #[test]
2281    fn check_pin_safety_reports_empty_analysis_when_the_vendored_pack_is_missing_from_disk() {
2282        let dir = tempfile::tempdir().unwrap();
2283        let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
2284        fs::remove_file(
2285            dir.path()
2286                .join("packs/ontologies/edu-fixture/edu-fixture.ontology.yaml"),
2287        )
2288        .unwrap();
2289
2290        let reports = super::check_pin_safety(
2291            dir.path(),
2292            &source,
2293            &dir.path().join("reports"),
2294            &[],
2295            &["edu-fixture".to_string()],
2296        )
2297        .unwrap();
2298
2299        assert_eq!(reports.len(), 1);
2300        assert_eq!(reports[0].locked_version, "0.1.0");
2301        assert_eq!(reports[0].registry_version, "0.2.0");
2302        assert!(!reports[0].analyzed);
2303        assert!(reports[0].newly_required.is_empty());
2304        assert!(reports[0].gaps.is_empty());
2305    }
2306
2307    #[test]
2308    fn check_pin_safety_reports_an_unrelated_ontology_id_error_for_an_unregistered_id() {
2309        let dir = tempfile::tempdir().unwrap();
2310        let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
2311        // Pin an id the registry doesn't know about.
2312        let mut lock: serde_json::Value = serde_json::from_str(
2313            &fs::read_to_string(dir.path().join("ontologies.lock.json")).unwrap(),
2314        )
2315        .unwrap();
2316        lock["ontologies"]["ghost-ontology"] =
2317            serde_json::json!({"version": "0.1.0", "sha256": "irrelevant"});
2318        fs::write(
2319            dir.path().join("ontologies.lock.json"),
2320            serde_json::to_string(&lock).unwrap(),
2321        )
2322        .unwrap();
2323
2324        let error = super::check_pin_safety(
2325            dir.path(),
2326            &source,
2327            &dir.path().join("reports"),
2328            &[],
2329            &["ghost-ontology".to_string()],
2330        )
2331        .unwrap_err();
2332        assert!(matches!(
2333            error,
2334            super::MifRhError::OntologyNotInRegistry { .. }
2335        ));
2336    }
2337
2338    #[test]
2339    fn check_pin_safety_fails_closed_on_a_checksum_mismatch() {
2340        let dir = tempfile::tempdir().unwrap();
2341        let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
2342        // Corrupt the registry's declared sha256 for the drifted id's file.
2343        let index_path = dir.path().join("registry/index.json");
2344        let mut index: serde_json::Value =
2345            serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
2346        index["ontologies"]["edu-fixture"]["sha256"] =
2347            serde_json::json!("0000000000000000000000000000000000000000000000000000000000000000");
2348        fs::write(&index_path, serde_json::to_string(&index).unwrap()).unwrap();
2349        // Re-write the lock's index_sha256 to match the corrupted index, so
2350        // this test isolates the file-checksum check from the index-pin
2351        // check.
2352        let lock_path = dir.path().join("ontologies.lock.json");
2353        let mut lock: serde_json::Value =
2354            serde_json::from_str(&fs::read_to_string(&lock_path).unwrap()).unwrap();
2355        lock["index_sha256"] =
2356            serde_json::json!(sha256_of(fs::read(&index_path).unwrap().as_slice()));
2357        fs::write(&lock_path, serde_json::to_string(&lock).unwrap()).unwrap();
2358
2359        let error = super::check_pin_safety(
2360            dir.path(),
2361            &source,
2362            &dir.path().join("reports"),
2363            &[],
2364            &["edu-fixture".to_string()],
2365        )
2366        .unwrap_err();
2367        assert!(matches!(error, super::MifRhError::ChecksumMismatch { .. }));
2368    }
2369
2370    #[test]
2371    fn check_pin_safety_fails_closed_on_a_lock_source_mismatch() {
2372        let dir = tempfile::tempdir().unwrap();
2373        let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
2374        // Point the lock at a different source than the one this call
2375        // checks against — its per-id pins were established there, not
2376        // against `source`, so they must not be trusted here.
2377        let lock_path = dir.path().join("ontologies.lock.json");
2378        let mut lock: serde_json::Value =
2379            serde_json::from_str(&fs::read_to_string(&lock_path).unwrap()).unwrap();
2380        lock["source"] = serde_json::json!("https://a-different-registry.test/ontologies");
2381        fs::write(&lock_path, serde_json::to_string(&lock).unwrap()).unwrap();
2382
2383        let error = super::check_pin_safety(
2384            dir.path(),
2385            &source,
2386            &dir.path().join("reports"),
2387            &[],
2388            &["edu-fixture".to_string()],
2389        )
2390        .unwrap_err();
2391        assert!(matches!(
2392            error,
2393            super::MifRhError::LockSourceMismatch { .. }
2394        ));
2395    }
2396
2397    #[test]
2398    fn check_pin_safety_fails_closed_on_a_lock_with_pins_but_no_recorded_source() {
2399        let dir = tempfile::tempdir().unwrap();
2400        let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
2401        // A legacy/hand-edited lock: pins present, source empty. Must not
2402        // be trusted against any caller-supplied source.
2403        let lock_path = dir.path().join("ontologies.lock.json");
2404        let mut lock: serde_json::Value =
2405            serde_json::from_str(&fs::read_to_string(&lock_path).unwrap()).unwrap();
2406        lock["source"] = serde_json::json!("");
2407        fs::write(&lock_path, serde_json::to_string(&lock).unwrap()).unwrap();
2408
2409        let error = super::check_pin_safety(
2410            dir.path(),
2411            &source,
2412            &dir.path().join("reports"),
2413            &[],
2414            &["edu-fixture".to_string()],
2415        )
2416        .unwrap_err();
2417        assert!(matches!(
2418            error,
2419            super::MifRhError::LockSourceMismatch { .. }
2420        ));
2421    }
2422
2423    #[test]
2424    fn check_pin_safety_short_circuits_before_any_fetch_when_nothing_is_pinned() {
2425        let dir = tempfile::tempdir().unwrap();
2426        // No ontologies.lock.json at all, and an unreachable source: if the
2427        // short-circuit didn't happen before fetching the registry index,
2428        // this would fail with a RegistryFetch error instead of returning
2429        // an empty result.
2430        let reports = super::check_pin_safety(
2431            dir.path(),
2432            "http://127.0.0.1.invalid/unreachable",
2433            &dir.path().join("reports"),
2434            &[],
2435            &["not-pinned-anywhere".to_string()],
2436        )
2437        .unwrap();
2438        assert!(reports.is_empty());
2439    }
2440
2441    #[test]
2442    fn check_pin_safety_fails_closed_on_non_utf8_registry_bytes() {
2443        let dir = tempfile::tempdir().unwrap();
2444        let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
2445        // Replace the registry's (checksum-verified) file with bytes that
2446        // are not valid UTF-8; the index and lock are updated to match so
2447        // the failure isolates the UTF-8 check from the checksum check.
2448        let registry_file = dir.path().join("registry/edu-fixture.ontology.yaml");
2449        let invalid_utf8: &[u8] = &[0xff, 0xfe, 0xfd];
2450        fs::write(&registry_file, invalid_utf8).unwrap();
2451        let index_path = dir.path().join("registry/index.json");
2452        let mut index: serde_json::Value =
2453            serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
2454        index["ontologies"]["edu-fixture"]["sha256"] = serde_json::json!(sha256_of(invalid_utf8));
2455        fs::write(&index_path, serde_json::to_string(&index).unwrap()).unwrap();
2456        let lock_path = dir.path().join("ontologies.lock.json");
2457        let mut lock: serde_json::Value =
2458            serde_json::from_str(&fs::read_to_string(&lock_path).unwrap()).unwrap();
2459        lock["index_sha256"] =
2460            serde_json::json!(sha256_of(fs::read(&index_path).unwrap().as_slice()));
2461        fs::write(&lock_path, serde_json::to_string(&lock).unwrap()).unwrap();
2462
2463        let error = super::check_pin_safety(
2464            dir.path(),
2465            &source,
2466            &dir.path().join("reports"),
2467            &[],
2468            &["edu-fixture".to_string()],
2469        )
2470        .unwrap_err();
2471        assert!(matches!(
2472            error,
2473            super::MifRhError::OntologyPackNotUtf8 { .. }
2474        ));
2475    }
2476
2477    #[test]
2478    fn check_pin_safety_rejects_an_unsafe_index_path() {
2479        let dir = tempfile::tempdir().unwrap();
2480        let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
2481        let index_path = dir.path().join("registry/index.json");
2482        let mut index: serde_json::Value =
2483            serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
2484        index["ontologies"]["edu-fixture"]["file"] = serde_json::json!("../../../../etc/passwd");
2485        fs::write(&index_path, serde_json::to_string(&index).unwrap()).unwrap();
2486        let lock_path = dir.path().join("ontologies.lock.json");
2487        let mut lock: serde_json::Value =
2488            serde_json::from_str(&fs::read_to_string(&lock_path).unwrap()).unwrap();
2489        lock["index_sha256"] =
2490            serde_json::json!(sha256_of(fs::read(&index_path).unwrap().as_slice()));
2491        fs::write(&lock_path, serde_json::to_string(&lock).unwrap()).unwrap();
2492
2493        let error = super::check_pin_safety(
2494            dir.path(),
2495            &source,
2496            &dir.path().join("reports"),
2497            &[],
2498            &["edu-fixture".to_string()],
2499        )
2500        .unwrap_err();
2501        assert!(matches!(error, super::MifRhError::UnsafeIndexPath { .. }));
2502    }
2503
2504    #[test]
2505    fn check_pin_safety_refuses_a_registry_index_that_no_longer_matches_the_pinned_trust_root() {
2506        let dir = tempfile::tempdir().unwrap();
2507        let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
2508        // seed_pin_safety_fixture already pins the real index sha256;
2509        // corrupt it to simulate a swapped/tampered registry source.
2510        let lock_path = dir.path().join("ontologies.lock.json");
2511        let mut lock: serde_json::Value =
2512            serde_json::from_str(&fs::read_to_string(&lock_path).unwrap()).unwrap();
2513        lock["index_sha256"] = serde_json::json!("not-the-real-index-sha256");
2514        fs::write(&lock_path, serde_json::to_string(&lock).unwrap()).unwrap();
2515
2516        let error = super::check_pin_safety(
2517            dir.path(),
2518            &source,
2519            &dir.path().join("reports"),
2520            &[],
2521            &["edu-fixture".to_string()],
2522        )
2523        .unwrap_err();
2524        assert!(matches!(error, super::MifRhError::IndexPinMismatch { .. }));
2525    }
2526
2527    #[test]
2528    fn check_pin_safety_rejects_a_malformed_id_before_touching_the_filesystem() {
2529        let dir = tempfile::tempdir().unwrap();
2530        let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
2531
2532        let error = super::check_pin_safety(
2533            dir.path(),
2534            &source,
2535            &dir.path().join("reports"),
2536            &[],
2537            &["../../../../etc/passwd".to_string()],
2538        )
2539        .unwrap_err();
2540        assert!(matches!(
2541            error,
2542            super::MifRhError::MalformedOntologyId { .. }
2543        ));
2544    }
2545
2546    #[test]
2547    fn check_pin_safety_fails_closed_on_a_malformed_schema_required_field() {
2548        let dir = tempfile::tempdir().unwrap();
2549        let old_yaml = "ontology:\n  id: edu-fixture\n  version: \"0.1.0\"\n  extends: \
2550                         [mif-base]\nentity_types:\n  - name: title\n    schema:\n      \
2551                         required: [name]\n      properties:\n        name: {type: string}\n";
2552        fs::create_dir_all(dir.path().join("packs/ontologies/edu-fixture")).unwrap();
2553        fs::write(
2554            dir.path()
2555                .join("packs/ontologies/edu-fixture/edu-fixture.ontology.yaml"),
2556            old_yaml,
2557        )
2558        .unwrap();
2559
2560        // `required` is present but is a string, not an array: malformed.
2561        let registry = dir.path().join("registry");
2562        fs::create_dir_all(&registry).unwrap();
2563        let new_yaml = "ontology:\n  id: edu-fixture\n  version: \"0.2.0\"\n  extends: \
2564                         [mif-base]\nentity_types:\n  - name: title\n    schema:\n      \
2565                         required: \"name\"\n      properties:\n        name: {type: string}\n";
2566        let sha = sha256_of(new_yaml.as_bytes());
2567        let index = format!(
2568            r#"{{"ontologies":{{"edu-fixture":{{"version":"0.2.0","sha256":"{sha}","file":"edu-fixture.ontology.yaml","extends":["mif-base"]}}}}}}"#
2569        );
2570        fs::write(registry.join("index.json"), &index).unwrap();
2571        fs::write(registry.join("edu-fixture.ontology.yaml"), new_yaml).unwrap();
2572
2573        let source = registry.display().to_string();
2574        let lock = serde_json::json!({
2575            "schema": "mif-ontology-lock/v1",
2576            "source": source,
2577            "index_sha256": sha256_of(index.as_bytes()),
2578            "ontologies": {
2579                "edu-fixture": {"version": "0.1.0", "sha256": "irrelevant-for-this-test"}
2580            }
2581        });
2582        fs::write(
2583            dir.path().join("ontologies.lock.json"),
2584            serde_json::to_string(&lock).unwrap(),
2585        )
2586        .unwrap();
2587
2588        let error = super::check_pin_safety(
2589            dir.path(),
2590            &source,
2591            &dir.path().join("reports"),
2592            &[],
2593            &["edu-fixture".to_string()],
2594        )
2595        .unwrap_err();
2596        assert!(matches!(
2597            error,
2598            super::MifRhError::EntityTypeSchemaInvalid { .. }
2599        ));
2600    }
2601}