Skip to main content

decdn_client/
uri.rs

1//! `decdn:` URIs (#2449): one string names a blob, a whole bundle, or one file
2//! inside a bundle, with an optional namespace routing hint.
3//!
4//! ```text
5//! decdn:<hash>[/<path>][?ns=<id>]
6//! ```
7//!
8//! | URI | Names |
9//! |---|---|
10//! | `decdn:<hash>` | the raw blob |
11//! | `decdn:<hash>/` | the whole bundle whose manifest has this hash |
12//! | `decdn:<hash>/a/b.txt` | one entry of that bundle |
13//! | any of the above with `?ns=42` | the same, with the namespace routing hint |
14//!
15//! - `<hash>` is 64 hex characters, bare: no `b3:` or `0x` prefix. Parsing
16//!   accepts uppercase and [`DecdnUri`]'s `Display` writes lowercase.
17//! - The URI has no `//` authority part, like `magnet:` and `urn:`.
18//! - A trailing `/` marks a bundle. The URI author knows whether they published
19//!   one, so the client never inspects content to decide.
20//! - `<path>` is the manifest entry's relative POSIX path, each segment
21//!   percent-encoded as an RFC 3986 `pchar`. It names exactly one manifest
22//!   `path`, literally.
23//! - `ns` is the namespace id in decimal (ADR 002), never `0`. It is a routing
24//!   hint, not part of the identity, so it sits in the query.
25//! - Any other query key is an error.
26//!
27//! The URI is client-side syntax only: it resolves to what the wire already
28//! carries, a BLAKE3 hash plus the `namespace_id` hint.
29
30use std::fmt;
31use std::str::FromStr;
32
33/// The scheme prefix every `decdn:` URI starts with.
34pub const SCHEME: &str = "decdn:";
35
36/// A parsed `decdn:` URI. `Display` writes its canonical form, which parses
37/// back to an equal value.
38#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39#[non_exhaustive]
40pub struct DecdnUri {
41    /// The BLAKE3 hash: of the blob, or of the bundle manifest.
42    pub hash: [u8; 32],
43    /// What the hash names.
44    pub target: UriTarget,
45    /// The namespace routing hint (ADR 002), never `Some(0)`.
46    pub namespace: Option<u64>,
47}
48
49/// What a [`DecdnUri`]'s hash names.
50#[derive(Debug, Clone, PartialEq, Eq, Hash)]
51#[non_exhaustive]
52pub enum UriTarget {
53    /// The raw blob (`decdn:<hash>`).
54    Blob,
55    /// The whole bundle whose manifest has the hash (`decdn:<hash>/`).
56    Bundle,
57    /// One entry of that bundle, by its decoded manifest path
58    /// (`decdn:<hash>/a/b.txt`).
59    BundleEntry(String),
60}
61
62/// Why a string is not a `decdn:` URI.
63#[derive(Debug, Clone, PartialEq, Eq)]
64#[non_exhaustive]
65pub enum UriError {
66    /// The string does not start with `decdn:`.
67    MissingScheme,
68    /// The URI has a `//` authority part, which the grammar does not allow.
69    Authority,
70    /// The hash is not 64 bare hex characters.
71    Hash(String),
72    /// The bundle entry path is malformed or unsafe.
73    Path(String),
74    /// The `ns` value is not a decimal id of at least 1.
75    Namespace(String),
76    /// The query carries something other than one `ns=<id>`.
77    Query(String),
78}
79
80impl fmt::Display for UriError {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        match self {
83            Self::MissingScheme => write!(f, "not a decdn URI: expected `decdn:<hash>`"),
84            Self::Authority => write!(
85                f,
86                "a decdn URI has no `//`: write `decdn:<hash>`, not `decdn://<hash>`"
87            ),
88            Self::Hash(why) => write!(
89                f,
90                "invalid hash in decdn URI ({why}): expected 64 hex characters with no \
91                 `b3:` or `0x` prefix"
92            ),
93            Self::Path(why) => write!(f, "invalid bundle entry path in decdn URI: {why}"),
94            Self::Namespace(why) => write!(
95                f,
96                "invalid namespace in decdn URI ({why}): expected `ns=<id>` with a decimal \
97                 id of at least 1; omit `ns` for no namespace"
98            ),
99            Self::Query(why) => write!(
100                f,
101                "invalid query in decdn URI ({why}): the only key is `ns`"
102            ),
103        }
104    }
105}
106
107impl std::error::Error for UriError {}
108
109impl DecdnUri {
110    /// A URI naming `target` under `hash`, with an optional namespace hint.
111    #[must_use]
112    pub const fn new(hash: [u8; 32], target: UriTarget, namespace: Option<u64>) -> Self {
113        Self {
114            hash,
115            target,
116            namespace,
117        }
118    }
119}
120
121impl FromStr for DecdnUri {
122    type Err = UriError;
123
124    fn from_str(s: &str) -> Result<Self, Self::Err> {
125        let rest = s.strip_prefix(SCHEME).ok_or(UriError::MissingScheme)?;
126        if rest.starts_with("//") {
127            return Err(UriError::Authority);
128        }
129        let (body, query) = match rest.split_once('?') {
130            Some((body, query)) => (body, Some(query)),
131            None => (rest, None),
132        };
133        let (hash, path) = match body.split_once('/') {
134            Some((hash, path)) => (hash, Some(path)),
135            None => (body, None),
136        };
137        let hash = parse_hash(hash)?;
138        let target = match path {
139            None => UriTarget::Blob,
140            Some("") => UriTarget::Bundle,
141            Some(path) => UriTarget::BundleEntry(decode_path(path)?),
142        };
143        let namespace = query.map(parse_query).transpose()?;
144        Ok(Self {
145            hash,
146            target,
147            namespace,
148        })
149    }
150}
151
152impl fmt::Display for DecdnUri {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        f.write_str(SCHEME)?;
155        for b in self.hash {
156            write!(f, "{b:02x}")?;
157        }
158        match &self.target {
159            UriTarget::Blob => {}
160            UriTarget::Bundle => f.write_str("/")?,
161            UriTarget::BundleEntry(path) => {
162                for segment in path.split('/') {
163                    f.write_str("/")?;
164                    for b in segment.bytes() {
165                        if is_pchar(b) {
166                            write!(f, "{}", char::from(b))?;
167                        } else {
168                            write!(f, "%{b:02X}")?;
169                        }
170                    }
171                }
172            }
173        }
174        if let Some(ns) = self.namespace {
175            write!(f, "?ns={ns}")?;
176        }
177        Ok(())
178    }
179}
180
181fn parse_hash(hex: &str) -> Result<[u8; 32], UriError> {
182    if hex.len() != 64 {
183        return Err(UriError::Hash(format!("{} characters", hex.len())));
184    }
185    if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
186        return Err(UriError::Hash("not hex".to_owned()));
187    }
188    blake3::Hash::from_hex(hex.to_ascii_lowercase())
189        .map(|h| *h.as_bytes())
190        .map_err(|e| UriError::Hash(e.to_string()))
191}
192
193/// The decoded entry path: each `/`-separated segment percent-decoded, and
194/// the whole held to the manifest's path rules (relative, no `.` or `..`, no
195/// empty segment, UTF-8).
196fn decode_path(raw: &str) -> Result<String, UriError> {
197    let mut segments = Vec::new();
198    for segment in raw.split('/') {
199        let decoded = decode_segment(segment)?;
200        if decoded.is_empty() {
201            return Err(UriError::Path(
202                "empty segment (a leading, trailing or doubled `/` inside the path)".to_owned(),
203            ));
204        }
205        if decoded == "." || decoded == ".." {
206            return Err(UriError::Path(format!("`{decoded}` segment")));
207        }
208        if decoded.contains('/') {
209            return Err(UriError::Path(
210                "encoded `/` (`%2F`) in a segment".to_owned(),
211            ));
212        }
213        if decoded.contains('\0') {
214            return Err(UriError::Path("NUL byte".to_owned()));
215        }
216        segments.push(decoded);
217    }
218    Ok(segments.join("/"))
219}
220
221fn decode_segment(segment: &str) -> Result<String, UriError> {
222    let mut out = Vec::with_capacity(segment.len());
223    let mut bytes = segment.bytes();
224    while let Some(b) = bytes.next() {
225        if b == b'%' {
226            let hi = bytes.next().and_then(hex_value);
227            let lo = bytes.next().and_then(hex_value);
228            match (hi, lo) {
229                (Some(hi), Some(lo)) => out.push(hi << 4 | lo),
230                _ => return Err(UriError::Path("bad `%` escape".to_owned())),
231            }
232        } else if is_pchar(b) {
233            out.push(b);
234        } else {
235            return Err(UriError::Path(format!(
236                "character {:?} must be percent-encoded",
237                char::from(b)
238            )));
239        }
240    }
241    String::from_utf8(out).map_err(|_| UriError::Path("not UTF-8 once decoded".to_owned()))
242}
243
244fn hex_value(b: u8) -> Option<u8> {
245    char::from(b)
246        .to_digit(16)
247        .and_then(|d| u8::try_from(d).ok())
248}
249
250/// An RFC 3986 `pchar` other than `%`: unreserved, sub-delims, `:` and `@`.
251const fn is_pchar(b: u8) -> bool {
252    b.is_ascii_alphanumeric()
253        || matches!(
254            b,
255            b'-' | b'.'
256                | b'_'
257                | b'~'
258                | b'!'
259                | b'$'
260                | b'&'
261                | b'\''
262                | b'('
263                | b')'
264                | b'*'
265                | b'+'
266                | b','
267                | b';'
268                | b'='
269                | b':'
270                | b'@'
271        )
272}
273
274fn parse_query(query: &str) -> Result<u64, UriError> {
275    let mut namespace = None;
276    for pair in query.split('&') {
277        let (key, value) = pair
278            .split_once('=')
279            .ok_or_else(|| UriError::Query(format!("`{pair}` is not `key=value`")))?;
280        if key != "ns" {
281            return Err(UriError::Query(format!("unknown key `{key}`")));
282        }
283        if namespace.is_some() {
284            return Err(UriError::Query("`ns` given twice".to_owned()));
285        }
286        if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
287            return Err(UriError::Namespace(format!(
288                "`{value}` is not a decimal id"
289            )));
290        }
291        let id: u64 = value
292            .parse()
293            .map_err(|_| UriError::Namespace(format!("`{value}` is out of range")))?;
294        if id == 0 {
295            return Err(UriError::Namespace("0 means no namespace".to_owned()));
296        }
297        namespace = Some(id);
298    }
299    namespace.ok_or_else(|| UriError::Query("empty query".to_owned()))
300}
301
302#[cfg(test)]
303#[allow(
304    clippy::unwrap_used,
305    clippy::expect_used,
306    clippy::indexing_slicing,
307    clippy::panic
308)]
309mod tests;