Skip to main content

maincopy_shared/
profile.rs

1//! Validated public profile values shared by the admin server and its clients.
2
3use std::{fmt, net::IpAddr, str::FromStr};
4
5use serde::{Deserialize, Serialize, de};
6
7pub const MAX_LIGHTNING_ADDRESS_BYTES: usize = 320;
8pub const MAX_PROFILE_DISPLAY_NAME_BYTES: usize = 160;
9
10const MAX_PROFILE_VERSION: u64 = i64::MAX as u64;
11
12const MAX_DNS_DOMAIN_BYTES: usize = 253;
13const MAX_DNS_LABEL_BYTES: usize = 63;
14
15/// A positive profile resource version representable by SQLite.
16#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
17pub struct ProfileVersion(u64);
18
19impl ProfileVersion {
20    pub const fn new(value: u64) -> Result<Self, ProfileVersionError> {
21        match value {
22            0 => Err(ProfileVersionError::Zero),
23            value if value > MAX_PROFILE_VERSION => Err(ProfileVersionError::OutsideStorageRange),
24            value => Ok(Self(value)),
25        }
26    }
27
28    pub const fn into_u64(self) -> u64 {
29        self.0
30    }
31}
32
33impl TryFrom<u64> for ProfileVersion {
34    type Error = ProfileVersionError;
35
36    fn try_from(value: u64) -> Result<Self, Self::Error> {
37        Self::new(value)
38    }
39}
40
41impl From<ProfileVersion> for i64 {
42    fn from(value: ProfileVersion) -> Self {
43        value.0 as i64
44    }
45}
46
47impl Serialize for ProfileVersion {
48    fn serialize<SerializerType>(
49        &self,
50        serializer: SerializerType,
51    ) -> Result<SerializerType::Ok, SerializerType::Error>
52    where
53        SerializerType: serde::Serializer,
54    {
55        serializer.serialize_u64(self.0)
56    }
57}
58
59impl<'de> Deserialize<'de> for ProfileVersion {
60    fn deserialize<DeserializerType>(
61        deserializer: DeserializerType,
62    ) -> Result<Self, DeserializerType::Error>
63    where
64        DeserializerType: serde::Deserializer<'de>,
65    {
66        let value = u64::deserialize(deserializer)?;
67        Self::new(value).map_err(de::Error::custom)
68    }
69}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum ProfileVersionError {
73    Zero,
74    OutsideStorageRange,
75}
76
77impl fmt::Display for ProfileVersionError {
78    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79        match self {
80            Self::Zero => formatter.write_str("a profile resource version must be positive"),
81            Self::OutsideStorageRange => {
82                formatter.write_str("a profile resource version is outside the storage range")
83            }
84        }
85    }
86}
87
88impl std::error::Error for ProfileVersionError {}
89
90#[cfg(feature = "schema")]
91impl utoipa::PartialSchema for ProfileVersion {
92    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
93        utoipa::openapi::schema::ObjectBuilder::new()
94            .schema_type(utoipa::openapi::schema::Type::Integer)
95            .minimum(Some(1))
96            .maximum(Some(MAX_PROFILE_VERSION))
97            .into()
98    }
99}
100
101#[cfg(feature = "schema")]
102impl utoipa::ToSchema for ProfileVersion {}
103
104/// A canonical clearnet LUD-16 internet identifier.
105#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
106pub struct LightningAddress {
107    encoded: Box<str>,
108    separator: usize,
109}
110
111impl LightningAddress {
112    pub fn parse(value: &str) -> Result<Self, LightningAddressError> {
113        if value.is_empty() {
114            return Err(LightningAddressError::Empty);
115        }
116        if value.len() > MAX_LIGHTNING_ADDRESS_BYTES {
117            return Err(LightningAddressError::TooLong {
118                actual: value.len(),
119                maximum: MAX_LIGHTNING_ADDRESS_BYTES,
120            });
121        }
122
123        let Some(separator) = value.find('@') else {
124            return Err(LightningAddressError::MissingSeparator);
125        };
126        if value[separator + 1..].contains('@') {
127            return Err(LightningAddressError::MultipleSeparators);
128        }
129
130        validate_username(&value[..separator])?;
131        validate_domain(&value[separator + 1..])?;
132
133        Ok(Self {
134            encoded: value.into(),
135            separator,
136        })
137    }
138
139    pub fn as_str(&self) -> &str {
140        &self.encoded
141    }
142
143    pub fn as_username(&self) -> &str {
144        &self.encoded[..self.separator]
145    }
146
147    pub fn as_domain(&self) -> &str {
148        &self.encoded[self.separator + 1..]
149    }
150}
151
152impl fmt::Display for LightningAddress {
153    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
154        formatter.write_str(self.as_str())
155    }
156}
157
158impl FromStr for LightningAddress {
159    type Err = LightningAddressError;
160
161    fn from_str(value: &str) -> Result<Self, Self::Err> {
162        Self::parse(value)
163    }
164}
165
166impl Serialize for LightningAddress {
167    fn serialize<SerializerType>(
168        &self,
169        serializer: SerializerType,
170    ) -> Result<SerializerType::Ok, SerializerType::Error>
171    where
172        SerializerType: serde::Serializer,
173    {
174        serializer.serialize_str(self.as_str())
175    }
176}
177
178impl<'de> Deserialize<'de> for LightningAddress {
179    fn deserialize<DeserializerType>(
180        deserializer: DeserializerType,
181    ) -> Result<Self, DeserializerType::Error>
182    where
183        DeserializerType: serde::Deserializer<'de>,
184    {
185        let value = Box::<str>::deserialize(deserializer)?;
186        Self::parse(&value).map_err(de::Error::custom)
187    }
188}
189
190fn validate_username(username: &str) -> Result<(), LightningAddressError> {
191    if username.is_empty() {
192        return Err(LightningAddressError::DefaultIdentifierShorthand);
193    }
194    if !username
195        .bytes()
196        .all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'+'))
197    {
198        return Err(LightningAddressError::InvalidUsername);
199    }
200    Ok(())
201}
202
203fn validate_domain(domain: &str) -> Result<(), LightningAddressError> {
204    if domain.is_empty() || domain.len() > MAX_DNS_DOMAIN_BYTES {
205        return Err(LightningAddressError::InvalidDomain);
206    }
207    if domain == "onion" || domain.ends_with(".onion") {
208        return Err(LightningAddressError::OnionDomain);
209    }
210    if domain.parse::<IpAddr>().is_ok() {
211        return Err(LightningAddressError::IpLiteralDomain);
212    }
213    if domain.ends_with('.')
214        || !domain.bytes().all(|byte| {
215            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'.')
216        })
217    {
218        return Err(LightningAddressError::InvalidDomain);
219    }
220    if domain.split('.').any(|label| {
221        label.is_empty()
222            || label.len() > MAX_DNS_LABEL_BYTES
223            || !label.as_bytes()[0].is_ascii_alphanumeric()
224            || !label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
225    }) {
226        return Err(LightningAddressError::InvalidDomain);
227    }
228    Ok(())
229}
230
231#[derive(Clone, Copy, Debug, Eq, PartialEq)]
232pub enum LightningAddressError {
233    Empty,
234    TooLong { actual: usize, maximum: usize },
235    MissingSeparator,
236    MultipleSeparators,
237    DefaultIdentifierShorthand,
238    InvalidUsername,
239    InvalidDomain,
240    IpLiteralDomain,
241    OnionDomain,
242}
243
244impl fmt::Display for LightningAddressError {
245    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
246        match self {
247            Self::Empty => formatter.write_str("a Lightning Address must not be empty"),
248            Self::TooLong { actual, maximum } => write!(
249                formatter,
250                "the Lightning Address is {actual} bytes; the maximum is {maximum}"
251            ),
252            Self::MissingSeparator => {
253                formatter.write_str("a Lightning Address must use the username@domain form")
254            }
255            Self::MultipleSeparators => {
256                formatter.write_str("a Lightning Address must contain exactly one @ separator")
257            }
258            Self::DefaultIdentifierShorthand => {
259                formatter.write_str("the optional LUD-16 @domain shorthand is unsupported")
260            }
261            Self::InvalidUsername => {
262                formatter.write_str("the Lightning Address username is not canonical")
263            }
264            Self::InvalidDomain => formatter
265                .write_str("the Lightning Address domain is not a canonical lowercase DNS name"),
266            Self::IpLiteralDomain => {
267                formatter.write_str("an IP literal cannot be a Lightning Address domain")
268            }
269            Self::OnionDomain => formatter.write_str("onion Lightning Addresses are unsupported"),
270        }
271    }
272}
273
274impl std::error::Error for LightningAddressError {}
275
276#[cfg(feature = "schema")]
277impl utoipa::PartialSchema for LightningAddress {
278    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
279        utoipa::openapi::schema::ObjectBuilder::new()
280            .schema_type(utoipa::openapi::schema::Type::String)
281            .min_length(Some(1))
282            .max_length(Some(MAX_LIGHTNING_ADDRESS_BYTES))
283            .into()
284    }
285}
286
287#[cfg(feature = "schema")]
288impl utoipa::ToSchema for LightningAddress {}
289
290/// A bounded, visible profile name preserved exactly as entered.
291#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
292pub struct ProfileDisplayName(Box<str>);
293
294impl ProfileDisplayName {
295    pub fn parse(value: &str) -> Result<Self, ProfileDisplayNameError> {
296        if value.is_empty() {
297            return Err(ProfileDisplayNameError::Empty);
298        }
299        if value.len() > MAX_PROFILE_DISPLAY_NAME_BYTES {
300            return Err(ProfileDisplayNameError::TooLong {
301                actual: value.len(),
302                maximum: MAX_PROFILE_DISPLAY_NAME_BYTES,
303            });
304        }
305        if value.trim() != value {
306            return Err(ProfileDisplayNameError::SurroundingWhitespace);
307        }
308        if value.chars().any(char::is_control) {
309            return Err(ProfileDisplayNameError::ControlCharacter);
310        }
311        Ok(Self(value.into()))
312    }
313
314    pub fn as_str(&self) -> &str {
315        &self.0
316    }
317}
318
319impl fmt::Display for ProfileDisplayName {
320    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
321        formatter.write_str(self.as_str())
322    }
323}
324
325impl FromStr for ProfileDisplayName {
326    type Err = ProfileDisplayNameError;
327
328    fn from_str(value: &str) -> Result<Self, Self::Err> {
329        Self::parse(value)
330    }
331}
332
333impl Serialize for ProfileDisplayName {
334    fn serialize<SerializerType>(
335        &self,
336        serializer: SerializerType,
337    ) -> Result<SerializerType::Ok, SerializerType::Error>
338    where
339        SerializerType: serde::Serializer,
340    {
341        serializer.serialize_str(self.as_str())
342    }
343}
344
345impl<'de> Deserialize<'de> for ProfileDisplayName {
346    fn deserialize<DeserializerType>(
347        deserializer: DeserializerType,
348    ) -> Result<Self, DeserializerType::Error>
349    where
350        DeserializerType: serde::Deserializer<'de>,
351    {
352        let value = Box::<str>::deserialize(deserializer)?;
353        Self::parse(&value).map_err(de::Error::custom)
354    }
355}
356
357#[derive(Clone, Copy, Debug, Eq, PartialEq)]
358pub enum ProfileDisplayNameError {
359    Empty,
360    TooLong { actual: usize, maximum: usize },
361    SurroundingWhitespace,
362    ControlCharacter,
363}
364
365impl fmt::Display for ProfileDisplayNameError {
366    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
367        match self {
368            Self::Empty => formatter.write_str("a profile display name must not be empty"),
369            Self::TooLong { actual, maximum } => write!(
370                formatter,
371                "the profile display name is {actual} bytes; the maximum is {maximum}"
372            ),
373            Self::SurroundingWhitespace => {
374                formatter.write_str("a profile display name must not have surrounding whitespace")
375            }
376            Self::ControlCharacter => {
377                formatter.write_str("a profile display name must not contain control characters")
378            }
379        }
380    }
381}
382
383impl std::error::Error for ProfileDisplayNameError {}
384
385#[cfg(feature = "schema")]
386impl utoipa::PartialSchema for ProfileDisplayName {
387    fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
388        utoipa::openapi::schema::ObjectBuilder::new()
389            .schema_type(utoipa::openapi::schema::Type::String)
390            .min_length(Some(1))
391            .description(Some(
392                "A public display name containing at most 160 UTF-8 bytes; surrounding whitespace and control characters are rejected.",
393            ))
394            .into()
395    }
396}
397
398#[cfg(feature = "schema")]
399impl utoipa::ToSchema for ProfileDisplayName {}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn profile_versions_reject_values_sqlite_cannot_store() {
407        assert_eq!(ProfileVersion::new(0), Err(ProfileVersionError::Zero));
408        assert_eq!(
409            ProfileVersion::new(MAX_PROFILE_VERSION + 1),
410            Err(ProfileVersionError::OutsideStorageRange)
411        );
412        let maximum = ProfileVersion::new(MAX_PROFILE_VERSION).unwrap();
413        assert_eq!(maximum.into_u64(), MAX_PROFILE_VERSION);
414        assert_eq!(
415            serde_json::from_str::<ProfileVersion>(&MAX_PROFILE_VERSION.to_string()).unwrap(),
416            maximum
417        );
418    }
419
420    #[test]
421    fn documented_lud16_username_characters_are_accepted() {
422        let address = LightningAddress::parse("name-._+tag123@example.com").unwrap();
423
424        assert_eq!(address.as_username(), "name-._+tag123");
425        assert_eq!(address.as_domain(), "example.com");
426        assert_eq!(address.as_str(), "name-._+tag123@example.com");
427    }
428
429    #[test]
430    fn lightning_address_round_trips_as_a_visible_non_secret_string() {
431        let address = LightningAddress::parse("alice@example.com").unwrap();
432        let encoded = serde_json::to_string(&Some(address.clone())).unwrap();
433
434        assert_eq!(encoded, "\"alice@example.com\"");
435        assert_eq!(
436            serde_json::from_str::<Option<LightningAddress>>(&encoded).unwrap(),
437            Some(address.clone())
438        );
439        assert!(format!("{address:?}").contains("alice@example.com"));
440    }
441
442    #[test]
443    fn complete_lightning_address_byte_limit_is_inclusive() {
444        let domain = format!(
445            "{}.{}.{}.{}",
446            "a".repeat(63),
447            "b".repeat(63),
448            "c".repeat(63),
449            "d".repeat(61)
450        );
451        let maximum = format!("{}@{domain}", "u".repeat(66));
452        assert_eq!(maximum.len(), MAX_LIGHTNING_ADDRESS_BYTES);
453        assert!(LightningAddress::parse(&maximum).is_ok());
454
455        let oversized = format!("u{maximum}");
456        assert_eq!(
457            LightningAddress::parse(&oversized),
458            Err(LightningAddressError::TooLong {
459                actual: MAX_LIGHTNING_ADDRESS_BYTES + 1,
460                maximum: MAX_LIGHTNING_ADDRESS_BYTES,
461            })
462        );
463    }
464
465    #[test]
466    fn unsupported_lightning_address_forms_fail_closed() {
467        let cases = [
468            ("", LightningAddressError::Empty),
469            ("alice", LightningAddressError::MissingSeparator),
470            (
471                "@example.com",
472                LightningAddressError::DefaultIdentifierShorthand,
473            ),
474            ("a@b@example.com", LightningAddressError::MultipleSeparators),
475            ("Alice@example.com", LightningAddressError::InvalidUsername),
476            ("ali ce@example.com", LightningAddressError::InvalidUsername),
477            (
478                "alice@example.com:443",
479                LightningAddressError::InvalidDomain,
480            ),
481            (
482                "alice@example.com/path",
483                LightningAddressError::InvalidDomain,
484            ),
485            (
486                "alice@example.com?x=1",
487                LightningAddressError::InvalidDomain,
488            ),
489            (
490                "alice@example.com#fragment",
491                LightningAddressError::InvalidDomain,
492            ),
493            ("alice@Example.com", LightningAddressError::InvalidDomain),
494            ("alice@example.com.", LightningAddressError::InvalidDomain),
495            ("alice@example..com", LightningAddressError::InvalidDomain),
496            ("alice@-example.com", LightningAddressError::InvalidDomain),
497            ("alice@example-.com", LightningAddressError::InvalidDomain),
498            ("alice@127.0.0.1", LightningAddressError::IpLiteralDomain),
499            ("alice@example.onion", LightningAddressError::OnionDomain),
500            ("alice@[::1]", LightningAddressError::InvalidDomain),
501            ("álîçé@example.com", LightningAddressError::InvalidUsername),
502        ];
503
504        for (value, expected) in cases {
505            assert_eq!(LightningAddress::parse(value), Err(expected), "{value}");
506        }
507    }
508
509    #[test]
510    fn dns_label_and_domain_limits_are_enforced() {
511        let long_label = "a".repeat(MAX_DNS_LABEL_BYTES + 1);
512        assert_eq!(
513            LightningAddress::parse(&format!("alice@{long_label}.com")),
514            Err(LightningAddressError::InvalidDomain)
515        );
516
517        let long_domain = format!(
518            "{}.{}.{}.{}",
519            "a".repeat(63),
520            "b".repeat(63),
521            "c".repeat(63),
522            "d".repeat(62)
523        );
524        assert_eq!(long_domain.len(), MAX_DNS_DOMAIN_BYTES + 1);
525        assert_eq!(
526            LightningAddress::parse(&format!("alice@{long_domain}")),
527            Err(LightningAddressError::InvalidDomain)
528        );
529    }
530
531    #[test]
532    fn lightning_address_deserialization_reuses_canonical_validation() {
533        let error = serde_json::from_str::<LightningAddress>("\"Alice@example.com\"")
534            .unwrap_err()
535            .to_string();
536
537        assert!(error.contains("username is not canonical"));
538    }
539
540    #[test]
541    fn profile_display_name_preserves_unicode_and_round_trips() {
542        let name = ProfileDisplayName::parse("Alice 文").unwrap();
543        let encoded = serde_json::to_string(&name).unwrap();
544
545        assert_eq!(name.as_str(), "Alice 文");
546        assert_eq!(encoded, "\"Alice 文\"");
547        assert_eq!(
548            serde_json::from_str::<ProfileDisplayName>(&encoded).unwrap(),
549            name
550        );
551    }
552
553    #[test]
554    fn profile_display_name_byte_limit_is_inclusive() {
555        let maximum = "é".repeat(MAX_PROFILE_DISPLAY_NAME_BYTES / 2);
556        assert_eq!(maximum.len(), MAX_PROFILE_DISPLAY_NAME_BYTES);
557        assert!(ProfileDisplayName::parse(&maximum).is_ok());
558
559        let oversized = format!("{maximum}a");
560        assert_eq!(
561            ProfileDisplayName::parse(&oversized),
562            Err(ProfileDisplayNameError::TooLong {
563                actual: MAX_PROFILE_DISPLAY_NAME_BYTES + 1,
564                maximum: MAX_PROFILE_DISPLAY_NAME_BYTES,
565            })
566        );
567    }
568
569    #[cfg(feature = "schema")]
570    #[test]
571    fn profile_display_name_schema_does_not_misstate_the_byte_limit_as_characters() {
572        let schema =
573            serde_json::to_value(<ProfileDisplayName as utoipa::PartialSchema>::schema()).unwrap();
574
575        assert_eq!(schema["minLength"], 1);
576        assert_eq!(schema.get("maxLength"), None);
577        assert_eq!(
578            schema["description"],
579            "A public display name containing at most 160 UTF-8 bytes; surrounding whitespace and control characters are rejected."
580        );
581    }
582
583    #[test]
584    fn invalid_profile_display_names_are_rejected() {
585        for (value, expected) in [
586            ("", ProfileDisplayNameError::Empty),
587            (" Alice", ProfileDisplayNameError::SurroundingWhitespace),
588            ("Alice ", ProfileDisplayNameError::SurroundingWhitespace),
589            ("Alice\nWriter", ProfileDisplayNameError::ControlCharacter),
590        ] {
591            assert_eq!(ProfileDisplayName::parse(value), Err(expected), "{value:?}");
592        }
593    }
594}