Skip to main content

strixonomy_catalog/
xml_catalog.rs

1//! Oasis / Protégé `catalog-v001.xml` IRI → local path redirects.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5use strixonomy_core::{is_path_within, read_to_string_capped, MAX_FILE_BYTES};
6use thiserror::Error;
7
8/// Maximum `nextCatalog` nesting depth (cycle-safe bound).
9const MAX_NEXTCATALOG_DEPTH: usize = 32;
10
11#[derive(Debug, Clone, PartialEq, Eq, Error)]
12pub enum XmlCatalogError {
13    #[error("io: {0}")]
14    Io(String),
15    #[error("parse: {0}")]
16    Parse(String),
17}
18
19/// Parsed XML Catalog redirect table (Protégé `catalog-v001.xml` subset).
20#[derive(Debug, Clone, Default)]
21pub struct XmlCatalog {
22    /// Absolute path of the catalog file (used to resolve relative uris).
23    pub catalog_path: PathBuf,
24    /// Exact IRI (`name`) → redirect URI string (may be relative).
25    pub uri_entries: BTreeMap<String, String>,
26    /// Prefix rewrite rules: start string → replacement prefix.
27    pub rewrite_entries: Vec<(String, String)>,
28}
29
30impl XmlCatalog {
31    /// Resolve an ontology import IRI to a local filesystem path when possible.
32    pub fn resolve(&self, iri: &str) -> Option<PathBuf> {
33        let redirect = self.resolve_uri(iri)?;
34        Some(self.absolutize(&redirect))
35    }
36
37    /// Resolve to the redirect URI string (relative or absolute).
38    pub fn resolve_uri(&self, iri: &str) -> Option<String> {
39        if let Some(u) = self.uri_entries.get(iri) {
40            return Some(u.clone());
41        }
42        // Longest rewriteURI prefix wins
43        let mut best: Option<&(String, String)> = None;
44        for rule in &self.rewrite_entries {
45            if iri.starts_with(&rule.0) && best.as_ref().is_none_or(|b| rule.0.len() > b.0.len()) {
46                best = Some(rule);
47            }
48        }
49        best.map(|(start, replace)| format!("{}{}", replace, &iri[start.len()..]))
50    }
51
52    fn absolutize(&self, redirect: &str) -> PathBuf {
53        let p = Path::new(redirect);
54        if p.is_absolute() {
55            return p.to_path_buf();
56        }
57        if let Some(rest) = redirect.strip_prefix("file://") {
58            return PathBuf::from(rest);
59        }
60        let parent = self.catalog_path.parent().unwrap_or_else(|| Path::new("."));
61        parent.join(redirect)
62    }
63}
64
65/// Parse a Protégé/Oasis catalog XML document.
66///
67/// Nested `nextCatalog` entries are jailed to the catalog file's parent directory
68/// (use [`parse_xml_catalog_in_workspace`] when a workspace root is known).
69pub fn parse_xml_catalog(path: &Path, xml: &str) -> Result<XmlCatalog, XmlCatalogError> {
70    let jail = path.parent().map(Path::to_path_buf);
71    parse_xml_catalog_inner(path, xml, jail.as_deref(), &mut Vec::new(), &mut BTreeSet::new(), 0)
72}
73
74/// Parse a catalog, jailing nested `nextCatalog` loads under `workspace_root`.
75pub fn parse_xml_catalog_in_workspace(
76    path: &Path,
77    xml: &str,
78    workspace_root: &Path,
79) -> Result<XmlCatalog, XmlCatalogError> {
80    parse_xml_catalog_inner(
81        path,
82        xml,
83        Some(workspace_root),
84        &mut Vec::new(),
85        &mut BTreeSet::new(),
86        0,
87    )
88}
89
90fn parse_xml_catalog_inner(
91    path: &Path,
92    xml: &str,
93    jail_root: Option<&Path>,
94    stack: &mut Vec<PathBuf>,
95    loaded: &mut BTreeSet<PathBuf>,
96    depth: usize,
97) -> Result<XmlCatalog, XmlCatalogError> {
98    if depth > MAX_NEXTCATALOG_DEPTH {
99        return Err(XmlCatalogError::Parse(format!(
100            "nextCatalog nesting exceeds limit ({MAX_NEXTCATALOG_DEPTH}) at {}",
101            path.display()
102        )));
103    }
104    let identity = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
105    if stack.iter().any(|p| p == &identity) {
106        return Err(XmlCatalogError::Parse(format!(
107            "nextCatalog cycle detected at {}",
108            path.display()
109        )));
110    }
111    if loaded.contains(&identity) {
112        // Already fully processed in this load — skip without error (DAG / diamond).
113        return Ok(XmlCatalog { catalog_path: path.to_path_buf(), ..Default::default() });
114    }
115    stack.push(identity.clone());
116
117    let mut catalog = XmlCatalog { catalog_path: path.to_path_buf(), ..Default::default() };
118    // Prefer xml:base on catalog/group when present
119    let mut active_base: Option<String> = None;
120
121    // Walk uri / rewriteURI tags with a lightweight scanner
122    for (tag, attrs) in iter_empty_elements(xml) {
123        let local = local_name(&tag);
124        if local == "catalog" || local == "group" {
125            if let Some(b) = attrs.get("xml:base").or_else(|| attrs.get("base")) {
126                if !b.is_empty() {
127                    active_base = Some(b.clone());
128                }
129            }
130            continue;
131        }
132        if local == "uri" {
133            let Some(name) = attrs.get("name").cloned() else {
134                continue;
135            };
136            let Some(uri) = attrs.get("uri").cloned() else {
137                continue;
138            };
139            let uri = apply_xml_base(&uri, active_base.as_deref());
140            catalog.uri_entries.insert(name, uri);
141        } else if local == "rewriteURI" {
142            let Some(start) = attrs.get("uriStartString").cloned() else {
143                continue;
144            };
145            let Some(rewrite) = attrs.get("rewritePrefix").cloned() else {
146                continue;
147            };
148            catalog.rewrite_entries.push((start, rewrite));
149        } else if local == "nextCatalog" {
150            if let Some(next) = attrs.get("catalog") {
151                let next_path = catalog.absolutize(next);
152                load_nested_catalog(&mut catalog, &next_path, jail_root, stack, loaded, depth + 1)?;
153            }
154        }
155    }
156    stack.pop();
157    loaded.insert(identity);
158    Ok(catalog)
159}
160
161fn load_nested_catalog(
162    catalog: &mut XmlCatalog,
163    next_path: &Path,
164    jail_root: Option<&Path>,
165    stack: &mut Vec<PathBuf>,
166    loaded: &mut BTreeSet<PathBuf>,
167    depth: usize,
168) -> Result<(), XmlCatalogError> {
169    if !next_path.is_file() {
170        return Ok(());
171    }
172    let resolved = next_path
173        .canonicalize()
174        .map_err(|e| XmlCatalogError::Io(format!("{}: {e}", next_path.display())))?;
175    if let Some(root) = jail_root {
176        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
177        if !is_path_within(&root, &resolved) {
178            return Err(XmlCatalogError::Parse(format!(
179                "nextCatalog path escapes workspace jail ({}): {}",
180                root.display(),
181                next_path.display()
182            )));
183        }
184    }
185    if loaded.contains(&resolved) {
186        return Ok(());
187    }
188    let text = read_to_string_capped(&resolved, MAX_FILE_BYTES)
189        .map_err(|e| XmlCatalogError::Io(e.to_string()))?;
190    let nested = parse_xml_catalog_inner(&resolved, &text, jail_root, stack, loaded, depth)?;
191    for (k, v) in nested.uri_entries {
192        catalog.uri_entries.entry(k).or_insert(v);
193    }
194    catalog.rewrite_entries.extend(nested.rewrite_entries);
195    Ok(())
196}
197
198/// Load and parse a catalog file from disk (size-capped).
199pub fn load_xml_catalog(path: &Path) -> Result<XmlCatalog, XmlCatalogError> {
200    let text = read_to_string_capped(path, MAX_FILE_BYTES)
201        .map_err(|e| XmlCatalogError::Io(e.to_string()))?;
202    parse_xml_catalog(path, &text)
203}
204
205/// Discover `catalog-v001.xml` / `catalog-*.xml` under `root` (non-recursive first, then depth 1).
206pub fn discover_workspace_catalogs(root: &Path) -> Vec<PathBuf> {
207    let mut out = Vec::new();
208    let candidates = [root.join("catalog-v001.xml"), root.join("catalog.xml")];
209    for c in candidates {
210        if c.is_file() {
211            out.push(c);
212        }
213    }
214    if let Ok(rd) = std::fs::read_dir(root) {
215        for entry in rd.flatten() {
216            let p = entry.path();
217            if p.is_file() {
218                if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
219                    if name.starts_with("catalog-") && name.ends_with(".xml") && !out.contains(&p) {
220                        out.push(p);
221                    }
222                }
223            }
224        }
225    }
226    out
227}
228
229/// Merge all discovered catalogs under a workspace root.
230pub fn load_workspace_xml_catalogs(root: &Path) -> Result<XmlCatalog, XmlCatalogError> {
231    let paths = discover_workspace_catalogs(root);
232    let mut merged =
233        XmlCatalog { catalog_path: root.join("catalog-v001.xml"), ..Default::default() };
234    for path in paths {
235        let text = read_to_string_capped(&path, MAX_FILE_BYTES)
236            .map_err(|e| XmlCatalogError::Io(e.to_string()))?;
237        let cat = parse_xml_catalog_in_workspace(&path, &text, root)?;
238        merged.catalog_path = path;
239        for (k, v) in cat.uri_entries {
240            merged.uri_entries.insert(k, v);
241        }
242        merged.rewrite_entries.extend(cat.rewrite_entries);
243    }
244    Ok(merged)
245}
246
247fn apply_xml_base(uri: &str, base: Option<&str>) -> String {
248    let p = Path::new(uri);
249    if p.is_absolute() || uri.contains("://") {
250        return uri.to_string();
251    }
252    if let Some(b) = base {
253        if b.is_empty() {
254            return uri.to_string();
255        }
256        if b.ends_with('/') {
257            format!("{b}{uri}")
258        } else {
259            format!("{b}/{uri}")
260        }
261    } else {
262        uri.to_string()
263    }
264}
265
266fn local_name(tag: &str) -> &str {
267    tag.rsplit([':', '}']).next().unwrap_or(tag)
268}
269
270/// Yield (tag_name, attrs) for empty-element or start tags.
271fn iter_empty_elements(xml: &str) -> Vec<(String, BTreeMap<String, String>)> {
272    let mut out = Vec::new();
273    let bytes = xml.as_bytes();
274    let mut i = 0;
275    while i < bytes.len() {
276        if bytes[i] != b'<' {
277            i += 1;
278            continue;
279        }
280        if i + 1 < bytes.len()
281            && (bytes[i + 1] == b'!' || bytes[i + 1] == b'?' || bytes[i + 1] == b'/')
282        {
283            // skip comments / decls / end tags — advance to '>'
284            if let Some(rel) = xml[i..].find('>') {
285                i += rel + 1;
286            } else {
287                break;
288            }
289            continue;
290        }
291        let start = i + 1;
292        let Some(end_rel) = xml[start..].find('>') else {
293            break;
294        };
295        let end = start + end_rel;
296        let mut inner = &xml[start..end];
297        inner = inner.trim_end_matches('/').trim();
298        let (name, rest) = match inner.split_once(|c: char| c.is_whitespace()) {
299            Some((n, r)) => (n, r),
300            None => (inner, ""),
301        };
302        let attrs = parse_attrs(rest);
303        out.push((name.to_string(), attrs));
304        i = end + 1;
305    }
306    out
307}
308
309fn parse_attrs(s: &str) -> BTreeMap<String, String> {
310    let mut map = BTreeMap::new();
311    let mut rest = s;
312    while let Some(eq) = rest.find('=') {
313        let key = rest[..eq]
314            .trim()
315            .rsplit_once(char::is_whitespace)
316            .map(|(_, k)| k)
317            .unwrap_or(rest[..eq].trim());
318        let after = rest[eq + 1..].trim_start();
319        let (val, next) = if let Some(stripped) = after.strip_prefix('"') {
320            if let Some(e) = stripped.find('"') {
321                (&stripped[..e], &stripped[e + 1..])
322            } else {
323                break;
324            }
325        } else if let Some(stripped) = after.strip_prefix('\'') {
326            if let Some(e) = stripped.find('\'') {
327                (&stripped[..e], &stripped[e + 1..])
328            } else {
329                break;
330            }
331        } else {
332            break;
333        };
334        if !key.is_empty() {
335            map.insert(key.to_string(), val.to_string());
336        }
337        rest = next;
338    }
339    map
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn parse_uri_redirect() {
348        let xml = r#"<?xml version="1.0"?>
349<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog" prefer="public">
350  <group id="Folder" prefer="public" xml:base="">
351    <uri name="http://example.org/ont/pizza" uri="pizza.ttl"/>
352  </group>
353</catalog>"#;
354        let dir = tempfile::tempdir().unwrap();
355        let path = dir.path().join("catalog-v001.xml");
356        std::fs::write(&path, xml).unwrap();
357        let cat = parse_xml_catalog(&path, xml).unwrap();
358        assert_eq!(cat.resolve_uri("http://example.org/ont/pizza").as_deref(), Some("pizza.ttl"));
359        let resolved = cat.resolve("http://example.org/ont/pizza").unwrap();
360        assert_eq!(resolved, dir.path().join("pizza.ttl"));
361    }
362
363    #[test]
364    fn xml_base_prefixes_relative_uri() {
365        let xml = r#"<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
366  <group xml:base="lib/">
367    <uri name="http://example.org/a" uri="a.ttl"/>
368  </group>
369</catalog>"#;
370        let path = Path::new("/tmp/catalog-v001.xml");
371        let cat = parse_xml_catalog(path, xml).unwrap();
372        assert_eq!(cat.resolve_uri("http://example.org/a").as_deref(), Some("lib/a.ttl"));
373    }
374
375    #[test]
376    fn next_catalog_cycle_errors() {
377        // #392
378        let dir = tempfile::tempdir().unwrap();
379        let a = dir.path().join("a.xml");
380        let b = dir.path().join("b.xml");
381        std::fs::write(
382            &a,
383            r#"<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
384  <nextCatalog catalog="b.xml"/>
385</catalog>"#,
386        )
387        .unwrap();
388        std::fs::write(
389            &b,
390            r#"<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
391  <nextCatalog catalog="a.xml"/>
392</catalog>"#,
393        )
394        .unwrap();
395        let text = std::fs::read_to_string(&a).unwrap();
396        let err = parse_xml_catalog(&a, &text).expect_err("cycle");
397        assert!(err.to_string().contains("cycle"), "{err}");
398    }
399
400    #[test]
401    fn next_catalog_outside_workspace_errors() {
402        // #393
403        let dir = tempfile::tempdir().unwrap();
404        let workspace = dir.path().join("ws");
405        let outside = dir.path().join("outside");
406        std::fs::create_dir_all(&workspace).unwrap();
407        std::fs::create_dir_all(&outside).unwrap();
408        let secret = outside.join("secret.xml");
409        std::fs::write(
410            &secret,
411            r#"<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
412  <uri name="http://evil" uri="x.ttl"/>
413</catalog>"#,
414        )
415        .unwrap();
416        let catalog = workspace.join("catalog-v001.xml");
417        std::fs::write(
418            &catalog,
419            format!(
420                r#"<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
421  <nextCatalog catalog="{}"/>
422</catalog>"#,
423                secret.display()
424            ),
425        )
426        .unwrap();
427        let text = std::fs::read_to_string(&catalog).unwrap();
428        let err = parse_xml_catalog_in_workspace(&catalog, &text, &workspace)
429            .expect_err("must reject outside path");
430        assert!(err.to_string().contains("escapes") || err.to_string().contains("jail"), "{err}");
431    }
432
433    #[test]
434    fn next_catalog_relative_inside_workspace_ok() {
435        let dir = tempfile::tempdir().unwrap();
436        let workspace = dir.path().join("ws");
437        std::fs::create_dir_all(workspace.join("lib")).unwrap();
438        std::fs::write(
439            workspace.join("lib/nested.xml"),
440            r#"<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
441  <uri name="http://example.org/n" uri="n.ttl"/>
442</catalog>"#,
443        )
444        .unwrap();
445        let catalog = workspace.join("catalog-v001.xml");
446        std::fs::write(
447            &catalog,
448            r#"<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog">
449  <nextCatalog catalog="lib/nested.xml"/>
450</catalog>"#,
451        )
452        .unwrap();
453        let text = std::fs::read_to_string(&catalog).unwrap();
454        let cat = parse_xml_catalog_in_workspace(&catalog, &text, &workspace).expect("nested");
455        assert_eq!(cat.resolve_uri("http://example.org/n").as_deref(), Some("n.ttl"));
456    }
457}