1use 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
8const 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#[derive(Debug, Clone, Default)]
21pub struct XmlCatalog {
22 pub catalog_path: PathBuf,
24 pub uri_entries: BTreeMap<String, String>,
26 pub rewrite_entries: Vec<(String, String)>,
28}
29
30impl XmlCatalog {
31 pub fn resolve(&self, iri: &str) -> Option<PathBuf> {
33 let redirect = self.resolve_uri(iri)?;
34 Some(self.absolutize(&redirect))
35 }
36
37 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 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
65pub 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
74pub 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 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 let mut active_base: Option<String> = None;
120
121 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
198pub 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
205pub 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
229pub 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
270fn 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 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 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 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}