1use serde::{Deserialize, Serialize};
2
3use crate::{PaykitError, Result};
4
5const PAYKIT_APP_ID_MAX_LEN: usize = 64;
6
7#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(try_from = "String", into = "String")]
13pub struct PaykitAppId(String);
14
15impl PaykitAppId {
16 pub fn new(value: impl Into<String>) -> Result<Self> {
18 let value = value.into();
19 validate_app_id(&value)?;
20 Ok(Self(value))
21 }
22
23 pub fn as_str(&self) -> &str {
25 &self.0
26 }
27}
28
29impl std::fmt::Display for PaykitAppId {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 f.write_str(&self.0)
32 }
33}
34
35impl AsRef<str> for PaykitAppId {
36 fn as_ref(&self) -> &str {
37 &self.0
38 }
39}
40
41impl TryFrom<String> for PaykitAppId {
42 type Error = PaykitError;
43
44 fn try_from(value: String) -> Result<Self> {
45 Self::new(value)
46 }
47}
48
49impl From<PaykitAppId> for String {
50 fn from(value: PaykitAppId) -> Self {
51 value.0
52 }
53}
54
55fn validate_app_id(value: &str) -> Result<()> {
56 if value.is_empty() {
57 return Err(PaykitError::Validation(
58 "PaykitAppId must not be empty".into(),
59 ));
60 }
61 if value.len() > PAYKIT_APP_ID_MAX_LEN {
62 return Err(PaykitError::Validation(format!(
63 "PaykitAppId must not exceed {PAYKIT_APP_ID_MAX_LEN} bytes"
64 )));
65 }
66 if value == "." || value == ".." {
67 return Err(PaykitError::Validation(
68 "PaykitAppId must not be a path-traversal component".into(),
69 ));
70 }
71 if value
72 .chars()
73 .any(|ch| !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-'))
74 {
75 return Err(PaykitError::Validation(
76 "PaykitAppId may only contain lowercase ASCII letters, digits, and '-'".into(),
77 ));
78 }
79 Ok(())
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 #[test]
87 fn test_app_id_accepts_path_safe_values() {
88 for value in ["bitkit", "paykit-server", "wallet-2"] {
89 assert_eq!(PaykitAppId::new(value).unwrap().as_str(), value);
90 }
91 }
92
93 #[test]
94 fn test_app_id_rejects_unsafe_values() {
95 for value in [
96 "",
97 ".",
98 "..",
99 "Bitkit",
100 "bitkit/wallet",
101 "bitkit_wallet",
102 "bitkit.wallet",
103 ] {
104 assert!(PaykitAppId::new(value).is_err(), "{value}");
105 }
106 }
107}