1use std::fmt;
31use std::str::FromStr;
32
33pub const SCHEME: &str = "decdn:";
35
36#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39#[non_exhaustive]
40pub struct DecdnUri {
41 pub hash: [u8; 32],
43 pub target: UriTarget,
45 pub namespace: Option<u64>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Hash)]
51#[non_exhaustive]
52pub enum UriTarget {
53 Blob,
55 Bundle,
57 BundleEntry(String),
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64#[non_exhaustive]
65pub enum UriError {
66 MissingScheme,
68 Authority,
70 Hash(String),
72 Path(String),
74 Namespace(String),
76 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 #[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
193fn 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
250const 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;