Monorepo for Tangled tangled.org
6

Configure Feed

Select the types of activity you want to include in your feed.

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}