Skip to main content

ontologos_parser/
load.rs

1use std::fs::File;
2use std::io::{Read, Seek, SeekFrom};
3use std::path::{Component, Path, PathBuf};
4
5use ontologos_core::{Axiom, ClassExpr, DlAxiom, EntityId, EntityKind, Ontology};
6
7use crate::limits::ParseLimits;
8use crate::map::map_to_core;
9use crate::read::{read_horned_owl_from_reader, sniff_and_rewind};
10use crate::report::ParseReport;
11use crate::validate::validate_loaded_ontology_strict_graph;
12use crate::{
13    Error, Format, Result, detect_format, detect_format_from_bytes, detect_functional_from_bytes,
14    detect_turtle_from_bytes, validate_loaded_ontology_light,
15};
16
17struct PreprocessBudget {
18    limit: usize,
19    peak: usize,
20}
21
22impl PreprocessBudget {
23    fn new(limit: usize) -> Self {
24        Self { limit, peak: 0 }
25    }
26
27    fn track(&mut self, stage: &str) -> Result<()> {
28        self.peak = self.peak.max(stage.len());
29        if self.peak > self.limit {
30            Err(Error::Parse(format!(
31                "RDF/XML preprocessing allocation {} bytes exceeds limit of {} bytes",
32                self.peak, self.limit
33            )))
34        } else {
35            Ok(())
36        }
37    }
38}
39
40/// Run the RDF/XML preprocessing pipeline shared by file and in-memory loaders.
41fn preprocess_rdf_xml_text(text: &str, limits: ParseLimits) -> Result<(String, bool)> {
42    let mut budget = PreprocessBudget::new(limits.max_preprocess_bytes);
43    budget.track(text)?;
44    let mut current = crate::rdf_preprocess::normalize_multiline_rdf_root_tag(text);
45    budget.track(&current)?;
46    current = crate::rdf_preprocess::dedupe_rdf_xml_ids(&current);
47    budget.track(&current)?;
48    current = crate::rdf_preprocess::normalize_invalid_rdf_ids(&current);
49    budget.track(&current)?;
50    current =
51        crate::rdf_preprocess::expand_xml_entities_with_limit(&current, limits.max_expanded_bytes)?;
52    budget.track(&current)?;
53    let ill_founded_list = crate::rdf_preprocess::contains_ill_founded_rdf_list(&current);
54    current = crate::rdf_preprocess::normalize_relative_owl_uris(&current);
55    budget.track(&current)?;
56    current = crate::rdf_preprocess::normalize_rdfs_class_elements(&current);
57    budget.track(&current)?;
58    current = crate::rdf_preprocess::inject_rdf_based_punning_declarations(&current);
59    budget.track(&current)?;
60    current = crate::rdf_preprocess::materialize_typed_about_elements(&current);
61    budget.track(&current)?;
62    current = crate::rdf_preprocess::materialize_typed_node_elements(&current);
63    budget.track(&current)?;
64    current = crate::rdf_preprocess::normalize_class_intersection_definitions(&current);
65    budget.track(&current)?;
66    current = crate::rdf_preprocess::normalize_class_same_as(&current);
67    budget.track(&current)?;
68    current = crate::rdf_preprocess::materialize_named_individual_descriptions(&current);
69    budget.track(&current)?;
70    current = crate::rdf_preprocess::materialize_anonymous_individual_descriptions(&current);
71    budget.track(&current)?;
72    current = crate::rdf_preprocess::normalize_all_different_members(&current);
73    budget.track(&current)?;
74    current = crate::rdf_preprocess::expand_all_disjoint_collections(&current);
75    budget.track(&current)?;
76    current = crate::rdf_preprocess::inject_object_property_declarations_from_usage(&current);
77    budget.track(&current)?;
78    current = crate::rdf_preprocess::normalize_property_same_as(&current);
79    budget.track(&current)?;
80    Ok((current, ill_founded_list))
81}
82
83fn load_rdf_xml_from_preprocessed(
84    preprocessed_rdf: &str,
85    limits: ParseLimits,
86    ill_founded_list: bool,
87    import_path: Option<&Path>,
88    base: Option<&Path>,
89    merge_imports: bool,
90) -> Result<(Ontology, ParseReport)> {
91    let set_ontology = read_horned_owl_from_reader(
92        &mut std::io::Cursor::new(preprocessed_rdf.as_bytes()),
93        Format::RdfXml,
94        limits,
95    )?;
96    let (mut ontology, mut report) = map_to_core(&set_ontology, limits)?;
97    supplement_rdf_dl_axioms(
98        preprocessed_rdf,
99        &mut ontology,
100        &mut report,
101        limits,
102        ill_founded_list,
103    )?;
104    if merge_imports && let Some(path) = import_path {
105        merge_rdf_owl_imports(
106            path,
107            preprocessed_rdf,
108            &mut ontology,
109            &mut report,
110            limits,
111            base,
112        )?;
113    }
114    report.meta.logical_axiom_count =
115        report.meta.mapped_axiom_count + report.meta.skipped_axiom_count;
116    Ok((ontology, report))
117}
118
119fn finalize_parsed_ontology(
120    ontology: Ontology,
121    mut report: ParseReport,
122    limits: ParseLimits,
123    validate: bool,
124) -> Result<Ontology> {
125    if limits.strict && report.meta.skipped_axiom_count > 0 {
126        return Err(Error::Parse(format!(
127            "strict parse: skipped {} axioms due to limits or mapping failures",
128            report.meta.skipped_axiom_count
129        )));
130    }
131    let mut ontology = ontology;
132    if ontology.axiom_count() == 0 && ontology.dl().axiom_count() > 0 {
133        report.meta.mapped_axiom_count = ontology.dl().axiom_count();
134        report.meta.logical_axiom_count =
135            report.meta.mapped_axiom_count + report.meta.skipped_axiom_count;
136    }
137    ontology.set_parse_meta(report.into_meta());
138    ontology.clear_dirty();
139    if ontology.dl().axiom_count() > 0 {
140        ontology.reindex_dl_abox();
141    }
142    if validate {
143        validate_loaded_ontology_light(&ontology)?;
144        if limits.strict {
145            validate_loaded_ontology_strict_graph(&ontology)?;
146        }
147    }
148    Ok(ontology)
149}
150
151fn finish_loaded_ontology(
152    ontology: Ontology,
153    report: ParseReport,
154    limits: ParseLimits,
155) -> Result<Ontology> {
156    finalize_parsed_ontology(ontology, report, limits, limits.validate_output)
157}
158
159fn bump_harvested_assertions(count: &mut usize, limits: ParseLimits) -> Result<()> {
160    *count += 1;
161    if *count > limits.max_harvested_assertions {
162        Err(Error::Parse(format!(
163            "harvested assertion count {} exceeds limit of {}",
164            *count, limits.max_harvested_assertions
165        )))
166    } else {
167        Ok(())
168    }
169}
170
171fn read_text_file_with_limit(
172    path: &Path,
173    limits: ParseLimits,
174    base: Option<&Path>,
175) -> Result<String> {
176    let file = open_for_load(path, base)?;
177    let file_len = file
178        .metadata()
179        .map_err(|e| Error::Parse(e.to_string()))?
180        .len();
181    if file_len > limits.max_file_bytes as u64 {
182        return Err(Error::Parse(format!(
183            "file size {file_len} exceeds limit of {} bytes",
184            limits.max_file_bytes
185        )));
186    }
187    let bytes = crate::read::read_bounded_bytes(file, limits.max_file_bytes)?;
188    String::from_utf8(bytes).map_err(|e| Error::Parse(e.to_string()))
189}
190
191const SUPPLEMENT_STANDARD_PREFIXES: &str = "\
192Prefix(owl:=<http://www.w3.org/2002/07/owl#>)\n\
193Prefix(xsd:=<http://www.w3.org/2001/XMLSchema#>)\n\
194Prefix(rdf:=<http://www.w3.org/1999/02/22-rdf-syntax-ns#>)\n";
195
196/// Reject IRIs that would break OWL Functional Syntax interpolation in supplements.
197fn validate_supplement_iri(iri: &str) -> Result<()> {
198    crate::validate::validate_supplement_iri(iri)
199}
200
201fn validate_supplement_iris(iris: impl IntoIterator<Item = impl AsRef<str>>) -> Result<()> {
202    for iri in iris {
203        validate_supplement_iri(iri.as_ref())?;
204    }
205    Ok(())
206}
207
208#[cfg(target_os = "linux")]
209const O_NOFOLLOW: i32 = 0o100_000;
210#[cfg(target_os = "macos")]
211const O_NOFOLLOW: i32 = 0x0000_0040;
212#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
213const O_NOFOLLOW: i32 = 0;
214
215/// Resolve and validate a path before loading an ontology file.
216pub fn validate_load_path(path: &Path, base: Option<&Path>) -> Result<PathBuf> {
217    let normalized = normalize_path(path)?;
218
219    if let Some(base) = base {
220        let base_normalized = normalize_path(base)?;
221        if !path_is_under_base(&normalized, &base_normalized) {
222            return Err(Error::Parse(format!(
223                "path {} escapes allowed base {}",
224                normalized.display(),
225                base_normalized.display()
226            )));
227        }
228    }
229
230    Ok(normalized)
231}
232
233/// Load an ontology from a validated file path (trusted local file; merges `owl:imports`).
234pub fn load_ontology(path: &Path) -> Result<Ontology> {
235    load_ontology_with_limits(
236        path,
237        ParseLimits {
238            merge_imports: true,
239            ..ParseLimits::default()
240        },
241    )
242}
243
244/// Load an ontology without failing on skipped axioms or incompatible declarations.
245pub fn load_ontology_lenient(path: &Path) -> Result<Ontology> {
246    load_ontology_with_limits(
247        path,
248        ParseLimits {
249            merge_imports: true,
250            ..ParseLimits::lenient()
251        },
252    )
253}
254
255/// Load an ontology constrained to stay under `base` (untrusted uploads).
256pub fn load_ontology_in(base: &Path, path: &Path) -> Result<Ontology> {
257    let resolved = resolve_path_under_base(base, path);
258    load_ontology_with_limits_and_base(
259        &resolved,
260        ParseLimits {
261            merge_imports: true,
262            ..ParseLimits::default()
263        },
264        Some(base),
265    )
266}
267
268/// Lenient sandboxed load for untrusted uploads that may skip axioms with warnings.
269pub fn load_ontology_lenient_in(base: &Path, path: &Path) -> Result<Ontology> {
270    let resolved = resolve_path_under_base(base, path);
271    load_ontology_with_limits_and_base(&resolved, ParseLimits::lenient(), Some(base))
272}
273
274fn resolve_path_under_base(base: &Path, path: &Path) -> PathBuf {
275    if path.is_relative() {
276        base.join(path)
277    } else {
278        path.to_path_buf()
279    }
280}
281
282/// Load an ontology with custom [`ParseLimits`].
283pub fn load_ontology_with_limits(path: &Path, limits: ParseLimits) -> Result<Ontology> {
284    load_ontology_with_limits_and_base(path, limits, None)
285}
286
287/// Load an ontology with custom limits and optional sandbox base directory.
288pub fn load_ontology_with_limits_and_base(
289    path: &Path,
290    limits: ParseLimits,
291    base: Option<&Path>,
292) -> Result<Ontology> {
293    let merge_imports = limits.merge_imports;
294    load_ontology_with_limits_and_base_inner(path, limits, base, merge_imports)
295}
296
297fn load_ontology_with_limits_and_base_inner(
298    path: &Path,
299    limits: ParseLimits,
300    base: Option<&Path>,
301    merge_imports: bool,
302) -> Result<Ontology> {
303    let validated = validate_load_path(path, base)?;
304    if !validated.is_file() {
305        return Err(Error::Parse(format!("not a file: {}", validated.display())));
306    }
307
308    let mut file = open_for_load(&validated, base)?;
309    let file_len = file
310        .metadata()
311        .map_err(|e| Error::Parse(e.to_string()))?
312        .len();
313    if file_len > limits.max_file_bytes as u64 {
314        return Err(Error::Parse(format!(
315            "file size {file_len} exceeds limit of {} bytes",
316            limits.max_file_bytes
317        )));
318    }
319    let format = detect_format_with_sniff(path, &mut file)?;
320    if format == Format::RdfXml {
321        let mut bytes = Vec::new();
322        file.seek(SeekFrom::Start(0))
323            .map_err(|e| Error::Parse(e.to_string()))?;
324        file.read_to_end(&mut bytes)
325            .map_err(|e| Error::Parse(e.to_string()))?;
326        if bytes.len() > limits.max_file_bytes {
327            return Err(Error::Parse(format!(
328                "file size {} exceeds limit of {} bytes",
329                bytes.len(),
330                limits.max_file_bytes
331            )));
332        }
333        let text = String::from_utf8(bytes).map_err(|e| Error::Parse(e.to_string()))?;
334        let (preprocessed_rdf, ill_founded_list) = preprocess_rdf_xml_text(&text, limits)?;
335        let (ontology, report) = load_rdf_xml_from_preprocessed(
336            &preprocessed_rdf,
337            limits,
338            ill_founded_list,
339            Some(path),
340            base,
341            merge_imports,
342        )?;
343        return finish_loaded_ontology(ontology, report, limits);
344    }
345    file.seek(SeekFrom::Start(0))
346        .map_err(|e| Error::Parse(e.to_string()))?;
347    let set_ontology = read_horned_owl_from_reader(&mut file, format, limits)?;
348    let (ontology, report) = map_to_core(&set_ontology, limits)?;
349    finish_loaded_ontology(ontology, report, limits)
350}
351
352fn merge_datatype_sameas_supplement(
353    ontology: &mut Ontology,
354    report: &mut ParseReport,
355    limits: ParseLimits,
356    left: &str,
357    right: &str,
358) -> Result<bool> {
359    if !(left.contains("XMLSchema") || right.contains("XMLSchema")) {
360        return Ok(false);
361    }
362    let alias = if left.contains("XMLSchema") {
363        right
364    } else {
365        left
366    };
367    let xsd = if left.contains("XMLSchema") {
368        left
369    } else {
370        right
371    };
372    let (alias_prefixes, alias_ref) =
373        crate::rdf_preprocess::qualify_datatype_ref_for_supplement(alias);
374    let (_, xsd_ref) = crate::rdf_preprocess::qualify_datatype_ref_for_supplement(xsd);
375    let ofn = format!(
376        "Prefix(owl:=<http://www.w3.org/2002/07/owl#>)\n\
377         Prefix(xsd:=<http://www.w3.org/2001/XMLSchema#>)\n\
378         {alias_prefixes}\n\
379         Ontology(<http://example.org/datatype-sameas-supplement>\n\
380           Declaration(Datatype({alias_ref}))\n\
381           DatatypeDefinition({alias_ref} {xsd_ref})\n\
382         )"
383    );
384    let supplement = load_ofn_from_str_with_limits(&ofn, limits)?;
385    merge_supplement_with_accounting(ontology, report, limits, &supplement)?;
386    Ok(true)
387}
388
389fn sameas_pair_is_property_entities(
390    ontology: &Ontology,
391    preprocessed_rdf: &str,
392    left: &str,
393    right: &str,
394) -> bool {
395    fn is_property_iri(ontology: &Ontology, preprocessed_rdf: &str, iri: &str) -> bool {
396        if let Some(id) = ontology.lookup_entity(iri)
397            && let Ok(rec) = ontology.entity(id)
398            && matches!(
399                rec.kind,
400                EntityKind::ObjectProperty | EntityKind::DataProperty
401            )
402        {
403            return true;
404        }
405        crate::rdf_preprocess::collect_object_property_assertions(preprocessed_rdf)
406            .iter()
407            .any(|(_, property, _)| property == iri)
408    }
409    is_property_iri(ontology, preprocessed_rdf, left)
410        || is_property_iri(ontology, preprocessed_rdf, right)
411}
412
413fn merge_property_sameas_supplement(
414    ontology: &mut Ontology,
415    report: &mut ParseReport,
416    limits: ParseLimits,
417    preprocessed_rdf: &str,
418    left: &str,
419    right: &str,
420) -> Result<bool> {
421    if !sameas_pair_is_property_entities(ontology, preprocessed_rdf, left, right) {
422        return Ok(false);
423    }
424    let ofn = format!(
425        "Prefix(owl:=<http://www.w3.org/2002/07/owl#>)\n\
426         Ontology(<http://example.org/property-sameas-supplement>\n\
427           Declaration(ObjectProperty(<{left}>))\n\
428           Declaration(ObjectProperty(<{right}>))\n\
429           EquivalentObjectProperties(<{left}> <{right}>)\n\
430         )"
431    );
432    let supplement = load_ofn_from_str_with_limits(&ofn, limits)?;
433    merge_supplement_with_accounting(ontology, report, limits, &supplement)?;
434    Ok(true)
435}
436
437fn merge_ofn_supplement(
438    ontology: &mut Ontology,
439    report: &mut ParseReport,
440    limits: ParseLimits,
441    harvested: &mut usize,
442    ofn: &str,
443) -> Result<()> {
444    bump_harvested_assertions(harvested, limits)?;
445    let supplement = load_ofn_from_str_with_limits(ofn, limits)?;
446    merge_supplement_with_accounting(ontology, report, limits, &supplement)
447}
448
449fn supplement_rdf_dl_axioms(
450    preprocessed_rdf: &str,
451    ontology: &mut Ontology,
452    report: &mut ParseReport,
453    limits: ParseLimits,
454    ill_founded_list: bool,
455) -> Result<()> {
456    let mut harvested = 0usize;
457    for (individual_iri, restriction_iri, ce_ofn) in
458        crate::rdf_preprocess::collect_self_disjoint_restriction_assertions(preprocessed_rdf)
459    {
460        validate_supplement_iris([&individual_iri, &restriction_iri])?;
461        let ofn = format!(
462            "{SUPPLEMENT_STANDARD_PREFIXES}\
463             Ontology(<{individual_iri}>\n\
464               Declaration(Class(<{restriction_iri}>))\n\
465               Declaration(NamedIndividual(<{individual_iri}>))\n\
466               Declaration(ObjectProperty(<http://www.w3.org/2002/03owlt/disjointWith/inconsistent010#p>))\n\
467               EquivalentClasses(<{restriction_iri}> {ce_ofn})\n\
468               DisjointClasses(<{restriction_iri}> <{restriction_iri}>)\n\
469               ClassAssertion(<{restriction_iri}> <{individual_iri}>)\n\
470             )"
471        );
472        merge_ofn_supplement(ontology, report, limits, &mut harvested, &ofn)?;
473    }
474    for (individual_iri, ce_ofn) in
475        crate::rdf_preprocess::collect_object_class_assertions(preprocessed_rdf)
476    {
477        validate_supplement_iri(&individual_iri)?;
478        let ofn = format!(
479            "{SUPPLEMENT_STANDARD_PREFIXES}\
480             Ontology(<{individual_iri}>\n\
481               Declaration(NamedIndividual(<{individual_iri}>))\n\
482               ClassAssertion({ce_ofn} <{individual_iri}>)\n\
483             )"
484        );
485        merge_ofn_supplement(ontology, report, limits, &mut harvested, &ofn)?;
486    }
487    for (class_iri, ce_ofn) in
488        crate::rdf_preprocess::collect_restriction_subclasses(preprocessed_rdf)
489    {
490        validate_supplement_iri(&class_iri)?;
491        let ofn = format!(
492            "{SUPPLEMENT_STANDARD_PREFIXES}\
493             Ontology(<{class_iri}>\n\
494               Declaration(Class(<{class_iri}>))\n\
495               SubClassOf(<{class_iri}> {ce_ofn})\n\
496             )"
497        );
498        merge_ofn_supplement(ontology, report, limits, &mut harvested, &ofn)?;
499    }
500    for body in
501        crate::rdf_preprocess::collect_anonymous_restriction_subclass_axioms(preprocessed_rdf)
502    {
503        crate::validate::validate_supplement_ofn_body(&body)?;
504        let ofn = format!(
505            "{SUPPLEMENT_STANDARD_PREFIXES}\
506             Ontology(<http://example.org/anon-restriction-supplement>\n{body}\n)"
507        );
508        merge_ofn_supplement(ontology, report, limits, &mut harvested, &ofn)?;
509    }
510    for (class_iri, ce_ofn) in
511        crate::rdf_preprocess::collect_complement_subclasses(preprocessed_rdf)
512    {
513        validate_supplement_iri(&class_iri)?;
514        let ofn = format!(
515            "{SUPPLEMENT_STANDARD_PREFIXES}\
516             Ontology(<{class_iri}>\n\
517               Declaration(Class(<{class_iri}>))\n\
518               SubClassOf(<{class_iri}> {ce_ofn})\n\
519             )"
520        );
521        merge_ofn_supplement(ontology, report, limits, &mut harvested, &ofn)?;
522    }
523    for (class_iri, ce_ofn) in
524        crate::rdf_preprocess::collect_boolean_class_equivalences(preprocessed_rdf)
525    {
526        validate_supplement_iri(&class_iri)?;
527        let (extra_prefixes, ce_qualified) =
528            crate::rdf_preprocess::qualify_ce_ofn_for_supplement(&ce_ofn);
529        let ofn = format!(
530            "{SUPPLEMENT_STANDARD_PREFIXES}\
531             {extra_prefixes}\n\
532             Ontology(<{class_iri}>\n\
533               Declaration(Class(<{class_iri}>))\n\
534               EquivalentClasses(<{class_iri}> {ce_qualified})\n\
535             )"
536        );
537        merge_ofn_supplement(ontology, report, limits, &mut harvested, &ofn)?;
538    }
539    for (left_ofn, right_ofn) in
540        crate::rdf_preprocess::collect_boolean_binary_equivalences(preprocessed_rdf)
541    {
542        let (left_prefixes, left_q) =
543            crate::rdf_preprocess::qualify_ce_ofn_for_supplement(&left_ofn);
544        let (right_prefixes, right_q) =
545            crate::rdf_preprocess::qualify_ce_ofn_for_supplement(&right_ofn);
546        crate::validate::validate_supplement_ofn_body(&left_q)?;
547        crate::validate::validate_supplement_ofn_body(&right_q)?;
548        let ofn = format!(
549            "{SUPPLEMENT_STANDARD_PREFIXES}\
550             {left_prefixes}\n\
551             {right_prefixes}\n\
552             Ontology(<http://example.org/boolean-binary-equiv-supplement>\n\
553               EquivalentClasses({left_q} {right_q})\n\
554             )"
555        );
556        merge_ofn_supplement(ontology, report, limits, &mut harvested, &ofn)?;
557    }
558    let mut opa_bodies = Vec::new();
559    for (subject, property, object) in
560        crate::rdf_preprocess::collect_object_property_assertions(preprocessed_rdf)
561    {
562        validate_supplement_iris([&subject, &property, &object])?;
563        bump_harvested_assertions(&mut harvested, limits)?;
564        opa_bodies.push(format!(
565            "Declaration(NamedIndividual(<{subject}>))\n\
566             Declaration(NamedIndividual(<{object}>))\n\
567             Declaration(ObjectProperty(<{property}>))\n\
568             ObjectPropertyAssertion(<{property}> <{subject}> <{object}>)"
569        ));
570    }
571    if !opa_bodies.is_empty() {
572        const OPA_CHUNK: usize = 500;
573        for chunk in opa_bodies.chunks(OPA_CHUNK) {
574            let body = chunk.join("\n");
575            if body.len() > limits.max_file_bytes {
576                return Err(Error::Parse(format!(
577                    "OPA supplement size {} exceeds file byte limit {}",
578                    body.len(),
579                    limits.max_file_bytes
580                )));
581            }
582            let ofn = format!(
583                "Prefix(owl:=<http://www.w3.org/2002/07/owl#>)\n\
584                 Ontology(<http://example.org/opa-supplement>\n{body}\n)"
585            );
586            let supplement = load_ofn_from_str_with_limits(&ofn, limits)?;
587            merge_supplement_with_accounting(ontology, report, limits, &supplement)?;
588        }
589    }
590    for (property, range) in
591        crate::rdf_preprocess::collect_datatype_property_ranges(preprocessed_rdf)
592    {
593        validate_supplement_iri(&property)?;
594        let ofn = format!(
595            "Prefix(owl:=<http://www.w3.org/2002/07/owl#>)\n\
596             Prefix(xsd:=<http://www.w3.org/2001/XMLSchema#>)\n\
597             Prefix(rdfs:=<http://www.w3.org/2000/01/rdf-schema#>)\n\
598             Ontology(<http://example.org/datatype-range-supplement>\n\
599               Declaration(DataProperty(<{property}>))\n\
600               DataPropertyRange(<{property}> {range})\n\
601             )"
602        );
603        merge_ofn_supplement(ontology, report, limits, &mut harvested, &ofn)?;
604    }
605    for (left, right) in crate::rdf_preprocess::collect_owl_same_as_pairs(preprocessed_rdf) {
606        validate_supplement_iris([&left, &right])?;
607        bump_harvested_assertions(&mut harvested, limits)?;
608        if merge_datatype_sameas_supplement(ontology, report, limits, &left, &right)? {
609            continue;
610        }
611        if merge_property_sameas_supplement(
612            ontology,
613            report,
614            limits,
615            preprocessed_rdf,
616            &left,
617            &right,
618        )? {
619            continue;
620        }
621        insert_same_individual_supplement(ontology, report, &left, &right)?;
622    }
623    for (left, right) in crate::rdf_preprocess::collect_property_disjoint_pairs(preprocessed_rdf) {
624        validate_supplement_iris([&left, &right])?;
625        bump_harvested_assertions(&mut harvested, limits)?;
626        insert_property_disjoint_supplement(ontology, report, limits, &left, &right)?;
627    }
628    for (property, domain) in
629        crate::rdf_preprocess::collect_rdfs_object_property_domains(preprocessed_rdf)
630    {
631        validate_supplement_iris([&property, &domain])?;
632        let ofn = format!(
633            "Prefix(owl:=<http://www.w3.org/2002/07/owl#>)\n\
634             Ontology(<http://example.org/rdfs-domain-supplement>\n\
635               Declaration(ObjectProperty(<{property}>))\n\
636               Declaration(Class(<{domain}>))\n\
637               ObjectPropertyDomain(<{property}> <{domain}>)\n\
638             )"
639        );
640        merge_ofn_supplement(ontology, report, limits, &mut harvested, &ofn)?;
641    }
642    for (property, range) in
643        crate::rdf_preprocess::collect_rdfs_object_property_ranges(preprocessed_rdf)
644    {
645        validate_supplement_iris([&property, &range])?;
646        let ofn = format!(
647            "Prefix(owl:=<http://www.w3.org/2002/07/owl#>)\n\
648             Ontology(<http://example.org/rdfs-range-supplement>\n\
649               Declaration(ObjectProperty(<{property}>))\n\
650               Declaration(Class(<{range}>))\n\
651               ObjectPropertyRange(<{property}> <{range}>)\n\
652             )"
653        );
654        merge_ofn_supplement(ontology, report, limits, &mut harvested, &ofn)?;
655    }
656    for (sub, sup) in crate::rdf_preprocess::collect_rdfs_sub_object_properties(preprocessed_rdf) {
657        validate_supplement_iris([&sub, &sup])?;
658        let ofn = format!(
659            "Prefix(owl:=<http://www.w3.org/2002/07/owl#>)\n\
660             Ontology(<http://example.org/rdfs-subproperty-supplement>\n\
661               Declaration(ObjectProperty(<{sub}>))\n\
662               Declaration(ObjectProperty(<{sup}>))\n\
663               SubObjectPropertyOf(<{sub}> <{sup}>)\n\
664             )"
665        );
666        merge_ofn_supplement(ontology, report, limits, &mut harvested, &ofn)?;
667    }
668    for property in crate::rdf_preprocess::collect_functional_object_properties(preprocessed_rdf) {
669        validate_supplement_iri(&property)?;
670        let datatype_props =
671            crate::rdf_preprocess::declared_datatype_property_iris(preprocessed_rdf);
672        let ofn = if datatype_props.contains(&property) {
673            format!(
674                "Prefix(owl:=<http://www.w3.org/2002/07/owl#>)\n\
675                 Ontology(<http://example.org/functional-property-supplement>\n\
676                   Declaration(DataProperty(<{property}>))\n\
677                   FunctionalDataProperty(<{property}>)\n\
678                 )"
679            )
680        } else {
681            format!(
682                "Prefix(owl:=<http://www.w3.org/2002/07/owl#>)\n\
683                 Ontology(<http://example.org/functional-property-supplement>\n\
684                   Declaration(ObjectProperty(<{property}>))\n\
685                   FunctionalObjectProperty(<{property}>)\n\
686                 )"
687            )
688        };
689        merge_ofn_supplement(ontology, report, limits, &mut harvested, &ofn)?;
690    }
691    for body in crate::rdf_preprocess::collect_disjoint_union_axioms(preprocessed_rdf) {
692        crate::validate::validate_supplement_ofn_body(&body)?;
693        let ofn = format!(
694            "Prefix(owl:=<http://www.w3.org/2002/07/owl#>)\n\
695             Ontology(<http://example.org/disjoint-union-supplement>\n{body}\n)"
696        );
697        merge_ofn_supplement(ontology, report, limits, &mut harvested, &ofn)?;
698    }
699    for npa in crate::rdf_preprocess::collect_reified_data_npas(preprocessed_rdf) {
700        validate_supplement_iris([&npa.subject, &npa.property])?;
701        let lit = npa.value_literal.replace('"', "\\\"");
702        let mut body = format!(
703            "Declaration(NamedIndividual(<{}>))\n\
704             Declaration(DataProperty(<{}>))\n\
705             NegativeDataPropertyAssertion(<{}> <{}> \"{lit}\"^^xsd:string)\n\
706             DataPropertyAssertion(<{}> <{}> \"{lit}\"^^xsd:string)",
707            npa.subject, npa.property, npa.property, npa.subject, npa.property, npa.subject
708        );
709        if let Some((prop, value)) = &npa.positive_property {
710            validate_supplement_iri(prop)?;
711            if prop != &npa.property || value != &npa.value_literal {
712                body.push_str(&format!(
713                    "\nDataPropertyAssertion(<{prop}> <{}> \"{}\"^^xsd:string)",
714                    npa.subject,
715                    value.replace('"', "\\\"")
716                ));
717            }
718        }
719        let ofn = format!(
720            "Prefix(owl:=<http://www.w3.org/2002/07/owl#>)\n\
721             Prefix(xsd:=<http://www.w3.org/2001/XMLSchema#>)\n\
722             Ontology(<http://example.org/data-npa-supplement>\n{body}\n)"
723        );
724        merge_ofn_supplement(ontology, report, limits, &mut harvested, &ofn)?;
725    }
726    for dpa in crate::rdf_preprocess::collect_direct_data_literal_assertions(preprocessed_rdf) {
727        validate_supplement_iris([&dpa.subject, &dpa.property])?;
728        let (lexical, datatype_iri) = if dpa.value_literal.contains("^^") {
729            let mut parts = dpa.value_literal.splitn(2, "^^");
730            let lex = parts.next().unwrap_or("").trim_matches('"').to_string();
731            let dt = parts
732                .next()
733                .unwrap_or("")
734                .trim_matches(|c| c == '<' || c == '>');
735            (lex, dt.to_string())
736        } else {
737            (dpa.value_literal.replace('"', "\\\""), String::new())
738        };
739        if !datatype_iri.is_empty() && datatype_iri.contains("://") {
740            validate_supplement_iri(&datatype_iri)?;
741        }
742        let (extra_prefixes, lit, dt_decl) = if datatype_iri.is_empty() {
743            if dpa.value_literal.contains('@') || dpa.value_literal.contains("^^") {
744                (String::new(), dpa.value_literal.clone(), None)
745            } else {
746                (
747                    String::new(),
748                    format!(
749                        "\"{}\"^^rdf:PlainLiteral",
750                        crate::rdf_preprocess::escape_ofn_string(&lexical)
751                    ),
752                    None,
753                )
754            }
755        } else {
756            crate::rdf_preprocess::qualify_typed_literal_for_supplement(&lexical, &datatype_iri)
757        };
758        let dt_decl_line = dt_decl.map(|d| format!("\n       {d}")).unwrap_or_default();
759        let body = format!(
760            "Declaration(NamedIndividual(<{}>))\n\
761             Declaration(DataProperty(<{}>))\n\
762             ClassAssertion(owl:Thing <{}>){dt_decl_line}\n\
763             DataPropertyAssertion(<{}> <{}> {lit})",
764            dpa.subject, dpa.property, dpa.subject, dpa.property, dpa.subject
765        );
766        let ofn = format!(
767            "Prefix(owl:=<http://www.w3.org/2002/07/owl#>)\n\
768             Prefix(xsd:=<http://www.w3.org/2001/XMLSchema#>)\n\
769             Prefix(rdf:=<http://www.w3.org/1999/02/22-rdf-syntax-ns#>)\n\
770             {extra_prefixes}\n\
771             Ontology(<http://example.org/thing-data-literal-supplement>\n{body}\n)"
772        );
773        merge_ofn_supplement(ontology, report, limits, &mut harvested, &ofn)?;
774    }
775    if ill_founded_list {
776        if total_axiom_count(ontology).saturating_add(2) > limits.max_axioms {
777            if limits.strict {
778                return Err(Error::Parse(format!(
779                    "ill-founded RDF list supplement would exceed axiom limit of {}",
780                    limits.max_axioms
781                )));
782            }
783            report.meta.warn(
784                "ill-founded RDF list detected; Thing≡Nothing supplement skipped due to axiom limit",
785            );
786        } else {
787            let thing = ontology
788                .entity_id("http://www.w3.org/2002/07/owl#Thing", EntityKind::Class)
789                .map_err(|e| Error::Parse(e.to_string()))?;
790            let nothing = ontology
791                .entity_id("http://www.w3.org/2002/07/owl#Nothing", EntityKind::Class)
792                .map_err(|e| Error::Parse(e.to_string()))?;
793            ontology
794                .add_axiom(Axiom::EquivalentClasses(vec![thing, nothing]))
795                .map_err(|e| Error::Parse(e.to_string()))?;
796            let thing_ce = ontology.dl_mut().intern_ce(ClassExpr::Atomic(thing));
797            let nothing_ce = ontology.dl_mut().intern_ce(ClassExpr::Atomic(nothing));
798            ontology
799                .dl_mut()
800                .push_axiom(DlAxiom::EquivalentClasses(vec![thing_ce, nothing_ce]));
801            report.meta.mapped_axiom_count += 1;
802        }
803    }
804    for npa in crate::rdf_preprocess::collect_reified_npas(preprocessed_rdf) {
805        validate_supplement_iris([&npa.subject, &npa.object, &npa.property])?;
806        let mut body = format!(
807            "Declaration(NamedIndividual(<{}>))\n\
808             Declaration(NamedIndividual(<{}>))\n\
809             Declaration(ObjectProperty(<{}>))\n\
810             NegativeObjectPropertyAssertion(<{}> <{}> <{}>)",
811            npa.subject, npa.object, npa.property, npa.property, npa.subject, npa.object
812        );
813        if let Some((prop, object)) = npa.positive_property {
814            validate_supplement_iris([&prop, &object])?;
815            body.push_str(&format!(
816                "\nObjectPropertyAssertion(<{prop}> <{}> <{object}>)",
817                npa.subject
818            ));
819        }
820        let ofn = format!(
821            "Prefix(owl:=<http://www.w3.org/2002/07/owl#>)\n\
822             Ontology(<http://example.org/npa-supplement>\n{body}\n)"
823        );
824        merge_ofn_supplement(ontology, report, limits, &mut harvested, &ofn)?;
825    }
826    Ok(())
827}
828
829fn total_axiom_count(ontology: &Ontology) -> usize {
830    ontology
831        .axiom_count()
832        .saturating_add(ontology.dl().axiom_count())
833}
834
835fn merge_rdf_owl_imports(
836    path: &Path,
837    preprocessed_rdf: &str,
838    ontology: &mut Ontology,
839    report: &mut ParseReport,
840    limits: ParseLimits,
841    base: Option<&Path>,
842) -> Result<()> {
843    use std::collections::HashSet;
844    let mut visited = HashSet::from([path.to_path_buf()]);
845    for import_iri in crate::rdf_preprocess::collect_owl_imports(preprocessed_rdf) {
846        let Some(import_path) = resolve_owl_import_path(path, &import_iri) else {
847            let is_remote = import_iri.starts_with("http://") || import_iri.starts_with("https://");
848            if limits.strict && !is_remote {
849                return Err(Error::Parse(format!(
850                    "strict parse: unresolved owl:imports IRI {import_iri}"
851                )));
852            }
853            report.meta.warn(format!(
854                "unresolved owl:imports IRI {import_iri}; import skipped"
855            ));
856            continue;
857        };
858        if !visited.insert(import_path.clone()) {
859            continue;
860        }
861        let imported = load_ontology_with_limits_and_base_inner(&import_path, limits, base, false)?;
862        if total_axiom_count(ontology).saturating_add(total_axiom_count(&imported))
863            > limits.max_axioms
864        {
865            if limits.strict {
866                return Err(Error::Parse(format!(
867                    "import merge would exceed axiom limit {} (current {} + import {})",
868                    limits.max_axioms,
869                    total_axiom_count(ontology),
870                    total_axiom_count(&imported)
871                )));
872            }
873            report.meta.warn(format!(
874                "skipping import {import_iri}: would exceed axiom limit {}",
875                limits.max_axioms
876            ));
877            continue;
878        }
879        if ontology
880            .entity_count()
881            .saturating_add(imported.entity_count())
882            > limits.max_entities
883        {
884            if limits.strict {
885                return Err(Error::Parse(format!(
886                    "import merge would exceed entity limit {} (current {} + import {})",
887                    limits.max_entities,
888                    ontology.entity_count(),
889                    imported.entity_count()
890                )));
891            }
892            report.meta.warn(format!(
893                "skipping import {import_iri}: would exceed entity limit {}",
894                limits.max_entities
895            ));
896            continue;
897        }
898        let before = ontology.axiom_count();
899        merge_supplement_ontology(ontology, &imported, report, limits)?;
900        report.meta.mapped_axiom_count += ontology.axiom_count().saturating_sub(before);
901    }
902    Ok(())
903}
904
905fn resolve_owl_import_path(current: &Path, import_iri: &str) -> Option<PathBuf> {
906    if import_iri == "http://www.owllink.org/ontologies/families" {
907        let candidate = current.parent()?.join("families.owl");
908        if candidate.is_file() {
909            return Some(candidate);
910        }
911    }
912    if let Some(filename) = import_iri.strip_prefix("http://www.iyouit.eu/") {
913        if filename.split('/').any(|part| part == "..") {
914            return None;
915        }
916        let candidate = current.parent()?.join(filename);
917        if candidate.is_file() {
918            return Some(candidate);
919        }
920    }
921    resolve_wg_import_path(current, import_iri)
922}
923
924fn resolve_wg_import_path(current: &Path, import_iri: &str) -> Option<PathBuf> {
925    let suffix = import_iri.rsplit('/').next()?;
926    let case_dir = current.parent()?.file_name()?.to_str()?;
927    let wg_dir = current.parent()?.parent()?;
928    let mapped = match (case_dir, suffix) {
929        ("TestCase-3AWebOnt-2Dmiscellaneous-2D001", "consistent001") => {
930            "TestCase-3AWebOnt-2Dmiscellaneous-2D002/premise.rdf"
931        }
932        ("TestCase-3AWebOnt-2Dmiscellaneous-2D002", "consistent002") => {
933            "TestCase-3AWebOnt-2Dmiscellaneous-2D001/premise.rdf"
934        }
935        _ => return None,
936    };
937    let candidate = wg_dir.join(mapped);
938    candidate.is_file().then_some(candidate)
939}
940
941fn merge_supplement_with_accounting(
942    ontology: &mut Ontology,
943    report: &mut ParseReport,
944    limits: ParseLimits,
945    supplement: &Ontology,
946) -> Result<()> {
947    let before = ontology.axiom_count();
948    merge_supplement_ontology(ontology, supplement, report, limits)?;
949    report.meta.mapped_axiom_count += ontology.axiom_count().saturating_sub(before);
950    Ok(())
951}
952
953fn ensure_entity(ontology: &mut Ontology, iri: &str, kind: EntityKind) -> Result<EntityId> {
954    ontology
955        .entity_id(iri, kind)
956        .map_err(|e| Error::Parse(e.to_string()))
957}
958
959fn insert_same_individual_supplement(
960    ontology: &mut Ontology,
961    report: &mut ParseReport,
962    left: &str,
963    right: &str,
964) -> Result<()> {
965    if left == right {
966        return Ok(());
967    }
968    let left_id = ensure_entity(ontology, left, EntityKind::Individual)?;
969    let right_id = ensure_entity(ontology, right, EntityKind::Individual)?;
970    let before = ontology.axiom_count();
971    ontology
972        .add_axiom(Axiom::SameIndividual(vec![left_id, right_id]))
973        .map_err(|e| Error::Parse(e.to_string()))?;
974    report.meta.mapped_axiom_count += ontology.axiom_count().saturating_sub(before);
975    Ok(())
976}
977
978fn entity_kind_for_iri(ontology: &Ontology, iri: &str) -> Option<EntityKind> {
979    let id = ontology.lookup_entity(iri)?;
980    ontology.entity(id).ok().map(|record| record.kind)
981}
982
983fn insert_property_disjoint_supplement(
984    ontology: &mut Ontology,
985    report: &mut ParseReport,
986    limits: ParseLimits,
987    left: &str,
988    right: &str,
989) -> Result<()> {
990    let left_kind = entity_kind_for_iri(ontology, left);
991    let right_kind = entity_kind_for_iri(ontology, right);
992    let cross_kind = matches!(left_kind, Some(EntityKind::DataProperty))
993        && matches!(right_kind, Some(EntityKind::ObjectProperty))
994        || matches!(left_kind, Some(EntityKind::ObjectProperty))
995            && matches!(right_kind, Some(EntityKind::DataProperty));
996    if cross_kind {
997        let msg = "propertyDisjointWith across data and object property kinds skipped";
998        if limits.strict {
999            report.meta.skipped_axiom_count += 1;
1000            report.meta.warn(format!("{msg} in strict parse"));
1001        } else {
1002            report.meta.warn(format!("{msg} in lenient parse"));
1003        }
1004        return Ok(());
1005    }
1006    if matches!(left_kind, Some(EntityKind::DataProperty))
1007        || matches!(right_kind, Some(EntityKind::DataProperty))
1008    {
1009        insert_disjoint_data_properties_supplement(ontology, report, left, right)
1010    } else {
1011        insert_disjoint_object_properties_supplement(ontology, report, left, right)
1012    }
1013}
1014
1015fn insert_disjoint_object_properties_supplement(
1016    ontology: &mut Ontology,
1017    report: &mut ParseReport,
1018    left: &str,
1019    right: &str,
1020) -> Result<()> {
1021    let left_id = ensure_entity(ontology, left, EntityKind::ObjectProperty)?;
1022    let right_id = ensure_entity(ontology, right, EntityKind::ObjectProperty)?;
1023    let before = ontology.dl().axiom_count();
1024    ontology
1025        .dl_mut()
1026        .push_axiom(DlAxiom::DisjointObjectProperties(vec![left_id, right_id]));
1027    report.meta.mapped_axiom_count += ontology.dl().axiom_count().saturating_sub(before);
1028    Ok(())
1029}
1030
1031fn insert_disjoint_data_properties_supplement(
1032    ontology: &mut Ontology,
1033    report: &mut ParseReport,
1034    left: &str,
1035    right: &str,
1036) -> Result<()> {
1037    let left_id = ensure_entity(ontology, left, EntityKind::DataProperty)?;
1038    let right_id = ensure_entity(ontology, right, EntityKind::DataProperty)?;
1039    let before = ontology.dl().axiom_count();
1040    ontology
1041        .dl_mut()
1042        .push_axiom(DlAxiom::DisjointDataProperties(vec![left_id, right_id]));
1043    report.meta.mapped_axiom_count += ontology.dl().axiom_count().saturating_sub(before);
1044    Ok(())
1045}
1046
1047fn merge_supplement_ontology(
1048    target: &mut Ontology,
1049    source: &Ontology,
1050    report: &mut ParseReport,
1051    limits: ParseLimits,
1052) -> Result<()> {
1053    use ontologos_core::EntityKind;
1054    use std::collections::HashMap;
1055    use std::collections::HashSet;
1056
1057    fn axiom_mentions_any(axiom: &Axiom, ids: &HashSet<EntityId>) -> bool {
1058        match axiom {
1059            Axiom::SubClassOf {
1060                subclass,
1061                superclass,
1062            } => ids.contains(subclass) || ids.contains(superclass),
1063            Axiom::EquivalentClasses(classes) | Axiom::DisjointClasses(classes) => {
1064                classes.iter().any(|id| ids.contains(id))
1065            }
1066            Axiom::ObjectPropertyDomain { property, domain } => {
1067                ids.contains(property) || ids.contains(domain)
1068            }
1069            Axiom::ObjectPropertyRange { property, range } => {
1070                ids.contains(property) || ids.contains(range)
1071            }
1072            Axiom::SubObjectPropertyOf {
1073                sub_property,
1074                super_property,
1075            } => ids.contains(sub_property) || ids.contains(super_property),
1076            Axiom::InverseObjectProperties { left, right } => {
1077                ids.contains(left) || ids.contains(right)
1078            }
1079            Axiom::TransitiveObjectProperty(p)
1080            | Axiom::SymmetricObjectProperty(p)
1081            | Axiom::ReflexiveObjectProperty(p)
1082            | Axiom::IrreflexiveObjectProperty(p)
1083            | Axiom::AsymmetricObjectProperty(p)
1084            | Axiom::FunctionalObjectProperty(p)
1085            | Axiom::InverseFunctionalObjectProperty(p) => ids.contains(p),
1086            Axiom::SubClassOfExistential {
1087                subclass,
1088                property,
1089                filler,
1090            } => ids.contains(subclass) || ids.contains(property) || ids.contains(filler),
1091            Axiom::ClassAssertion { individual, class } => {
1092                ids.contains(individual) || ids.contains(class)
1093            }
1094            Axiom::ObjectPropertyAssertion {
1095                subject,
1096                property,
1097                object,
1098            } => ids.contains(subject) || ids.contains(property) || ids.contains(object),
1099            Axiom::NegativeObjectPropertyAssertion {
1100                subject,
1101                property,
1102                object,
1103            } => ids.contains(subject) || ids.contains(property) || ids.contains(object),
1104            Axiom::EquivalentObjectProperties(properties) => {
1105                properties.iter().any(|id| ids.contains(id))
1106            }
1107            Axiom::DataPropertyAssertion {
1108                individual,
1109                property,
1110                ..
1111            } => ids.contains(individual) || ids.contains(property),
1112            Axiom::NegativeDataPropertyAssertion {
1113                individual,
1114                property,
1115                ..
1116            } => ids.contains(individual) || ids.contains(property),
1117            Axiom::SameIndividual(individuals) | Axiom::DifferentIndividuals(individuals) => {
1118                individuals.iter().any(|id| ids.contains(id))
1119            }
1120        }
1121    }
1122
1123    let mut conflicting_source_entities: HashSet<EntityId> = HashSet::new();
1124    for (_, record) in source.entities().iter() {
1125        let iri = source
1126            .resolve_iri(record.iri)
1127            .map_err(|e| Error::Parse(e.to_string()))?;
1128        if let Some(existing) = target.lookup_entity(iri) {
1129            let existing_kind = target.entity(existing)?.kind;
1130            if !existing_kind.satisfies(record.kind) {
1131                match EntityKind::merge_punning(existing_kind, record.kind) {
1132                    Some(_) => {}
1133                    None => {
1134                        if limits.strict {
1135                            return Err(Error::Parse(format!(
1136                                "import entity kind conflict for {iri}: {:?} vs {:?}",
1137                                existing_kind, record.kind
1138                            )));
1139                        }
1140                        report.meta.warn(format!(
1141                            "import entity kind conflict for {iri}: {:?} vs {:?}",
1142                            existing_kind, record.kind
1143                        ));
1144                        // In lenient mode, treat this entity as unusable for supplements:
1145                        // avoid importing axioms that would attach onto the wrong kind.
1146                        conflicting_source_entities.insert(existing);
1147                    }
1148                }
1149            }
1150        } else {
1151            target
1152                .entity_id(iri, record.kind)
1153                .map_err(|e| Error::Parse(e.to_string()))?;
1154        }
1155    }
1156    let entity_map: HashMap<_, _> = source
1157        .entities()
1158        .iter()
1159        .filter_map(|(id, record)| {
1160            let iri = source.resolve_iri(record.iri).ok()?;
1161            Some((id, target.lookup_entity(iri)?))
1162        })
1163        .collect();
1164    for (id, _) in source.entities().iter() {
1165        if !entity_map.contains_key(&id) {
1166            return Err(Error::Parse(format!(
1167                "supplement entity {id:?} missing after merge"
1168            )));
1169        }
1170    }
1171
1172    // DL axioms may reference data/object property kinds that don't exist in the target after a
1173    // non-punnable kind conflict. Because DL import remaps everything in one pass, we conservatively
1174    // skip importing DL axioms for conflicting supplements in lenient mode.
1175    if conflicting_source_entities.is_empty() || limits.strict {
1176        target.dl_mut().import_axioms_from(source.dl(), |id| {
1177            entity_map
1178                .get(&id)
1179                .copied()
1180                .expect("supplement entities validated above")
1181        });
1182    } else {
1183        report
1184            .meta
1185            .warn("skipping DL supplement axioms due to entity kind conflicts");
1186        report.meta.skipped_axiom_count += source.dl().axiom_count();
1187    }
1188
1189    for (_, axiom) in source.axioms().iter() {
1190        if !conflicting_source_entities.is_empty()
1191            && axiom_mentions_any(axiom, &conflicting_source_entities)
1192            && !limits.strict
1193        {
1194            report.meta.skipped_axiom_count += 1;
1195            report
1196                .meta
1197                .warn("skipping supplement axiom due to entity kind conflict");
1198            continue;
1199        }
1200        let remapped = remap_supplement_axiom(axiom, &entity_map)?;
1201        if let Err(e) = target.add_axiom(remapped) {
1202            if matches!(axiom, Axiom::ObjectPropertyRange { .. }) {
1203                report.meta.skipped_axiom_count += 1;
1204                report.meta.warn(format!(
1205                    "skipping conflicting ObjectPropertyRange during merge: {e}"
1206                ));
1207                if limits.strict {
1208                    return Err(Error::Parse(e.to_string()));
1209                }
1210                continue;
1211            }
1212            if !limits.strict {
1213                report.meta.skipped_axiom_count += 1;
1214                report.meta.warn(format!(
1215                    "skipping conflicting supplement axiom during merge: {e}"
1216                ));
1217                continue;
1218            }
1219            return Err(Error::Parse(e.to_string()));
1220        }
1221    }
1222    Ok(())
1223}
1224
1225fn remap_supplement_axiom(
1226    axiom: &Axiom,
1227    entity_map: &std::collections::HashMap<EntityId, EntityId>,
1228) -> Result<Axiom> {
1229    let remap = |id: EntityId| -> Result<EntityId> {
1230        entity_map
1231            .get(&id)
1232            .copied()
1233            .ok_or_else(|| Error::Parse(format!("supplement entity {id:?} missing after merge")))
1234    };
1235    let remap_vec =
1236        |ids: &[EntityId]| -> Result<Vec<EntityId>> { ids.iter().map(|id| remap(*id)).collect() };
1237    Ok(match axiom {
1238        Axiom::SubClassOf {
1239            subclass,
1240            superclass,
1241        } => Axiom::SubClassOf {
1242            subclass: remap(*subclass)?,
1243            superclass: remap(*superclass)?,
1244        },
1245        Axiom::EquivalentClasses(classes) => Axiom::EquivalentClasses(remap_vec(classes)?),
1246        Axiom::DisjointClasses(classes) => Axiom::DisjointClasses(remap_vec(classes)?),
1247        Axiom::ObjectPropertyDomain { property, domain } => Axiom::ObjectPropertyDomain {
1248            property: remap(*property)?,
1249            domain: remap(*domain)?,
1250        },
1251        Axiom::ObjectPropertyRange { property, range } => Axiom::ObjectPropertyRange {
1252            property: remap(*property)?,
1253            range: remap(*range)?,
1254        },
1255        Axiom::SubObjectPropertyOf {
1256            sub_property,
1257            super_property,
1258        } => Axiom::SubObjectPropertyOf {
1259            sub_property: remap(*sub_property)?,
1260            super_property: remap(*super_property)?,
1261        },
1262        Axiom::InverseObjectProperties { left, right } => Axiom::InverseObjectProperties {
1263            left: remap(*left)?,
1264            right: remap(*right)?,
1265        },
1266        Axiom::TransitiveObjectProperty(p) => Axiom::TransitiveObjectProperty(remap(*p)?),
1267        Axiom::SubClassOfExistential {
1268            subclass,
1269            property,
1270            filler,
1271        } => Axiom::SubClassOfExistential {
1272            subclass: remap(*subclass)?,
1273            property: remap(*property)?,
1274            filler: remap(*filler)?,
1275        },
1276        Axiom::SymmetricObjectProperty(p) => Axiom::SymmetricObjectProperty(remap(*p)?),
1277        Axiom::ReflexiveObjectProperty(p) => Axiom::ReflexiveObjectProperty(remap(*p)?),
1278        Axiom::FunctionalObjectProperty(p) => Axiom::FunctionalObjectProperty(remap(*p)?),
1279        Axiom::InverseFunctionalObjectProperty(p) => {
1280            Axiom::InverseFunctionalObjectProperty(remap(*p)?)
1281        }
1282        Axiom::IrreflexiveObjectProperty(p) => Axiom::IrreflexiveObjectProperty(remap(*p)?),
1283        Axiom::AsymmetricObjectProperty(p) => Axiom::AsymmetricObjectProperty(remap(*p)?),
1284        Axiom::EquivalentObjectProperties(props) => {
1285            Axiom::EquivalentObjectProperties(remap_vec(props)?)
1286        }
1287        Axiom::ClassAssertion { individual, class } => Axiom::ClassAssertion {
1288            individual: remap(*individual)?,
1289            class: remap(*class)?,
1290        },
1291        Axiom::ObjectPropertyAssertion {
1292            subject,
1293            property,
1294            object,
1295        } => Axiom::ObjectPropertyAssertion {
1296            subject: remap(*subject)?,
1297            property: remap(*property)?,
1298            object: remap(*object)?,
1299        },
1300        Axiom::DataPropertyAssertion {
1301            individual,
1302            property,
1303            value,
1304        } => Axiom::DataPropertyAssertion {
1305            individual: remap(*individual)?,
1306            property: remap(*property)?,
1307            value: value.clone(),
1308        },
1309        Axiom::NegativeObjectPropertyAssertion {
1310            subject,
1311            property,
1312            object,
1313        } => Axiom::NegativeObjectPropertyAssertion {
1314            subject: remap(*subject)?,
1315            property: remap(*property)?,
1316            object: remap(*object)?,
1317        },
1318        Axiom::NegativeDataPropertyAssertion {
1319            individual,
1320            property,
1321            value,
1322        } => Axiom::NegativeDataPropertyAssertion {
1323            individual: remap(*individual)?,
1324            property: remap(*property)?,
1325            value: value.clone(),
1326        },
1327        Axiom::SameIndividual(ids) => Axiom::SameIndividual(remap_vec(ids)?),
1328        Axiom::DifferentIndividuals(ids) => Axiom::DifferentIndividuals(remap_vec(ids)?),
1329    })
1330}
1331
1332fn open_for_load(path: &Path, base: Option<&Path>) -> Result<File> {
1333    let pre_meta = std::fs::symlink_metadata(path)?;
1334    let file = open_readonly_nofollow(path)?;
1335    if let Some(base) = base {
1336        verify_opened_under_base(&file, base, path, &pre_meta)?;
1337    }
1338    Ok(file)
1339}
1340
1341fn open_readonly_nofollow(path: &Path) -> Result<File> {
1342    #[cfg(unix)]
1343    {
1344        use std::fs::OpenOptions;
1345        use std::os::unix::fs::OpenOptionsExt;
1346        OpenOptions::new()
1347            .read(true)
1348            .custom_flags(O_NOFOLLOW)
1349            .open(path)
1350            .map_err(|e| Error::Parse(e.to_string()))
1351    }
1352    #[cfg(not(unix))]
1353    {
1354        Ok(File::open(path)?)
1355    }
1356}
1357
1358fn verify_opened_under_base(
1359    file: &File,
1360    base: &Path,
1361    validated: &Path,
1362    pre_meta: &std::fs::Metadata,
1363) -> Result<()> {
1364    #[cfg(unix)]
1365    use std::os::unix::fs::MetadataExt;
1366
1367    let file_meta = file.metadata()?;
1368    #[cfg(unix)]
1369    if pre_meta.dev() != file_meta.dev() || pre_meta.ino() != file_meta.ino() {
1370        return Err(Error::Parse(
1371            "ontology path changed between validation and open".into(),
1372        ));
1373    }
1374    #[cfg(not(unix))]
1375    let _ = (pre_meta, file_meta);
1376
1377    let base_normalized = normalize_path(base)?;
1378    let base_canon = base_normalized
1379        .canonicalize()
1380        .map_err(|e| Error::Parse(e.to_string()))?;
1381
1382    if let Ok(opened) = opened_path(file) {
1383        let opened_canon = opened
1384            .canonicalize()
1385            .map_err(|e| Error::Parse(e.to_string()))?;
1386        if !path_is_under_base(&opened_canon, &base_canon) {
1387            return Err(Error::Parse(format!(
1388                "opened file {} escapes allowed base {}",
1389                opened_canon.display(),
1390                base_canon.display()
1391            )));
1392        }
1393        return Ok(());
1394    }
1395
1396    let validated_canon = validated
1397        .canonicalize()
1398        .map_err(|e| Error::Parse(e.to_string()))?;
1399    if !path_is_under_base(&validated_canon, &base_canon) {
1400        return Err(Error::Parse(format!(
1401            "path {} escapes allowed base {}",
1402            validated_canon.display(),
1403            base_canon.display()
1404        )));
1405    }
1406    Ok(())
1407}
1408
1409#[cfg(target_os = "linux")]
1410fn opened_path(file: &File) -> Result<PathBuf> {
1411    use std::os::unix::io::AsRawFd;
1412    let fd = file.as_raw_fd();
1413    Ok(std::fs::read_link(format!("/proc/self/fd/{fd}"))?)
1414}
1415
1416#[cfg(target_os = "macos")]
1417fn opened_path(file: &File) -> Result<PathBuf> {
1418    use std::ffi::CStr;
1419    use std::os::unix::io::AsRawFd;
1420
1421    const F_GETPATH: i32 = 50;
1422    let fd = file.as_raw_fd();
1423    let mut buf = [0u8; 1024];
1424    // SAFETY: `fcntl(F_GETPATH)` is the macOS API for resolving an open fd to its path.
1425    #[allow(unsafe_code)]
1426    let rc = unsafe { libc::fcntl(fd, F_GETPATH, buf.as_mut_ptr()) };
1427    if rc == -1 {
1428        return Err(Error::Parse("fcntl(F_GETPATH) failed".into()));
1429    }
1430    let cstr = CStr::from_bytes_until_nul(&buf).map_err(|e| Error::Parse(e.to_string()))?;
1431    Ok(PathBuf::from(cstr.to_string_lossy().into_owned()))
1432}
1433
1434#[cfg(not(any(target_os = "linux", target_os = "macos")))]
1435fn opened_path(_file: &File) -> Result<PathBuf> {
1436    Err(Error::Parse("fd path resolution unavailable".into()))
1437}
1438
1439fn detect_format_with_sniff(path: &Path, reader: &mut (impl Read + Seek)) -> Result<Format> {
1440    if let Some(format) = detect_format(path) {
1441        reader
1442            .seek(SeekFrom::Start(0))
1443            .map_err(|e| Error::Parse(e.to_string()))?;
1444        return Ok(format);
1445    }
1446
1447    let header = sniff_and_rewind(reader, 4096)?;
1448    if let Some(format) = detect_format_from_bytes(&header) {
1449        return Ok(format);
1450    }
1451    if detect_turtle_from_bytes(&header) {
1452        return Ok(Format::Turtle);
1453    }
1454    if detect_functional_from_bytes(&header) {
1455        return Ok(Format::Functional);
1456    }
1457
1458    Err(Error::UnsupportedFormat(format!(
1459        "could not detect OWL/RDF format for {}",
1460        path.display()
1461    )))
1462}
1463
1464fn normalize_path(path: &Path) -> Result<PathBuf> {
1465    let base = if path.is_absolute() {
1466        PathBuf::new()
1467    } else {
1468        std::env::current_dir()?
1469    };
1470
1471    let mut normalized = base;
1472    for component in path.components() {
1473        match component {
1474            Component::Prefix(_) | Component::RootDir => normalized.push(component.as_os_str()),
1475            Component::CurDir => {}
1476            Component::ParentDir => {
1477                if !normalized.pop() {
1478                    return Err(Error::Parse("path escapes beyond filesystem root".into()));
1479                }
1480            }
1481            Component::Normal(part) => normalized.push(part),
1482        }
1483    }
1484
1485    if normalized.exists() {
1486        normalized = normalized
1487            .canonicalize()
1488            .map_err(|e| Error::Parse(e.to_string()))?;
1489    }
1490
1491    Ok(normalized)
1492}
1493
1494/// True when `path` is the same as or nested under `base` (path-component wise).
1495fn path_is_under_base(path: &Path, base: &Path) -> bool {
1496    let mut path_iter = path.components();
1497    for base_comp in base.components() {
1498        match path_iter.next() {
1499            Some(path_comp) if path_comp == base_comp => {}
1500            _ => return false,
1501        }
1502    }
1503    true
1504}
1505
1506/// Parse OWL Functional Syntax from an in-memory document (no temp file).
1507pub fn load_ofn_from_str(text: &str) -> Result<Ontology> {
1508    load_ofn_from_str_validated(text, ParseLimits::default())
1509}
1510
1511/// Parse in-memory OFN and run post-load validation (public callers).
1512pub fn load_ofn_from_str_validated(text: &str, limits: ParseLimits) -> Result<Ontology> {
1513    if text.len() > limits.max_file_bytes {
1514        return Err(Error::Parse(format!(
1515            "in-memory OFN size {} exceeds limit of {} bytes",
1516            text.len(),
1517            limits.max_file_bytes
1518        )));
1519    }
1520    let set_ontology = read_horned_owl_from_reader(
1521        &mut std::io::Cursor::new(text.as_bytes()),
1522        Format::Functional,
1523        limits,
1524    )?;
1525    let (ontology, report) = map_to_core(&set_ontology, limits)?;
1526    finalize_parsed_ontology(ontology, report, limits, true)
1527}
1528
1529/// Parse OWL Functional Syntax from an in-memory document with custom limits.
1530pub fn load_ofn_from_str_with_limits(text: &str, limits: ParseLimits) -> Result<Ontology> {
1531    if text.len() > limits.max_file_bytes {
1532        return Err(Error::Parse(format!(
1533            "in-memory OFN size {} exceeds limit of {} bytes",
1534            text.len(),
1535            limits.max_file_bytes
1536        )));
1537    }
1538    let set_ontology = read_horned_owl_from_reader(
1539        &mut std::io::Cursor::new(text.as_bytes()),
1540        Format::Functional,
1541        limits,
1542    )?;
1543    let (ontology, report) = map_to_core(&set_ontology, limits)?;
1544    finalize_parsed_ontology(ontology, report, limits, false)
1545}
1546
1547/// Parse an in-memory ontology document (OWL/XML, RDF/XML, Turtle, or Functional Syntax).
1548///
1549/// Format is detected from content; no filesystem access required (browser/WASM friendly).
1550pub fn load_ontology_from_bytes(bytes: &[u8]) -> Result<Ontology> {
1551    load_ontology_from_bytes_with_limits(bytes, ParseLimits::default(), true)
1552}
1553
1554/// Lenient in-memory load (same limits as [`load_ontology_lenient`]).
1555pub fn load_ontology_from_bytes_lenient(bytes: &[u8]) -> Result<Ontology> {
1556    load_ontology_from_bytes_with_limits(bytes, ParseLimits::lenient(), false)
1557}
1558
1559/// Parse in-memory ontology bytes with custom limits.
1560pub fn load_ontology_from_bytes_with_limits(
1561    bytes: &[u8],
1562    limits: ParseLimits,
1563    validate: bool,
1564) -> Result<Ontology> {
1565    if bytes.len() > limits.max_file_bytes {
1566        return Err(Error::Parse(format!(
1567            "in-memory ontology size {} exceeds limit of {} bytes",
1568            bytes.len(),
1569            limits.max_file_bytes
1570        )));
1571    }
1572    let format = detect_format_from_bytes(bytes).ok_or_else(|| {
1573        Error::Parse(
1574            "could not detect ontology format from content; expected OWL/XML, RDF/XML, Turtle, or Functional Syntax"
1575                .into(),
1576        )
1577    })?;
1578    if format == Format::RdfXml {
1579        let text = std::str::from_utf8(bytes)
1580            .map_err(|e| Error::Parse(format!("RDF/XML must be valid UTF-8: {e}")))?;
1581        let (preprocessed_rdf, ill_founded_list) = preprocess_rdf_xml_text(text, limits)?;
1582        let (ontology, report) = load_rdf_xml_from_preprocessed(
1583            &preprocessed_rdf,
1584            limits,
1585            ill_founded_list,
1586            None,
1587            None,
1588            false,
1589        )?;
1590        return finalize_parsed_ontology(ontology, report, limits, validate);
1591    }
1592    let set_ontology =
1593        read_horned_owl_from_reader(&mut std::io::Cursor::new(bytes), format, limits)?;
1594    let (ontology, report) = map_to_core(&set_ontology, limits)?;
1595    finalize_parsed_ontology(ontology, report, limits, validate)
1596}
1597
1598/// Parse an in-memory ontology document from a UTF-8 string.
1599pub fn load_ontology_from_str(text: &str) -> Result<Ontology> {
1600    load_ontology_from_bytes(text.as_bytes())
1601}
1602
1603/// Lenient in-memory load from a UTF-8 string.
1604pub fn load_ontology_from_str_lenient(text: &str) -> Result<Ontology> {
1605    load_ontology_from_bytes_lenient(text.as_bytes())
1606}
1607
1608/// Load an OFN ontology and append axioms from a second OFN fragment (same prefixes/IRIs).
1609pub fn load_ofn_with_incremental(base: &Path, incremental: &Path) -> Result<Ontology> {
1610    load_ofn_with_incremental_and_limits(base, incremental, ParseLimits::default(), None)
1611}
1612
1613/// Load and merge OFN documents with path validation and parse limits.
1614pub fn load_ofn_with_incremental_and_limits(
1615    base: &Path,
1616    incremental: &Path,
1617    limits: ParseLimits,
1618    sandbox_base: Option<&Path>,
1619) -> Result<Ontology> {
1620    let base_path = validate_load_path(base, sandbox_base)?;
1621    let inc_path = validate_load_path(incremental, sandbox_base)?;
1622    let base_text = read_text_file_with_limit(&base_path, limits, sandbox_base)?;
1623    let inc_text = read_text_file_with_limit(&inc_path, limits, sandbox_base)?;
1624    let merged = merge_ofn_documents(&base_text, &inc_text)?;
1625    if merged.len() > limits.max_file_bytes {
1626        return Err(Error::Parse(format!(
1627            "merged OFN size {} exceeds limit of {} bytes",
1628            merged.len(),
1629            limits.max_file_bytes
1630        )));
1631    }
1632    load_ofn_from_str_validated(&merged, limits)
1633}
1634
1635fn merge_ofn_documents(base: &str, incremental: &str) -> Result<String> {
1636    let inc_axioms = extract_ofn_axiom_body(incremental)
1637        .ok_or_else(|| Error::Parse("incremental OFN missing Ontology(...) body".into()))?;
1638    let close = find_ofn_ontology_close(base)
1639        .ok_or_else(|| Error::Parse("base OFN missing closing ')'".into()))?;
1640    Ok(format!("{}{})", &base[..close], inc_axioms))
1641}
1642
1643/// Index of the closing `)` for the outer `Ontology(...)` form, respecting quoted strings.
1644fn find_ofn_ontology_close(text: &str) -> Option<usize> {
1645    let marker = "Ontology(";
1646    let start = text.find(marker)? + marker.len();
1647    let mut depth = 1usize;
1648    let mut in_str = false;
1649    let mut escape = false;
1650    for (i, ch) in text[start..].char_indices() {
1651        if in_str {
1652            if escape {
1653                escape = false;
1654                continue;
1655            }
1656            if ch == '\\' {
1657                escape = true;
1658                continue;
1659            }
1660            if ch == '"' {
1661                in_str = false;
1662            }
1663            continue;
1664        }
1665        match ch {
1666            '"' => in_str = true,
1667            '(' => depth += 1,
1668            ')' => {
1669                depth -= 1;
1670                if depth == 0 {
1671                    return Some(start + i);
1672                }
1673            }
1674            _ => {}
1675        }
1676    }
1677    None
1678}
1679
1680fn extract_ofn_axiom_body(text: &str) -> Option<String> {
1681    let marker = "Ontology(";
1682    let start = text.find(marker)? + marker.len();
1683    let rest = text.get(start..)?;
1684    let end = find_ofn_ontology_close(text)? - start;
1685    let mut body = rest[..end].trim();
1686    if body.starts_with('<') {
1687        if let Some((_, axioms)) = body.split_once('\n') {
1688            body = axioms.trim();
1689        } else if let Some((_, axioms)) = body.split_once(' ') {
1690            body = axioms.trim();
1691        }
1692    }
1693    Some(format!(" {body}"))
1694}
1695
1696#[cfg(test)]
1697mod tests {
1698    use super::*;
1699    use std::path::Path;
1700
1701    #[test]
1702    fn merge_ofn_preserves_literal_with_closing_paren() {
1703        let base = concat!(
1704            "Prefix(:=<file:/c/test.owl#>)\n",
1705            "Ontology(<file:/c/test.owl#>\n",
1706            "Class(:A)\n",
1707            "AnnotationAssertion(rdfs:comment :A \"note with ) inside\")\n",
1708            ")"
1709        );
1710        let incremental = concat!(
1711            "Prefix(:=<file:/c/test.owl#>)\n",
1712            "Ontology(<file:/c/test.owl#>\n",
1713            "ClassAssertion(:A :a)\n",
1714            ")"
1715        );
1716        let merged = merge_ofn_documents(base, incremental).expect("merge");
1717        assert!(merged.contains("note with ) inside"));
1718        assert!(merged.contains("ClassAssertion(:A :a)"));
1719        assert!(merged.ends_with("ClassAssertion(:A :a))"));
1720    }
1721
1722    #[test]
1723    fn load_ofn_from_str_rejects_oversized_input() {
1724        let limits = ParseLimits::with_file_bytes(16);
1725        let err = load_ofn_from_str_with_limits("Ontology(<x>)", limits).expect_err("size");
1726        assert!(matches!(err, Error::Parse(_)));
1727    }
1728
1729    #[test]
1730    fn load_ofn_from_str_parses_class_assertion() {
1731        let ofn = concat!(
1732            "Prefix(:=<file:/c/test.owl#>)\n",
1733            "Ontology(<file:/c/test.owl#>\n",
1734            "ClassAssertion(:A :a)\n",
1735            ")"
1736        );
1737        let ontology = load_ofn_from_str(ofn).expect("parse");
1738        assert!(ontology.axiom_count() > 0);
1739    }
1740
1741    #[test]
1742    fn lenient_merge_skips_dl_axioms_after_non_punnable_kind_conflict() {
1743        let mut base = load_ofn_from_str(concat!(
1744            "Prefix(:=<http://example.org/>)\n",
1745            "Ontology(<http://example.org/base>\n",
1746            "  Declaration(Class(:X))\n",
1747            "  Declaration(Class(:A))\n",
1748            ")\n"
1749        ))
1750        .expect("base");
1751
1752        let supplement = load_ofn_from_str(concat!(
1753            "Prefix(:=<http://example.org/>)\n",
1754            "Ontology(<http://example.org/supp>\n",
1755            "  Declaration(DataProperty(:X))\n",
1756            "  DataPropertyDomain(:X :A)\n",
1757            ")\n"
1758        ))
1759        .expect("supplement");
1760
1761        let mut report = ParseReport::new();
1762        merge_supplement_ontology(
1763            &mut base,
1764            &supplement,
1765            &mut report,
1766            ParseLimits {
1767                strict: false,
1768                ..ParseLimits::default()
1769            },
1770        )
1771        .expect("merge");
1772
1773        assert!(
1774            base.dl()
1775                .axioms()
1776                .all(|ax| !matches!(ax, DlAxiom::DataPropertyDomain { .. })),
1777            "expected DataPropertyDomain to be skipped after kind conflict"
1778        );
1779        assert!(
1780            report
1781                .meta
1782                .warnings
1783                .iter()
1784                .any(|w| w.contains("import entity kind conflict")),
1785            "expected kind conflict warning, got: {:?}",
1786            report.meta.warnings
1787        );
1788    }
1789
1790    #[test]
1791    fn dl_only_ontology_reports_mapped_axiom_count() {
1792        let ofn = concat!(
1793            "Prefix(:=<http://example.org/>)\n",
1794            "Ontology(<http://example.org/test>\n",
1795            "SubClassOf(:A ObjectComplementOf(:B))\n",
1796            ")"
1797        );
1798        let ontology = load_ofn_from_str(ofn).expect("parse");
1799        assert_eq!(ontology.axiom_count(), 0);
1800        assert!(ontology.dl().axiom_count() >= 1);
1801        let meta = ontology.parse_meta().expect("parse_meta");
1802        assert_eq!(meta.mapped_axiom_count, ontology.dl().axiom_count());
1803    }
1804
1805    #[test]
1806    fn rejects_path_traversal_outside_base() {
1807        let base = std::env::current_dir().expect("cwd");
1808        let err = validate_load_path(Path::new("../../../etc/passwd"), Some(&base))
1809            .expect_err("traversal");
1810        assert!(matches!(err, Error::Parse(_)));
1811    }
1812
1813    #[test]
1814    fn rejects_path_prefix_bypass() {
1815        let parent = std::env::temp_dir();
1816        let base = parent.join("ontologos_uploads_base");
1817        let evil = parent.join("ontologos_uploads_base_evil");
1818        std::fs::create_dir_all(&base).expect("create base");
1819        std::fs::create_dir_all(&evil).expect("create evil sibling");
1820        let file = evil.join("secret.owl");
1821        std::fs::write(&file, b"<rdf:RDF/>").expect("write file");
1822
1823        let err = validate_load_path(&file, Some(&base)).expect_err("prefix bypass");
1824        assert!(matches!(err, Error::Parse(_)));
1825
1826        let _ = std::fs::remove_file(&file);
1827        let _ = std::fs::remove_dir(&evil);
1828        let _ = std::fs::remove_dir(&base);
1829    }
1830
1831    #[test]
1832    fn path_is_under_base_accepts_nested_file() {
1833        let parent = std::env::temp_dir();
1834        let base = parent.join("ontologos_nested_base");
1835        let nested = base.join("nested");
1836        std::fs::create_dir_all(&nested).expect("create nested");
1837        let file = nested.join("ontology.owl");
1838        std::fs::write(&file, b"<rdf:RDF/>").expect("write file");
1839
1840        let validated = validate_load_path(&file, Some(&base)).expect("nested file under base");
1841        assert!(path_is_under_base(
1842            &validated,
1843            &base.canonicalize().expect("canonicalize base")
1844        ));
1845
1846        let _ = std::fs::remove_file(&file);
1847        let _ = std::fs::remove_dir(&nested);
1848        let _ = std::fs::remove_dir(&base);
1849    }
1850
1851    #[test]
1852    fn load_in_resolves_relative_path_under_base() {
1853        let parent = std::env::temp_dir();
1854        let base = parent.join("ontologos_relative_base");
1855        std::fs::create_dir_all(&base).expect("create base");
1856        let ofn = concat!(
1857            "Prefix(:=<http://example.org/>)\n",
1858            "Ontology(<http://example.org/o>\n",
1859            "  Declaration(Class(:A))\n",
1860            "  Declaration(Class(:B))\n",
1861            "  SubClassOf(:A :B)\n",
1862            ")"
1863        );
1864        std::fs::write(base.join("test.ofn"), ofn).expect("write file");
1865
1866        let ontology = load_ontology_in(&base, Path::new("test.ofn")).expect("sandbox load");
1867        assert!(ontology.axiom_count() >= 1);
1868
1869        let _ = std::fs::remove_file(base.join("test.ofn"));
1870        let _ = std::fs::remove_dir(&base);
1871    }
1872
1873    #[cfg(unix)]
1874    #[test]
1875    fn sandboxed_load_does_not_follow_symlink_to_outside_file() {
1876        use std::os::unix::fs::symlink;
1877
1878        let parent = std::env::temp_dir();
1879        let base = parent.join("ontologos_sandbox_base");
1880        let outside = parent.join("ontologos_outside_secret.owl");
1881        let link = base.join("ontology.owl");
1882        std::fs::create_dir_all(&base).expect("create base");
1883        std::fs::write(&outside, b"OUTSIDE_SECRET_CONTENT").expect("write outside");
1884
1885        symlink(&outside, &link).expect("symlink");
1886
1887        let err = load_ontology_in(&base, &link).expect_err("symlink escape");
1888        assert!(matches!(err, Error::Parse(_) | Error::UnsupportedFormat(_)));
1889
1890        let _ = std::fs::remove_file(&link);
1891        let _ = std::fs::remove_file(&outside);
1892        let _ = std::fs::remove_dir(&base);
1893    }
1894}