Skip to content

Commit 056705f

Browse files
claudejjohare
andcommitted
fix(validator): expand_iri passes urn:/did:/http absolute IRIs through (not CURIEs)
Discovered via #6 live verify: every validation over real data failed with "Invalid IRI: Unknown prefix: urn". expand_iri only treated strings with '://' as absolute, so urn:ngm:class:*, urn:agentbox:decision:*, did:nostr:* fell into the CURIE branch and errored on the unregistered 'urn'/'did' prefix. Rule (in order): '://' → absolute; registered short prefix (rdf/rdfs/owl/xsd/ foaf) → CURIE-expand; else known non-hierarchical scheme (urn/did/http/https/ ftp/mailto/tag/file/data) OR valid RFC-3986 scheme with multi-segment remainder → absolute passthrough; no ':' → bare local; unregistered single-segment prefix → existing Unknown-prefix error. owl_validator 11/11 (4 new tests: urn/did/http passthrough, registered CURIE still expands, bogus:thing still errors). Activates on next dev rebuild; unblocks Whelk validation succeeding on real urn:-scheme corpus/decision data. Co-Authored-By: jjohare <github@thedreamlab.uk>
1 parent 55611b9 commit 056705f

1 file changed

Lines changed: 134 additions & 16 deletions

File tree

crates/visionclaw-ontology/src/services/owl_validator.rs

Lines changed: 134 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -644,24 +644,84 @@ impl OwlValidatorService {
644644
format!("ontology_{}", self.calculate_signature(source))
645645
}
646646

647+
/// Expand a graph identifier into an absolute IRI.
648+
///
649+
/// Decision rule (in order):
650+
/// 1. Any string containing `://` is an absolute IRI (hierarchical scheme) → pass through.
651+
/// 2. If the substring before the first `:` is a *registered* short CURIE prefix
652+
/// (rdf, rdfs, owl, xsd, foaf, …) → expand `prefix:local` to `namespace + local`.
653+
/// 3. Else if the string looks like an absolute IRI — a well-known non-hierarchical
654+
/// scheme (urn, did, http, https, ftp, ftps, mailto, tag, file, data) OR a generic
655+
/// RFC 3986 scheme followed by a multi-segment remainder (e.g. `scheme:a:b`) →
656+
/// pass through unchanged.
657+
/// 4. If there is no `:` at all, treat it as a bare local name under the default namespace.
658+
/// 5. Otherwise the prefix is neither a registered CURIE nor a recognised absolute scheme
659+
/// → `Unknown prefix` error.
647660
fn expand_iri(&self, iri: &str) -> Result<String> {
661+
// (1) Hierarchical absolute IRI (scheme://authority/...) — always absolute.
648662
if iri.contains("://") {
649-
650-
Ok(iri.to_string())
651-
} else if let Some(colon_pos) = iri.find(':') {
652-
653-
let (prefix, local) = iri.split_at(colon_pos);
654-
let local = &local[1..];
663+
return Ok(iri.to_string());
664+
}
655665

656-
if let Some(namespace) = self.default_namespaces.get(prefix) {
657-
Ok(format!("{}{}", namespace, local))
658-
} else {
666+
match iri.find(':') {
667+
Some(colon_pos) => {
668+
let (prefix, rest) = iri.split_at(colon_pos);
669+
let local = &rest[1..];
670+
671+
// (2) Registered short CURIE prefix → expand.
672+
if let Some(namespace) = self.default_namespaces.get(prefix) {
673+
return Ok(format!("{}{}", namespace, local));
674+
}
675+
676+
// (3) Recognised absolute-IRI scheme (urn:, did:, http:, …) → pass through.
677+
if Self::is_absolute_iri_scheme(prefix, local) {
678+
return Ok(iri.to_string());
679+
}
680+
681+
// (5) Unknown prefix that is neither a CURIE nor an absolute IRI.
659682
Err(ValidationError::InvalidIri(format!("Unknown prefix: {}", prefix)).into())
660683
}
661-
} else {
662-
663-
Ok(format!("http://example.org/{}", iri))
684+
// (4) Bare local name → default namespace.
685+
None => Ok(format!("http://example.org/{}", iri)),
686+
}
687+
}
688+
689+
/// Decide whether a `prefix:local` pair (already known **not** to use a registered
690+
/// CURIE prefix) should be treated as an absolute IRI rather than an error.
691+
///
692+
/// Returns `true` when either:
693+
/// * `prefix` is a well-known non-hierarchical absolute-IRI scheme
694+
/// (urn, did, http, https, ftp, ftps, mailto, tag, file, data), or
695+
/// * `prefix` is a syntactically valid RFC 3986 scheme AND the remainder is
696+
/// itself multi-segment (contains a further `:`), which is the shape of
697+
/// `urn`-style absolute IRIs such as `scheme:a:b`.
698+
///
699+
/// A bare `unregistered:thing` (single-segment remainder, unknown scheme) returns
700+
/// `false` so the caller can raise the existing `Unknown prefix` error.
701+
fn is_absolute_iri_scheme(prefix: &str, local: &str) -> bool {
702+
const KNOWN_ABSOLUTE_SCHEMES: &[&str] = &[
703+
"urn", "did", "http", "https", "ftp", "ftps", "mailto", "tag", "file", "data",
704+
];
705+
706+
let scheme = prefix.to_ascii_lowercase();
707+
if KNOWN_ABSOLUTE_SCHEMES.contains(&scheme.as_str()) {
708+
return true;
664709
}
710+
711+
// Generic RFC 3986 scheme grammar: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ).
712+
let valid_scheme = {
713+
let mut chars = prefix.chars();
714+
match chars.next() {
715+
Some(c) if c.is_ascii_alphabetic() => {
716+
chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
717+
}
718+
_ => false,
719+
}
720+
};
721+
722+
// Multi-segment remainder distinguishes an absolute IRI (`scheme:a:b`) from a
723+
// CURIE-shaped `prefix:local`.
724+
valid_scheme && local.contains(':')
665725
}
666726

667727
fn serialize_property_value(
@@ -1146,14 +1206,72 @@ mod tests {
11461206
fn test_iri_expansion() {
11471207
let validator = OwlValidatorService::new();
11481208

1149-
1209+
// Registered CURIE prefixes still expand to their full namespace IRI.
11501210
let expanded = validator.expand_iri("foaf:Person").unwrap();
11511211
assert_eq!(expanded, "http://xmlns.com/foaf/0.1/Person");
11521212

1153-
1213+
let expanded = validator.expand_iri("owl:Class").unwrap();
1214+
assert_eq!(expanded, "http://www.w3.org/2002/07/owl#Class");
1215+
1216+
let expanded = validator.expand_iri("rdfs:subClassOf").unwrap();
1217+
assert_eq!(expanded, "http://www.w3.org/2000/01/rdf-schema#subClassOf");
1218+
1219+
// Hierarchical absolute IRIs pass through unchanged.
11541220
let full_iri = "http://example.org/Person";
1155-
let expanded = validator.expand_iri(full_iri).unwrap();
1156-
assert_eq!(expanded, full_iri);
1221+
assert_eq!(validator.expand_iri(full_iri).unwrap(), full_iri);
1222+
}
1223+
1224+
#[test]
1225+
fn test_urn_iris_pass_through_as_absolute() {
1226+
// Regression: real KG/corpus data is full of urn:ngm:* and urn:agentbox:* IRIs.
1227+
// These are absolute IRIs, NOT CURIEs, and must never trigger "Unknown prefix".
1228+
let validator = OwlValidatorService::new();
1229+
1230+
for iri in [
1231+
"urn:ngm:class:foo",
1232+
"urn:ngm:class:x",
1233+
"urn:agentbox:decision:y",
1234+
"urn:agentbox:decision:2026-08-08:abc",
1235+
] {
1236+
let expanded = validator.expand_iri(iri).unwrap();
1237+
assert_eq!(expanded, iri, "urn IRI must pass through unchanged: {iri}");
1238+
}
1239+
}
1240+
1241+
#[test]
1242+
fn test_did_and_http_iris_pass_through_as_absolute() {
1243+
let validator = OwlValidatorService::new();
1244+
1245+
for iri in [
1246+
"did:nostr:abc",
1247+
"http://example.org/z",
1248+
"https://example.org/z",
1249+
] {
1250+
let expanded = validator.expand_iri(iri).unwrap();
1251+
assert_eq!(expanded, iri, "absolute IRI must pass through unchanged: {iri}");
1252+
}
1253+
}
1254+
1255+
#[test]
1256+
fn test_unregistered_curie_prefix_still_errors() {
1257+
// A single-segment, unknown-scheme value like `bogus:thing` is neither a
1258+
// registered CURIE prefix nor an absolute-IRI shape, so it still errors.
1259+
let validator = OwlValidatorService::new();
1260+
1261+
assert!(
1262+
validator.expand_iri("bogus:thing").is_err(),
1263+
"unregistered bare prefix must still error"
1264+
);
1265+
}
1266+
1267+
#[test]
1268+
fn test_generic_multi_segment_scheme_is_absolute() {
1269+
// An unknown but syntactically valid scheme with a multi-segment remainder
1270+
// (urn-style `scheme:a:b`) is treated as an absolute IRI, not a CURIE.
1271+
let validator = OwlValidatorService::new();
1272+
1273+
let iri = "myscheme:a:b";
1274+
assert_eq!(validator.expand_iri(iri).unwrap(), iri);
11571275
}
11581276

11591277
#[test]

0 commit comments

Comments
 (0)