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