atproto blogging
1//! DNS-safe encoding for publication AT-URIs.
2//!
3//! Custom domains use self-describing CNAME records pointing to
4//! `nb-{encoded-did}-{rkey}.weaver.sh`. This module handles
5//! encoding and decoding the subdomain portion.
6
7use jacquard::IntoStatic;
8use jacquard::types::string::{Did, Tid};
9
10const PREFIX: &str = "nb-";
11
12/// Encode a Publication AT-URI into a DNS-safe subdomain.
13///
14/// Encoding rules:
15/// - DID `:` separators become `-`
16/// - `did:web` dots become `_` (underscores are invalid in domain names, so unambiguous)
17/// - rkey is appended as last segment (TIDs never contain hyphens)
18///
19/// Examples:
20/// - `did:plc:z72i7hdynmk6r22` + `3jui7kd2y2e2b` → `nb-plc-z72i7hdynmk6r22-3jui7kd2y2e2b`
21/// - `did:web:example.com` + `3jui7kd2y2e2b` → `nb-web-example_com-3jui7kd2y2e2b`
22pub fn encode_publication_subdomain(did: &Did, rkey: &Tid) -> String {
23 let did_str = did.as_str();
24
25 // did:plc:abc123 -> plc-abc123
26 // did:web:example.com -> web-example_com
27 let encoded_did = did_str
28 .strip_prefix("did:")
29 .expect("valid DID")
30 .replace(':', "-")
31 .replace('.', "_");
32
33 format!("{}{}-{}", PREFIX, encoded_did, rkey.as_str())
34}
35
36/// Decode a publication subdomain back to DID and rkey.
37///
38/// Parsing strategy: work from both ends since rkeys (TIDs) never contain hyphens.
39pub fn decode_publication_subdomain(subdomain: &str) -> Option<(Did<'static>, Tid)> {
40 let inner = subdomain.strip_prefix(PREFIX)?;
41
42 // Split at the LAST hyphen - rkey is on the right (TIDs have no hyphens)
43 let (did_part, rkey_str) = inner.rsplit_once('-')?;
44
45 // Parse rkey as TID
46 let rkey = Tid::from(rkey_str.to_owned());
47
48 // Reconstruct DID from encoded form
49 // plc-abc123 -> did:plc:abc123
50 // web-example_com -> did:web:example.com
51 let (method, identifier) = did_part.split_once('-')?;
52 let decoded_identifier = identifier.replace('_', ".");
53 let did_str = format!("did:{}:{}", method, decoded_identifier);
54 let did = Did::new(&did_str).ok()?;
55
56 Some((did.into_static(), rkey))
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62 use jacquard::IntoStatic;
63
64 #[test]
65 fn roundtrip_did_plc() {
66 let did = Did::new("did:plc:z72i7hdynmk6r22").unwrap();
67 let rkey = Tid::from("3jui7kd2y2e2b".to_owned());
68
69 let encoded = encode_publication_subdomain(&did, &rkey);
70 assert_eq!(encoded, "nb-plc-z72i7hdynmk6r22-3jui7kd2y2e2b");
71
72 let (decoded_did, decoded_rkey) = decode_publication_subdomain(&encoded).unwrap();
73 assert_eq!(decoded_did, did.into_static());
74 assert_eq!(decoded_rkey, rkey);
75 }
76
77 #[test]
78 fn roundtrip_did_web() {
79 let did = Did::new("did:web:example.com").unwrap();
80 let rkey = Tid::from("3jui7kd2y2e2b".to_owned());
81
82 let encoded = encode_publication_subdomain(&did, &rkey);
83 assert_eq!(encoded, "nb-web-example_com-3jui7kd2y2e2b");
84
85 let (decoded_did, decoded_rkey) = decode_publication_subdomain(&encoded).unwrap();
86 assert_eq!(decoded_did, did.into_static());
87 assert_eq!(decoded_rkey, rkey);
88 }
89
90 #[test]
91 fn roundtrip_did_web_with_hyphens() {
92 let did = Did::new("did:web:my-cool-site.example.com").unwrap();
93 let rkey = Tid::from("3jui7kd2y2e2b".to_owned());
94
95 let encoded = encode_publication_subdomain(&did, &rkey);
96 assert_eq!(encoded, "nb-web-my-cool-site_example_com-3jui7kd2y2e2b");
97
98 let (decoded_did, decoded_rkey) = decode_publication_subdomain(&encoded).unwrap();
99 assert_eq!(decoded_did, did.into_static());
100 assert_eq!(decoded_rkey, rkey);
101 }
102
103 #[test]
104 fn decode_invalid_prefix() {
105 assert!(decode_publication_subdomain("foo-plc-abc123-rkey").is_none());
106 }
107
108 #[test]
109 fn decode_missing_method() {
110 assert!(decode_publication_subdomain("nb-abc123").is_none());
111 }
112}