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 start = colon + 3;
256 if b.get(start) != Some(&b'[') {
257 return None;
258 }
259 // The authority ends at the first '/', '?' or '#'; the ']' must be inside it.
260 let end = b[start..]
261 .iter()
262 .position(|&c| c == b'/' || c == b'?' || c == b'#')
263 .map(|p| start + p)
264 .unwrap_or(b.len());
265 let close = b[start..end].iter().position(|&c| c == b']')? + start;
266 Some((start, close))
267}
268
269/// Resolve the `UCHAR` escapes (`\uXXXX` / `\UXXXXXXXX`) an `IRIREF` may carry,
270/// yielding the IRI the escapes *denote*.
271///
272/// The backslash belongs to N-Triples, not to the IRI: `<http://ex/café>`
273/// names `http://ex/café`. Handing the raw bracket content to an RFC 3987 parser
274/// would reject the `\` and make every escaped dump a false positive, so the
275/// escapes are resolved first — which is exactly what `oxttl`'s lexer does
276/// before it calls the same parser.
277///
278/// `None` when an escape is malformed or names a surrogate, both of which a
279/// strict reader rejects outright (`char::from_u32` refuses `D800`–`DFFF`).
280///
281/// Borrows — and so costs nothing — for the overwhelmingly common IRI that
282/// carries no backslash at all.
283fn resolve_uchars(s: &str) -> Option<Cow<'_, str>> {
284 if !s.as_bytes().contains(&b'\\') {
285 return Some(Cow::Borrowed(s));
286 }
287 let b = s.as_bytes();
288 let mut out = String::with_capacity(s.len());
289 let mut i = 0;
290 while i < b.len() {
291 if b[i] != b'\\' {
292 let start = i;
293 while i < b.len() && b[i] != b'\\' {
294 i += 1;
295 }
296 out.push_str(&s[start..i]);
297 continue;
298 }
299 // Only UCHAR is legal inside an IRIREF — ECHAR (`\n`, `\"`, `\\`) is
300 // not, and `uchar_len` accepts nothing else.
301 let n = uchar_len(b, i)?;
302 let cp = u32::from_str_radix(&s[i + 2..i + n], 16).ok()?;
303 out.push(char::from_u32(cp)?);
304 i += n;
305 }
306 Some(Cow::Owned(out))
307}
308
309/// **The validity verdict.** Does `s` parse as an absolute RFC 3987 IRI?
310///
311/// Decided by `oxiri`, the crate Oxigraph's own N-Triples reader validates with,
312/// so the answer agrees with the loader that will read the dump. Nothing else in
313/// this module decides validity; the [`IriDefect`] classes only describe repairs.
314fn parses_as_absolute_iri(s: &str) -> bool {
315 match resolve_uchars(s) {
316 Some(resolved) => oxiri::Iri::parse(resolved.as_ref()).is_ok(),
317 None => false,
318 }
319}
320
321/// The **repair** classification: the N-Triples `IRIREF` character set plus the
322/// whole-IRI shapes escaping is known to fix. Says nothing about validity —
323/// [`iri_content_defect`] is the entry point, and it asks the parser.
324///
325/// One pass over the bytes with no allocation.
326fn shape_defect(s: &str) -> Option<IriDefect> {
327 let colon = match scheme_colon(s) {
328 Some(c) => c,
329 // A whole-IRI property, and the unrepairable one: report it before any
330 // character defect, so a relative IRI is never mistaken for something a
331 // sanitizer could fix.
332 None => return Some(IriDefect::NotAbsolute),
333 };
334 let brackets = ip_literal_brackets(s, colon);
335 let b = s.as_bytes();
336 let mut i = 0;
337 let mut seen_hash = false;
338 while i < b.len() {
339 let c = b[i];
340 if c >= 0x80 {
341 // RFC 3987 `ucschar` — a legal IRI character. Never touched.
342 i += 1;
343 continue;
344 }
345 match c {
346 0x00..=0x20 | 0x7f => return Some(IriDefect::ForbiddenChar),
347 b'<' | b'>' | b'"' | b'{' | b'}' | b'|' | b'^' | b'`' => {
348 return Some(IriDefect::ForbiddenChar)
349 }
350 b'\\' => match uchar_len(b, i) {
351 Some(n) => {
352 i += n;
353 continue;
354 }
355 None => return Some(IriDefect::ForbiddenChar),
356 },
357 b'[' | b']' => {
358 let ok = matches!(brackets, Some((o, c2)) if i == o || i == c2);
359 if !ok {
360 return Some(IriDefect::Bracket);
361 }
362 }
363 b'#' => {
364 if seen_hash {
365 return Some(IriDefect::ExtraHash);
366 }
367 seen_hash = true;
368 }
369 b'%' => {
370 if !(i + 2 < b.len() && is_hex(b[i + 1]) && is_hex(b[i + 2])) {
371 return Some(IriDefect::BadPercent);
372 }
373 i += 3;
374 continue;
375 }
376 _ => {}
377 }
378 i += 1;
379 }
380 None
381}
382
383/// Classify the **content of an `IRIREF`** — what sits between `<` and `>` —
384/// returning `None` when it is a valid absolute IRI, and otherwise the class
385/// that says what can be done about it.
386///
387/// **Validity is the parser's call, not the taxonomy's.** The order is:
388///
389/// 1. the cheap `shape_defect` scan, which settles the `IRIREF` character set
390/// and names a repair when it recognises one;
391/// 2. a repairable shape is only *reported* as repairable when escaping it
392/// verifiably lands an IRI the parser accepts — otherwise the IRI carries a
393/// second defect no class names, and calling it `Bracket` would have the
394/// sanitizer claim a fix it did not make;
395/// 3. anything the scan finds nothing wrong with still has to satisfy RFC 3987,
396/// and [`IriDefect::Unclassified`] is what a rejection with no known shape is
397/// called. It is counted, it is unrepairable, and it blocks.
398///
399/// The common case — a valid IRI — costs one byte scan plus one `oxiri` parse,
400/// with no allocation. Measured over 2,000,000 distinct real IRIs (104 MB, 52
401/// bytes mean) from a published dump: **224 ns each, 4.5 M IRI/s**, of which the
402/// parser is 190 ns and walking the bytes at all is 101 ns. The parser is
403/// therefore roughly a doubling of a check that was already far cheaper than the
404/// sort and the I/O around it. The re-escape in step 2 runs only for an IRI
405/// already known to be broken, which is the rare case by construction.
406pub fn iri_content_defect(s: &str) -> Option<IriDefect> {
407 match shape_defect(s) {
408 // A shape we know how to escape — but only if escaping it works.
409 Some(d) if d.repairable() => {
410 let fixed = escape_shape_defects(s);
411 match fixed {
412 Some(f) if shape_defect(&f).is_none() && parses_as_absolute_iri(&f) => Some(d),
413 _ => Some(IriDefect::Unclassified),
414 }
415 }
416 // NotAbsolute: no scheme, so there is nothing for a parser to add.
417 Some(d) => Some(d),
418 None => {
419 if parses_as_absolute_iri(s) {
420 None
421 } else {
422 Some(IriDefect::Unclassified)
423 }
424 }
425 }
426}
427
428fn push_pct(out: &mut Vec<u8>, byte: u8) {
429 const HEX: &[u8; 16] = b"0123456789ABCDEF";
430 out.push(b'%');
431 out.push(HEX[(byte >> 4) as usize]);
432 out.push(HEX[(byte & 0x0f) as usize]);
433}
434
435/// Percent-encode the offending characters of an `IRIREF`'s content.
436///
437/// Returns `None` when there is nothing to do (`s` is already a valid IRI) and
438/// when the defect is not repairable by escaping — [`IriDefect::NotAbsolute`],
439/// which needs a base IRI the file never recorded, and
440/// [`IriDefect::Unclassified`], where we do not know what is wrong.
441///
442/// **A `Some` result is guaranteed to satisfy [`iri_content_defect`]** — and,
443/// since that now asks a real RFC 3987 parser, guaranteed to be an IRI the
444/// loader accepts. `iri_content_defect` establishes this before it names a
445/// repairable class, so the check is not repeated here.
446///
447/// **`None` for an IRI that was already fine** is the other half of the
448/// contract, and the one failure mode a sanitizer must not have: this function
449/// never percent-encodes something valid. It is `iri_content_defect` that
450/// decides "fine", and it decides it with the parser.
451///
452/// This changes the IRI. `<http://ex/a[b]>` becomes `<http://ex/a%5Bb%5D>`,
453/// which is a *different* IRI: a sanitized dump no longer joins against the
454/// source graph, and `rete → store → rete` stops being the identity. That is why
455/// it is behind a flag and never the default.
456pub fn sanitize_iri_content(s: &str) -> Option<String> {
457 let defect = iri_content_defect(s)?;
458 if !defect.repairable() {
459 return None;
460 }
461 escape_shape_defects(s)
462}
463
464/// The escaping pass itself: percent-encode every character `shape_defect`
465/// objects to, and nothing else. Pure syntax — it does not decide whether the
466/// result is valid, which is [`iri_content_defect`]'s job.
467///
468/// `None` only when `s` has no scheme, where there is no authority to locate and
469/// so no way to tell a host's brackets from a path's.
470fn escape_shape_defects(s: &str) -> Option<String> {
471 let colon = scheme_colon(s)?;
472 let brackets = ip_literal_brackets(s, colon);
473 let b = s.as_bytes();
474 // Built as bytes: every branch below appends either ASCII or a verbatim run
475 // of the input's own bytes, so the result is valid UTF-8 by construction —
476 // whereas a `char`-wise copy would have to re-decode each multi-byte
477 // `ucschar` only to re-encode it.
478 let mut out: Vec<u8> = Vec::with_capacity(s.len() + 8);
479 let mut i = 0;
480 let mut seen_hash = false;
481 while i < b.len() {
482 let c = b[i];
483 if c >= 0x80 {
484 out.push(c); // RFC 3987 `ucschar` byte — copied untouched.
485 i += 1;
486 continue;
487 }
488 match c {
489 0x00..=0x20 | 0x7f | b'<' | b'>' | b'"' | b'{' | b'}' | b'|' | b'^' | b'`' => {
490 push_pct(&mut out, c)
491 }
492 b'\\' => match uchar_len(b, i) {
493 Some(n) => {
494 out.extend_from_slice(&b[i..i + n]);
495 i += n;
496 continue;
497 }
498 None => push_pct(&mut out, c),
499 },
500 b'[' | b']' => {
501 if matches!(brackets, Some((o, c2)) if i == o || i == c2) {
502 out.push(c);
503 } else {
504 push_pct(&mut out, c);
505 }
506 }
507 b'#' => {
508 if seen_hash {
509 push_pct(&mut out, c);
510 } else {
511 seen_hash = true;
512 out.push(b'#');
513 }
514 }
515 b'%' => {
516 if i + 2 < b.len() && is_hex(b[i + 1]) && is_hex(b[i + 2]) {
517 out.extend_from_slice(&b[i..i + 3]);
518 i += 3;
519 continue;
520 }
521 push_pct(&mut out, c);
522 }
523 _ => out.push(c),
524 }
525 i += 1;
526 }
527 String::from_utf8(out).ok()
528}
529
530/// The `^^<datatype>` IRI of a literal token, without its angle brackets.
531fn literal_datatype_content(token: &str) -> Option<&str> {
532 let inner = token.strip_suffix('>')?;
533 let at = inner.rfind("^^<")?;
534 Some(&inner[at + 3..])
535}
536
537/// Classify a **canonical N-Triples term token** — `<iri>`, `_:b`, `"lit"`,
538/// `"lit"^^<dt>`, `"lit"@en`, or an RDF-star `<<s p o>>` — returning the first
539/// IRI defect it carries, or `None`.
540///
541/// Every IRI a term can hide is checked: the term itself, a literal's datatype,
542/// and (recursively) the three components of a quoted triple.
543pub fn term_defect(token: &str) -> Option<IriDefect> {
544 if let Some((s, p, o)) = crate::ingest::quoted_triple_parts(token) {
545 return term_defect(&s)
546 .or_else(|| term_defect(&p))
547 .or_else(|| term_defect(&o));
548 }
549 if crate::terms::is_iri(token) {
550 return iri_content_defect(&token[1..token.len() - 1]);
551 }
552 if token.starts_with('"') {
553 return literal_datatype_content(token).and_then(iri_content_defect);
554 }
555 None
556}
557
558/// **Every** defect a term carries, not only the first.
559///
560/// A term is not always one IRI: an RDF-star quoted triple holds three, each
561/// able to be broken in its own way. [`term_defect`] answers "is this term
562/// invalid" and stops at the first reason, which is all a strict caller needs —
563/// but a *report* that stopped there would count `<<<http://ex/a[b]> <http://ex/p>
564/// <relative>>>` as a repairable `Bracket` and never record the relative IRI that
565/// actually blocks the dump. So the report counts per IRI, and this is what it
566/// walks.
567///
568/// Returns an empty `Vec` — which does not allocate — for a clean term, so the
569/// hot path pays nothing.
570pub fn term_defects(token: &str) -> Vec<IriDefect> {
571 let mut out = Vec::new();
572 collect_term_defects(token, &mut out);
573 out
574}
575
576fn collect_term_defects(token: &str, out: &mut Vec<IriDefect>) {
577 if let Some((s, p, o)) = crate::ingest::quoted_triple_parts(token) {
578 collect_term_defects(&s, out);
579 collect_term_defects(&p, out);
580 collect_term_defects(&o, out);
581 return;
582 }
583 if crate::terms::is_iri(token) {
584 out.extend(iri_content_defect(&token[1..token.len() - 1]));
585 } else if token.starts_with('"') {
586 out.extend(literal_datatype_content(token).and_then(iri_content_defect));
587 }
588}
589
590/// Percent-encode every repairable IRI inside a canonical term token, returning
591/// `None` when nothing changed. The token-level counterpart of
592/// [`sanitize_iri_content`]; it rebuilds quoted triples and literal datatypes
593/// around the repaired IRI so the result is still a canonical token.
594///
595/// **A `Some` result carries no defect**, exactly as for
596/// [`sanitize_iri_content`]. A quoted triple whose subject cannot be repaired is
597/// therefore returned as `None` even when its object could be: a *partial*
598/// repair changes IRIs without making the term loadable, and the report would
599/// count it as fixed. Found by the `iri` fuzz target.
600pub fn sanitize_term(token: &str) -> Option<String> {
601 if let Some((s, p, o)) = crate::ingest::quoted_triple_parts(token) {
602 let (rs, rp, ro) = (sanitize_term(&s), sanitize_term(&p), sanitize_term(&o));
603 if rs.is_none() && rp.is_none() && ro.is_none() {
604 return None;
605 }
606 let pick = |r: Option<String>, orig: String| r.unwrap_or(orig);
607 let rebuilt = format!("<<{} {} {}>>", pick(rs, s), pick(rp, p), pick(ro, o));
608 // All or nothing: a component we could not fix leaves the whole term
609 // invalid, and claiming the repair would be the lie this module exists
610 // to prevent.
611 return term_defect(&rebuilt).is_none().then_some(rebuilt);
612 }
613 if crate::terms::is_iri(token) {
614 return sanitize_iri_content(&token[1..token.len() - 1]).map(|c| format!("<{c}>"));
615 }
616 if token.starts_with('"') {
617 let dt = literal_datatype_content(token)?;
618 let fixed = sanitize_iri_content(dt)?;
619 let head = &token[..token.len() - 1 - dt.len()];
620 return Some(format!("{head}{fixed}>"));
621 }
622 None
623}
624
625/// A bounded tally of invalid IRIs — the shape both the build-time audit and the
626/// export-time sanitizer report.
627///
628/// Memory is **constant**, not proportional to the damage: per-class occurrence
629/// counters plus at most one example string per class. A graph where the same
630/// bad IRI appears ten million times costs five sample strings, not ten million.
631#[derive(Debug, Default, Clone)]
632pub struct IriReport {
633 statements: u64,
634 occurrences: u64,
635 repaired: u64,
636 counts: [u64; DEFECT_CLASSES],
637 samples: [Option<String>; DEFECT_CLASSES],
638}
639
640impl IriReport {
641 /// Record one offending term. Prefer [`IriReport::observe_term`] /
642 /// [`IriReport::sanitize`], which classify first.
643 fn note(&mut self, defect: IriDefect, token: &str) {
644 let i = defect.index();
645 self.counts[i] += 1;
646 self.occurrences += 1;
647 if self.samples[i].is_none() {
648 self.samples[i] = Some(token.to_string());
649 }
650 }
651
652 /// Classify one term token and record **every** defect it carries — an
653 /// RDF-star quoted triple holds three IRIs and can be broken in three ways.
654 /// Returns the first, so a strict caller can fail on the spot.
655 pub fn observe_term(&mut self, token: &str) -> Option<IriDefect> {
656 let defects = term_defects(token);
657 let first = defects.first().copied();
658 for d in defects {
659 self.note(d, token);
660 }
661 first
662 }
663
664 /// Classify a whole statement — subject, predicate, object and (for
665 /// N-Quads) the graph — recording every offending term and counting the
666 /// statement once. Returns the first defect found.
667 pub fn observe_quad(
668 &mut self,
669 s: &str,
670 p: &str,
671 o: &str,
672 g: Option<&str>,
673 ) -> Option<IriDefect> {
674 let mut first = None;
675 for t in [Some(s), Some(p), Some(o), g].into_iter().flatten() {
676 if let Some(d) = self.observe_term(t) {
677 first.get_or_insert(d);
678 }
679 }
680 if first.is_some() {
681 self.statements += 1;
682 }
683 first
684 }
685
686 /// Repair one term for export: percent-encode what escaping can fix, record
687 /// what it found, and return the token to emit. An unrepairable IRI is
688 /// counted and returned **unchanged** — the caller is told, and the data is
689 /// not silently dropped or invented.
690 pub fn sanitize<'a>(&mut self, token: &'a str) -> Cow<'a, str> {
691 let defects = term_defects(token);
692 if defects.is_empty() {
693 return Cow::Borrowed(token);
694 }
695 for &d in &defects {
696 self.note(d, token);
697 }
698 match sanitize_term(token) {
699 // `sanitize_term` only returns `Some` for a term that is now clean,
700 // so every defect above really was repaired.
701 Some(fixed) => {
702 self.repaired += defects.len() as u64;
703 Cow::Owned(fixed)
704 }
705 None => Cow::Borrowed(token),
706 }
707 }
708
709 /// Nothing invalid was seen.
710 pub fn is_empty(&self) -> bool {
711 self.occurrences == 0
712 }
713
714 /// Statements carrying at least one invalid IRI (build audit only; the
715 /// export sanitizer works term by term and leaves this at zero).
716 pub fn statements(&self) -> u64 {
717 self.statements
718 }
719
720 /// Invalid IRI **term occurrences** — the same IRI in a million statements
721 /// counts a million times.
722 pub fn occurrences(&self) -> u64 {
723 self.occurrences
724 }
725
726 /// Occurrences [`IriReport::sanitize`] actually rewrote.
727 pub fn repaired(&self) -> u64 {
728 self.repaired
729 }
730
731 /// Occurrences no escaping can repair — **every class** for which
732 /// [`IriDefect::repairable`] is false, asked class by class rather than
733 /// named one at a time.
734 ///
735 /// This is the number a publication gate must look at. `> 0` means the dump
736 /// contains an IRI that is still invalid after everything the sanitizer
737 /// could do, so a strict loader will reject it. Gating on one specific class
738 /// instead is how an unrecognised defect gets published.
739 pub fn unrepairable(&self) -> u64 {
740 IriDefect::ALL
741 .iter()
742 .filter(|d| !d.repairable())
743 .map(|d| self.counts[d.index()])
744 .sum()
745 }
746
747 /// Occurrences escaping *can* repair. Complements [`IriReport::unrepairable`]
748 /// — the two partition [`IriReport::occurrences`].
749 pub fn repairable(&self) -> u64 {
750 IriDefect::ALL
751 .iter()
752 .filter(|d| d.repairable())
753 .map(|d| self.counts[d.index()])
754 .sum()
755 }
756
757 /// Occurrences the RFC 3987 parser rejected that **no repair class
758 /// recognised** — [`IriDefect::Unclassified`].
759 ///
760 /// Reported separately because it is the number that says the taxonomy has a
761 /// gap. It is already inside [`IriReport::unrepairable`], so a gate that
762 /// reads that one does not need this; it is for the human who has to decide
763 /// whether a new class is worth adding.
764 pub fn unclassified(&self) -> u64 {
765 self.counts[IriDefect::Unclassified.index()]
766 }
767
768 /// Per-class occurrence count.
769 pub fn count(&self, defect: IriDefect) -> u64 {
770 self.counts[defect.index()]
771 }
772
773 /// The first term seen in this class, if any.
774 pub fn sample(&self, defect: IriDefect) -> Option<&str> {
775 self.samples[defect.index()].as_deref()
776 }
777
778 /// The non-empty classes, in declaration order, with their count and example
779 /// — the rows of a warning block.
780 pub fn classes(&self) -> impl Iterator<Item = (IriDefect, u64, Option<&str>)> + '_ {
781 IriDefect::ALL
782 .into_iter()
783 .filter(move |d| self.counts[d.index()] > 0)
784 .map(move |d| (d, self.counts[d.index()], self.sample(d)))
785 }
786
787 /// Fold another report in — used to total the per-input audits of a
788 /// multi-input build.
789 pub fn merge(&mut self, other: &IriReport) {
790 self.statements += other.statements;
791 self.occurrences += other.occurrences;
792 self.repaired += other.repaired;
793 for i in 0..DEFECT_CLASSES {
794 self.counts[i] += other.counts[i];
795 if self.samples[i].is_none() {
796 self.samples[i].clone_from(&other.samples[i]);
797 }
798 }
799 }
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805
806 #[test]
807 fn valid_iris_are_left_alone() {
808 for ok in [
809 "http://example.org/ok",
810 "https://example.org/p?q=1&r=2#frag",
811 "http://[::1]:7878/sparql",
812 "http://[2001:db8::1]/a",
813 "urn:uuid:2a5c3f60-0000-4000-8000-000000000000",
814 "mailto:someone@example.org",
815 "http://example.org/caf\u{e9}",
816 "http://example.org/a%20b",
817 "http://example.org/\\u00E9",
818 "tag:example.org,2026:x",
819 "http://example.org/(a),b;c=d!e$f*g'h+i",
820 "http://example.org/@at:colon",
821 "file:///tmp/x",
822 ] {
823 assert_eq!(iri_content_defect(ok), None, "{ok} should be valid");
824 assert_eq!(sanitize_iri_content(ok), None, "{ok} should need no repair");
825 }
826 }
827
828 /// The false-positive set. A wrong flag blocks a valid dump, so these are
829 /// the critical cases: every one is legal RFC 3987 and must stay clean.
830 #[test]
831 fn legal_iris_are_never_flagged() {
832 for ok in [
833 "http://user:pass@host/", // ':' inside userinfo
834 "http://[::1]:8080/p", // bracketed IPv6 with a port
835 "urn:isbn:0451450523", // no authority; colons are path
836 "mailto:a@b.com", // no authority; '@' is path
837 "http://host:8080/a:b", // colons in the path
838 "http://caf\u{e9}.example/", // RFC 3987 admits `ucschar`
839 "http://example.org/caf\u{e9}",
840 "https://[2001:db8::1]:443/x?q=1#f",
841 "http://host/", // empty port is legal
842 "http://host:/p",
843 "ftp://ftp.example.org/pub/",
844 "did:example:123456789abcdefghi",
845 "http://example.org/a?b=c&d=%20e#frag",
846 ] {
847 assert_eq!(iri_content_defect(ok), None, "{ok} must not be flagged");
848 assert_eq!(
849 sanitize_iri_content(ok),
850 None,
851 "{ok} was already fine and must not be rewritten"
852 );
853 }
854 }
855
856 /// The authority defects the five-class taxonomy could not see. Each must be
857 /// flagged, and flagged as something that BLOCKS.
858 #[test]
859 fn a_bad_authority_is_flagged_and_unrepairable() {
860 for bad in [
861 "https://::1", // the incident: unbracketed IPv6
862 "http://h:80x/", // non-digit port
863 "http://a@b@c/", // two '@' in the authority
864 "http://[::1/x", // unclosed bracket
865 "http://ho st/", // (space is ForbiddenChar first — see below)
866 ] {
867 let d = iri_content_defect(bad).unwrap_or_else(|| panic!("{bad} not flagged"));
868 assert!(
869 d == IriDefect::Unclassified || !d.repairable() || d == IriDefect::ForbiddenChar,
870 "{bad} -> {d:?}"
871 );
872 }
873 // Specifically: the incident IRI, and it does not repair.
874 assert_eq!(
875 iri_content_defect("https://::1"),
876 Some(IriDefect::Unclassified)
877 );
878 assert_eq!(sanitize_iri_content("https://::1"), None);
879 assert_eq!(
880 iri_content_defect("http://h:80x/"),
881 Some(IriDefect::Unclassified)
882 );
883 assert_eq!(
884 iri_content_defect("http://a@b@c/"),
885 Some(IriDefect::Unclassified)
886 );
887 }
888
889 /// A repairable shape wrapped around an unrepairable one must NOT be
890 /// reported as repairable: escaping the bracket leaves an IRI the parser
891 /// still rejects, and claiming a repair there is how a broken dump gets
892 /// marked publishable.
893 #[test]
894 fn a_repair_that_would_not_land_valid_is_not_claimed() {
895 let bad = "https://::1/a[b]";
896 assert_eq!(shape_defect(bad), Some(IriDefect::Bracket));
897 assert_eq!(iri_content_defect(bad), Some(IriDefect::Unclassified));
898 assert_eq!(sanitize_iri_content(bad), None);
899 }
900
901 /// `UCHAR` is N-Triples syntax, not IRI content. Resolving it before the
902 /// parser sees it is what keeps an escaped dump from being a false positive.
903 #[test]
904 fn uchar_escapes_are_resolved_before_the_parser_sees_them() {
905 // `é` denotes `é`, which RFC 3987 allows.
906 assert_eq!(iri_content_defect("http://example.org/\\u00E9"), None);
907 assert_eq!(sanitize_iri_content("http://example.org/\\u00E9"), None);
908 assert_eq!(iri_content_defect("http://example.org/a\\U0001F600b"), None);
909 // An escape can smuggle a character the raw grammar forbids. The parser
910 // is the only thing that catches it, and it must.
911 for smuggled in [
912 "http://example.org/a\\u0020b", // space
913 "http://example.org/a\\u003Eb", // '>'
914 "http://example.org/a\\u005Cb", // '\'
915 "http://example.org/a\\u0022b", // '"'
916 ] {
917 assert_eq!(
918 iri_content_defect(smuggled),
919 Some(IriDefect::Unclassified),
920 "{smuggled}"
921 );
922 }
923 // A surrogate is not a character; a strict reader refuses it.
924 assert_eq!(
925 iri_content_defect("http://example.org/\\uD800"),
926 Some(IriDefect::Unclassified)
927 );
928 // `#` and `?` arrive legally through an escape.
929 assert_eq!(iri_content_defect("http://example.org/a\\u003Fq"), None);
930 }
931
932 #[test]
933 fn the_five_classes_are_recognized() {
934 let cases = [
935 ("noscheme/path", IriDefect::NotAbsolute),
936 ("", IriDefect::NotAbsolute),
937 ("/absolute/path", IriDefect::NotAbsolute),
938 ("1http://example.org/", IriDefect::NotAbsolute),
939 ("http://example.org/a b", IriDefect::ForbiddenChar),
940 ("http://example.org/a\"b", IriDefect::ForbiddenChar),
941 ("http://example.org/a|b", IriDefect::ForbiddenChar),
942 ("http://example.org/a\\b", IriDefect::ForbiddenChar),
943 ("http://example.org/a\u{7f}b", IriDefect::ForbiddenChar),
944 ("http://example.org/a[b]", IriDefect::Bracket),
945 ("http://example.org/?q=[1]", IriDefect::Bracket),
946 ("http://example.org/c#d#e", IriDefect::ExtraHash),
947 ("http://example.org/%x", IriDefect::BadPercent),
948 ("http://example.org/%", IriDefect::BadPercent),
949 ("http://example.org/%2", IriDefect::BadPercent),
950 ];
951 for (bad, want) in cases {
952 assert_eq!(iri_content_defect(bad), Some(want), "{bad}");
953 }
954 }
955
956 #[test]
957 fn repairs_are_idempotent_and_land_valid() {
958 for bad in [
959 "http://example.org/a[b]",
960 "http://example.org/c#d#e",
961 "http://example.org/%x",
962 "http://example.org/a b",
963 "http://example.org/a\"b|c{d}e^f`g",
964 "http://example.org/caf\u{e9}[x]",
965 "http://[::1]/a[b]",
966 "http://example.org/a\\b",
967 ] {
968 let fixed = sanitize_iri_content(bad).unwrap_or_else(|| panic!("{bad} unrepaired"));
969 assert_eq!(iri_content_defect(&fixed), None, "{bad} -> {fixed}");
970 assert_eq!(
971 sanitize_iri_content(&fixed),
972 None,
973 "not idempotent: {fixed}"
974 );
975 }
976 }
977
978 #[test]
979 fn the_issue_examples_repair_to_the_expected_iris() {
980 assert_eq!(
981 sanitize_iri_content("http://example.org/a[b]").as_deref(),
982 Some("http://example.org/a%5Bb%5D")
983 );
984 assert_eq!(
985 sanitize_iri_content("http://example.org/c#d#e").as_deref(),
986 Some("http://example.org/c#d%23e")
987 );
988 // No scheme: reported, never rewritten.
989 assert_eq!(sanitize_iri_content("noscheme/path"), None);
990 assert_eq!(
991 iri_content_defect("noscheme/path"),
992 Some(IriDefect::NotAbsolute)
993 );
994 }
995
996 #[test]
997 fn an_ip_literal_host_keeps_its_brackets_but_a_path_does_not() {
998 assert_eq!(iri_content_defect("http://[::1]/x"), None);
999 assert_eq!(
1000 sanitize_iri_content("http://[::1]/x[y]").as_deref(),
1001 Some("http://[::1]/x%5By%5D")
1002 );
1003 // A bracket that is not the authority's is still a defect.
1004 assert_eq!(
1005 iri_content_defect("http://ex/[::1]"),
1006 Some(IriDefect::Bracket)
1007 );
1008 }
1009
1010 #[test]
1011 fn non_ascii_survives_a_repair_byte_for_byte() {
1012 let fixed = sanitize_iri_content("http://example.org/\u{4e2d}\u{6587}[x]").unwrap();
1013 assert_eq!(fixed, "http://example.org/\u{4e2d}\u{6587}%5Bx%5D");
1014 }
1015
1016 #[test]
1017 fn term_level_checks_reach_datatypes_and_quoted_triples() {
1018 assert_eq!(term_defect("<http://ex/ok>"), None);
1019 assert_eq!(term_defect("_:b0"), None);
1020 assert_eq!(term_defect("\"plain\""), None);
1021 assert_eq!(term_defect("\"x\"@en"), None);
1022 assert_eq!(
1023 term_defect("\"x\"^^<http://ex/dt[1]>"),
1024 Some(IriDefect::Bracket)
1025 );
1026 assert_eq!(
1027 sanitize_term("\"x\"^^<http://ex/dt[1]>").as_deref(),
1028 Some("\"x\"^^<http://ex/dt%5B1%5D>")
1029 );
1030 assert_eq!(
1031 term_defect("<<<http://ex/a[b]> <http://ex/p> \"o\">>"),
1032 Some(IriDefect::Bracket)
1033 );
1034 assert_eq!(
1035 sanitize_term("<<<http://ex/a[b]> <http://ex/p> \"o\">>").as_deref(),
1036 Some("<<<http://ex/a%5Bb%5D> <http://ex/p> \"o\">>")
1037 );
1038 }
1039
1040 #[test]
1041 fn a_report_counts_occurrences_and_keeps_one_sample_per_class() {
1042 let mut r = IriReport::default();
1043 r.observe_quad(
1044 "<http://ex/a[b]>",
1045 "<http://ex/p>",
1046 "<http://ex/c#d#e>",
1047 None,
1048 );
1049 r.observe_quad("<noscheme/x>", "<http://ex/p>", "\"lit\"", None);
1050 r.observe_quad("<http://ex/a[b]>", "<http://ex/p>", "\"lit\"", None);
1051 assert_eq!(r.statements(), 3);
1052 assert_eq!(r.occurrences(), 4);
1053 assert_eq!(r.count(IriDefect::Bracket), 2);
1054 assert_eq!(r.count(IriDefect::ExtraHash), 1);
1055 assert_eq!(r.unrepairable(), 1);
1056 assert_eq!(r.sample(IriDefect::Bracket), Some("<http://ex/a[b]>"));
1057 assert_eq!(r.classes().count(), 3);
1058 }
1059
1060 /// Found by the `iri` fuzz target. A quoted triple whose subject is a
1061 /// relative IRI and whose object is merely badly escaped used to be
1062 /// "repaired" into a term that was still invalid — and, worse, counted as
1063 /// repaired, with only the *first* defect recorded, so the relative IRI
1064 /// never reached `unrepairable()` and never blocked.
1065 #[test]
1066 fn a_partly_repairable_quoted_triple_is_not_claimed_as_repaired() {
1067 let token = "<<<relative/x> <http://ex/p> <http://ex/o[1]>>>";
1068 assert_eq!(sanitize_term(token), None, "a partial repair was claimed");
1069
1070 let mut r = IriReport::default();
1071 assert_eq!(r.sanitize(token), token, "the term must be left verbatim");
1072 assert_eq!(r.repaired(), 0);
1073 // BOTH defects are recorded, not just the first.
1074 assert_eq!(r.occurrences(), 2);
1075 assert_eq!(r.count(IriDefect::NotAbsolute), 1);
1076 assert_eq!(r.count(IriDefect::Bracket), 1);
1077 // And the one that blocks is visible to the gate.
1078 assert_eq!(r.unrepairable(), 1);
1079 }
1080
1081 /// The other half: when every component *can* be repaired, it is.
1082 #[test]
1083 fn a_fully_repairable_quoted_triple_still_repairs() {
1084 let token = "<<<http://ex/a[b]> <http://ex/p> <http://ex/c#d#e>>>";
1085 let mut r = IriReport::default();
1086 assert_eq!(
1087 r.sanitize(token),
1088 "<<<http://ex/a%5Bb%5D> <http://ex/p> <http://ex/c#d%23e>>>"
1089 );
1090 assert_eq!(r.occurrences(), 2);
1091 assert_eq!(r.repaired(), 2);
1092 assert_eq!(r.unrepairable(), 0);
1093 }
1094
1095 #[test]
1096 fn sanitizing_reports_what_it_could_not_repair() {
1097 let mut r = IriReport::default();
1098 assert_eq!(r.sanitize("<http://ex/a[b]>"), "<http://ex/a%5Bb%5D>");
1099 assert_eq!(r.sanitize("<noscheme/x>"), "<noscheme/x>");
1100 assert_eq!(r.sanitize("<http://ex/fine>"), "<http://ex/fine>");
1101 assert_eq!(r.occurrences(), 2);
1102 assert_eq!(r.repaired(), 1);
1103 assert_eq!(r.unrepairable(), 1);
1104 }
1105}