1use 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
21pub const DEFAULT_REGISTRY_SOURCE: &str = "https://mif-spec.dev/ontologies";
23
24const CORE_IDS: [&str; 3] = ["mif-base", "mif-generic", "shared-traits"];
27
28#[derive(Debug, Clone, Deserialize)]
31pub struct IndexEntry {
32 pub version: String,
34 pub sha256: String,
36 pub file: String,
38 #[serde(default)]
40 pub extends: Vec<String>,
41}
42
43#[derive(Debug, Clone, Deserialize)]
46pub struct RegistryIndex {
47 pub ontologies: BTreeMap<String, IndexEntry>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct LockEntry {
55 pub version: String,
57 pub sha256: String,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct LockFile {
65 #[serde(default = "lock_schema")]
67 pub schema: String,
68 #[serde(default)]
70 pub source: String,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub index_sha256: Option<String>,
74 #[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 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#[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
146fn 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#[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
191fn is_committed_base(root: &Path, id: &str) -> bool {
195 root.join("schemas/ontologies").join(id).is_dir()
196}
197
198fn 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
210fn is_bare_filename(file: &str) -> bool {
216 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
228fn 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 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#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct VendoredOntology {
285 pub id: String,
287 pub version: String,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct PinnedSkipped {
299 pub id: String,
301 pub locked_version: String,
303 pub registry_version: String,
305}
306
307#[derive(Debug, Clone, Default)]
309pub struct FetchReport {
310 pub vendored: Vec<VendoredOntology>,
313 pub pinned_skipped: Vec<PinnedSkipped>,
317}
318
319fn 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
342pub 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 lock.index_sha256 = Some(index_sha256);
417 lock.source = source.to_string();
418 for id in &fetch_list {
419 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 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 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 crate::write_json_atomic(&lock_path, &lock)?;
495 }
496
497 Ok(FetchReport {
498 vendored,
499 pinned_skipped,
500 })
501}
502
503fn 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#[derive(Debug, Clone, Serialize)]
553struct CatalogOntologyEntry {
554 id: String,
555 version: String,
556 source: String,
557 core: bool,
558}
559
560#[derive(Debug, Clone, Default)]
562pub struct CatalogSyncReport {
563 pub cataloged: usize,
565}
566
567pub 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#[derive(Debug, Clone, Default)]
705pub struct RegistrySyncReport {
706 pub discovered: Vec<String>,
709 pub fetch: FetchReport,
711}
712
713pub fn sync_registry(
730 root: &Path,
731 config_path: &Path,
732 sidecar_path: &Path,
733 source: &str,
734) -> Result<RegistrySyncReport, MifRhError> {
735 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 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 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#[derive(Debug, Clone)]
826pub struct DriftEntry {
827 pub id: String,
829 pub pinned: String,
831 pub got: String,
833}
834
835#[derive(Debug, Clone, Default)]
837pub struct LockCheckReport {
838 pub missing_pins: Vec<String>,
840 pub not_vendored: Vec<String>,
842 pub drift: Vec<DriftEntry>,
845 pub checked: usize,
847}
848
849impl LockCheckReport {
850 #[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
858pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
927pub struct NewlyRequiredField {
928 pub entity_type: String,
930 pub field: String,
932}
933
934#[derive(Debug, Clone, PartialEq, Eq)]
939pub struct PinSafetyGap {
940 pub topic: String,
942 pub finding_id: String,
944 pub entity_type: String,
946 pub field: String,
948}
949
950#[derive(Debug, Clone, Default)]
952pub struct PinSafetyReport {
953 pub id: String,
955 pub locked_version: String,
957 pub registry_version: String,
959 pub analyzed: bool,
965 pub newly_required: Vec<NewlyRequiredField>,
968 pub gaps: Vec<PinSafetyGap>,
971}
972
973fn 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
1011fn 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
1045fn 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 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; };
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
1132fn 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
1170fn 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
1214fn 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 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
1241pub 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 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 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 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; }
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 fn write_local_registry(dir: &std::path::Path) -> String {
1416 let registry = dir.join("registry");
1417 fs::create_dir_all(®istry).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 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 assert!(!dir.path().join("packs/ontologies/mif-base").exists());
1499 }
1500
1501 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(®istry).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 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 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(®istry).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 ®istry.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(®istry).unwrap();
1664 for unsafe_file in [
1665 "..\\..\\etc\\passwd",
1666 "C:\\Windows\\evil.yaml",
1667 "/etc/passwd",
1668 ] {
1669 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 ®istry.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(®istry).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 ®istry.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 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 let dir = tempfile::tempdir().unwrap();
1760 let registry = dir.path().join("registry");
1761 fs::create_dir_all(®istry).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 ®istry.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(®istry).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 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 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 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 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 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 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(®istry).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 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 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(®istry).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 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 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 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 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 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 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 let registry_file = dir.path().join("registry/edu-fixture.ontology.yaml");
2449 let invalid_utf8: &[u8] = &[0xff, 0xfe, 0xfd];
2450 fs::write(®istry_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 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 let registry = dir.path().join("registry");
2562 fs::create_dir_all(®istry).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}