use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::Path;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::MifRhError;
use crate::ontology_pack::parse_pack;
pub const DEFAULT_REGISTRY_SOURCE: &str = "https://mif-spec.dev/ontologies";
const CORE_IDS: [&str; 3] = ["mif-base", "mif-generic", "shared-traits"];
#[derive(Debug, Clone, Deserialize)]
pub struct IndexEntry {
pub version: String,
pub sha256: String,
pub file: String,
#[serde(default)]
pub extends: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RegistryIndex {
pub ontologies: BTreeMap<String, IndexEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockEntry {
pub version: String,
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockFile {
#[serde(default = "lock_schema")]
pub schema: String,
#[serde(default)]
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index_sha256: Option<String>,
#[serde(default)]
pub ontologies: BTreeMap<String, LockEntry>,
}
fn lock_schema() -> String {
"mif-ontology-lock/v1".to_string()
}
impl Default for LockFile {
fn default() -> Self {
Self {
schema: lock_schema(),
source: String::new(),
index_sha256: None,
ontologies: BTreeMap::new(),
}
}
}
impl LockFile {
pub fn load_or_default(path: &Path) -> Result<Self, MifRhError> {
if !path.exists() {
return Ok(Self::default());
}
let contents = std::fs::read_to_string(path).map_err(|source| MifRhError::Io {
path: path.display().to_string(),
source,
})?;
serde_json::from_str(&contents).map_err(|source| MifRhError::Json {
path: path.display().to_string(),
source,
})
}
}
#[must_use]
pub fn resolve_source(root: &Path, source_override: Option<&str>) -> String {
if let Some(src) = source_override
&& !src.is_empty()
{
return trim_trailing_slash(src);
}
if let Ok(contents) = std::fs::read_to_string(root.join(".ontologies.source"))
&& let Some(first_line) = contents.lines().next()
&& !first_line.is_empty()
{
return trim_trailing_slash(first_line);
}
DEFAULT_REGISTRY_SOURCE.to_string()
}
fn trim_trailing_slash(source: &str) -> String {
source.strip_suffix('/').unwrap_or(source).to_string()
}
fn is_http(source: &str) -> bool {
source.starts_with("http://") || source.starts_with("https://")
}
fn fetch_raw(source: &str, relpath: &str) -> Result<Vec<u8>, MifRhError> {
if is_http(source) {
let url = format!("{source}/{relpath}");
let mut response = ureq::get(&url)
.call()
.map_err(|err| MifRhError::RegistryFetch {
registry_source: source.to_string(),
detail: err.to_string(),
})?;
response
.body_mut()
.read_to_vec()
.map_err(|err| MifRhError::RegistryFetch {
registry_source: source.to_string(),
detail: err.to_string(),
})
} else {
let base = source.strip_prefix("file://").unwrap_or(source);
let path = Path::new(base).join(relpath);
std::fs::read(&path).map_err(|err| MifRhError::RegistryFetch {
registry_source: source.to_string(),
detail: format!("cannot read {}: {err}", path.display()),
})
}
}
#[must_use]
fn sha256_hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut hasher = Sha256::new();
hasher.update(bytes);
hasher
.finalize()
.iter()
.fold(String::new(), |mut hex, byte| {
let _ = write!(hex, "{byte:02x}");
hex
})
}
fn is_committed_base(root: &Path, id: &str) -> bool {
root.join("schemas/ontologies").join(id).is_dir()
}
fn is_wellformed_id(id: &str) -> bool {
let mut chars = id.chars();
let Some(first) = chars.next() else {
return false;
};
first.is_ascii_lowercase()
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn resolve_fetch_set(
root: &Path,
index: &RegistryIndex,
requested: &[String],
) -> Result<Vec<String>, MifRhError> {
let mut seen: HashSet<String> = HashSet::new();
let mut queue: Vec<String> = Vec::new();
for id in requested {
if seen.insert(id.clone()) {
queue.push(id.clone());
}
}
let mut fetch_list: Vec<String> = Vec::new();
let mut cursor = 0;
while cursor < queue.len() {
let id = queue[cursor].clone();
cursor += 1;
if !is_wellformed_id(&id) {
return Err(MifRhError::MalformedOntologyId { id });
}
if is_committed_base(root, &id) {
continue;
}
let entry = index
.ontologies
.get(&id)
.ok_or_else(|| MifRhError::OntologyNotInRegistry { id: id.clone() })?;
if !fetch_list.contains(&id) {
fetch_list.push(id.clone());
}
for ancestor in &entry.extends {
if seen.insert(ancestor.clone()) {
queue.push(ancestor.clone());
}
}
}
Ok(fetch_list)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VendoredOntology {
pub id: String,
pub version: String,
}
#[derive(Debug, Clone, Default)]
pub struct FetchReport {
pub vendored: Vec<VendoredOntology>,
}
pub fn fetch(root: &Path, source: &str, ids: &[String]) -> Result<FetchReport, MifRhError> {
let index_bytes = fetch_raw(source, "index.json")?;
let index_sha256 = sha256_hex(&index_bytes);
let index: RegistryIndex =
serde_json::from_slice(&index_bytes).map_err(|err| MifRhError::RegistryIndexInvalid {
registry_source: source.to_string(),
detail: err.to_string(),
})?;
let lock_path = root.join("ontologies.lock.json");
let mut lock = LockFile::load_or_default(&lock_path)?;
if let Some(pinned) = &lock.index_sha256
&& lock.source == source
&& *pinned != index_sha256
{
return Err(MifRhError::IndexPinMismatch {
registry_source: source.to_string(),
pinned: pinned.clone(),
got: index_sha256,
});
}
let fetch_list = resolve_fetch_set(root, &index, ids)?;
let mut vendored = Vec::new();
let packs_dir = root.join("packs/ontologies");
lock.index_sha256 = Some(index_sha256);
lock.source = source.to_string();
for id in &fetch_list {
let Some(entry) = index.ontologies.get(id) else {
continue;
};
let is_bare_filename = matches!(
std::path::Path::new(&entry.file)
.components()
.collect::<Vec<_>>()
.as_slice(),
[std::path::Component::Normal(_)]
) && !entry.file.contains('\\');
if !is_bare_filename {
return Err(MifRhError::UnsafeIndexPath {
id: id.clone(),
file: entry.file.clone(),
});
}
let bytes = fetch_raw(source, &entry.file)?;
let got = sha256_hex(&bytes);
if got != entry.sha256 {
return Err(MifRhError::ChecksumMismatch {
id: id.clone(),
file: entry.file.clone(),
expected: entry.sha256.clone(),
got,
});
}
let dest_dir = packs_dir.join(id);
let out_yaml = dest_dir.join(format!("{id}.ontology.yaml"));
let pack = parse_pack(
&String::from_utf8_lossy(&bytes),
&out_yaml.display().to_string(),
)?;
std::fs::create_dir_all(&dest_dir).map_err(|source| MifRhError::Io {
path: dest_dir.display().to_string(),
source,
})?;
std::fs::write(&out_yaml, &bytes).map_err(|source| MifRhError::Io {
path: out_yaml.display().to_string(),
source,
})?;
let sidecar = serde_json::json!({
"name": id,
"version": entry.version,
"kind": "ontology",
"description": pack.description.as_deref().unwrap_or(""),
"provides": {"ontologies": [id]},
});
crate::write_json_atomic(&dest_dir.join("ontology.pack.json"), &sidecar)?;
lock.ontologies.insert(
id.clone(),
LockEntry {
version: entry.version.clone(),
sha256: entry.sha256.clone(),
},
);
crate::write_json_atomic(&lock_path, &lock)?;
vendored.push(VendoredOntology {
id: id.clone(),
version: entry.version.clone(),
});
}
if fetch_list.is_empty() {
crate::write_json_atomic(&lock_path, &lock)?;
}
Ok(FetchReport { vendored })
}
fn load_config_json(path: &Path) -> Result<serde_json::Value, MifRhError> {
if !path.exists() {
return Err(MifRhError::ConfigMissing {
path: path.display().to_string(),
});
}
let contents = std::fs::read_to_string(path).map_err(|source| MifRhError::Io {
path: path.display().to_string(),
source,
})?;
serde_json::from_str(&contents).map_err(|source| MifRhError::Json {
path: path.display().to_string(),
source,
})
}
fn ontologies_array(config: &serde_json::Value) -> &[serde_json::Value] {
config
.get("ontologies")
.and_then(serde_json::Value::as_array)
.map_or(&[][..], Vec::as_slice)
}
fn enabled_ontology_ids(config: &serde_json::Value) -> Vec<String> {
ontologies_array(config)
.iter()
.filter(|entry| {
entry
.get("enabled")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
})
.filter_map(|entry| entry.get("id").and_then(serde_json::Value::as_str))
.map(str::to_string)
.collect()
}
fn known_ontology_ids(config: &serde_json::Value) -> HashSet<String> {
ontologies_array(config)
.iter()
.filter_map(|entry| entry.get("id").and_then(serde_json::Value::as_str))
.map(str::to_string)
.collect()
}
#[derive(Debug, Clone, Serialize)]
struct CatalogOntologyEntry {
id: String,
version: String,
source: String,
core: bool,
}
#[derive(Debug, Clone, Default)]
pub struct CatalogSyncReport {
pub cataloged: usize,
}
pub fn sync_catalog(
root: &Path,
config_path: &Path,
sidecar_path: &Path,
) -> Result<CatalogSyncReport, MifRhError> {
let config = load_config_json(config_path)?;
let mut entries = Vec::new();
let core_dir = root.join("schemas/ontologies");
if core_dir.is_dir() {
let dir_entries = std::fs::read_dir(&core_dir).map_err(|source| MifRhError::Io {
path: core_dir.display().to_string(),
source,
})?;
let mut subdirs: Vec<_> = dir_entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect();
subdirs.sort();
for subdir in subdirs {
for pack_path in yaml_files_in(&subdir)? {
let display = pack_path.display().to_string();
let contents =
std::fs::read_to_string(&pack_path).map_err(|source| MifRhError::Io {
path: display.clone(),
source,
})?;
let pack = parse_pack(&contents, &display)?;
let core = CORE_IDS.contains(&pack.id.as_str());
entries.push(CatalogOntologyEntry {
id: pack.id,
version: pack.version,
source: repo_relative(root, &pack_path),
core,
});
}
}
}
let mut enabled: Vec<String> = enabled_ontology_ids(&config);
enabled.sort();
for id in enabled {
let pack_path = root
.join("packs/ontologies")
.join(&id)
.join(format!("{id}.ontology.yaml"));
if !pack_path.is_file() {
continue;
}
let display = pack_path.display().to_string();
let contents = std::fs::read_to_string(&pack_path).map_err(|source| MifRhError::Io {
path: display.clone(),
source,
})?;
let pack = parse_pack(&contents, &display)?;
entries.push(CatalogOntologyEntry {
id: pack.id,
version: pack.version,
source: repo_relative(root, &pack_path),
core: false,
});
}
let mut sidecar = if sidecar_path.exists() {
load_config_json(sidecar_path)?
} else {
serde_json::json!({})
};
let cataloged = entries.len();
if let Some(object) = sidecar.as_object_mut() {
object.insert(
"ontologies".to_string(),
serde_json::to_value(&entries).map_err(|source| MifRhError::JsonSerialize {
path: sidecar_path.display().to_string(),
source,
})?,
);
}
if let Some(parent) = sidecar_path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|source| MifRhError::Io {
path: parent.display().to_string(),
source,
})?;
}
crate::write_json_atomic(sidecar_path, &sidecar)?;
Ok(CatalogSyncReport { cataloged })
}
fn yaml_files_in(dir: &Path) -> Result<Vec<std::path::PathBuf>, MifRhError> {
let entries = std::fs::read_dir(dir).map_err(|source| MifRhError::Io {
path: dir.display().to_string(),
source,
})?;
let mut files: Vec<_> = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext == "yaml" || ext == "yml")
})
.collect();
files.sort();
Ok(files)
}
fn repo_relative(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string()
}
#[derive(Debug, Clone, Default)]
pub struct RegistrySyncReport {
pub discovered: Vec<String>,
pub fetch: FetchReport,
}
pub fn sync_registry(
root: &Path,
config_path: &Path,
sidecar_path: &Path,
source: &str,
) -> Result<RegistrySyncReport, MifRhError> {
let mut config = load_config_json(config_path)?;
if !config
.get("ontologies")
.is_some_and(serde_json::Value::is_array)
{
return Err(MifRhError::ConfigMalformed {
path: config_path.display().to_string(),
detail: ".ontologies is missing or not an array".to_string(),
});
}
let index_bytes = fetch_raw(source, "index.json")?;
let index: RegistryIndex =
serde_json::from_slice(&index_bytes).map_err(|err| MifRhError::RegistryIndexInvalid {
registry_source: source.to_string(),
detail: err.to_string(),
})?;
let lock = LockFile::load_or_default(&root.join("ontologies.lock.json"))?;
if let Some(pinned) = &lock.index_sha256
&& lock.source == source
&& *pinned != sha256_hex(&index_bytes)
{
return Err(MifRhError::IndexPinMismatch {
registry_source: source.to_string(),
pinned: pinned.clone(),
got: sha256_hex(&index_bytes),
});
}
let known = known_ontology_ids(&config);
let mut discovered: Vec<String> = index
.ontologies
.keys()
.filter(|id| !is_committed_base(root, id) && !known.contains(*id))
.cloned()
.collect();
discovered.sort();
for id in &discovered {
if !is_wellformed_id(id) {
return Err(MifRhError::MalformedOntologyId { id: id.clone() });
}
}
if !discovered.is_empty() {
let object = config
.as_object_mut()
.ok_or_else(|| MifRhError::ConfigMalformed {
path: config_path.display().to_string(),
detail: "top-level document is not a JSON object".to_string(),
})?;
let array = object
.entry("ontologies")
.or_insert_with(|| serde_json::Value::Array(Vec::new()))
.as_array_mut()
.ok_or_else(|| MifRhError::ConfigMalformed {
path: config_path.display().to_string(),
detail: ".ontologies exists but is not an array".to_string(),
})?;
for id in &discovered {
array.push(serde_json::json!({"id": id, "enabled": true}));
}
crate::write_json_atomic(config_path, &config)?;
}
let enabled = enabled_ontology_ids(&config);
let fetch_report = fetch(root, source, &enabled)?;
sync_catalog(root, config_path, sidecar_path)?;
Ok(RegistrySyncReport {
discovered,
fetch: fetch_report,
})
}
#[derive(Debug, Clone)]
pub struct DriftEntry {
pub id: String,
pub pinned: String,
pub got: String,
}
#[derive(Debug, Clone, Default)]
pub struct LockCheckReport {
pub missing_pins: Vec<String>,
pub not_vendored: Vec<String>,
pub drift: Vec<DriftEntry>,
pub checked: usize,
}
impl LockCheckReport {
#[must_use]
pub const fn ok(&self) -> bool {
self.missing_pins.is_empty() && self.not_vendored.is_empty() && self.drift.is_empty()
}
}
pub fn lock_check(root: &Path, config_path: &Path) -> Result<LockCheckReport, MifRhError> {
let lock_path = root.join("ontologies.lock.json");
if !lock_path.exists() {
return Ok(LockCheckReport::default());
}
let lock = LockFile::load_or_default(&lock_path)?;
let config = load_config_json(config_path)?;
let mut report = LockCheckReport::default();
let enabled: BTreeSet<String> = enabled_ontology_ids(&config).into_iter().collect();
for id in &enabled {
if is_committed_base(root, id) {
continue;
}
if !lock.ontologies.contains_key(id) {
report.missing_pins.push(id.clone());
}
}
for (id, entry) in &lock.ontologies {
let yaml = root
.join("packs/ontologies")
.join(id)
.join(format!("{id}.ontology.yaml"));
if !yaml.is_file() {
if enabled.contains(id) {
report.not_vendored.push(id.clone());
}
continue;
}
let bytes = std::fs::read(&yaml).map_err(|source| MifRhError::Io {
path: yaml.display().to_string(),
source,
})?;
let got = sha256_hex(&bytes);
if got == entry.sha256 {
report.checked += 1;
} else {
report.drift.push(DriftEntry {
id: id.clone(),
pinned: entry.sha256.clone(),
got,
});
}
}
Ok(report)
}
#[cfg(test)]
mod tests {
use std::fs;
use super::{
DEFAULT_REGISTRY_SOURCE, fetch, is_wellformed_id, lock_check, resolve_source, sync_catalog,
sync_registry,
};
const EDU_INDEX: &str = r#"{
"ontologies": {
"edu-fixture": {
"version": "0.1.0",
"sha256": "REPLACED",
"file": "edu-fixture.ontology.yaml",
"extends": ["mif-base"]
}
}
}"#;
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";
fn sha256_of(bytes: &[u8]) -> String {
super::sha256_hex(bytes)
}
fn write_local_registry(dir: &std::path::Path) -> String {
let registry = dir.join("registry");
fs::create_dir_all(®istry).unwrap();
let sha = sha256_of(EDU_YAML.as_bytes());
let index = EDU_INDEX.replace("REPLACED", &sha);
fs::write(registry.join("index.json"), index).unwrap();
fs::write(registry.join("edu-fixture.ontology.yaml"), EDU_YAML).unwrap();
registry.display().to_string()
}
fn write_base_layer(root: &std::path::Path) {
fs::create_dir_all(root.join("schemas/ontologies/mif-base")).unwrap();
fs::write(
root.join("schemas/ontologies/mif-base/mif-base.ontology.yaml"),
"ontology:\n id: mif-base\n version: \"1.0.0\"\nentity_types: []\n",
)
.unwrap();
}
#[test]
fn resolve_source_defaults_to_the_canonical_registry() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(resolve_source(dir.path(), None), DEFAULT_REGISTRY_SOURCE);
}
#[test]
fn resolve_source_prefers_an_explicit_override() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".ontologies.source"), "/marker/path\n").unwrap();
assert_eq!(resolve_source(dir.path(), Some("/override/")), "/override");
}
#[test]
fn resolve_source_falls_back_to_the_marker_file() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".ontologies.source"), "/marker/path/\n").unwrap();
assert_eq!(resolve_source(dir.path(), None), "/marker/path");
}
#[test]
fn is_wellformed_id_accepts_bare_lowercase_slugs_only() {
assert!(is_wellformed_id("clinical-trials"));
assert!(is_wellformed_id("edu2"));
assert!(!is_wellformed_id(""));
assert!(!is_wellformed_id("../etc"));
assert!(!is_wellformed_id("Clinical"));
assert!(!is_wellformed_id("has space"));
}
#[test]
fn fetch_vendors_a_verified_ontology_and_skips_committed_bases() {
let dir = tempfile::tempdir().unwrap();
write_base_layer(dir.path());
let source = write_local_registry(dir.path());
let report = fetch(dir.path(), &source, &["edu-fixture".to_string()]).unwrap();
assert_eq!(report.vendored.len(), 1);
assert_eq!(report.vendored[0].id, "edu-fixture");
let vendored_yaml = dir
.path()
.join("packs/ontologies/edu-fixture/edu-fixture.ontology.yaml");
assert!(vendored_yaml.is_file());
let lock: super::LockFile = serde_json::from_str(
&fs::read_to_string(dir.path().join("ontologies.lock.json")).unwrap(),
)
.unwrap();
assert!(lock.ontologies.contains_key("edu-fixture"));
assert!(lock.index_sha256.is_some());
assert!(!dir.path().join("packs/ontologies/mif-base").exists());
}
#[test]
fn fetch_fails_closed_on_a_checksum_mismatch() {
let dir = tempfile::tempdir().unwrap();
write_base_layer(dir.path());
let registry = dir.path().join("registry");
fs::create_dir_all(®istry).unwrap();
let index = EDU_INDEX.replace(
"REPLACED",
"0000000000000000000000000000000000000000000000000000000000000000",
);
fs::write(registry.join("index.json"), index).unwrap();
fs::write(registry.join("edu-fixture.ontology.yaml"), EDU_YAML).unwrap();
let error = fetch(
dir.path(),
®istry.display().to_string(),
&["edu-fixture".to_string()],
)
.unwrap_err();
assert!(matches!(error, super::MifRhError::ChecksumMismatch { .. }));
assert!(!dir.path().join("packs/ontologies/edu-fixture").exists());
}
#[test]
fn fetch_rejects_a_windows_style_or_absolute_index_file_path() {
let dir = tempfile::tempdir().unwrap();
write_base_layer(dir.path());
let registry = dir.path().join("registry");
fs::create_dir_all(®istry).unwrap();
for unsafe_file in [
"..\\..\\etc\\passwd",
"C:\\Windows\\evil.yaml",
"/etc/passwd",
] {
let index = EDU_INDEX
.replace("REPLACED", &sha256_of(EDU_YAML.as_bytes()))
.replace(
"\"edu-fixture.ontology.yaml\"",
&serde_json::to_string(unsafe_file).unwrap(),
);
fs::write(registry.join("index.json"), index).unwrap();
let error = fetch(
dir.path(),
®istry.display().to_string(),
&["edu-fixture".to_string()],
)
.unwrap_err();
assert!(
matches!(error, super::MifRhError::UnsafeIndexPath { .. }),
"expected UnsafeIndexPath for {unsafe_file:?}, got {error:?}"
);
}
}
#[test]
fn fetch_leaves_no_file_behind_when_the_vendored_yaml_fails_to_parse() {
let dir = tempfile::tempdir().unwrap();
write_base_layer(dir.path());
let registry = dir.path().join("registry");
fs::create_dir_all(®istry).unwrap();
let malformed = b"not: [valid, yaml, ontology shape";
let index = EDU_INDEX.replace("REPLACED", &sha256_of(malformed));
fs::write(registry.join("index.json"), index).unwrap();
fs::write(registry.join("edu-fixture.ontology.yaml"), malformed).unwrap();
let error = fetch(
dir.path(),
®istry.display().to_string(),
&["edu-fixture".to_string()],
)
.unwrap_err();
assert!(
matches!(error, super::MifRhError::OntologyPackYaml { .. }),
"{error:?}"
);
assert!(!dir.path().join("packs/ontologies/edu-fixture").exists());
}
#[test]
fn fetch_refuses_a_moved_trust_root_for_the_same_source() {
let dir = tempfile::tempdir().unwrap();
write_base_layer(dir.path());
let source = write_local_registry(dir.path());
fetch(dir.path(), &source, &["edu-fixture".to_string()]).unwrap();
fs::write(
std::path::Path::new(&source).join("index.json"),
EDU_INDEX
.replace("REPLACED", &sha256_of(EDU_YAML.as_bytes()))
.replace('0', "1"),
)
.unwrap();
let error = fetch(dir.path(), &source, &["edu-fixture".to_string()]).unwrap_err();
assert!(matches!(error, super::MifRhError::IndexPinMismatch { .. }));
}
#[test]
fn fetch_reports_an_id_absent_from_the_registry() {
let dir = tempfile::tempdir().unwrap();
let source = write_local_registry(dir.path());
let error = fetch(dir.path(), &source, &["nonexistent-id".to_string()]).unwrap_err();
assert!(matches!(
error,
super::MifRhError::OntologyNotInRegistry { .. }
));
}
#[test]
fn fetch_rejects_a_path_traversal_id_reached_via_an_extends_ancestor() {
let dir = tempfile::tempdir().unwrap();
let registry = dir.path().join("registry");
fs::create_dir_all(®istry).unwrap();
let sha = sha256_of(EDU_YAML.as_bytes());
let malicious_index = format!(
r#"{{"ontologies":{{"edu-fixture":{{"version":"0.1.0","sha256":"{sha}","file":"edu-fixture.ontology.yaml","extends":["../../../etc"]}}}}}}"#
);
fs::write(registry.join("index.json"), malicious_index).unwrap();
fs::write(registry.join("edu-fixture.ontology.yaml"), EDU_YAML).unwrap();
let error = fetch(
dir.path(),
®istry.display().to_string(),
&["edu-fixture".to_string()],
)
.unwrap_err();
assert!(matches!(
error,
super::MifRhError::MalformedOntologyId { .. }
));
assert!(!dir.path().join("packs/ontologies/edu-fixture").exists());
}
#[test]
fn a_failure_partway_through_a_batch_still_pins_the_ontologies_already_vendored() {
let dir = tempfile::tempdir().unwrap();
let registry = dir.path().join("registry");
fs::create_dir_all(®istry).unwrap();
let good_sha = sha256_of(EDU_YAML.as_bytes());
let index = format!(
r#"{{"ontologies":{{
"edu-fixture":{{"version":"0.1.0","sha256":"{good_sha}","file":"edu-fixture.ontology.yaml","extends":[]}},
"bad-fixture":{{"version":"0.1.0","sha256":"0000000000000000000000000000000000000000000000000000000000000000","file":"edu-fixture.ontology.yaml","extends":[]}}
}}}}"#
);
fs::write(registry.join("index.json"), index).unwrap();
fs::write(registry.join("edu-fixture.ontology.yaml"), EDU_YAML).unwrap();
let source = registry.display().to_string();
let error = fetch(
dir.path(),
&source,
&["edu-fixture".to_string(), "bad-fixture".to_string()],
)
.unwrap_err();
assert!(matches!(error, super::MifRhError::ChecksumMismatch { .. }));
let lock: super::LockFile = serde_json::from_str(
&fs::read_to_string(dir.path().join("ontologies.lock.json")).unwrap(),
)
.unwrap();
assert!(lock.ontologies.contains_key("edu-fixture"));
assert!(dir.path().join("packs/ontologies/edu-fixture").is_dir());
}
#[test]
fn lock_check_passes_cleanly_with_no_lock_file() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("harness.config.json"),
r#"{"ontologies":[]}"#,
)
.unwrap();
let report = lock_check(dir.path(), &dir.path().join("harness.config.json")).unwrap();
assert!(report.ok());
}
#[test]
fn lock_check_flags_a_missing_pin_for_an_enabled_ontology() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("harness.config.json");
fs::write(
&config_path,
r#"{"ontologies":[{"id":"edu-fixture","enabled":true}]}"#,
)
.unwrap();
fs::write(
dir.path().join("ontologies.lock.json"),
r#"{"schema":"mif-ontology-lock/v1","source":"x","ontologies":{}}"#,
)
.unwrap();
let report = lock_check(dir.path(), &config_path).unwrap();
assert!(!report.ok());
assert_eq!(report.missing_pins, ["edu-fixture"]);
}
#[test]
fn lock_check_flags_drift_when_a_vendored_file_no_longer_matches_its_pin() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("harness.config.json");
fs::write(&config_path, r#"{"ontologies":[]}"#).unwrap();
fs::create_dir_all(dir.path().join("packs/ontologies/edu-fixture")).unwrap();
fs::write(
dir.path()
.join("packs/ontologies/edu-fixture/edu-fixture.ontology.yaml"),
"edited locally",
)
.unwrap();
fs::write(
dir.path().join("ontologies.lock.json"),
r#"{"schema":"mif-ontology-lock/v1","source":"x","ontologies":{"edu-fixture":{"version":"0.1.0","sha256":"deadbeef"}}}"#,
)
.unwrap();
let report = lock_check(dir.path(), &config_path).unwrap();
assert!(!report.ok());
assert_eq!(report.drift.len(), 1);
assert_eq!(report.drift[0].id, "edu-fixture");
}
#[test]
fn sync_catalog_catalogs_core_and_enabled_ontologies() {
let dir = tempfile::tempdir().unwrap();
write_base_layer(dir.path());
let config_path = dir.path().join("harness.config.json");
fs::write(
&config_path,
r#"{"ontologies":[{"id":"edu-fixture","enabled":true}]}"#,
)
.unwrap();
fs::create_dir_all(dir.path().join("packs/ontologies/edu-fixture")).unwrap();
fs::write(
dir.path()
.join("packs/ontologies/edu-fixture/edu-fixture.ontology.yaml"),
EDU_YAML,
)
.unwrap();
let sidecar_path = dir.path().join("enabled-packs.json");
fs::write(&sidecar_path, r#"{"enabledPlugins":["x"]}"#).unwrap();
let report = sync_catalog(dir.path(), &config_path, &sidecar_path).unwrap();
assert_eq!(report.cataloged, 2);
let sidecar: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&sidecar_path).unwrap()).unwrap();
assert_eq!(sidecar["enabledPlugins"], serde_json::json!(["x"]));
let ontologies = sidecar["ontologies"].as_array().unwrap();
assert!(
ontologies
.iter()
.any(|e| e["id"] == "mif-base" && e["core"] == true)
);
assert!(
ontologies
.iter()
.any(|e| e["id"] == "edu-fixture" && e["core"] == false)
);
}
#[test]
fn sync_catalog_creates_the_sidecars_parent_directory_when_missing() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("harness.config.json");
fs::write(&config_path, r#"{"ontologies":[]}"#).unwrap();
let sidecar_path = dir.path().join(".claude/enabled-packs.json");
let report = sync_catalog(dir.path(), &config_path, &sidecar_path).unwrap();
assert_eq!(report.cataloged, 0);
assert!(sidecar_path.is_file());
}
#[test]
fn sync_registry_discovers_and_enables_a_new_registry_ontology_and_preserves_other_config_fields()
{
let dir = tempfile::tempdir().unwrap();
write_base_layer(dir.path());
let source = write_local_registry(dir.path());
let config_path = dir.path().join("harness.config.json");
fs::write(
&config_path,
r#"{"version":"1.2.3","topics":[{"id":"t","ontologies":[]}],"ontologies":[]}"#,
)
.unwrap();
let sidecar_path = dir.path().join("enabled-packs.json");
let report = sync_registry(dir.path(), &config_path, &sidecar_path, &source).unwrap();
assert_eq!(report.discovered, ["edu-fixture"]);
assert_eq!(report.fetch.vendored.len(), 1);
let config: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
assert_eq!(config["version"], "1.2.3");
assert_eq!(config["topics"][0]["id"], "t");
assert_eq!(config["ontologies"][0]["id"], "edu-fixture");
assert_eq!(config["ontologies"][0]["enabled"], true);
}
#[test]
fn sync_registry_is_idempotent_when_nothing_new_is_published() {
let dir = tempfile::tempdir().unwrap();
write_base_layer(dir.path());
let source = write_local_registry(dir.path());
let config_path = dir.path().join("harness.config.json");
fs::write(
&config_path,
r#"{"ontologies":[{"id":"edu-fixture","enabled":true}]}"#,
)
.unwrap();
let sidecar_path = dir.path().join("enabled-packs.json");
let report = sync_registry(dir.path(), &config_path, &sidecar_path, &source).unwrap();
assert!(report.discovered.is_empty());
}
#[test]
fn sync_registry_rejects_a_non_array_ontologies_field_without_touching_the_network() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("harness.config.json");
fs::write(&config_path, r#"{"ontologies":"not-an-array"}"#).unwrap();
let sidecar_path = dir.path().join("enabled-packs.json");
let error = sync_registry(
dir.path(),
&config_path,
&sidecar_path,
"http://127.0.0.1.invalid/unreachable",
)
.unwrap_err();
assert!(matches!(error, super::MifRhError::ConfigMalformed { .. }));
}
#[test]
fn sync_registry_rejects_invalid_json_config_without_touching_the_network() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("harness.config.json");
fs::write(&config_path, "not json at all").unwrap();
let sidecar_path = dir.path().join("enabled-packs.json");
let error = sync_registry(
dir.path(),
&config_path,
&sidecar_path,
"http://127.0.0.1.invalid/unreachable",
)
.unwrap_err();
assert!(matches!(error, super::MifRhError::Json { .. }));
}
}