Skip to main content

paykit_lib/
app.rs

1use serde::{Deserialize, Serialize};
2
3use crate::{PaykitError, Result};
4
5const PAYKIT_APP_ID_MAX_LEN: usize = 64;
6
7/// Stable identifier for one application participating in a Paykit identity.
8///
9/// App IDs are used for endpoint ownership and private-message attribution.
10/// They do not create separate Paykit identities or Encrypted Links.
11#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(try_from = "String", into = "String")]
13pub struct PaykitAppId(String);
14
15impl PaykitAppId {
16    /// Create a validated Paykit App ID.
17    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    /// Return the path-safe App ID.
24    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}