use std::borrow::Cow;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum IriDefect {
NotAbsolute,
ForbiddenChar,
Bracket,
ExtraHash,
BadPercent,
Unclassified,
}
pub const DEFECT_CLASSES: usize = 6;
impl IriDefect {
pub const ALL: [IriDefect; DEFECT_CLASSES] = [
IriDefect::NotAbsolute,
IriDefect::ForbiddenChar,
IriDefect::Bracket,
IriDefect::ExtraHash,
IriDefect::BadPercent,
IriDefect::Unclassified,
];
#[inline]
pub fn index(self) -> usize {
match self {
IriDefect::NotAbsolute => 0,
IriDefect::ForbiddenChar => 1,
IriDefect::Bracket => 2,
IriDefect::ExtraHash => 3,
IriDefect::BadPercent => 4,
IriDefect::Unclassified => 5,
}
}
pub fn reason(self) -> &'static str {
match self {
IriDefect::NotAbsolute => "no scheme — a relative IRI, not an absolute one",
IriDefect::ForbiddenChar => {
"a character the IRIREF grammar excludes (space, control, or one of <>\"{}|^`\\)"
}
IriDefect::Bracket => "'[' or ']' outside an IP-literal host",
IriDefect::ExtraHash => "more than one '#'",
IriDefect::BadPercent => "'%' not followed by two hex digits",
IriDefect::Unclassified => {
"rejected by the RFC 3987 parser, and no repair class recognises it"
}
}
}
#[inline]
pub fn repairable(self) -> bool {
!matches!(self, IriDefect::NotAbsolute | IriDefect::Unclassified)
}
}
#[inline]
fn is_hex(b: u8) -> bool {
b.is_ascii_hexdigit()
}
fn uchar_len(b: &[u8], i: usize) -> Option<usize> {
let (n, digits) = match b.get(i + 1) {
Some(b'u') => (6usize, 4usize),
Some(b'U') => (10usize, 8usize),
_ => return None,
};
if i + n > b.len() {
return None;
}
b[i + 2..i + 2 + digits]
.iter()
.all(|&d| is_hex(d))
.then_some(n)
}
fn scheme_colon(s: &str) -> Option<usize> {
let b = s.as_bytes();
if !b.first()?.is_ascii_alphabetic() {
return None;
}
let mut i = 1;
while i < b.len() {
match b[i] {
b':' => return Some(i),
c if c.is_ascii_alphanumeric() || c == b'+' || c == b'-' || c == b'.' => i += 1,
_ => return None,
}
}
None
}
fn ip_literal_brackets(s: &str, colon: usize) -> Option<(usize, usize)> {
let b = s.as_bytes();
if b.get(colon + 1) != Some(&b'/') || b.get(colon + 2) != Some(&b'/') {
return None;
}
let start = colon + 3;
if b.get(start) != Some(&b'[') {
return None;
}
let end = b[start..]
.iter()
.position(|&c| c == b'/' || c == b'?' || c == b'#')
.map(|p| start + p)
.unwrap_or(b.len());
let close = b[start..end].iter().position(|&c| c == b']')? + start;
Some((start, close))
}
fn resolve_uchars(s: &str) -> Option<Cow<'_, str>> {
if !s.as_bytes().contains(&b'\\') {
return Some(Cow::Borrowed(s));
}
let b = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < b.len() {
if b[i] != b'\\' {
let start = i;
while i < b.len() && b[i] != b'\\' {
i += 1;
}
out.push_str(&s[start..i]);
continue;
}
let n = uchar_len(b, i)?;
let cp = u32::from_str_radix(&s[i + 2..i + n], 16).ok()?;
out.push(char::from_u32(cp)?);
i += n;
}
Some(Cow::Owned(out))
}
fn parses_as_absolute_iri(s: &str) -> bool {
match resolve_uchars(s) {
Some(resolved) => oxiri::Iri::parse(resolved.as_ref()).is_ok(),
None => false,
}
}
fn shape_defect(s: &str) -> Option<IriDefect> {
let colon = match scheme_colon(s) {
Some(c) => c,
None => return Some(IriDefect::NotAbsolute),
};
let brackets = ip_literal_brackets(s, colon);
let b = s.as_bytes();
let mut i = 0;
let mut seen_hash = false;
while i < b.len() {
let c = b[i];
if c >= 0x80 {
i += 1;
continue;
}
match c {
0x00..=0x20 | 0x7f => return Some(IriDefect::ForbiddenChar),
b'<' | b'>' | b'"' | b'{' | b'}' | b'|' | b'^' | b'`' => {
return Some(IriDefect::ForbiddenChar)
}
b'\\' => match uchar_len(b, i) {
Some(n) => {
i += n;
continue;
}
None => return Some(IriDefect::ForbiddenChar),
},
b'[' | b']' => {
let ok = matches!(brackets, Some((o, c2)) if i == o || i == c2);
if !ok {
return Some(IriDefect::Bracket);
}
}
b'#' => {
if seen_hash {
return Some(IriDefect::ExtraHash);
}
seen_hash = true;
}
b'%' => {
if !(i + 2 < b.len() && is_hex(b[i + 1]) && is_hex(b[i + 2])) {
return Some(IriDefect::BadPercent);
}
i += 3;
continue;
}
_ => {}
}
i += 1;
}
None
}
pub fn iri_content_defect(s: &str) -> Option<IriDefect> {
match shape_defect(s) {
Some(d) if d.repairable() => {
let fixed = escape_shape_defects(s);
match fixed {
Some(f) if shape_defect(&f).is_none() && parses_as_absolute_iri(&f) => Some(d),
_ => Some(IriDefect::Unclassified),
}
}
Some(d) => Some(d),
None => {
if parses_as_absolute_iri(s) {
None
} else {
Some(IriDefect::Unclassified)
}
}
}
}
fn push_pct(out: &mut Vec<u8>, byte: u8) {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
out.push(b'%');
out.push(HEX[(byte >> 4) as usize]);
out.push(HEX[(byte & 0x0f) as usize]);
}
pub fn sanitize_iri_content(s: &str) -> Option<String> {
let defect = iri_content_defect(s)?;
if !defect.repairable() {
return None;
}
escape_shape_defects(s)
}
fn escape_shape_defects(s: &str) -> Option<String> {
let colon = scheme_colon(s)?;
let brackets = ip_literal_brackets(s, colon);
let b = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(s.len() + 8);
let mut i = 0;
let mut seen_hash = false;
while i < b.len() {
let c = b[i];
if c >= 0x80 {
out.push(c); i += 1;
continue;
}
match c {
0x00..=0x20 | 0x7f | b'<' | b'>' | b'"' | b'{' | b'}' | b'|' | b'^' | b'`' => {
push_pct(&mut out, c)
}
b'\\' => match uchar_len(b, i) {
Some(n) => {
out.extend_from_slice(&b[i..i + n]);
i += n;
continue;
}
None => push_pct(&mut out, c),
},
b'[' | b']' => {
if matches!(brackets, Some((o, c2)) if i == o || i == c2) {
out.push(c);
} else {
push_pct(&mut out, c);
}
}
b'#' => {
if seen_hash {
push_pct(&mut out, c);
} else {
seen_hash = true;
out.push(b'#');
}
}
b'%' => {
if i + 2 < b.len() && is_hex(b[i + 1]) && is_hex(b[i + 2]) {
out.extend_from_slice(&b[i..i + 3]);
i += 3;
continue;
}
push_pct(&mut out, c);
}
_ => out.push(c),
}
i += 1;
}
String::from_utf8(out).ok()
}
fn literal_datatype_content(token: &str) -> Option<&str> {
let inner = token.strip_suffix('>')?;
let at = inner.rfind("^^<")?;
Some(&inner[at + 3..])
}
pub fn term_defect(token: &str) -> Option<IriDefect> {
if let Some((s, p, o)) = crate::ingest::quoted_triple_parts(token) {
return term_defect(&s)
.or_else(|| term_defect(&p))
.or_else(|| term_defect(&o));
}
if crate::terms::is_iri(token) {
return iri_content_defect(&token[1..token.len() - 1]);
}
if token.starts_with('"') {
return literal_datatype_content(token).and_then(iri_content_defect);
}
None
}
pub fn term_defects(token: &str) -> Vec<IriDefect> {
let mut out = Vec::new();
collect_term_defects(token, &mut out);
out
}
fn collect_term_defects(token: &str, out: &mut Vec<IriDefect>) {
if let Some((s, p, o)) = crate::ingest::quoted_triple_parts(token) {
collect_term_defects(&s, out);
collect_term_defects(&p, out);
collect_term_defects(&o, out);
return;
}
if crate::terms::is_iri(token) {
out.extend(iri_content_defect(&token[1..token.len() - 1]));
} else if token.starts_with('"') {
out.extend(literal_datatype_content(token).and_then(iri_content_defect));
}
}
pub fn sanitize_term(token: &str) -> Option<String> {
if let Some((s, p, o)) = crate::ingest::quoted_triple_parts(token) {
let (rs, rp, ro) = (sanitize_term(&s), sanitize_term(&p), sanitize_term(&o));
if rs.is_none() && rp.is_none() && ro.is_none() {
return None;
}
let pick = |r: Option<String>, orig: String| r.unwrap_or(orig);
let rebuilt = format!("<<{} {} {}>>", pick(rs, s), pick(rp, p), pick(ro, o));
return term_defect(&rebuilt).is_none().then_some(rebuilt);
}
if crate::terms::is_iri(token) {
return sanitize_iri_content(&token[1..token.len() - 1]).map(|c| format!("<{c}>"));
}
if token.starts_with('"') {
let dt = literal_datatype_content(token)?;
let fixed = sanitize_iri_content(dt)?;
let head = &token[..token.len() - 1 - dt.len()];
return Some(format!("{head}{fixed}>"));
}
None
}
#[derive(Debug, Default, Clone)]
pub struct IriReport {
statements: u64,
occurrences: u64,
repaired: u64,
counts: [u64; DEFECT_CLASSES],
samples: [Option<String>; DEFECT_CLASSES],
}
impl IriReport {
fn note(&mut self, defect: IriDefect, token: &str) {
let i = defect.index();
self.counts[i] += 1;
self.occurrences += 1;
if self.samples[i].is_none() {
self.samples[i] = Some(token.to_string());
}
}
pub fn observe_term(&mut self, token: &str) -> Option<IriDefect> {
let defects = term_defects(token);
let first = defects.first().copied();
for d in defects {
self.note(d, token);
}
first
}
pub fn observe_quad(
&mut self,
s: &str,
p: &str,
o: &str,
g: Option<&str>,
) -> Option<IriDefect> {
let mut first = None;
for t in [Some(s), Some(p), Some(o), g].into_iter().flatten() {
if let Some(d) = self.observe_term(t) {
first.get_or_insert(d);
}
}
if first.is_some() {
self.statements += 1;
}
first
}
pub fn sanitize<'a>(&mut self, token: &'a str) -> Cow<'a, str> {
let defects = term_defects(token);
if defects.is_empty() {
return Cow::Borrowed(token);
}
for &d in &defects {
self.note(d, token);
}
match sanitize_term(token) {
Some(fixed) => {
self.repaired += defects.len() as u64;
Cow::Owned(fixed)
}
None => Cow::Borrowed(token),
}
}
pub fn is_empty(&self) -> bool {
self.occurrences == 0
}
pub fn statements(&self) -> u64 {
self.statements
}
pub fn occurrences(&self) -> u64 {
self.occurrences
}
pub fn repaired(&self) -> u64 {
self.repaired
}
pub fn unrepairable(&self) -> u64 {
IriDefect::ALL
.iter()
.filter(|d| !d.repairable())
.map(|d| self.counts[d.index()])
.sum()
}
pub fn repairable(&self) -> u64 {
IriDefect::ALL
.iter()
.filter(|d| d.repairable())
.map(|d| self.counts[d.index()])
.sum()
}
pub fn unclassified(&self) -> u64 {
self.counts[IriDefect::Unclassified.index()]
}
pub fn count(&self, defect: IriDefect) -> u64 {
self.counts[defect.index()]
}
pub fn sample(&self, defect: IriDefect) -> Option<&str> {
self.samples[defect.index()].as_deref()
}
pub fn classes(&self) -> impl Iterator<Item = (IriDefect, u64, Option<&str>)> + '_ {
IriDefect::ALL
.into_iter()
.filter(move |d| self.counts[d.index()] > 0)
.map(move |d| (d, self.counts[d.index()], self.sample(d)))
}
pub fn merge(&mut self, other: &IriReport) {
self.statements += other.statements;
self.occurrences += other.occurrences;
self.repaired += other.repaired;
for i in 0..DEFECT_CLASSES {
self.counts[i] += other.counts[i];
if self.samples[i].is_none() {
self.samples[i].clone_from(&other.samples[i]);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_iris_are_left_alone() {
for ok in [
"http://example.org/ok",
"https://example.org/p?q=1&r=2#frag",
"http://[::1]:7878/sparql",
"http://[2001:db8::1]/a",
"urn:uuid:2a5c3f60-0000-4000-8000-000000000000",
"mailto:someone@example.org",
"http://example.org/caf\u{e9}",
"http://example.org/a%20b",
"http://example.org/\\u00E9",
"tag:example.org,2026:x",
"http://example.org/(a),b;c=d!e$f*g'h+i",
"http://example.org/@at:colon",
"file:///tmp/x",
] {
assert_eq!(iri_content_defect(ok), None, "{ok} should be valid");
assert_eq!(sanitize_iri_content(ok), None, "{ok} should need no repair");
}
}
#[test]
fn legal_iris_are_never_flagged() {
for ok in [
"http://user:pass@host/", "http://[::1]:8080/p", "urn:isbn:0451450523", "mailto:a@b.com", "http://host:8080/a:b", "http://caf\u{e9}.example/", "http://example.org/caf\u{e9}",
"https://[2001:db8::1]:443/x?q=1#f",
"http://host/", "http://host:/p",
"ftp://ftp.example.org/pub/",
"did:example:123456789abcdefghi",
"http://example.org/a?b=c&d=%20e#frag",
] {
assert_eq!(iri_content_defect(ok), None, "{ok} must not be flagged");
assert_eq!(
sanitize_iri_content(ok),
None,
"{ok} was already fine and must not be rewritten"
);
}
}
#[test]
fn a_bad_authority_is_flagged_and_unrepairable() {
for bad in [
"https://::1", "http://h:80x/", "http://a@b@c/", "http://[::1/x", "http://ho st/", ] {
let d = iri_content_defect(bad).unwrap_or_else(|| panic!("{bad} not flagged"));
assert!(
d == IriDefect::Unclassified || !d.repairable() || d == IriDefect::ForbiddenChar,
"{bad} -> {d:?}"
);
}
assert_eq!(
iri_content_defect("https://::1"),
Some(IriDefect::Unclassified)
);
assert_eq!(sanitize_iri_content("https://::1"), None);
assert_eq!(
iri_content_defect("http://h:80x/"),
Some(IriDefect::Unclassified)
);
assert_eq!(
iri_content_defect("http://a@b@c/"),
Some(IriDefect::Unclassified)
);
}
#[test]
fn a_repair_that_would_not_land_valid_is_not_claimed() {
let bad = "https://::1/a[b]";
assert_eq!(shape_defect(bad), Some(IriDefect::Bracket));
assert_eq!(iri_content_defect(bad), Some(IriDefect::Unclassified));
assert_eq!(sanitize_iri_content(bad), None);
}
#[test]
fn uchar_escapes_are_resolved_before_the_parser_sees_them() {
assert_eq!(iri_content_defect("http://example.org/\\u00E9"), None);
assert_eq!(sanitize_iri_content("http://example.org/\\u00E9"), None);
assert_eq!(iri_content_defect("http://example.org/a\\U0001F600b"), None);
for smuggled in [
"http://example.org/a\\u0020b", "http://example.org/a\\u003Eb", "http://example.org/a\\u005Cb", "http://example.org/a\\u0022b", ] {
assert_eq!(
iri_content_defect(smuggled),
Some(IriDefect::Unclassified),
"{smuggled}"
);
}
assert_eq!(
iri_content_defect("http://example.org/\\uD800"),
Some(IriDefect::Unclassified)
);
assert_eq!(iri_content_defect("http://example.org/a\\u003Fq"), None);
}
#[test]
fn the_five_classes_are_recognized() {
let cases = [
("noscheme/path", IriDefect::NotAbsolute),
("", IriDefect::NotAbsolute),
("/absolute/path", IriDefect::NotAbsolute),
("1http://example.org/", IriDefect::NotAbsolute),
("http://example.org/a b", IriDefect::ForbiddenChar),
("http://example.org/a\"b", IriDefect::ForbiddenChar),
("http://example.org/a|b", IriDefect::ForbiddenChar),
("http://example.org/a\\b", IriDefect::ForbiddenChar),
("http://example.org/a\u{7f}b", IriDefect::ForbiddenChar),
("http://example.org/a[b]", IriDefect::Bracket),
("http://example.org/?q=[1]", IriDefect::Bracket),
("http://example.org/c#d#e", IriDefect::ExtraHash),
("http://example.org/%x", IriDefect::BadPercent),
("http://example.org/%", IriDefect::BadPercent),
("http://example.org/%2", IriDefect::BadPercent),
];
for (bad, want) in cases {
assert_eq!(iri_content_defect(bad), Some(want), "{bad}");
}
}
#[test]
fn repairs_are_idempotent_and_land_valid() {
for bad in [
"http://example.org/a[b]",
"http://example.org/c#d#e",
"http://example.org/%x",
"http://example.org/a b",
"http://example.org/a\"b|c{d}e^f`g",
"http://example.org/caf\u{e9}[x]",
"http://[::1]/a[b]",
"http://example.org/a\\b",
] {
let fixed = sanitize_iri_content(bad).unwrap_or_else(|| panic!("{bad} unrepaired"));
assert_eq!(iri_content_defect(&fixed), None, "{bad} -> {fixed}");
assert_eq!(
sanitize_iri_content(&fixed),
None,
"not idempotent: {fixed}"
);
}
}
#[test]
fn the_issue_examples_repair_to_the_expected_iris() {
assert_eq!(
sanitize_iri_content("http://example.org/a[b]").as_deref(),
Some("http://example.org/a%5Bb%5D")
);
assert_eq!(
sanitize_iri_content("http://example.org/c#d#e").as_deref(),
Some("http://example.org/c#d%23e")
);
assert_eq!(sanitize_iri_content("noscheme/path"), None);
assert_eq!(
iri_content_defect("noscheme/path"),
Some(IriDefect::NotAbsolute)
);
}
#[test]
fn an_ip_literal_host_keeps_its_brackets_but_a_path_does_not() {
assert_eq!(iri_content_defect("http://[::1]/x"), None);
assert_eq!(
sanitize_iri_content("http://[::1]/x[y]").as_deref(),
Some("http://[::1]/x%5By%5D")
);
assert_eq!(
iri_content_defect("http://ex/[::1]"),
Some(IriDefect::Bracket)
);
}
#[test]
fn non_ascii_survives_a_repair_byte_for_byte() {
let fixed = sanitize_iri_content("http://example.org/\u{4e2d}\u{6587}[x]").unwrap();
assert_eq!(fixed, "http://example.org/\u{4e2d}\u{6587}%5Bx%5D");
}
#[test]
fn term_level_checks_reach_datatypes_and_quoted_triples() {
assert_eq!(term_defect("<http://ex/ok>"), None);
assert_eq!(term_defect("_:b0"), None);
assert_eq!(term_defect("\"plain\""), None);
assert_eq!(term_defect("\"x\"@en"), None);
assert_eq!(
term_defect("\"x\"^^<http://ex/dt[1]>"),
Some(IriDefect::Bracket)
);
assert_eq!(
sanitize_term("\"x\"^^<http://ex/dt[1]>").as_deref(),
Some("\"x\"^^<http://ex/dt%5B1%5D>")
);
assert_eq!(
term_defect("<<<http://ex/a[b]> <http://ex/p> \"o\">>"),
Some(IriDefect::Bracket)
);
assert_eq!(
sanitize_term("<<<http://ex/a[b]> <http://ex/p> \"o\">>").as_deref(),
Some("<<<http://ex/a%5Bb%5D> <http://ex/p> \"o\">>")
);
}
#[test]
fn a_report_counts_occurrences_and_keeps_one_sample_per_class() {
let mut r = IriReport::default();
r.observe_quad(
"<http://ex/a[b]>",
"<http://ex/p>",
"<http://ex/c#d#e>",
None,
);
r.observe_quad("<noscheme/x>", "<http://ex/p>", "\"lit\"", None);
r.observe_quad("<http://ex/a[b]>", "<http://ex/p>", "\"lit\"", None);
assert_eq!(r.statements(), 3);
assert_eq!(r.occurrences(), 4);
assert_eq!(r.count(IriDefect::Bracket), 2);
assert_eq!(r.count(IriDefect::ExtraHash), 1);
assert_eq!(r.unrepairable(), 1);
assert_eq!(r.sample(IriDefect::Bracket), Some("<http://ex/a[b]>"));
assert_eq!(r.classes().count(), 3);
}
#[test]
fn a_partly_repairable_quoted_triple_is_not_claimed_as_repaired() {
let token = "<<<relative/x> <http://ex/p> <http://ex/o[1]>>>";
assert_eq!(sanitize_term(token), None, "a partial repair was claimed");
let mut r = IriReport::default();
assert_eq!(r.sanitize(token), token, "the term must be left verbatim");
assert_eq!(r.repaired(), 0);
assert_eq!(r.occurrences(), 2);
assert_eq!(r.count(IriDefect::NotAbsolute), 1);
assert_eq!(r.count(IriDefect::Bracket), 1);
assert_eq!(r.unrepairable(), 1);
}
#[test]
fn a_fully_repairable_quoted_triple_still_repairs() {
let token = "<<<http://ex/a[b]> <http://ex/p> <http://ex/c#d#e>>>";
let mut r = IriReport::default();
assert_eq!(
r.sanitize(token),
"<<<http://ex/a%5Bb%5D> <http://ex/p> <http://ex/c#d%23e>>>"
);
assert_eq!(r.occurrences(), 2);
assert_eq!(r.repaired(), 2);
assert_eq!(r.unrepairable(), 0);
}
#[test]
fn sanitizing_reports_what_it_could_not_repair() {
let mut r = IriReport::default();
assert_eq!(r.sanitize("<http://ex/a[b]>"), "<http://ex/a%5Bb%5D>");
assert_eq!(r.sanitize("<noscheme/x>"), "<noscheme/x>");
assert_eq!(r.sanitize("<http://ex/fine>"), "<http://ex/fine>");
assert_eq!(r.occurrences(), 2);
assert_eq!(r.repaired(), 1);
assert_eq!(r.unrepairable(), 1);
}
}