rete_core/iri.rs
1//! **IRI validity** — what may appear between the angle brackets of an
2//! N-Triples / N-Quads `IRIREF`, and the one lossy repair the exporter offers.
3//!
4//! rete's line-based N-Triples/N-Quads reader is deliberately tolerant: it takes
5//! whatever sits between `<` and the next `>` and stores it as a dictionary key.
6//! That makes the file a faithful container of what it was given, but it also
7//! means a `.rete` can hold IRIs that no strict parser accepts — and
8//! `rete export --format nq` then names a grammar it does not emit. Oxigraph,
9//! Jena and rapper reject such a dump; a bulk loader rejects the *chunk*, not
10//! the line, so one bad IRI can cost a hundred thousand good statements.
11//!
12//! This module is the single definition of "invalid" used by the build-time
13//! audit (`rete build`, `rete validate`) and the export-time repair
14//! (`rete export --sanitize-iris`).
15//!
16//! # Validity is decided by a parser; the classes only say how to repair
17//!
18//! Two documents apply, and both must hold:
19//!
20//! * the **N-Triples/N-Quads `IRIREF` production**
21//! (`'<' ([^#x00-#x20<>"{}|^`\] | UCHAR)* '>'`), which excludes a fixed set of
22//! characters outright, and
23//! * **RFC 3987**, which the same grammar requires the content to satisfy as an
24//! *absolute* IRI (with an optional fragment).
25//!
26//! The first is a character set, so a one-pass scan settles it. The second is a
27//! grammar, and it is **not** settled by enumerating bad shapes: a taxonomy of
28//! known defects can only recognise what has already been seen, so making it the
29//! arbiter of validity guarantees that the next unseen shape is published as
30//! valid. That is not hypothetical — `<https://::1>` (an IPv6 literal in the
31//! authority without the brackets 3987 requires) matched none of the five
32//! classes, and a dump carrying it passed the export gate and was rejected by
33//! Oxigraph.
34//!
35//! So the two questions are separated:
36//!
37//! * **Is it valid?** `oxiri` answers, and nothing else does. That is the
38//! crate Oxigraph's own N-Triples reader validates with — `oxttl`'s lexer
39//! tokenises `<`…`>` per the `IRIREF` production and hands the content to
40//! `oxiri::Iri::parse` — so rete's verdict agrees with the reference loader's
41//! *by construction* rather than by our keeping a list up to date. `oxiri` is
42//! already in this crate's dependency tree via `oxrdf`/`oxttl`, so deciding it
43//! properly costs no new dependency and no new wasm surface.
44//! * **Can we repair it, and how?** The classes below answer, and only that.
45//! Each names a shape percent-encoding can fix without inventing information.
46//!
47//! | class | example | repairable by escaping |
48//! |---|---|---|
49//! | [`IriDefect::NotAbsolute`] | `<noscheme/path>` | **no** |
50//! | [`IriDefect::ForbiddenChar`] | `<http://ex/a b>`, `<http://ex/a"b>` | yes |
51//! | [`IriDefect::Bracket`] | `<http://ex/a[b]>` | yes |
52//! | [`IriDefect::ExtraHash`] | `<http://ex/c#d#e>` | yes |
53//! | [`IriDefect::BadPercent`] | `<http://ex/%x>` | yes |
54//! | [`IriDefect::Unclassified`] | `<https://::1>` | **no** |
55//!
56//! [`IriDefect::Unclassified`] is the load-bearing one: an IRI the parser
57//! rejects that **no class recognises**. It is still counted and it is still
58//! unrepairable, so it still blocks. A gap in the taxonomy is now benign — we
59//! failed to repair something we might have — instead of harmful, which is a
60//! published dump that does not load. A class that is added later only moves
61//! occurrences out of this bucket; it never changes a verdict from invalid to
62//! valid.
63//!
64//! A repairable class is only reported when escaping actually **lands a valid
65//! IRI**: `<https://::1/a[b]>` has a bracket, but encoding it leaves an IRI the
66//! parser still rejects, so it is reported as [`IriDefect::Unclassified`] rather
67//! than as a `Bracket` the sanitizer would claim to have fixed.
68//!
69//! # `UCHAR` escapes are syntax, not content
70//!
71//! `<http://ex/café>` is a legal `IRIREF` whose IRI is `http://ex/café` —
72//! the backslash belongs to N-Triples, not to the IRI. The escapes are therefore
73//! resolved *before* the parser sees the string, exactly as `oxttl` does.
74//! Handing the raw bracket content to an RFC 3987 parser would reject `\` and
75//! make every escaped dump a false positive.
76//!
77//! # What this deliberately does not judge
78//!
79//! * **Scheme semantics.** `<nonsense://x>` is syntactically a fine IRI and is
80//! accepted. Whether the scheme is registered, resolvable or meaningful is not
81//! this module's question.
82//! * **Whether the IRI names anything.** A 404, a typo'd host and a dead DOI are
83//! all valid IRIs.
84//! * **Normalisation.** `<HTTP://EX/a/../b>` is valid and is left exactly as it
85//! was given; rete never case-folds a scheme or removes a dot segment, because
86//! both change the dictionary key and break the round-trip.
87//! * **Anything escaping cannot fix.** A relative IRI ([`IriDefect::NotAbsolute`])
88//! and an [`IriDefect::Unclassified`] one are *reported and left alone* —
89//! [`sanitize_iri_content`] returns `None` and the term is emitted verbatim. A
90//! dump containing one is still not valid N-Quads, and `--sanitize-iris` says
91//! so rather than implying a fix it did not make.
92//!
93//! **Non-ASCII used to be on this list and no longer is.** RFC 3987 admits
94//! `ucschar`, so `<http://ex/café>` is valid — but the narrow sub-ranges 3987
95//! excludes (surrogates, `iprivate` outside the query) were previously not
96//! policed at all. The parser polices them now, because it is the same parser
97//! the loader uses: if it accepts a character, so does the dump's reader.
98
99use std::borrow::Cow;
100
101/// Why an IRI is not one — a **repair** classification, not the validity
102/// verdict. [`iri_content_defect`] decides validity with an RFC 3987 parser and
103/// uses these only to say what, if anything, can be done about it.
104///
105/// The classes are ordered by how they are found, not by severity;
106/// [`IriDefect::NotAbsolute`] is checked first because it is a property of the
107/// whole IRI, and [`IriDefect::Unclassified`] last because it is what is left
108/// when no other class explains a parser rejection.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
110pub enum IriDefect {
111 /// No `scheme:` prefix, so the `IRIREF` is relative. N-Triples requires an
112 /// absolute IRI. **Not repairable** — see the module docs.
113 NotAbsolute,
114 /// A character the `IRIREF` production excludes outright: `U+0000`–`U+0020`
115 /// (space and the controls), `U+007F`, and `< > " { } | ^ ` \`. A backslash
116 /// that opens a well-formed `UCHAR` (`\uXXXX` / `\UXXXXXXXX`) is fine.
117 ForbiddenChar,
118 /// `[` or `]` outside an IP-literal host. RFC 3987 reserves the brackets for
119 /// `http://[::1]/…`; anywhere else — a path, a query — they must be
120 /// percent-encoded. The single largest class in the field data (unescaped
121 /// brackets in harvested PDF URLs).
122 Bracket,
123 /// More than one `#`. The first starts the fragment; a `#` inside a fragment
124 /// is not a `pchar` and must be `%23`.
125 ExtraHash,
126 /// A `%` not followed by two hex digits, so it is not a `pct-encoded`
127 /// triplet. Often the trace of a broken string template rather than an
128 /// escaping mistake (`%x`, `%p`) — escaping it makes the dump loadable
129 /// without making the IRI *right*, which is the publisher's bug to fix.
130 BadPercent,
131 /// **The RFC 3987 parser rejected it and no class above explains why.**
132 /// Either the defect is a shape this taxonomy has never seen (`https://::1`
133 /// — an IPv6 literal in the authority without its brackets, the case this
134 /// bucket was added for), or escaping the shape that *was* recognised still
135 /// leaves an IRI the parser refuses.
136 ///
137 /// **Not repairable** — we do not know what is wrong, so we cannot claim to
138 /// have fixed it. It is counted and it blocks, which is the whole point: a
139 /// gap in the taxonomy must never read as a clean bill of health. Adding a
140 /// class later only moves occurrences out of here; it never turns an invalid
141 /// IRI into a valid one.
142 Unclassified,
143}
144
145/// Number of [`IriDefect`] classes — the width of a report's counter array.
146pub const DEFECT_CLASSES: usize = 6;
147
148impl IriDefect {
149 /// Every class, in declaration order — the iteration order of a report.
150 pub const ALL: [IriDefect; DEFECT_CLASSES] = [
151 IriDefect::NotAbsolute,
152 IriDefect::ForbiddenChar,
153 IriDefect::Bracket,
154 IriDefect::ExtraHash,
155 IriDefect::BadPercent,
156 IriDefect::Unclassified,
157 ];
158
159 /// Index into a report's per-class counters.
160 #[inline]
161 pub fn index(self) -> usize {
162 match self {
163 IriDefect::NotAbsolute => 0,
164 IriDefect::ForbiddenChar => 1,
165 IriDefect::Bracket => 2,
166 IriDefect::ExtraHash => 3,
167 IriDefect::BadPercent => 4,
168 IriDefect::Unclassified => 5,
169 }
170 }
171
172 /// A short human reason, for a warning line.
173 ///
174 /// These strings are **parsed downstream** — `scripts/export_scholar_nquads.sh`
175 /// matches a stable fragment of each to fill `state.tsv`. Changing one means
176 /// changing that parser in the same commit.
177 pub fn reason(self) -> &'static str {
178 match self {
179 IriDefect::NotAbsolute => "no scheme — a relative IRI, not an absolute one",
180 IriDefect::ForbiddenChar => {
181 "a character the IRIREF grammar excludes (space, control, or one of <>\"{}|^`\\)"
182 }
183 IriDefect::Bracket => "'[' or ']' outside an IP-literal host",
184 IriDefect::ExtraHash => "more than one '#'",
185 IriDefect::BadPercent => "'%' not followed by two hex digits",
186 IriDefect::Unclassified => {
187 "rejected by the RFC 3987 parser, and no repair class recognises it"
188 }
189 }
190 }
191
192 /// Can percent-encoding repair it without inventing information?
193 ///
194 /// Everything except [`IriDefect::NotAbsolute`], which needs a base IRI the
195 /// file does not carry, and [`IriDefect::Unclassified`], where we do not
196 /// know what is wrong.
197 ///
198 /// Callers deciding whether a dump may be published must ask **this**, over
199 /// every class — never whether a particular class is present. Hardcoding one
200 /// class is how `<https://::1>` got through.
201 #[inline]
202 pub fn repairable(self) -> bool {
203 !matches!(self, IriDefect::NotAbsolute | IriDefect::Unclassified)
204 }
205}
206
207#[inline]
208fn is_hex(b: u8) -> bool {
209 b.is_ascii_hexdigit()
210}
211
212/// Length of the well-formed `UCHAR` starting at `b[i]` (`\uXXXX` = 6,
213/// `\UXXXXXXXX` = 10), or `None` if this backslash does not open one.
214fn uchar_len(b: &[u8], i: usize) -> Option<usize> {
215 let (n, digits) = match b.get(i + 1) {
216 Some(b'u') => (6usize, 4usize),
217 Some(b'U') => (10usize, 8usize),
218 _ => return None,
219 };
220 if i + n > b.len() {
221 return None;
222 }
223 b[i + 2..i + 2 + digits]
224 .iter()
225 .all(|&d| is_hex(d))
226 .then_some(n)
227}
228
229/// The byte index of the scheme's `:`, if `s` opens with a well-formed
230/// `scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )` followed by `:`.
231fn scheme_colon(s: &str) -> Option<usize> {
232 let b = s.as_bytes();
233 if !b.first()?.is_ascii_alphabetic() {
234 return None;
235 }
236 let mut i = 1;
237 while i < b.len() {
238 match b[i] {
239 b':' => return Some(i),
240 c if c.is_ascii_alphanumeric() || c == b'+' || c == b'-' || c == b'.' => i += 1,
241 _ => return None,
242 }
243 }
244 None
245}
246
247/// The `[`/`]` byte positions of an IP-literal host (`scheme://[::1]…`), the one
248/// place RFC 3987 allows brackets. `None` when the IRI has no bracketed
249/// authority, in which case every bracket is a defect.
250fn ip_literal_brackets(s: &str, colon: usize) -> Option<(usize, usize)> {
251 let b = s.as_bytes();
252 if b.get(colon + 1) != Some(&b'/') || b.get(colon + 2) != Some(&b'/') {
253 return None;
254 }
255 let auth = colon + 3;
256 // The authority ends at the first '/', '?' or '#'; the ']' must be inside it.
257 let end = b[auth..]
258 .iter()
259 .position(|&c| c == b'/' || c == b'?' || c == b'#')
260 .map(|p| auth + p)
261 .unwrap_or(b.len());
262 // The host follows the userinfo, if any (`user:pw@[::1]`; either part may be
263 // empty). '@' is not legal inside userinfo, so the first one ends it.
264 let start = match b[auth..end].iter().position(|&c| c == b'@') {
265 Some(p) => auth + p + 1,
266 None => auth,
267 };
268 if b.get(start) != Some(&b'[') || start >= end {
269 return None;
270 }
271 let close = b[start..end].iter().position(|&c| c == b']')? + start;
272 Some((start, close))
273}
274
275/// Resolve the `UCHAR` escapes (`\uXXXX` / `\UXXXXXXXX`) an `IRIREF` may carry,
276/// yielding the IRI the escapes *denote*.
277///
278/// The backslash belongs to N-Triples, not to the IRI: `<http://ex/café>`
279/// names `http://ex/café`. Handing the raw bracket content to an RFC 3987 parser
280/// would reject the `\` and make every escaped dump a false positive, so the
281/// escapes are resolved first — which is exactly what `oxttl`'s lexer does
282/// before it calls the same parser.
283///
284/// `None` when an escape is malformed or names a surrogate, both of which a
285/// strict reader rejects outright (`char::from_u32` refuses `D800`–`DFFF`).
286///
287/// Borrows — and so costs nothing — for the overwhelmingly common IRI that
288/// carries no backslash at all.
289fn resolve_uchars(s: &str) -> Option<Cow<'_, str>> {
290 if !s.as_bytes().contains(&b'\\') {
291 return Some(Cow::Borrowed(s));
292 }
293 let b = s.as_bytes();
294 let mut out = String::with_capacity(s.len());
295 let mut i = 0;
296 while i < b.len() {
297 if b[i] != b'\\' {
298 let start = i;
299 while i < b.len() && b[i] != b'\\' {
300 i += 1;
301 }
302 out.push_str(&s[start..i]);
303 continue;
304 }
305 // Only UCHAR is legal inside an IRIREF — ECHAR (`\n`, `\"`, `\\`) is
306 // not, and `uchar_len` accepts nothing else.
307 let n = uchar_len(b, i)?;
308 let cp = u32::from_str_radix(&s[i + 2..i + n], 16).ok()?;
309 out.push(char::from_u32(cp)?);
310 i += n;
311 }
312 Some(Cow::Owned(out))
313}
314
315/// **The validity verdict.** Does `s` parse as an absolute RFC 3987 IRI?
316///
317/// Decided by `oxiri`, the crate Oxigraph's own N-Triples reader validates with,
318/// so the answer agrees with the loader that will read the dump. Nothing else in
319/// this module decides validity; the [`IriDefect`] classes only describe repairs.
320fn parses_as_absolute_iri(s: &str) -> bool {
321 match resolve_uchars(s) {
322 Some(resolved) => oxiri::Iri::parse(resolved.as_ref()).is_ok(),
323 None => false,
324 }
325}
326
327/// The **repair** classification: the N-Triples `IRIREF` character set plus the
328/// whole-IRI shapes escaping is known to fix. Says nothing about validity —
329/// [`iri_content_defect`] is the entry point, and it asks the parser.
330///
331/// One pass over the bytes with no allocation.
332fn shape_defect(s: &str) -> Option<IriDefect> {
333 let colon = match scheme_colon(s) {
334 Some(c) => c,
335 // A whole-IRI property, and the unrepairable one: report it before any
336 // character defect, so a relative IRI is never mistaken for something a
337 // sanitizer could fix.
338 None => return Some(IriDefect::NotAbsolute),
339 };
340 let brackets = ip_literal_brackets(s, colon);
341 let b = s.as_bytes();
342 let mut i = 0;
343 let mut seen_hash = false;
344 while i < b.len() {
345 let c = b[i];
346 if c >= 0x80 {
347 // RFC 3987 `ucschar` — a legal IRI character. Never touched.
348 i += 1;
349 continue;
350 }
351 match c {
352 0x00..=0x20 | 0x7f => return Some(IriDefect::ForbiddenChar),
353 b'<' | b'>' | b'"' | b'{' | b'}' | b'|' | b'^' | b'`' => {
354 return Some(IriDefect::ForbiddenChar)
355 }
356 b'\\' => match uchar_len(b, i) {
357 Some(n) => {
358 i += n;
359 continue;
360 }
361 None => return Some(IriDefect::ForbiddenChar),
362 },
363 b'[' | b']' => {
364 let ok = matches!(brackets, Some((o, c2)) if i == o || i == c2);
365 if !ok {
366 return Some(IriDefect::Bracket);
367 }
368 }
369 b'#' => {
370 if seen_hash {
371 return Some(IriDefect::ExtraHash);
372 }
373 seen_hash = true;
374 }
375 b'%' => {
376 if !(i + 2 < b.len() && is_hex(b[i + 1]) && is_hex(b[i + 2])) {
377 return Some(IriDefect::BadPercent);
378 }
379 i += 3;
380 continue;
381 }
382 _ => {}
383 }
384 i += 1;
385 }
386 None
387}
388
389/// Classify the **content of an `IRIREF`** — what sits between `<` and `>` —
390/// returning `None` when it is a valid absolute IRI, and otherwise the class
391/// that says what can be done about it.
392///
393/// **Validity is the parser's call, not the taxonomy's.** The order is:
394///
395/// 1. the cheap `shape_defect` scan, which settles the `IRIREF` character set
396/// and names a repair when it recognises one;
397/// 2. a repairable shape is only *reported* as repairable when escaping it
398/// verifiably lands an IRI the parser accepts — otherwise the IRI carries a
399/// second defect no class names, and calling it `Bracket` would have the
400/// sanitizer claim a fix it did not make;
401/// 3. anything the scan finds nothing wrong with still has to satisfy RFC 3987,
402/// and [`IriDefect::Unclassified`] is what a rejection with no known shape is
403/// called. It is counted, it is unrepairable, and it blocks.
404///
405/// The common case — a valid IRI — costs one byte scan plus one `oxiri` parse,
406/// with no allocation. Measured over 2,000,000 distinct real IRIs (104 MB, 52
407/// bytes mean) from a published dump: **224 ns each, 4.5 M IRI/s**, of which the
408/// parser is 190 ns and walking the bytes at all is 101 ns. The parser is
409/// therefore roughly a doubling of a check that was already far cheaper than the
410/// sort and the I/O around it. The re-escape in step 2 runs only for an IRI
411/// already known to be broken, which is the rare case by construction.
412pub fn iri_content_defect(s: &str) -> Option<IriDefect> {
413 match shape_defect(s) {
414 // A shape we know how to escape — but only if escaping it works.
415 Some(d) if d.repairable() => {
416 let fixed = escape_shape_defects(s);
417 match fixed {
418 Some(f) if shape_defect(&f).is_none() && parses_as_absolute_iri(&f) => Some(d),
419 _ => Some(IriDefect::Unclassified),
420 }
421 }
422 // NotAbsolute: no scheme, so there is nothing for a parser to add.
423 Some(d) => Some(d),
424 None => {
425 if parses_as_absolute_iri(s) {
426 None
427 } else {
428 Some(IriDefect::Unclassified)
429 }
430 }
431 }
432}
433
434fn push_pct(out: &mut Vec<u8>, byte: u8) {
435 const HEX: &[u8; 16] = b"0123456789ABCDEF";
436 out.push(b'%');
437 out.push(HEX[(byte >> 4) as usize]);
438 out.push(HEX[(byte & 0x0f) as usize]);
439}
440
441/// Percent-encode the offending characters of an `IRIREF`'s content.
442///
443/// Returns `None` when there is nothing to do (`s` is already a valid IRI) and
444/// when the defect is not repairable by escaping — [`IriDefect::NotAbsolute`],
445/// which needs a base IRI the file never recorded, and
446/// [`IriDefect::Unclassified`], where we do not know what is wrong.
447///
448/// **A `Some` result is guaranteed to satisfy [`iri_content_defect`]** — and,
449/// since that now asks a real RFC 3987 parser, guaranteed to be an IRI the
450/// loader accepts. `iri_content_defect` establishes this before it names a
451/// repairable class, so the check is not repeated here.
452///
453/// **`None` for an IRI that was already fine** is the other half of the
454/// contract, and the one failure mode a sanitizer must not have: this function
455/// never percent-encodes something valid. It is `iri_content_defect` that
456/// decides "fine", and it decides it with the parser.
457///
458/// This changes the IRI. `<http://ex/a[b]>` becomes `<http://ex/a%5Bb%5D>`,
459/// which is a *different* IRI: a sanitized dump no longer joins against the
460/// source graph, and `rete → store → rete` stops being the identity. That is why
461/// it is behind a flag and never the default.
462pub fn sanitize_iri_content(s: &str) -> Option<String> {
463 let defect = iri_content_defect(s)?;
464 if !defect.repairable() {
465 return None;
466 }
467 escape_shape_defects(s)
468}
469
470/// The escaping pass itself: percent-encode every character `shape_defect`
471/// objects to, and nothing else. Pure syntax — it does not decide whether the
472/// result is valid, which is [`iri_content_defect`]'s job.
473///
474/// `None` only when `s` has no scheme, where there is no authority to locate and
475/// so no way to tell a host's brackets from a path's.
476fn escape_shape_defects(s: &str) -> Option<String> {
477 let colon = scheme_colon(s)?;
478 let brackets = ip_literal_brackets(s, colon);
479 let b = s.as_bytes();
480 // Built as bytes: every branch below appends either ASCII or a verbatim run
481 // of the input's own bytes, so the result is valid UTF-8 by construction —
482 // whereas a `char`-wise copy would have to re-decode each multi-byte
483 // `ucschar` only to re-encode it.
484 let mut out: Vec<u8> = Vec::with_capacity(s.len() + 8);
485 let mut i = 0;
486 let mut seen_hash = false;
487 while i < b.len() {
488 let c = b[i];
489 if c >= 0x80 {
490 out.push(c); // RFC 3987 `ucschar` byte — copied untouched.
491 i += 1;
492 continue;
493 }
494 match c {
495 0x00..=0x20 | 0x7f | b'<' | b'>' | b'"' | b'{' | b'}' | b'|' | b'^' | b'`' => {
496 push_pct(&mut out, c)
497 }
498 b'\\' => match uchar_len(b, i) {
499 Some(n) => {
500 out.extend_from_slice(&b[i..i + n]);
501 i += n;
502 continue;
503 }
504 None => push_pct(&mut out, c),
505 },
506 b'[' | b']' => {
507 if matches!(brackets, Some((o, c2)) if i == o || i == c2) {
508 out.push(c);
509 } else {
510 push_pct(&mut out, c);
511 }
512 }
513 b'#' => {
514 if seen_hash {
515 push_pct(&mut out, c);
516 } else {
517 seen_hash = true;
518 out.push(b'#');
519 }
520 }
521 b'%' => {
522 if i + 2 < b.len() && is_hex(b[i + 1]) && is_hex(b[i + 2]) {
523 out.extend_from_slice(&b[i..i + 3]);
524 i += 3;
525 continue;
526 }
527 push_pct(&mut out, c);
528 }
529 _ => out.push(c),
530 }
531 i += 1;
532 }
533 String::from_utf8(out).ok()
534}
535
536/// The `^^<datatype>` IRI of a literal token, without its angle brackets.
537fn literal_datatype_content(token: &str) -> Option<&str> {
538 let inner = token.strip_suffix('>')?;
539 let at = inner.rfind("^^<")?;
540 Some(&inner[at + 3..])
541}
542
543/// Classify a **canonical N-Triples term token** — `<iri>`, `_:b`, `"lit"`,
544/// `"lit"^^<dt>`, `"lit"@en`, or an RDF-star `<<s p o>>` — returning the first
545/// IRI defect it carries, or `None`.
546///
547/// Every IRI a term can hide is checked: the term itself, a literal's datatype,
548/// and (recursively) the three components of a quoted triple.
549pub fn term_defect(token: &str) -> Option<IriDefect> {
550 if let Some((s, p, o)) = crate::ingest::quoted_triple_parts(token) {
551 return term_defect(&s)
552 .or_else(|| term_defect(&p))
553 .or_else(|| term_defect(&o));
554 }
555 if crate::terms::is_iri(token) {
556 return iri_content_defect(&token[1..token.len() - 1]);
557 }
558 if token.starts_with('"') {
559 return literal_datatype_content(token).and_then(iri_content_defect);
560 }
561 None
562}
563
564/// **Every** defect a term carries, not only the first.
565///
566/// A term is not always one IRI: an RDF-star quoted triple holds three, each
567/// able to be broken in its own way. [`term_defect`] answers "is this term
568/// invalid" and stops at the first reason, which is all a strict caller needs —
569/// but a *report* that stopped there would count `<<<http://ex/a[b]> <http://ex/p>
570/// <relative>>>` as a repairable `Bracket` and never record the relative IRI that
571/// actually blocks the dump. So the report counts per IRI, and this is what it
572/// walks.
573///
574/// Returns an empty `Vec` — which does not allocate — for a clean term, so the
575/// hot path pays nothing.
576pub fn term_defects(token: &str) -> Vec<IriDefect> {
577 let mut out = Vec::new();
578 collect_term_defects(token, &mut out);
579 out
580}
581
582fn collect_term_defects(token: &str, out: &mut Vec<IriDefect>) {
583 if let Some((s, p, o)) = crate::ingest::quoted_triple_parts(token) {
584 collect_term_defects(&s, out);
585 collect_term_defects(&p, out);
586 collect_term_defects(&o, out);
587 return;
588 }
589 if crate::terms::is_iri(token) {
590 out.extend(iri_content_defect(&token[1..token.len() - 1]));
591 } else if token.starts_with('"') {
592 out.extend(literal_datatype_content(token).and_then(iri_content_defect));
593 }
594}
595
596/// Percent-encode every repairable IRI inside a canonical term token, returning
597/// `None` when nothing changed. The token-level counterpart of
598/// [`sanitize_iri_content`]; it rebuilds quoted triples and literal datatypes
599/// around the repaired IRI so the result is still a canonical token.
600///
601/// **A `Some` result carries no defect**, exactly as for
602/// [`sanitize_iri_content`]. A quoted triple whose subject cannot be repaired is
603/// therefore returned as `None` even when its object could be: a *partial*
604/// repair changes IRIs without making the term loadable, and the report would
605/// count it as fixed. Found by the `iri` fuzz target.
606pub fn sanitize_term(token: &str) -> Option<String> {
607 if let Some((s, p, o)) = crate::ingest::quoted_triple_parts(token) {
608 let (rs, rp, ro) = (sanitize_term(&s), sanitize_term(&p), sanitize_term(&o));
609 if rs.is_none() && rp.is_none() && ro.is_none() {
610 return None;
611 }
612 let pick = |r: Option<String>, orig: String| r.unwrap_or(orig);
613 let rebuilt = format!("<<{} {} {}>>", pick(rs, s), pick(rp, p), pick(ro, o));
614 // All or nothing: a component we could not fix leaves the whole term
615 // invalid, and claiming the repair would be the lie this module exists
616 // to prevent.
617 return term_defect(&rebuilt).is_none().then_some(rebuilt);
618 }
619 if crate::terms::is_iri(token) {
620 return sanitize_iri_content(&token[1..token.len() - 1]).map(|c| format!("<{c}>"));
621 }
622 if token.starts_with('"') {
623 let dt = literal_datatype_content(token)?;
624 let fixed = sanitize_iri_content(dt)?;
625 let head = &token[..token.len() - 1 - dt.len()];
626 return Some(format!("{head}{fixed}>"));
627 }
628 None
629}
630
631/// A bounded tally of invalid IRIs — the shape both the build-time audit and the
632/// export-time sanitizer report.
633///
634/// Memory is **constant**, not proportional to the damage: per-class occurrence
635/// counters plus at most one example string per class. A graph where the same
636/// bad IRI appears ten million times costs five sample strings, not ten million.
637#[derive(Debug, Default, Clone)]
638pub struct IriReport {
639 statements: u64,
640 occurrences: u64,
641 repaired: u64,
642 counts: [u64; DEFECT_CLASSES],
643 samples: [Option<String>; DEFECT_CLASSES],
644}
645
646impl IriReport {
647 /// Record one offending term. Prefer [`IriReport::observe_term`] /
648 /// [`IriReport::sanitize`], which classify first.
649 fn note(&mut self, defect: IriDefect, token: &str) {
650 let i = defect.index();
651 self.counts[i] += 1;
652 self.occurrences += 1;
653 if self.samples[i].is_none() {
654 self.samples[i] = Some(token.to_string());
655 }
656 }
657
658 /// Classify one term token and record **every** defect it carries — an
659 /// RDF-star quoted triple holds three IRIs and can be broken in three ways.
660 /// Returns the first, so a strict caller can fail on the spot.
661 pub fn observe_term(&mut self, token: &str) -> Option<IriDefect> {
662 let defects = term_defects(token);
663 let first = defects.first().copied();
664 for d in defects {
665 self.note(d, token);
666 }
667 first
668 }
669
670 /// Classify a whole statement — subject, predicate, object and (for
671 /// N-Quads) the graph — recording every offending term and counting the
672 /// statement once. Returns the first defect found.
673 pub fn observe_quad(
674 &mut self,
675 s: &str,
676 p: &str,
677 o: &str,
678 g: Option<&str>,
679 ) -> Option<IriDefect> {
680 let mut first = None;
681 for t in [Some(s), Some(p), Some(o), g].into_iter().flatten() {
682 if let Some(d) = self.observe_term(t) {
683 first.get_or_insert(d);
684 }
685 }
686 if first.is_some() {
687 self.statements += 1;
688 }
689 first
690 }
691
692 /// Repair one term for export: percent-encode what escaping can fix, record
693 /// what it found, and return the token to emit. An unrepairable IRI is
694 /// counted and returned **unchanged** — the caller is told, and the data is
695 /// not silently dropped or invented.
696 pub fn sanitize<'a>(&mut self, token: &'a str) -> Cow<'a, str> {
697 let defects = term_defects(token);
698 if defects.is_empty() {
699 return Cow::Borrowed(token);
700 }
701 for &d in &defects {
702 self.note(d, token);
703 }
704 match sanitize_term(token) {
705 // `sanitize_term` only returns `Some` for a term that is now clean,
706 // so every defect above really was repaired.
707 Some(fixed) => {
708 self.repaired += defects.len() as u64;
709 Cow::Owned(fixed)
710 }
711 None => Cow::Borrowed(token),
712 }
713 }
714
715 /// Nothing invalid was seen.
716 pub fn is_empty(&self) -> bool {
717 self.occurrences == 0
718 }
719
720 /// Statements carrying at least one invalid IRI (build audit only; the
721 /// export sanitizer works term by term and leaves this at zero).
722 pub fn statements(&self) -> u64 {
723 self.statements
724 }
725
726 /// Invalid IRI **term occurrences** — the same IRI in a million statements
727 /// counts a million times.
728 pub fn occurrences(&self) -> u64 {
729 self.occurrences
730 }
731
732 /// Occurrences [`IriReport::sanitize`] actually rewrote.
733 pub fn repaired(&self) -> u64 {
734 self.repaired
735 }
736
737 /// Occurrences no escaping can repair — **every class** for which
738 /// [`IriDefect::repairable`] is false, asked class by class rather than
739 /// named one at a time.
740 ///
741 /// This is the number a publication gate must look at. `> 0` means the dump
742 /// contains an IRI that is still invalid after everything the sanitizer
743 /// could do, so a strict loader will reject it. Gating on one specific class
744 /// instead is how an unrecognised defect gets published.
745 pub fn unrepairable(&self) -> u64 {
746 IriDefect::ALL
747 .iter()
748 .filter(|d| !d.repairable())
749 .map(|d| self.counts[d.index()])
750 .sum()
751 }
752
753 /// Occurrences escaping *can* repair. Complements [`IriReport::unrepairable`]
754 /// — the two partition [`IriReport::occurrences`].
755 pub fn repairable(&self) -> u64 {
756 IriDefect::ALL
757 .iter()
758 .filter(|d| d.repairable())
759 .map(|d| self.counts[d.index()])
760 .sum()
761 }
762
763 /// Occurrences the RFC 3987 parser rejected that **no repair class
764 /// recognised** — [`IriDefect::Unclassified`].
765 ///
766 /// Reported separately because it is the number that says the taxonomy has a
767 /// gap. It is already inside [`IriReport::unrepairable`], so a gate that
768 /// reads that one does not need this; it is for the human who has to decide
769 /// whether a new class is worth adding.
770 pub fn unclassified(&self) -> u64 {
771 self.counts[IriDefect::Unclassified.index()]
772 }
773
774 /// Per-class occurrence count.
775 pub fn count(&self, defect: IriDefect) -> u64 {
776 self.counts[defect.index()]
777 }
778
779 /// The first term seen in this class, if any.
780 pub fn sample(&self, defect: IriDefect) -> Option<&str> {
781 self.samples[defect.index()].as_deref()
782 }
783
784 /// The non-empty classes, in declaration order, with their count and example
785 /// — the rows of a warning block.
786 pub fn classes(&self) -> impl Iterator<Item = (IriDefect, u64, Option<&str>)> + '_ {
787 IriDefect::ALL
788 .into_iter()
789 .filter(move |d| self.counts[d.index()] > 0)
790 .map(move |d| (d, self.counts[d.index()], self.sample(d)))
791 }
792
793 /// Fold another report in — used to total the per-input audits of a
794 /// multi-input build.
795 pub fn merge(&mut self, other: &IriReport) {
796 self.statements += other.statements;
797 self.occurrences += other.occurrences;
798 self.repaired += other.repaired;
799 for i in 0..DEFECT_CLASSES {
800 self.counts[i] += other.counts[i];
801 if self.samples[i].is_none() {
802 self.samples[i].clone_from(&other.samples[i]);
803 }
804 }
805 }
806}
807
808#[cfg(test)]
809mod tests {
810 use super::*;
811
812 #[test]
813 fn valid_iris_are_left_alone() {
814 for ok in [
815 "http://example.org/ok",
816 "https://example.org/p?q=1&r=2#frag",
817 "http://[::1]:7878/sparql",
818 "http://[2001:db8::1]/a",
819 "urn:uuid:2a5c3f60-0000-4000-8000-000000000000",
820 "mailto:someone@example.org",
821 "http://example.org/caf\u{e9}",
822 "http://example.org/a%20b",
823 "http://example.org/\\u00E9",
824 "tag:example.org,2026:x",
825 "http://example.org/(a),b;c=d!e$f*g'h+i",
826 "http://example.org/@at:colon",
827 "file:///tmp/x",
828 ] {
829 assert_eq!(iri_content_defect(ok), None, "{ok} should be valid");
830 assert_eq!(sanitize_iri_content(ok), None, "{ok} should need no repair");
831 }
832 }
833
834 /// The false-positive set. A wrong flag blocks a valid dump, so these are
835 /// the critical cases: every one is legal RFC 3987 and must stay clean.
836 #[test]
837 fn legal_iris_are_never_flagged() {
838 for ok in [
839 "http://user:pass@host/", // ':' inside userinfo
840 "http://[::1]:8080/p", // bracketed IPv6 with a port
841 "urn:isbn:0451450523", // no authority; colons are path
842 "mailto:a@b.com", // no authority; '@' is path
843 "http://host:8080/a:b", // colons in the path
844 "http://caf\u{e9}.example/", // RFC 3987 admits `ucschar`
845 "http://example.org/caf\u{e9}",
846 "https://[2001:db8::1]:443/x?q=1#f",
847 "http://host/", // empty port is legal
848 "http://host:/p",
849 "ftp://ftp.example.org/pub/",
850 "did:example:123456789abcdefghi",
851 "http://example.org/a?b=c&d=%20e#frag",
852 ] {
853 assert_eq!(iri_content_defect(ok), None, "{ok} must not be flagged");
854 assert_eq!(
855 sanitize_iri_content(ok),
856 None,
857 "{ok} was already fine and must not be rewritten"
858 );
859 }
860 }
861
862 /// The authority defects the five-class taxonomy could not see. Each must be
863 /// flagged, and flagged as something that BLOCKS.
864 #[test]
865 fn a_bad_authority_is_flagged_and_unrepairable() {
866 for bad in [
867 "https://::1", // the incident: unbracketed IPv6
868 "http://h:80x/", // non-digit port
869 "http://a@b@c/", // two '@' in the authority
870 "http://[::1/x", // unclosed bracket
871 "http://ho st/", // (space is ForbiddenChar first — see below)
872 ] {
873 let d = iri_content_defect(bad).unwrap_or_else(|| panic!("{bad} not flagged"));
874 assert!(
875 d == IriDefect::Unclassified || !d.repairable() || d == IriDefect::ForbiddenChar,
876 "{bad} -> {d:?}"
877 );
878 }
879 // Specifically: the incident IRI, and it does not repair.
880 assert_eq!(
881 iri_content_defect("https://::1"),
882 Some(IriDefect::Unclassified)
883 );
884 assert_eq!(sanitize_iri_content("https://::1"), None);
885 assert_eq!(
886 iri_content_defect("http://h:80x/"),
887 Some(IriDefect::Unclassified)
888 );
889 assert_eq!(
890 iri_content_defect("http://a@b@c/"),
891 Some(IriDefect::Unclassified)
892 );
893 }
894
895 /// A repairable shape wrapped around an unrepairable one must NOT be
896 /// reported as repairable: escaping the bracket leaves an IRI the parser
897 /// still rejects, and claiming a repair there is how a broken dump gets
898 /// marked publishable.
899 #[test]
900 fn a_repair_that_would_not_land_valid_is_not_claimed() {
901 let bad = "https://::1/a[b]";
902 assert_eq!(shape_defect(bad), Some(IriDefect::Bracket));
903 assert_eq!(iri_content_defect(bad), Some(IriDefect::Unclassified));
904 assert_eq!(sanitize_iri_content(bad), None);
905 }
906
907 /// `UCHAR` is N-Triples syntax, not IRI content. Resolving it before the
908 /// parser sees it is what keeps an escaped dump from being a false positive.
909 #[test]
910 fn uchar_escapes_are_resolved_before_the_parser_sees_them() {
911 // `é` denotes `é`, which RFC 3987 allows.
912 assert_eq!(iri_content_defect("http://example.org/\\u00E9"), None);
913 assert_eq!(sanitize_iri_content("http://example.org/\\u00E9"), None);
914 assert_eq!(iri_content_defect("http://example.org/a\\U0001F600b"), None);
915 // An escape can smuggle a character the raw grammar forbids. The parser
916 // is the only thing that catches it, and it must.
917 for smuggled in [
918 "http://example.org/a\\u0020b", // space
919 "http://example.org/a\\u003Eb", // '>'
920 "http://example.org/a\\u005Cb", // '\'
921 "http://example.org/a\\u0022b", // '"'
922 ] {
923 assert_eq!(
924 iri_content_defect(smuggled),
925 Some(IriDefect::Unclassified),
926 "{smuggled}"
927 );
928 }
929 // A surrogate is not a character; a strict reader refuses it.
930 assert_eq!(
931 iri_content_defect("http://example.org/\\uD800"),
932 Some(IriDefect::Unclassified)
933 );
934 // `#` and `?` arrive legally through an escape.
935 assert_eq!(iri_content_defect("http://example.org/a\\u003Fq"), None);
936 }
937
938 #[test]
939 fn the_five_classes_are_recognized() {
940 let cases = [
941 ("noscheme/path", IriDefect::NotAbsolute),
942 ("", IriDefect::NotAbsolute),
943 ("/absolute/path", IriDefect::NotAbsolute),
944 ("1http://example.org/", IriDefect::NotAbsolute),
945 ("http://example.org/a b", IriDefect::ForbiddenChar),
946 ("http://example.org/a\"b", IriDefect::ForbiddenChar),
947 ("http://example.org/a|b", IriDefect::ForbiddenChar),
948 ("http://example.org/a\\b", IriDefect::ForbiddenChar),
949 ("http://example.org/a\u{7f}b", IriDefect::ForbiddenChar),
950 ("http://example.org/a[b]", IriDefect::Bracket),
951 ("http://example.org/?q=[1]", IriDefect::Bracket),
952 ("http://example.org/c#d#e", IriDefect::ExtraHash),
953 ("http://example.org/%x", IriDefect::BadPercent),
954 ("http://example.org/%", IriDefect::BadPercent),
955 ("http://example.org/%2", IriDefect::BadPercent),
956 ];
957 for (bad, want) in cases {
958 assert_eq!(iri_content_defect(bad), Some(want), "{bad}");
959 }
960 }
961
962 #[test]
963 fn repairs_are_idempotent_and_land_valid() {
964 for bad in [
965 "http://example.org/a[b]",
966 "http://example.org/c#d#e",
967 "http://example.org/%x",
968 "http://example.org/a b",
969 "http://example.org/a\"b|c{d}e^f`g",
970 "http://example.org/caf\u{e9}[x]",
971 "http://[::1]/a[b]",
972 "http://example.org/a\\b",
973 ] {
974 let fixed = sanitize_iri_content(bad).unwrap_or_else(|| panic!("{bad} unrepaired"));
975 assert_eq!(iri_content_defect(&fixed), None, "{bad} -> {fixed}");
976 assert_eq!(
977 sanitize_iri_content(&fixed),
978 None,
979 "not idempotent: {fixed}"
980 );
981 }
982 }
983
984 #[test]
985 fn the_issue_examples_repair_to_the_expected_iris() {
986 assert_eq!(
987 sanitize_iri_content("http://example.org/a[b]").as_deref(),
988 Some("http://example.org/a%5Bb%5D")
989 );
990 assert_eq!(
991 sanitize_iri_content("http://example.org/c#d#e").as_deref(),
992 Some("http://example.org/c#d%23e")
993 );
994 // No scheme: reported, never rewritten.
995 assert_eq!(sanitize_iri_content("noscheme/path"), None);
996 assert_eq!(
997 iri_content_defect("noscheme/path"),
998 Some(IriDefect::NotAbsolute)
999 );
1000 }
1001
1002 #[test]
1003 fn an_ip_literal_host_keeps_its_brackets_but_a_path_does_not() {
1004 assert_eq!(iri_content_defect("http://[::1]/x"), None);
1005 assert_eq!(
1006 sanitize_iri_content("http://[::1]/x[y]").as_deref(),
1007 Some("http://[::1]/x%5By%5D")
1008 );
1009 // A bracket that is not the authority's is still a defect.
1010 assert_eq!(
1011 iri_content_defect("http://ex/[::1]"),
1012 Some(IriDefect::Bracket)
1013 );
1014 }
1015
1016 #[test]
1017 fn non_ascii_survives_a_repair_byte_for_byte() {
1018 let fixed = sanitize_iri_content("http://example.org/\u{4e2d}\u{6587}[x]").unwrap();
1019 assert_eq!(fixed, "http://example.org/\u{4e2d}\u{6587}%5Bx%5D");
1020 }
1021
1022 #[test]
1023 fn term_level_checks_reach_datatypes_and_quoted_triples() {
1024 assert_eq!(term_defect("<http://ex/ok>"), None);
1025 assert_eq!(term_defect("_:b0"), None);
1026 assert_eq!(term_defect("\"plain\""), None);
1027 assert_eq!(term_defect("\"x\"@en"), None);
1028 assert_eq!(
1029 term_defect("\"x\"^^<http://ex/dt[1]>"),
1030 Some(IriDefect::Bracket)
1031 );
1032 assert_eq!(
1033 sanitize_term("\"x\"^^<http://ex/dt[1]>").as_deref(),
1034 Some("\"x\"^^<http://ex/dt%5B1%5D>")
1035 );
1036 assert_eq!(
1037 term_defect("<<<http://ex/a[b]> <http://ex/p> \"o\">>"),
1038 Some(IriDefect::Bracket)
1039 );
1040 assert_eq!(
1041 sanitize_term("<<<http://ex/a[b]> <http://ex/p> \"o\">>").as_deref(),
1042 Some("<<<http://ex/a%5Bb%5D> <http://ex/p> \"o\">>")
1043 );
1044 }
1045
1046 #[test]
1047 fn a_report_counts_occurrences_and_keeps_one_sample_per_class() {
1048 let mut r = IriReport::default();
1049 r.observe_quad(
1050 "<http://ex/a[b]>",
1051 "<http://ex/p>",
1052 "<http://ex/c#d#e>",
1053 None,
1054 );
1055 r.observe_quad("<noscheme/x>", "<http://ex/p>", "\"lit\"", None);
1056 r.observe_quad("<http://ex/a[b]>", "<http://ex/p>", "\"lit\"", None);
1057 assert_eq!(r.statements(), 3);
1058 assert_eq!(r.occurrences(), 4);
1059 assert_eq!(r.count(IriDefect::Bracket), 2);
1060 assert_eq!(r.count(IriDefect::ExtraHash), 1);
1061 assert_eq!(r.unrepairable(), 1);
1062 assert_eq!(r.sample(IriDefect::Bracket), Some("<http://ex/a[b]>"));
1063 assert_eq!(r.classes().count(), 3);
1064 }
1065
1066 /// Found by the `iri` fuzz target. A quoted triple whose subject is a
1067 /// relative IRI and whose object is merely badly escaped used to be
1068 /// "repaired" into a term that was still invalid — and, worse, counted as
1069 /// repaired, with only the *first* defect recorded, so the relative IRI
1070 /// never reached `unrepairable()` and never blocked.
1071 #[test]
1072 fn a_partly_repairable_quoted_triple_is_not_claimed_as_repaired() {
1073 let token = "<<<relative/x> <http://ex/p> <http://ex/o[1]>>>";
1074 assert_eq!(sanitize_term(token), None, "a partial repair was claimed");
1075
1076 let mut r = IriReport::default();
1077 assert_eq!(r.sanitize(token), token, "the term must be left verbatim");
1078 assert_eq!(r.repaired(), 0);
1079 // BOTH defects are recorded, not just the first.
1080 assert_eq!(r.occurrences(), 2);
1081 assert_eq!(r.count(IriDefect::NotAbsolute), 1);
1082 assert_eq!(r.count(IriDefect::Bracket), 1);
1083 // And the one that blocks is visible to the gate.
1084 assert_eq!(r.unrepairable(), 1);
1085 }
1086
1087 /// The other half: when every component *can* be repaired, it is.
1088 #[test]
1089 fn a_fully_repairable_quoted_triple_still_repairs() {
1090 let token = "<<<http://ex/a[b]> <http://ex/p> <http://ex/c#d#e>>>";
1091 let mut r = IriReport::default();
1092 assert_eq!(
1093 r.sanitize(token),
1094 "<<<http://ex/a%5Bb%5D> <http://ex/p> <http://ex/c#d%23e>>>"
1095 );
1096 assert_eq!(r.occurrences(), 2);
1097 assert_eq!(r.repaired(), 2);
1098 assert_eq!(r.unrepairable(), 0);
1099 }
1100
1101 #[test]
1102 fn sanitizing_reports_what_it_could_not_repair() {
1103 let mut r = IriReport::default();
1104 assert_eq!(r.sanitize("<http://ex/a[b]>"), "<http://ex/a%5Bb%5D>");
1105 assert_eq!(r.sanitize("<noscheme/x>"), "<noscheme/x>");
1106 assert_eq!(r.sanitize("<http://ex/fine>"), "<http://ex/fine>");
1107 assert_eq!(r.occurrences(), 2);
1108 assert_eq!(r.repaired(), 1);
1109 assert_eq!(r.unrepairable(), 1);
1110 }
1111}