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 is_bare_filename(file: &str) -> bool {
let mut components = std::path::Path::new(file).components();
matches!(components.next(), Some(std::path::Component::Normal(_)))
&& components.next().is_none()
&& !file.contains('\\')
&& !file.contains('/')
}
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, PartialEq, Eq)]
pub struct PinnedSkipped {
pub id: String,
pub locked_version: String,
pub registry_version: String,
}
#[derive(Debug, Clone, Default)]
pub struct FetchReport {
pub vendored: Vec<VendoredOntology>,
pub pinned_skipped: Vec<PinnedSkipped>,
}
fn pinned_below_registry(
refresh: bool,
lock: &LockFile,
id: &str,
entry: &IndexEntry,
) -> Option<PinnedSkipped> {
if refresh {
return None;
}
let locked = lock.ontologies.get(id)?;
if locked.version == entry.version {
return None;
}
Some(PinnedSkipped {
id: id.to_string(),
locked_version: locked.version.clone(),
registry_version: entry.version.clone(),
})
}
pub fn fetch(
root: &Path,
source: &str,
ids: &[String],
refresh: bool,
) -> 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 mut pinned_skipped = 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;
};
if let Some(skip) = pinned_below_registry(refresh, &lock, id, entry) {
pinned_skipped.push(skip);
continue;
}
if !is_bare_filename(&entry.file) {
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 vendored.is_empty() {
crate::write_json_atomic(&lock_path, &lock)?;
}
Ok(FetchReport {
vendored,
pinned_skipped,
})
}
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, false)?;
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)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewlyRequiredField {
pub entity_type: String,
pub field: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PinSafetyGap {
pub topic: String,
pub finding_id: String,
pub entity_type: String,
pub field: String,
}
#[derive(Debug, Clone, Default)]
pub struct PinSafetyReport {
pub id: String,
pub locked_version: String,
pub registry_version: String,
pub analyzed: bool,
pub newly_required: Vec<NewlyRequiredField>,
pub gaps: Vec<PinSafetyGap>,
}
fn required_fields(
entity_type: &str,
schema: &serde_json::Value,
) -> Result<Vec<String>, MifRhError> {
let Some(required) = schema.get("required") else {
return Ok(Vec::new());
};
let Some(values) = required.as_array() else {
return Err(MifRhError::EntityTypeSchemaInvalid {
entity_type: entity_type.to_string(),
detail: "schema.required is present but is not an array".to_string(),
});
};
values
.iter()
.map(|value| {
value
.as_str()
.map(str::to_string)
.ok_or_else(|| MifRhError::EntityTypeSchemaInvalid {
entity_type: entity_type.to_string(),
detail: format!("schema.required contains a non-string element: {value}"),
})
})
.collect()
}
fn diff_newly_required(
old: &crate::ontology_pack::OntologyPack,
new: &crate::ontology_pack::OntologyPack,
) -> Result<Vec<NewlyRequiredField>, MifRhError> {
let mut out = Vec::new();
for new_type in &new.entity_types {
let Some(old_type) = old.entity_types.iter().find(|t| t.name == new_type.name) else {
continue;
};
let old_required: HashSet<String> = required_fields(&old_type.name, &old_type.schema)?
.into_iter()
.collect();
let mut seen_new = HashSet::new();
for field in required_fields(&new_type.name, &new_type.schema)? {
if !old_required.contains(&field) && seen_new.insert(field.clone()) {
out.push(NewlyRequiredField {
entity_type: new_type.name.clone(),
field,
});
}
}
}
Ok(out)
}
fn find_pin_safety_gaps(
reports_dir: &Path,
topics: &[String],
ontology_id: &str,
newly_required: &[NewlyRequiredField],
) -> Result<Vec<PinSafetyGap>, MifRhError> {
let mut gaps = Vec::new();
for topic in topics {
let map_path = reports_dir.join(topic).join("ontology-map.json");
let contents = match std::fs::read_to_string(&map_path) {
Ok(contents) => contents,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => continue,
Err(source) => {
return Err(MifRhError::Io {
path: map_path.display().to_string(),
source,
});
},
};
let records: Vec<crate::resolve::MapRecord> =
serde_json::from_str(&contents).map_err(|source| MifRhError::Json {
path: map_path.display().to_string(),
source,
})?;
let records_by_finding_id: std::collections::HashMap<&str, &crate::resolve::MapRecord> =
records.iter().map(|r| (r.finding_id.as_str(), r)).collect();
let findings_dir = reports_dir.join(topic).join("findings");
if !findings_dir.is_dir() {
continue;
}
for file in crate::review::list_finding_files(&findings_dir)? {
let Ok(finding) = crate::finding::Finding::load(&file) else {
continue; };
let Some(record) = records_by_finding_id.get(finding.id.as_str()).copied() else {
continue;
};
let stamped = record.valid
&& matches!(
record.basis,
crate::resolve::Basis::Declared | crate::resolve::Basis::Resolved
);
if !stamped {
continue;
}
let bound_to_this_ontology = record
.resolved_ontology
.as_deref()
.and_then(|resolved| resolved.split('@').next())
== Some(ontology_id);
if !bound_to_this_ontology {
continue;
}
let Some(entity_type) = record.entity_type.as_deref() else {
continue;
};
gaps.extend(
newly_required
.iter()
.filter(|nrf| nrf.entity_type == entity_type)
.filter(|nrf| {
finding
.entity
.as_ref()
.and_then(|entity| entity.get(&nrf.field))
.is_none_or(serde_json::Value::is_null)
})
.map(|nrf| PinSafetyGap {
topic: topic.clone(),
finding_id: finding.id.clone(),
entity_type: entity_type.to_string(),
field: nrf.field.clone(),
}),
);
}
}
Ok(gaps)
}
fn check_lock_source(lock: &LockFile, source: &str) -> Result<(), MifRhError> {
if lock.source.is_empty() {
if lock.ontologies.is_empty() {
return Ok(());
}
return Err(MifRhError::LockSourceMismatch {
lock_source: "<unset>".to_string(),
requested_source: source.to_string(),
});
}
if lock.source != source {
return Err(MifRhError::LockSourceMismatch {
lock_source: lock.source.clone(),
requested_source: source.to_string(),
});
}
Ok(())
}
fn fetch_and_parse_registry_pack(
source: &str,
id: &str,
entry: &IndexEntry,
) -> Result<crate::ontology_pack::OntologyPack, MifRhError> {
if !is_bare_filename(&entry.file) {
return Err(MifRhError::UnsafeIndexPath {
id: id.to_string(),
file: entry.file.clone(),
});
}
let new_bytes = fetch_raw(source, &entry.file)?;
let got = sha256_hex(&new_bytes);
if got != entry.sha256 {
return Err(MifRhError::ChecksumMismatch {
id: id.to_string(),
file: entry.file.clone(),
expected: entry.sha256.clone(),
got,
});
}
let new_text = String::from_utf8(new_bytes).map_err(|_| MifRhError::OntologyPackNotUtf8 {
id: id.to_string(),
file: entry.file.clone(),
})?;
parse_pack(&new_text, &format!("{source}/{}", entry.file))
}
fn pinned_requested_ids(lock: &LockFile, ids: &[String]) -> Result<Vec<String>, MifRhError> {
let mut seen = HashSet::new();
let mut pinned = Vec::new();
for id in ids {
if !is_wellformed_id(id) {
return Err(MifRhError::MalformedOntologyId { id: id.clone() });
}
if seen.insert(id.as_str()) && lock.ontologies.contains_key(id) {
pinned.push(id.clone());
}
}
Ok(pinned)
}
pub fn check_pin_safety(
root: &Path,
source: &str,
reports_dir: &Path,
topics: &[String],
ids: &[String],
) -> Result<Vec<PinSafetyReport>, MifRhError> {
let lock = LockFile::load_or_default(&root.join("ontologies.lock.json"))?;
let pinned_ids = pinned_requested_ids(&lock, ids)?;
if pinned_ids.is_empty() {
return Ok(Vec::new());
}
check_lock_source(&lock, source)?;
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(),
})?;
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 mut reports = Vec::new();
for id in &pinned_ids {
let locked = &lock.ontologies[id];
let entry = index
.ontologies
.get(id)
.ok_or_else(|| MifRhError::OntologyNotInRegistry { id: id.clone() })?;
if locked.version == entry.version {
continue; }
let old_path = root
.join("packs/ontologies")
.join(id)
.join(format!("{id}.ontology.yaml"));
let old_pack = match std::fs::read_to_string(&old_path) {
Ok(yaml) => Some(parse_pack(&yaml, &old_path.display().to_string())?),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => None,
Err(source) => {
return Err(MifRhError::Io {
path: old_path.display().to_string(),
source,
});
},
};
let Some(old_pack) = old_pack else {
reports.push(PinSafetyReport {
id: id.clone(),
locked_version: locked.version.clone(),
registry_version: entry.version.clone(),
analyzed: false,
newly_required: Vec::new(),
gaps: Vec::new(),
});
continue;
};
let new_pack = fetch_and_parse_registry_pack(source, id, entry)?;
let newly_required = diff_newly_required(&old_pack, &new_pack)?;
let gaps = if newly_required.is_empty() {
Vec::new()
} else {
find_pin_safety_gaps(reports_dir, topics, id, &newly_required)?
};
reports.push(PinSafetyReport {
id: id.clone(),
locked_version: locked.version.clone(),
registry_version: entry.version.clone(),
analyzed: true,
newly_required,
gaps,
});
}
Ok(reports)
}
#[cfg(test)]
mod tests {
use std::fs;
use super::{
DEFAULT_REGISTRY_SOURCE, diff_newly_required, fetch, is_bare_filename, is_wellformed_id,
lock_check, resolve_source, sync_catalog, sync_registry,
};
use crate::ontology_pack::parse_pack;
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 is_bare_filename_rejects_a_trailing_slash() {
assert!(!is_bare_filename("foo/"));
assert!(!is_bare_filename("foo/bar"));
assert!(!is_bare_filename("/foo"));
assert!(!is_bare_filename("foo\\bar"));
assert!(is_bare_filename("edu-fixture.ontology.yaml"));
}
#[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()], false).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());
}
fn seed_registry_ahead_of_a_pinned_lock(
dir: &std::path::Path,
registry_version: &str,
locked_version: &str,
) -> String {
let registry = dir.join("registry");
fs::create_dir_all(®istry).unwrap();
let yaml = format!(
"ontology:\n id: edu-fixture\n version: \"{registry_version}\"\n description: \
\"An edu fixture at {registry_version}\"\n extends: [mif-base]\nentity_types: \
[]\n"
);
let sha = sha256_of(yaml.as_bytes());
let index = format!(
r#"{{"ontologies":{{"edu-fixture":{{"version":"{registry_version}","sha256":"{sha}","file":"edu-fixture.ontology.yaml","extends":["mif-base"]}}}}}}"#
);
fs::write(registry.join("index.json"), &index).unwrap();
fs::write(registry.join("edu-fixture.ontology.yaml"), yaml).unwrap();
let source = registry.display().to_string();
let lock = serde_json::json!({
"schema": "mif-ontology-lock/v1",
"source": source,
"index_sha256": sha256_of(index.as_bytes()),
"ontologies": {
"edu-fixture": {"version": locked_version, "sha256": "irrelevant-for-this-test"}
}
});
fs::write(
dir.join("ontologies.lock.json"),
serde_json::to_string(&lock).unwrap(),
)
.unwrap();
source
}
#[test]
fn fetch_leaves_a_pinned_ontology_untouched_when_the_registry_advances_without_refresh() {
let dir = tempfile::tempdir().unwrap();
write_base_layer(dir.path());
let source = seed_registry_ahead_of_a_pinned_lock(dir.path(), "0.2.0", "0.1.0");
let report = fetch(dir.path(), &source, &["edu-fixture".to_string()], false).unwrap();
assert!(report.vendored.is_empty());
assert_eq!(report.pinned_skipped.len(), 1);
assert_eq!(report.pinned_skipped[0].id, "edu-fixture");
assert_eq!(report.pinned_skipped[0].locked_version, "0.1.0");
assert_eq!(report.pinned_skipped[0].registry_version, "0.2.0");
let lock: super::LockFile = serde_json::from_str(
&fs::read_to_string(dir.path().join("ontologies.lock.json")).unwrap(),
)
.unwrap();
assert_eq!(lock.ontologies["edu-fixture"].version, "0.1.0");
assert!(
!dir.path()
.join("packs/ontologies/edu-fixture/edu-fixture.ontology.yaml")
.exists(),
"a pinned-and-skipped id must never be written to disk"
);
}
#[test]
fn fetch_refresh_advances_a_pinned_ontology_to_the_registry_version() {
let dir = tempfile::tempdir().unwrap();
write_base_layer(dir.path());
let source = seed_registry_ahead_of_a_pinned_lock(dir.path(), "0.2.0", "0.1.0");
let report = fetch(dir.path(), &source, &["edu-fixture".to_string()], true).unwrap();
assert!(report.pinned_skipped.is_empty());
assert_eq!(report.vendored.len(), 1);
assert_eq!(report.vendored[0].version, "0.2.0");
let lock: super::LockFile = serde_json::from_str(
&fs::read_to_string(dir.path().join("ontologies.lock.json")).unwrap(),
)
.unwrap();
assert_eq!(lock.ontologies["edu-fixture"].version, "0.2.0");
assert!(
dir.path()
.join("packs/ontologies/edu-fixture/edu-fixture.ontology.yaml")
.exists()
);
}
#[test]
fn fetch_persists_a_cleared_index_pin_even_when_every_id_is_pinned_skipped() {
let dir = tempfile::tempdir().unwrap();
write_base_layer(dir.path());
let source = seed_registry_ahead_of_a_pinned_lock(dir.path(), "0.2.0", "0.1.0");
let mut lock: serde_json::Value = serde_json::from_str(
&fs::read_to_string(dir.path().join("ontologies.lock.json")).unwrap(),
)
.unwrap();
lock.as_object_mut().unwrap().remove("index_sha256");
fs::write(
dir.path().join("ontologies.lock.json"),
serde_json::to_string(&lock).unwrap(),
)
.unwrap();
let report = fetch(dir.path(), &source, &["edu-fixture".to_string()], false).unwrap();
assert!(report.vendored.is_empty());
assert_eq!(report.pinned_skipped.len(), 1);
let after: super::LockFile = serde_json::from_str(
&fs::read_to_string(dir.path().join("ontologies.lock.json")).unwrap(),
)
.unwrap();
assert!(
after.index_sha256.is_some(),
"the re-pinned index_sha256 must be persisted even though every \
requested id was pinned-skipped"
);
}
#[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()],
false,
)
.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()],
false,
)
.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()],
false,
)
.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()], false).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()], false).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()], false).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()],
false,
)
.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()],
false,
)
.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 { .. }));
}
fn seed_pin_safety_fixture(
dir: &std::path::Path,
locked_version: &str,
registry_version: &str,
) -> String {
let old_yaml = format!(
"ontology:\n id: edu-fixture\n version: \"{locked_version}\"\n extends: \
[mif-base]\nentity_types:\n - name: title\n schema:\n required: \
[name]\n properties:\n name: {{type: string}}\n"
);
fs::create_dir_all(dir.join("packs/ontologies/edu-fixture")).unwrap();
fs::write(
dir.join("packs/ontologies/edu-fixture/edu-fixture.ontology.yaml"),
&old_yaml,
)
.unwrap();
let registry = dir.join("registry");
fs::create_dir_all(®istry).unwrap();
let new_yaml = format!(
"ontology:\n id: edu-fixture\n version: \"{registry_version}\"\n extends: \
[mif-base]\nentity_types:\n - name: title\n schema:\n required: [name, \
isbn]\n properties:\n name: {{type: string}}\n isbn: {{type: \
string}}\n"
);
let sha = sha256_of(new_yaml.as_bytes());
let index = format!(
r#"{{"ontologies":{{"edu-fixture":{{"version":"{registry_version}","sha256":"{sha}","file":"edu-fixture.ontology.yaml","extends":["mif-base"]}}}}}}"#
);
fs::write(registry.join("index.json"), &index).unwrap();
fs::write(registry.join("edu-fixture.ontology.yaml"), &new_yaml).unwrap();
let source = registry.display().to_string();
let lock = serde_json::json!({
"schema": "mif-ontology-lock/v1",
"source": source,
"index_sha256": sha256_of(index.as_bytes()),
"ontologies": {
"edu-fixture": {"version": locked_version, "sha256": "irrelevant-for-this-test"}
}
});
fs::write(
dir.join("ontologies.lock.json"),
serde_json::to_string(&lock).unwrap(),
)
.unwrap();
source
}
fn write_stamped_finding(
reports_dir: &std::path::Path,
topic: &str,
locked_version: &str,
entity_json: &str,
) {
let topic_dir = reports_dir.join(topic);
fs::create_dir_all(topic_dir.join("findings")).unwrap();
fs::write(
topic_dir.join("ontology-map.json"),
format!(
r#"[{{"finding_id":"f-1","entity_type":"title","resolved_ontology":"edu-fixture@{locked_version}","basis":"resolved","valid":true}}]"#
),
)
.unwrap();
fs::write(
topic_dir.join("findings/f-1.json"),
format!(r#"{{"@id":"f-1","entity":{entity_json}}}"#),
)
.unwrap();
}
#[test]
fn diff_newly_required_dedupes_a_field_repeated_in_the_new_schema() {
let old_yaml = "ontology:\n id: edu-fixture\n version: \"0.1.0\"\n extends: \
[mif-base]\nentity_types:\n - name: title\n schema:\n required: \
[name]\n properties:\n name: {type: string}\n";
let new_yaml = "ontology:\n id: edu-fixture\n version: \"0.2.0\"\n extends: \
[mif-base]\nentity_types:\n - name: title\n schema:\n required: \
[name, isbn, isbn]\n properties:\n name: {type: string}\n \
isbn: {type: string}\n";
let old_pack = parse_pack(old_yaml, "old").unwrap();
let new_pack = parse_pack(new_yaml, "new").unwrap();
let newly_required = diff_newly_required(&old_pack, &new_pack).unwrap();
assert_eq!(
newly_required.len(),
1,
"a required field repeated in the new schema must be reported once, not once per repetition"
);
assert_eq!(newly_required[0].entity_type, "title");
assert_eq!(newly_required[0].field, "isbn");
}
#[test]
fn check_pin_safety_reports_nothing_for_an_id_with_no_lock_entry() {
let dir = tempfile::tempdir().unwrap();
let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
fs::remove_file(dir.path().join("ontologies.lock.json")).unwrap();
let reports = super::check_pin_safety(
dir.path(),
&source,
&dir.path().join("reports"),
&[],
&["edu-fixture".to_string()],
)
.unwrap();
assert!(reports.is_empty());
}
#[test]
fn check_pin_safety_reports_nothing_when_not_drifted() {
let dir = tempfile::tempdir().unwrap();
let source = seed_pin_safety_fixture(dir.path(), "0.2.0", "0.2.0");
let reports = super::check_pin_safety(
dir.path(),
&source,
&dir.path().join("reports"),
&[],
&["edu-fixture".to_string()],
)
.unwrap();
assert!(reports.is_empty());
}
#[test]
fn check_pin_safety_dedupes_a_repeated_id() {
let dir = tempfile::tempdir().unwrap();
let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
let reports = super::check_pin_safety(
dir.path(),
&source,
&dir.path().join("reports"),
&[],
&["edu-fixture".to_string(), "edu-fixture".to_string()],
)
.unwrap();
assert_eq!(
reports.len(),
1,
"a duplicate id in the request must be analyzed once, not once per repetition"
);
}
#[test]
fn check_pin_safety_flags_a_newly_required_field_missing_from_a_stamped_finding() {
let dir = tempfile::tempdir().unwrap();
let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
let reports_dir = dir.path().join("reports");
write_stamped_finding(&reports_dir, "edu", "0.1.0", r#"{"name":"Algebra I"}"#);
let reports = super::check_pin_safety(
dir.path(),
&source,
&reports_dir,
&["edu".to_string()],
&["edu-fixture".to_string()],
)
.unwrap();
assert_eq!(reports.len(), 1);
let report = &reports[0];
assert_eq!(report.locked_version, "0.1.0");
assert_eq!(report.registry_version, "0.2.0");
assert_eq!(report.newly_required.len(), 1);
assert_eq!(report.newly_required[0].entity_type, "title");
assert_eq!(report.newly_required[0].field, "isbn");
assert_eq!(report.gaps.len(), 1);
assert_eq!(report.gaps[0].finding_id, "f-1");
assert_eq!(report.gaps[0].topic, "edu");
assert_eq!(report.gaps[0].field, "isbn");
}
#[test]
fn check_pin_safety_reports_no_gap_when_the_stamped_finding_already_has_the_field() {
let dir = tempfile::tempdir().unwrap();
let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
let reports_dir = dir.path().join("reports");
write_stamped_finding(
&reports_dir,
"edu",
"0.1.0",
r#"{"name":"Algebra I","isbn":"978-0-13-000000-0"}"#,
);
let reports = super::check_pin_safety(
dir.path(),
&source,
&reports_dir,
&["edu".to_string()],
&["edu-fixture".to_string()],
)
.unwrap();
assert_eq!(reports.len(), 1);
assert_eq!(reports[0].newly_required.len(), 1);
assert!(
reports[0].gaps.is_empty(),
"a finding that already carries the newly required field must not be flagged"
);
}
#[test]
fn check_pin_safety_reports_no_extra_warning_when_nothing_newly_required() {
let dir = tempfile::tempdir().unwrap();
let old_yaml = "ontology:\n id: edu-fixture\n version: \"0.1.0\"\n extends: \
[mif-base]\nentity_types:\n - name: title\n schema:\n \
required: [name]\n";
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"),
old_yaml,
)
.unwrap();
let registry = dir.path().join("registry");
fs::create_dir_all(®istry).unwrap();
let new_yaml = "ontology:\n id: edu-fixture\n version: \"0.2.0\"\n extends: \
[mif-base]\nentity_types:\n - name: title\n schema:\n \
required: [name]\n";
let sha = sha256_of(new_yaml.as_bytes());
let index = format!(
r#"{{"ontologies":{{"edu-fixture":{{"version":"0.2.0","sha256":"{sha}","file":"edu-fixture.ontology.yaml","extends":["mif-base"]}}}}}}"#
);
fs::write(registry.join("index.json"), &index).unwrap();
fs::write(registry.join("edu-fixture.ontology.yaml"), new_yaml).unwrap();
let source = registry.display().to_string();
let lock = serde_json::json!({
"schema": "mif-ontology-lock/v1",
"source": source,
"index_sha256": sha256_of(index.as_bytes()),
"ontologies": {"edu-fixture": {"version": "0.1.0", "sha256": "irrelevant"}}
});
fs::write(
dir.path().join("ontologies.lock.json"),
serde_json::to_string(&lock).unwrap(),
)
.unwrap();
let reports = super::check_pin_safety(
dir.path(),
&source,
&dir.path().join("reports"),
&[],
&["edu-fixture".to_string()],
)
.unwrap();
assert_eq!(reports.len(), 1);
assert!(reports[0].newly_required.is_empty());
assert!(reports[0].gaps.is_empty());
}
#[test]
fn check_pin_safety_reports_empty_analysis_when_the_vendored_pack_is_missing_from_disk() {
let dir = tempfile::tempdir().unwrap();
let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
fs::remove_file(
dir.path()
.join("packs/ontologies/edu-fixture/edu-fixture.ontology.yaml"),
)
.unwrap();
let reports = super::check_pin_safety(
dir.path(),
&source,
&dir.path().join("reports"),
&[],
&["edu-fixture".to_string()],
)
.unwrap();
assert_eq!(reports.len(), 1);
assert_eq!(reports[0].locked_version, "0.1.0");
assert_eq!(reports[0].registry_version, "0.2.0");
assert!(!reports[0].analyzed);
assert!(reports[0].newly_required.is_empty());
assert!(reports[0].gaps.is_empty());
}
#[test]
fn check_pin_safety_reports_an_unrelated_ontology_id_error_for_an_unregistered_id() {
let dir = tempfile::tempdir().unwrap();
let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
let mut lock: serde_json::Value = serde_json::from_str(
&fs::read_to_string(dir.path().join("ontologies.lock.json")).unwrap(),
)
.unwrap();
lock["ontologies"]["ghost-ontology"] =
serde_json::json!({"version": "0.1.0", "sha256": "irrelevant"});
fs::write(
dir.path().join("ontologies.lock.json"),
serde_json::to_string(&lock).unwrap(),
)
.unwrap();
let error = super::check_pin_safety(
dir.path(),
&source,
&dir.path().join("reports"),
&[],
&["ghost-ontology".to_string()],
)
.unwrap_err();
assert!(matches!(
error,
super::MifRhError::OntologyNotInRegistry { .. }
));
}
#[test]
fn check_pin_safety_fails_closed_on_a_checksum_mismatch() {
let dir = tempfile::tempdir().unwrap();
let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
let index_path = dir.path().join("registry/index.json");
let mut index: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
index["ontologies"]["edu-fixture"]["sha256"] =
serde_json::json!("0000000000000000000000000000000000000000000000000000000000000000");
fs::write(&index_path, serde_json::to_string(&index).unwrap()).unwrap();
let lock_path = dir.path().join("ontologies.lock.json");
let mut lock: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&lock_path).unwrap()).unwrap();
lock["index_sha256"] =
serde_json::json!(sha256_of(fs::read(&index_path).unwrap().as_slice()));
fs::write(&lock_path, serde_json::to_string(&lock).unwrap()).unwrap();
let error = super::check_pin_safety(
dir.path(),
&source,
&dir.path().join("reports"),
&[],
&["edu-fixture".to_string()],
)
.unwrap_err();
assert!(matches!(error, super::MifRhError::ChecksumMismatch { .. }));
}
#[test]
fn check_pin_safety_fails_closed_on_a_lock_source_mismatch() {
let dir = tempfile::tempdir().unwrap();
let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
let lock_path = dir.path().join("ontologies.lock.json");
let mut lock: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&lock_path).unwrap()).unwrap();
lock["source"] = serde_json::json!("https://a-different-registry.test/ontologies");
fs::write(&lock_path, serde_json::to_string(&lock).unwrap()).unwrap();
let error = super::check_pin_safety(
dir.path(),
&source,
&dir.path().join("reports"),
&[],
&["edu-fixture".to_string()],
)
.unwrap_err();
assert!(matches!(
error,
super::MifRhError::LockSourceMismatch { .. }
));
}
#[test]
fn check_pin_safety_fails_closed_on_a_lock_with_pins_but_no_recorded_source() {
let dir = tempfile::tempdir().unwrap();
let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
let lock_path = dir.path().join("ontologies.lock.json");
let mut lock: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&lock_path).unwrap()).unwrap();
lock["source"] = serde_json::json!("");
fs::write(&lock_path, serde_json::to_string(&lock).unwrap()).unwrap();
let error = super::check_pin_safety(
dir.path(),
&source,
&dir.path().join("reports"),
&[],
&["edu-fixture".to_string()],
)
.unwrap_err();
assert!(matches!(
error,
super::MifRhError::LockSourceMismatch { .. }
));
}
#[test]
fn check_pin_safety_short_circuits_before_any_fetch_when_nothing_is_pinned() {
let dir = tempfile::tempdir().unwrap();
let reports = super::check_pin_safety(
dir.path(),
"http://127.0.0.1.invalid/unreachable",
&dir.path().join("reports"),
&[],
&["not-pinned-anywhere".to_string()],
)
.unwrap();
assert!(reports.is_empty());
}
#[test]
fn check_pin_safety_fails_closed_on_non_utf8_registry_bytes() {
let dir = tempfile::tempdir().unwrap();
let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
let registry_file = dir.path().join("registry/edu-fixture.ontology.yaml");
let invalid_utf8: &[u8] = &[0xff, 0xfe, 0xfd];
fs::write(®istry_file, invalid_utf8).unwrap();
let index_path = dir.path().join("registry/index.json");
let mut index: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
index["ontologies"]["edu-fixture"]["sha256"] = serde_json::json!(sha256_of(invalid_utf8));
fs::write(&index_path, serde_json::to_string(&index).unwrap()).unwrap();
let lock_path = dir.path().join("ontologies.lock.json");
let mut lock: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&lock_path).unwrap()).unwrap();
lock["index_sha256"] =
serde_json::json!(sha256_of(fs::read(&index_path).unwrap().as_slice()));
fs::write(&lock_path, serde_json::to_string(&lock).unwrap()).unwrap();
let error = super::check_pin_safety(
dir.path(),
&source,
&dir.path().join("reports"),
&[],
&["edu-fixture".to_string()],
)
.unwrap_err();
assert!(matches!(
error,
super::MifRhError::OntologyPackNotUtf8 { .. }
));
}
#[test]
fn check_pin_safety_rejects_an_unsafe_index_path() {
let dir = tempfile::tempdir().unwrap();
let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
let index_path = dir.path().join("registry/index.json");
let mut index: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
index["ontologies"]["edu-fixture"]["file"] = serde_json::json!("../../../../etc/passwd");
fs::write(&index_path, serde_json::to_string(&index).unwrap()).unwrap();
let lock_path = dir.path().join("ontologies.lock.json");
let mut lock: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&lock_path).unwrap()).unwrap();
lock["index_sha256"] =
serde_json::json!(sha256_of(fs::read(&index_path).unwrap().as_slice()));
fs::write(&lock_path, serde_json::to_string(&lock).unwrap()).unwrap();
let error = super::check_pin_safety(
dir.path(),
&source,
&dir.path().join("reports"),
&[],
&["edu-fixture".to_string()],
)
.unwrap_err();
assert!(matches!(error, super::MifRhError::UnsafeIndexPath { .. }));
}
#[test]
fn check_pin_safety_refuses_a_registry_index_that_no_longer_matches_the_pinned_trust_root() {
let dir = tempfile::tempdir().unwrap();
let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
let lock_path = dir.path().join("ontologies.lock.json");
let mut lock: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&lock_path).unwrap()).unwrap();
lock["index_sha256"] = serde_json::json!("not-the-real-index-sha256");
fs::write(&lock_path, serde_json::to_string(&lock).unwrap()).unwrap();
let error = super::check_pin_safety(
dir.path(),
&source,
&dir.path().join("reports"),
&[],
&["edu-fixture".to_string()],
)
.unwrap_err();
assert!(matches!(error, super::MifRhError::IndexPinMismatch { .. }));
}
#[test]
fn check_pin_safety_rejects_a_malformed_id_before_touching_the_filesystem() {
let dir = tempfile::tempdir().unwrap();
let source = seed_pin_safety_fixture(dir.path(), "0.1.0", "0.2.0");
let error = super::check_pin_safety(
dir.path(),
&source,
&dir.path().join("reports"),
&[],
&["../../../../etc/passwd".to_string()],
)
.unwrap_err();
assert!(matches!(
error,
super::MifRhError::MalformedOntologyId { .. }
));
}
#[test]
fn check_pin_safety_fails_closed_on_a_malformed_schema_required_field() {
let dir = tempfile::tempdir().unwrap();
let old_yaml = "ontology:\n id: edu-fixture\n version: \"0.1.0\"\n extends: \
[mif-base]\nentity_types:\n - name: title\n schema:\n \
required: [name]\n properties:\n name: {type: string}\n";
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"),
old_yaml,
)
.unwrap();
let registry = dir.path().join("registry");
fs::create_dir_all(®istry).unwrap();
let new_yaml = "ontology:\n id: edu-fixture\n version: \"0.2.0\"\n extends: \
[mif-base]\nentity_types:\n - name: title\n schema:\n \
required: \"name\"\n properties:\n name: {type: string}\n";
let sha = sha256_of(new_yaml.as_bytes());
let index = format!(
r#"{{"ontologies":{{"edu-fixture":{{"version":"0.2.0","sha256":"{sha}","file":"edu-fixture.ontology.yaml","extends":["mif-base"]}}}}}}"#
);
fs::write(registry.join("index.json"), &index).unwrap();
fs::write(registry.join("edu-fixture.ontology.yaml"), new_yaml).unwrap();
let source = registry.display().to_string();
let lock = serde_json::json!({
"schema": "mif-ontology-lock/v1",
"source": source,
"index_sha256": sha256_of(index.as_bytes()),
"ontologies": {
"edu-fixture": {"version": "0.1.0", "sha256": "irrelevant-for-this-test"}
}
});
fs::write(
dir.path().join("ontologies.lock.json"),
serde_json::to_string(&lock).unwrap(),
)
.unwrap();
let error = super::check_pin_safety(
dir.path(),
&source,
&dir.path().join("reports"),
&[],
&["edu-fixture".to_string()],
)
.unwrap_err();
assert!(matches!(
error,
super::MifRhError::EntityTypeSchemaInvalid { .. }
));
}
}