Skip to main content

pas_external/
types.rs

1use derive_more::{Display, From, FromStr, Into};
2use serde::{Deserialize, Serialize};
3use ulid::Ulid;
4
5use crate::error::Error;
6
7/// PAS ppnum identifier (OAuth `sub` claim, ULID format).
8///
9/// Immutable, unique per Ppoppo account. Returned as `sub` in OAuth tokens.
10/// Consumers store this as the sole link to PAS identity.
11#[derive(
12    Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Display, FromStr, From, Into,
13)]
14#[serde(transparent)]
15pub struct PpnumId(pub Ulid);
16
17/// Validated Ppoppo Number (≥11 digits, ASCII digits only).
18///
19/// Format matches PAS DB CHECK constraint `^[0-9]{11,}$`. Variable
20/// length (11/15/19/...): 11 digits = independent ppnum, 15+ digits =
21/// dependent (sub-agent hierarchy, +4 digits per nesting level).
22/// Wire form (this type's `Display` impl) is the raw digit string
23/// (`12312345678`). UI layers may render with hyphen grouping
24/// (`123-1234-5678`); that formatting is the consumer's choice and
25/// is NOT performed by `Display` or `as_str()`. The validator also
26/// rejects hyphenated input on parse — only the wire form is accepted.
27/// Prefix is band-allocated and carries no semantic meaning — class
28/// is decided by `ppnums.entity_type` / `ppnums.number_class` columns
29/// server-side, never by leading digits (PAS Constitution Principle III).
30/// No upper length bound is enforced; trust PAS issuance.
31///
32/// Guaranteed valid by construction: holding a `Ppnum` proves the format is correct.
33/// Use `"12312345678".parse::<Ppnum>()` or `Ppnum::try_from(string)` to create.
34#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(try_from = "String", into = "String")]
36pub struct Ppnum(String);
37
38impl Ppnum {
39    #[must_use]
40    pub fn as_str(&self) -> &str {
41        &self.0
42    }
43}
44
45impl std::fmt::Display for Ppnum {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.write_str(&self.0)
48    }
49}
50
51impl std::str::FromStr for Ppnum {
52    type Err = Error;
53
54    fn from_str(s: &str) -> Result<Self, Self::Err> {
55        Self::try_from(s.to_owned())
56    }
57}
58
59impl TryFrom<String> for Ppnum {
60    type Error = Error;
61
62    fn try_from(s: String) -> Result<Self, Self::Error> {
63        // Matches PAS DB CHECK `^[0-9]{11,}$` exactly. Prefix is
64        // band-allocated and carries no semantic meaning (Constitution
65        // Principle III) — do not validate prefix here.
66        if s.len() >= 11 && s.bytes().all(|b| b.is_ascii_digit()) {
67            Ok(Self(s))
68        } else {
69            Err(Error::InvalidPpnum(s))
70        }
71    }
72}
73
74impl From<Ppnum> for String {
75    fn from(p: Ppnum) -> Self {
76        p.0
77    }
78}
79
80/// Consumer-defined user identifier (opaque string).
81///
82/// Returned by [`AccountResolver::resolve`](crate::middleware::AccountResolver::resolve).
83/// The consumer chooses the format (ULID, UUID, etc.).
84#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display, From, Into)]
85#[serde(transparent)]
86pub struct UserId(pub String);
87
88/// Consumer-defined session identifier (opaque string).
89///
90/// Returned by [`SessionStore::create`](crate::middleware::SessionStore::create).
91/// The consumer chooses the format (ULID, UUID, etc.).
92#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display, From, Into)]
93#[serde(transparent)]
94pub struct SessionId(pub String);
95
96/// PASERK key identifier.
97#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Display, From, Into)]
98#[serde(transparent)]
99pub struct KeyId(pub String);
100
101#[cfg(test)]
102#[allow(clippy::unwrap_used)]
103mod tests {
104    use super::*;
105    use static_assertions::assert_impl_all;
106
107    assert_impl_all!(PpnumId: Send, Sync, Copy);
108    assert_impl_all!(Ppnum: Send, Sync);
109    assert_impl_all!(UserId: Send, Sync);
110    assert_impl_all!(SessionId: Send, Sync);
111    assert_impl_all!(KeyId: Send, Sync);
112
113    #[test]
114    fn valid_ppnum_independent_11_digits() {
115        // Any prefix valid — band-allocated, prefix-agnostic
116        assert!("12312345678".parse::<Ppnum>().is_ok()); // 100 band (canonical seed)
117        assert!("77712345678".parse::<Ppnum>().is_ok()); // legacy 777 band
118        assert!("00000000000".parse::<Ppnum>().is_ok()); // edge: all zeros
119        assert!("99999999999".parse::<Ppnum>().is_ok());
120    }
121
122    #[test]
123    fn valid_ppnum_dependent_variable_length() {
124        // Sub-agent hierarchy: 11 + 4n digits
125        assert!("123123456780001".parse::<Ppnum>().is_ok()); // 15 digits (depth 1)
126        assert!("1231234567800010001".parse::<Ppnum>().is_ok()); // 19 digits (depth 2)
127        assert!("12312345678000100010001".parse::<Ppnum>().is_ok()); // 23 digits (depth 3)
128    }
129
130    #[test]
131    fn invalid_ppnum_too_short() {
132        // matches! pins the variant — guards against silent collapse to
133        // a generic Error variant in future refactors (consumers route
134        // on InvalidPpnum to map to 400 BAD_REQUEST).
135        assert!(matches!("1234567890".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // 10 digits
136        assert!(matches!("123".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // 3 digits
137        assert!(matches!("".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // 0 digits
138    }
139
140    #[test]
141    fn invalid_ppnum_non_digits() {
142        assert!(matches!("123abcdefgh".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // letters
143        assert!(matches!("12312345678a".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // trailing letter
144        assert!(matches!("123-1234-5678".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // hyphenated (display form, not wire)
145        assert!(matches!("12312345678 ".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // trailing space
146    }
147
148    #[test]
149    fn invalid_ppnum_unicode_digits() {
150        // is_ascii_digit is byte-level (matches only 0x30..=0x39). Unicode
151        // digit characters never pass. This test guards against a future
152        // refactor that swaps to chars().any(|c| c.is_numeric()) which
153        // would silently start accepting non-ASCII digits the DB rejects.
154        assert!(matches!("12312345678".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // fullwidth (U+FF11..)
155        assert!(matches!("١٢٣١٢٣٤٥٦٧٨".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // Eastern Arabic-Indic (U+0660..)
156        assert!(matches!("১২৩১২৩৪৫৬৭৮".parse::<Ppnum>(), Err(Error::InvalidPpnum(_)))); // Bengali (U+09E6..)
157    }
158
159    #[test]
160    fn ppnum_serde_rejects_invalid() {
161        // Serde must run validation via try_from = "String" — guard
162        // against a future migration to #[serde(transparent)] (the
163        // pattern PpnumId uses) that would silently bypass.
164        assert!(serde_json::from_str::<Ppnum>("\"123\"").is_err()); // too short
165        assert!(serde_json::from_str::<Ppnum>("\"abc12345678\"").is_err()); // non-digit
166        assert!(serde_json::from_str::<Ppnum>("\"\"").is_err()); // empty
167        assert!(serde_json::from_str::<Ppnum>("\"123-1234-5678\"").is_err()); // hyphenated display form
168    }
169
170    #[test]
171    fn ppnum_serde_roundtrip() {
172        let ppnum: Ppnum = "12312345678".parse().unwrap();
173        let json = serde_json::to_string(&ppnum).unwrap();
174        assert_eq!(json, "\"12312345678\"");
175        let parsed: Ppnum = serde_json::from_str(&json).unwrap();
176        assert_eq!(parsed, ppnum);
177    }
178
179    #[test]
180    fn ppnum_id_serde_roundtrip() {
181        let id = PpnumId(Ulid::nil());
182        let json = serde_json::to_string(&id).unwrap();
183        let parsed: PpnumId = serde_json::from_str(&json).unwrap();
184        assert_eq!(parsed, id);
185    }
186
187    #[test]
188    fn user_id_from_string() {
189        let id = UserId::from("user-123".to_string());
190        assert_eq!(id.to_string(), "user-123");
191    }
192
193    #[test]
194    fn session_id_from_string() {
195        let id = SessionId::from("sess-abc".to_string());
196        assert_eq!(id.to_string(), "sess-abc");
197    }
198
199    #[test]
200    fn newtypes_prevent_mixing() {
201        fn takes_user_id(_: &UserId) {}
202        fn takes_session_id(_: &SessionId) {}
203
204        let user = UserId::from("id".to_string());
205        let session = SessionId::from("id".to_string());
206
207        takes_user_id(&user);
208        takes_session_id(&session);
209        // takes_user_id(&session);  // Compile error!
210        // takes_session_id(&user);  // Compile error!
211    }
212}