Monorepo for Tangled
tangled.org
1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
2
3use jacquard_common::BosStr;
4use jacquard_common::types::did::Did;
5use jacquard_common::types::nsid::Nsid;
6use thiserror::Error;
7use url::{Host, Url};
8
9#[derive(Clone, Debug, Eq, Hash, PartialEq)]
10pub struct KnotHost(Url);
11
12#[derive(Clone, Debug, Error)]
13pub enum KnotHostError {
14 #[error("empty host")]
15 Empty,
16 #[error("parse: {0}")]
17 Parse(url::ParseError),
18 #[error("scheme must be http or https, got {0}")]
19 BadScheme(String),
20 #[error("host has no authority")]
21 NoAuthority,
22}
23
24impl KnotHost {
25 pub fn parse(raw: &str) -> Result<Self, KnotHostError> {
26 let trimmed = raw.trim();
27 if trimmed.is_empty() {
28 return Err(KnotHostError::Empty);
29 }
30 let candidate = if trimmed.contains("://") {
31 trimmed.to_owned()
32 } else {
33 format!("https://{trimmed}")
34 };
35 let mut url = Url::parse(&candidate).map_err(KnotHostError::Parse)?;
36 match url.scheme() {
37 "http" | "https" => {}
38 other => return Err(KnotHostError::BadScheme(other.to_owned())),
39 }
40 if url.host().is_none() {
41 return Err(KnotHostError::NoAuthority);
42 }
43 url.set_path("/");
44 url.set_query(None);
45 url.set_fragment(None);
46 let _ = url.set_username("");
47 let _ = url.set_password(None);
48 Ok(Self(url))
49 }
50
51 pub fn url(&self) -> &Url {
52 &self.0
53 }
54
55 pub fn xrpc_url<S: BosStr + AsRef<str>>(&self, nsid: &Nsid<S>) -> Url {
56 let mut url = self.0.clone();
57 url.set_path(&format!("/xrpc/{}", nsid.as_ref()));
58 url
59 }
60
61 pub fn private_literal_reason(&self) -> Option<PrivateHostReason> {
62 match self.0.host()? {
63 Host::Ipv4(ip) => classify_v4(ip),
64 Host::Ipv6(ip) => classify_v6(ip),
65 Host::Domain(name) if is_loopback_domain(name) => Some(PrivateHostReason::Loopback),
66 Host::Domain(_) => None,
67 }
68 }
69}
70
71pub fn classify_ip(ip: &IpAddr) -> Option<PrivateHostReason> {
72 match ip {
73 IpAddr::V4(v4) => classify_v4(*v4),
74 IpAddr::V6(v6) => classify_v6(*v6),
75 }
76}
77
78fn is_loopback_domain(name: &str) -> bool {
79 name.eq_ignore_ascii_case("localhost")
80 || name
81 .rsplit_once('.')
82 .is_some_and(|(_, label)| label.eq_ignore_ascii_case("localhost"))
83}
84
85#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86pub enum PrivateHostReason {
87 Loopback,
88 Private,
89 LinkLocal,
90 Unspecified,
91 Multicast,
92 Broadcast,
93 Documentation,
94 UniqueLocal,
95}
96
97impl std::fmt::Display for PrivateHostReason {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 f.write_str(match self {
100 Self::Loopback => "loopback",
101 Self::Private => "private",
102 Self::LinkLocal => "link-local",
103 Self::Unspecified => "unspecified",
104 Self::Multicast => "multicast",
105 Self::Broadcast => "broadcast",
106 Self::Documentation => "documentation",
107 Self::UniqueLocal => "unique-local",
108 })
109 }
110}
111
112fn classify_v4(ip: Ipv4Addr) -> Option<PrivateHostReason> {
113 if ip.is_loopback() {
114 Some(PrivateHostReason::Loopback)
115 } else if ip.is_private() {
116 Some(PrivateHostReason::Private)
117 } else if ip.is_link_local() {
118 Some(PrivateHostReason::LinkLocal)
119 } else if ip.is_unspecified() {
120 Some(PrivateHostReason::Unspecified)
121 } else if ip.is_broadcast() {
122 Some(PrivateHostReason::Broadcast)
123 } else if ip.is_multicast() {
124 Some(PrivateHostReason::Multicast)
125 } else if ip.is_documentation() {
126 Some(PrivateHostReason::Documentation)
127 } else if is_v4_carrier_grade_nat(ip) {
128 Some(PrivateHostReason::Private)
129 } else {
130 None
131 }
132}
133
134fn is_v4_carrier_grade_nat(ip: Ipv4Addr) -> bool {
135 let [a, b, ..] = ip.octets();
136 a == 100 && (0x40..=0x7f).contains(&b)
137}
138
139fn classify_v6(ip: Ipv6Addr) -> Option<PrivateHostReason> {
140 if ip.is_loopback() {
141 Some(PrivateHostReason::Loopback)
142 } else if ip.is_unspecified() {
143 Some(PrivateHostReason::Unspecified)
144 } else if ip.is_multicast() {
145 Some(PrivateHostReason::Multicast)
146 } else if is_v6_link_local(ip) {
147 Some(PrivateHostReason::LinkLocal)
148 } else if is_v6_unique_local(ip) {
149 Some(PrivateHostReason::UniqueLocal)
150 } else if let Some(v4) = ip.to_ipv4_mapped() {
151 classify_v4(v4)
152 } else {
153 None
154 }
155}
156
157fn is_v6_link_local(ip: Ipv6Addr) -> bool {
158 ip.segments()[0] & 0xffc0 == 0xfe80
159}
160
161fn is_v6_unique_local(ip: Ipv6Addr) -> bool {
162 ip.segments()[0] & 0xfe00 == 0xfc00
163}
164
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct RepoSlug(String);
167
168#[derive(Clone, Debug, Error)]
169pub enum RepoSlugError {
170 #[error("empty repo name")]
171 EmptyName,
172 #[error("repo name contains slash: {0}")]
173 NameHasSlash(String),
174}
175
176impl RepoSlug {
177 pub fn new<S: BosStr + AsRef<str>>(did: &Did<S>, name: &str) -> Result<Self, RepoSlugError> {
178 if name.is_empty() {
179 return Err(RepoSlugError::EmptyName);
180 }
181 if name.contains('/') {
182 return Err(RepoSlugError::NameHasSlash(name.to_owned()));
183 }
184 Ok(Self(format!("{}/{}", did.as_ref(), name)))
185 }
186
187 pub fn as_str(&self) -> &str {
188 &self.0
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use jacquard_common::DefaultStr;
196
197 fn did(s: &'static str) -> Did<DefaultStr> {
198 Did::new_static(s).unwrap()
199 }
200
201 fn nsid(s: &'static str) -> Nsid<DefaultStr> {
202 Nsid::new_static(s).unwrap()
203 }
204
205 #[test]
206 fn parses_bare_host_as_https() {
207 let host = KnotHost::parse("oyster.cafe").unwrap();
208 assert_eq!(host.url().as_str(), "https://oyster.cafe/");
209 }
210
211 #[test]
212 fn parses_explicit_scheme() {
213 let host = KnotHost::parse("http://127.0.0.1:5555").unwrap();
214 assert_eq!(host.url().as_str(), "http://127.0.0.1:5555/");
215 }
216
217 #[test]
218 fn strips_path_query_fragment() {
219 let host = KnotHost::parse("https://nel.pet/some/path?x=1#y").unwrap();
220 assert_eq!(host.url().as_str(), "https://nel.pet/");
221 }
222
223 #[test]
224 fn strips_userinfo() {
225 let host = KnotHost::parse("https://attacker:secret@oyster.cafe/").unwrap();
226 assert_eq!(host.url().as_str(), "https://oyster.cafe/");
227 assert!(host.url().username().is_empty());
228 assert!(host.url().password().is_none());
229 }
230
231 #[test]
232 fn strips_userinfo_only_username() {
233 let host = KnotHost::parse("https://nel@nel.pet").unwrap();
234 assert_eq!(host.url().as_str(), "https://nel.pet/");
235 assert!(host.url().username().is_empty());
236 }
237
238 #[test]
239 fn rejects_bad_scheme() {
240 let err = KnotHost::parse("ftp://oyster.cafe").unwrap_err();
241 assert!(matches!(err, KnotHostError::BadScheme(s) if s == "ftp"));
242 }
243
244 #[test]
245 fn rejects_empty() {
246 assert!(matches!(
247 KnotHost::parse(" ").unwrap_err(),
248 KnotHostError::Empty,
249 ));
250 }
251
252 #[test]
253 fn xrpc_url_appends_nsid() {
254 let host = KnotHost::parse("oyster.cafe").unwrap();
255 let url = host.xrpc_url(&nsid("sh.tangled.repo.blob"));
256 assert_eq!(
257 url.as_str(),
258 "https://oyster.cafe/xrpc/sh.tangled.repo.blob",
259 );
260 }
261
262 #[test]
263 fn slug_joins_did_and_name() {
264 let slug = RepoSlug::new(&did("did:plc:squid"), "barnacle").unwrap();
265 assert_eq!(slug.as_str(), "did:plc:squid/barnacle");
266 }
267
268 #[test]
269 fn slug_rejects_slash_in_name() {
270 assert!(matches!(
271 RepoSlug::new(&did("did:plc:squid"), "bad/name").unwrap_err(),
272 RepoSlugError::NameHasSlash(s) if s == "bad/name",
273 ));
274 }
275
276 #[test]
277 fn slug_rejects_empty_name() {
278 assert!(matches!(
279 RepoSlug::new(&did("did:plc:squid"), "").unwrap_err(),
280 RepoSlugError::EmptyName,
281 ));
282 }
283
284 #[test]
285 fn flags_loopback_v4() {
286 let host = KnotHost::parse("http://127.0.0.1").unwrap();
287 assert_eq!(
288 host.private_literal_reason(),
289 Some(PrivateHostReason::Loopback),
290 );
291 }
292
293 #[test]
294 fn flags_private_rfc1918() {
295 for raw in ["http://10.0.0.1", "http://192.168.1.1", "http://172.16.0.1"] {
296 let host = KnotHost::parse(raw).unwrap();
297 assert_eq!(
298 host.private_literal_reason(),
299 Some(PrivateHostReason::Private),
300 "{raw}",
301 );
302 }
303 }
304
305 #[test]
306 fn flags_link_local_v4() {
307 let host = KnotHost::parse("http://169.254.169.254").unwrap();
308 assert_eq!(
309 host.private_literal_reason(),
310 Some(PrivateHostReason::LinkLocal),
311 );
312 }
313
314 #[test]
315 fn flags_carrier_grade_nat() {
316 let host = KnotHost::parse("http://100.64.0.1").unwrap();
317 assert_eq!(
318 host.private_literal_reason(),
319 Some(PrivateHostReason::Private),
320 );
321 }
322
323 #[test]
324 fn flags_loopback_v6() {
325 let host = KnotHost::parse("http://[::1]").unwrap();
326 assert_eq!(
327 host.private_literal_reason(),
328 Some(PrivateHostReason::Loopback),
329 );
330 }
331
332 #[test]
333 fn flags_link_local_v6() {
334 let host = KnotHost::parse("http://[fe80::1]").unwrap();
335 assert_eq!(
336 host.private_literal_reason(),
337 Some(PrivateHostReason::LinkLocal),
338 );
339 }
340
341 #[test]
342 fn flags_unique_local_v6() {
343 let host = KnotHost::parse("http://[fc00::1]").unwrap();
344 assert_eq!(
345 host.private_literal_reason(),
346 Some(PrivateHostReason::UniqueLocal),
347 );
348 }
349
350 #[test]
351 fn flags_v4_mapped_v6() {
352 let host = KnotHost::parse("http://[::ffff:10.0.0.1]").unwrap();
353 assert_eq!(
354 host.private_literal_reason(),
355 Some(PrivateHostReason::Private),
356 );
357 }
358
359 #[test]
360 fn allows_public_v4() {
361 let host = KnotHost::parse("http://93.184.216.34").unwrap();
362 assert_eq!(host.private_literal_reason(), None);
363 }
364
365 #[test]
366 fn dns_names_are_not_classified() {
367 let host = KnotHost::parse("https://oyster.cafe").unwrap();
368 assert_eq!(host.private_literal_reason(), None);
369 }
370
371 #[test]
372 fn flags_localhost_domain() {
373 let host = KnotHost::parse("http://localhost").unwrap();
374 assert_eq!(
375 host.private_literal_reason(),
376 Some(PrivateHostReason::Loopback),
377 );
378 }
379
380 #[test]
381 fn flags_localhost_subdomain() {
382 let host = KnotHost::parse("http://internal.localhost").unwrap();
383 assert_eq!(
384 host.private_literal_reason(),
385 Some(PrivateHostReason::Loopback),
386 );
387 }
388
389 #[test]
390 fn flags_localhost_case_insensitive() {
391 let host = KnotHost::parse("http://LOCALHOST").unwrap();
392 assert_eq!(
393 host.private_literal_reason(),
394 Some(PrivateHostReason::Loopback),
395 );
396 }
397
398 #[test]
399 fn does_not_flag_domain_merely_containing_localhost() {
400 let host = KnotHost::parse("https://localhost.attacker.example").unwrap();
401 assert_eq!(host.private_literal_reason(), None);
402 }
403}