Skip to main content

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}