diff --git a/src/lexicon/aturi.rs b/src/lexicon/aturi.rs index 2cbf5f0..3771bfe 100644 --- a/src/lexicon/aturi.rs +++ b/src/lexicon/aturi.rs @@ -16,7 +16,33 @@ pub(crate) fn is_aturi_char(c: char) -> bool { } /// Split `at:////` into its parts, if it is one. +/// +/// **jacquard decides, this slices.** [`jacquard::types::string::AtUri`] +/// carries the at-URI grammar, and it knows one thing the split below cannot +/// see: whether the collection is a real NSID. `at://did:plc:abc/not an +/// nsid/3ms` used to come back as a happy three-tuple and fail later, at +/// whichever request was built from it. +/// +/// The slicing stays local because jacquard's accessors hand back newtypes +/// that own the borrow — `Nsid<&str>` keeps its `&str` behind a `pub(crate)` +/// field — so taking the `&str` back out means allocating three `String`s per +/// call. This function is called on every candidate URI found in a scraped +/// page, and returning slices of the caller's own string is what keeps it +/// free. The offsets are re-derived rather than borrowed; the verdict is not. +/// +/// Still `Option`: a caller here is asking *is this one*, usually of a string +/// scraped off a page, and no is an ordinary answer rather than an error. pub(crate) fn split_aturi(uri: &str) -> Option<(&str, &str, &str)> { + let parsed = jacquard::types::string::AtUri::<&str>::new(uri).ok()?; + // `AtUri` is happy with `at://` alone, or with a collection + // and no key, and neither of those names a record. A fragment does not + // either — it points inside one. + parsed.collection()?; + parsed.rkey()?; + if parsed.fragment().is_some() { + return None; + } + let mut parts = uri.strip_prefix("at://")?.split('/'); let did = parts.next()?; let collection = parts.next()?; @@ -27,3 +53,54 @@ pub(crate) fn split_aturi(uri: &str) -> Option<(&str, &str, &str)> { } Some((did, collection, rkey)) } + +#[cfg(test)] +mod tests { + use super::split_aturi; + + /// The three parts come back as slices of the caller's own string. + #[test] + fn a_record_uri_splits_into_three() { + assert_eq!( + split_aturi("at://did:plc:abc/sh.tangled.repo.pull/3ms"), + Some(("did:plc:abc", "sh.tangled.repo.pull", "3ms")) + ); + } + + /// **What moving to jacquard bought.** The collection is checked as an + /// NSID, so a URI that is the right *shape* but names something that + /// cannot be a collection is refused here rather than at whichever + /// request was built from it. + #[test] + fn a_collection_that_is_not_an_nsid_is_not_a_record_uri() { + for bad in [ + "at://did:plc:abc/not an nsid/3ms", + "at://did:plc:abc/no-dots/3ms", + "at://did:plc:abc/trailing./3ms", + ] { + assert_eq!(split_aturi(bad), None, "{bad} should not be a record URI"); + } + + // Not everything the NSID spec forbids: jacquard takes an uppercase + // collection, and this asserts that rather than wishing otherwise, so + // that a future jacquard tightening shows up here as a failing test + // rather than as a silent behaviour change. + assert!(split_aturi("at://did:plc:abc/UPPER.Case.Nsid/3ms").is_some()); + } + + /// Everything that is not exactly three parts of a record URI. + #[test] + fn anything_else_is_not_a_record_uri() { + for bad in [ + "at://did:plc:abc", + "at://did:plc:abc/sh.tangled.repo.pull", + "at://did:plc:abc/sh.tangled.repo.pull/", + "at://did:plc:abc/sh.tangled.repo.pull/3ms/extra", + "at://did:plc:abc/sh.tangled.repo.pull/3ms#frag", + "https://tangled.org/permadeath.com/atgc", + "", + ] { + assert_eq!(split_aturi(bad), None, "{bad} should not be a record URI"); + } + } +}