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
40fn 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(¤t)?;
46 current = crate::rdf_preprocess::dedupe_rdf_xml_ids(¤t);
47 budget.track(¤t)?;
48 current = crate::rdf_preprocess::normalize_invalid_rdf_ids(¤t);
49 budget.track(¤t)?;
50 current =
51 crate::rdf_preprocess::expand_xml_entities_with_limit(¤t, limits.max_expanded_bytes)?;
52 budget.track(¤t)?;
53 let ill_founded_list = crate::rdf_preprocess::contains_ill_founded_rdf_list(¤t);
54 current = crate::rdf_preprocess::normalize_relative_owl_uris(¤t);
55 budget.track(¤t)?;
56 current = crate::rdf_preprocess::normalize_rdfs_class_elements(¤t);
57 budget.track(¤t)?;
58 current = crate::rdf_preprocess::inject_rdf_based_punning_declarations(¤t);
59 budget.track(¤t)?;
60 current = crate::rdf_preprocess::materialize_typed_about_elements(¤t);
61 budget.track(¤t)?;
62 current = crate::rdf_preprocess::materialize_typed_node_elements(¤t);
63 budget.track(¤t)?;
64 current = crate::rdf_preprocess::normalize_class_intersection_definitions(¤t);
65 budget.track(¤t)?;
66 current = crate::rdf_preprocess::normalize_class_same_as(¤t);
67 budget.track(¤t)?;
68 current = crate::rdf_preprocess::materialize_named_individual_descriptions(¤t);
69 budget.track(¤t)?;
70 current = crate::rdf_preprocess::materialize_anonymous_individual_descriptions(¤t);
71 budget.track(¤t)?;
72 current = crate::rdf_preprocess::normalize_all_different_members(¤t);
73 budget.track(¤t)?;
74 current = crate::rdf_preprocess::expand_all_disjoint_collections(¤t);
75 budget.track(¤t)?;
76 current = crate::rdf_preprocess::inject_object_property_declarations_from_usage(¤t);
77 budget.track(¤t)?;
78 current = crate::rdf_preprocess::normalize_property_same_as(¤t);
79 budget.track(¤t)?;
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
196fn 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
215pub 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
233pub 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
244pub 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
255pub 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
268pub 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
282pub fn load_ontology_with_limits(path: &Path, limits: ParseLimits) -> Result<Ontology> {
284 load_ontology_with_limits_and_base(path, limits, None)
285}
286
287pub 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 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 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 #[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
1494fn 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
1506pub fn load_ofn_from_str(text: &str) -> Result<Ontology> {
1508 load_ofn_from_str_validated(text, ParseLimits::default())
1509}
1510
1511pub 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
1529pub 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
1547pub fn load_ontology_from_bytes(bytes: &[u8]) -> Result<Ontology> {
1551 load_ontology_from_bytes_with_limits(bytes, ParseLimits::default(), true)
1552}
1553
1554pub fn load_ontology_from_bytes_lenient(bytes: &[u8]) -> Result<Ontology> {
1556 load_ontology_from_bytes_with_limits(bytes, ParseLimits::lenient(), false)
1557}
1558
1559pub 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
1598pub fn load_ontology_from_str(text: &str) -> Result<Ontology> {
1600 load_ontology_from_bytes(text.as_bytes())
1601}
1602
1603pub fn load_ontology_from_str_lenient(text: &str) -> Result<Ontology> {
1605 load_ontology_from_bytes_lenient(text.as_bytes())
1606}
1607
1608pub 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
1613pub 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
1643fn 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}