1use 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#[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#[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#[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}