Skip to main content

wechat_mp_sdk/types/
ids.rs

1use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
2use serde::{Deserialize, Serialize};
3use std::fmt;
4
5use crate::error::WechatError;
6
7fn contains_control_chars(s: &str) -> bool {
8    s.chars().any(|c| c.is_ascii_control())
9}
10
11fn is_whitespace_only(s: &str) -> bool {
12    !s.is_empty() && s.chars().all(|c| c.is_whitespace())
13}
14
15fn has_leading_trailing_whitespace(s: &str) -> bool {
16    s != s.trim()
17}
18
19fn validate_base64_and_decode(s: &str) -> Result<Vec<u8>, String> {
20    let valid_chars = |c: char| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=';
21    if !s.chars().all(valid_chars) {
22        return Err("contains invalid base64 characters".to_string());
23    }
24    BASE64_STANDARD
25        .decode(s)
26        .map_err(|e| format!("invalid base64: {}", e))
27}
28
29/// WeChat Mini Program AppID (18 characters)
30#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
31pub struct AppId(String);
32
33impl AppId {
34    pub fn new(id: impl Into<String>) -> Result<Self, WechatError> {
35        let id = id.into();
36        if !id.starts_with("wx") {
37            return Err(WechatError::InvalidAppId(format!(
38                "AppId must start with 'wx', got {}",
39                id
40            )));
41        }
42        if id.len() != 18 {
43            return Err(WechatError::InvalidAppId(format!(
44                "AppId must be 18 characters, got {}",
45                id.len()
46            )));
47        }
48        Ok(Self(id))
49    }
50
51    /// Creates an AppId without validation.
52    ///
53    /// This is a safe function — no undefined behavior occurs with invalid input,
54    /// but subsequent API calls may fail if the value is not a valid WeChat AppId.
55    /// Prefer [`AppId::new`] for user-supplied input.
56    #[must_use]
57    pub fn new_unchecked(id: impl Into<String>) -> Self {
58        Self(id.into())
59    }
60
61    pub fn as_str(&self) -> &str {
62        &self.0
63    }
64}
65
66impl fmt::Display for AppId {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        write!(f, "{}", self.as_str())
69    }
70}
71
72/// WeChat Mini Program AppSecret
73#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
74pub struct AppSecret(String);
75
76impl fmt::Debug for AppSecret {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        write!(f, "AppSecret(****)")
79    }
80}
81
82impl AppSecret {
83    pub fn new(secret: impl Into<String>) -> Result<Self, WechatError> {
84        let secret = secret.into();
85        if secret.is_empty() {
86            return Err(WechatError::InvalidAppSecret(
87                "AppSecret must not be empty".to_string(),
88            ));
89        }
90        if is_whitespace_only(&secret) {
91            return Err(WechatError::InvalidAppSecret(
92                "AppSecret must not be whitespace-only".to_string(),
93            ));
94        }
95        if contains_control_chars(&secret) {
96            return Err(WechatError::InvalidAppSecret(
97                "AppSecret must not contain control characters".to_string(),
98            ));
99        }
100        Ok(Self(secret))
101    }
102
103    pub fn as_str(&self) -> &str {
104        &self.0
105    }
106}
107
108impl fmt::Display for AppSecret {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        write!(f, "***")
111    }
112}
113
114/// WeChat Mini Program OpenID (20-40 characters)
115#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
116pub struct OpenId(String);
117
118impl OpenId {
119    pub fn new(id: impl Into<String>) -> Result<Self, WechatError> {
120        let id = id.into();
121        if id.is_empty() || id.len() < 20 || id.len() > 40 {
122            return Err(WechatError::InvalidOpenId(format!(
123                "OpenId must be 20-40 characters, got {}",
124                id.len()
125            )));
126        }
127        Ok(Self(id))
128    }
129
130    pub fn as_str(&self) -> &str {
131        &self.0
132    }
133}
134
135impl fmt::Display for OpenId {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        write!(f, "{}", self.as_str())
138    }
139}
140
141/// WeChat UnionID
142#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
143pub struct UnionId(String);
144
145impl UnionId {
146    pub fn new(id: impl Into<String>) -> Result<Self, WechatError> {
147        let id = id.into();
148        if id.is_empty() {
149            return Err(WechatError::InvalidUnionId(
150                "UnionId must not be empty".to_string(),
151            ));
152        }
153        if is_whitespace_only(&id) {
154            return Err(WechatError::InvalidUnionId(
155                "UnionId must not be whitespace-only".to_string(),
156            ));
157        }
158        if contains_control_chars(&id) {
159            return Err(WechatError::InvalidUnionId(
160                "UnionId must not contain control characters".to_string(),
161            ));
162        }
163        Ok(Self(id))
164    }
165
166    pub fn as_str(&self) -> &str {
167        &self.0
168    }
169}
170
171impl fmt::Display for UnionId {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        write!(f, "{}", self.as_str())
174    }
175}
176
177/// WeChat Session Key (base64 encoded, typically 24 characters)
178#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
179pub struct SessionKey(String);
180
181impl fmt::Debug for SessionKey {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        write!(f, "SessionKey(****)")
184    }
185}
186
187impl SessionKey {
188    pub fn new(key: impl Into<String>) -> Result<Self, WechatError> {
189        let key = key.into();
190        if key.is_empty() {
191            return Err(WechatError::InvalidSessionKey(
192                "SessionKey must not be empty".to_string(),
193            ));
194        }
195        if is_whitespace_only(&key) {
196            return Err(WechatError::InvalidSessionKey(
197                "SessionKey must not be whitespace-only".to_string(),
198            ));
199        }
200        if has_leading_trailing_whitespace(&key) {
201            return Err(WechatError::InvalidSessionKey(
202                "SessionKey must not have leading/trailing whitespace".to_string(),
203            ));
204        }
205        if contains_control_chars(&key) {
206            return Err(WechatError::InvalidSessionKey(
207                "SessionKey must not contain control characters".to_string(),
208            ));
209        }
210        let decoded = validate_base64_and_decode(&key)
211            .map_err(|e| WechatError::InvalidSessionKey(format!("SessionKey {}", e)))?;
212        if decoded.len() != 16 {
213            return Err(WechatError::InvalidSessionKey(format!(
214                "SessionKey must decode to 16 bytes for AES-128, got {}",
215                decoded.len()
216            )));
217        }
218        Ok(Self(key))
219    }
220
221    pub fn as_str(&self) -> &str {
222        &self.0
223    }
224}
225
226impl fmt::Display for SessionKey {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        write!(f, "***")
229    }
230}
231
232/// WeChat Access Token
233#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
234pub struct AccessToken(String);
235
236impl AccessToken {
237    pub fn new(token: impl Into<String>) -> Result<Self, WechatError> {
238        let token = token.into();
239        if token.is_empty() {
240            return Err(WechatError::InvalidAccessToken(
241                "AccessToken must not be empty".to_string(),
242            ));
243        }
244        if is_whitespace_only(&token) {
245            return Err(WechatError::InvalidAccessToken(
246                "AccessToken must not be whitespace-only".to_string(),
247            ));
248        }
249        if contains_control_chars(&token) {
250            return Err(WechatError::InvalidAccessToken(
251                "AccessToken must not contain control characters".to_string(),
252            ));
253        }
254        if has_leading_trailing_whitespace(&token) {
255            return Err(WechatError::InvalidAccessToken(
256                "AccessToken must not have leading/trailing whitespace".to_string(),
257            ));
258        }
259        Ok(Self(token))
260    }
261
262    pub fn as_str(&self) -> &str {
263        &self.0
264    }
265}
266
267impl fmt::Display for AccessToken {
268    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
269        write!(f, "{}", self.as_str())
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_app_id_valid() {
279        let id = "wx1234567890abcdef".to_string();
280        let app_id = AppId::new(id.clone()).unwrap();
281        assert_eq!(app_id.as_str(), id);
282    }
283
284    #[test]
285    fn test_app_id_invalid_length() {
286        let result = AppId::new("short");
287        assert!(result.is_err());
288    }
289
290    #[test]
291    fn test_app_id_invalid_prefix() {
292        let result = AppId::new("abcdefghijklmnop");
293        assert!(result.is_err());
294        let err = result.unwrap_err();
295        let err_str = err.to_string();
296        assert!(err_str.contains("must start with 'wx'"));
297    }
298
299    #[test]
300    fn test_app_secret_valid() {
301        let secret = "abc123".to_string();
302        let app_secret = AppSecret::new(secret.clone()).unwrap();
303        assert_eq!(app_secret.as_str(), secret);
304    }
305
306    #[test]
307    fn test_app_secret_empty() {
308        let result = AppSecret::new("");
309        assert!(result.is_err());
310    }
311
312    #[test]
313    fn test_app_secret_debug_redacted() {
314        let secret = AppSecret::new("super_secret_value").unwrap();
315        let debug_output = format!("{:?}", secret);
316        assert_eq!(debug_output, "AppSecret(****)");
317        assert!(!debug_output.contains("super_secret_value"));
318    }
319
320    #[test]
321    fn test_open_id_valid() {
322        let id20 = "o1234567890123456789".to_string();
323        assert_eq!(id20.len(), 20);
324        assert!(OpenId::new(id20).is_ok());
325
326        let id40 = "o123456789012345678901234567890123456789".to_string();
327        assert_eq!(id40.len(), 40);
328        assert!(OpenId::new(id40).is_ok());
329
330        let id28 = "o123456789012345678901234567".to_string();
331        assert_eq!(id28.len(), 28);
332        assert!(OpenId::new(id28).is_ok());
333    }
334
335    #[test]
336    fn test_open_id_invalid_length() {
337        assert!(OpenId::new("").is_err());
338
339        let short = "o123456789012345678".to_string();
340        assert_eq!(short.len(), 19);
341        assert!(OpenId::new(short).is_err());
342
343        let long = "o1234567890123456789012345678901234567890".to_string();
344        assert_eq!(long.len(), 41);
345        assert!(OpenId::new(long).is_err());
346    }
347
348    #[test]
349    fn test_union_id_valid() {
350        let id = "union1234567890".to_string();
351        let union_id = UnionId::new(id.clone()).unwrap();
352        assert_eq!(union_id.as_str(), id);
353    }
354
355    #[test]
356    fn test_union_id_empty() {
357        let result = UnionId::new("");
358        assert!(result.is_err());
359    }
360
361    #[test]
362    fn test_session_key_valid() {
363        // Valid base64 that decodes to 16 bytes for AES-128
364        // "YWJjZGVmZ2hpamtsbW5vcA==" = base64("abcdefghijklmnop")
365        let key = "YWJjZGVmZ2hpamtsbW5vcA==".to_string();
366        let session_key = SessionKey::new(key.clone()).unwrap();
367        assert_eq!(session_key.as_str(), key);
368    }
369
370    #[test]
371    fn test_session_key_empty() {
372        let result = SessionKey::new("");
373        assert!(result.is_err());
374    }
375
376    #[test]
377    fn test_session_key_debug_redacted() {
378        let key = SessionKey::new("YWJjZGVmZ2hpamtsbW5vcA==").unwrap();
379        let debug_output = format!("{:?}", key);
380        assert_eq!(debug_output, "SessionKey(****)");
381        assert!(!debug_output.contains("YWJjZGVmZ2hpamtsbW5vcA=="));
382    }
383
384    #[test]
385    fn test_access_token_valid() {
386        let token = "token1234567890abcdef".to_string();
387        let access_token = AccessToken::new(token.clone()).unwrap();
388        assert_eq!(access_token.as_str(), token);
389    }
390
391    #[test]
392    fn test_access_token_empty() {
393        let result = AccessToken::new("");
394        assert!(result.is_err());
395    }
396
397    // ========================================================================
398    // BOUNDARY TESTS - ID/Token Validation (hardened)
399    // ========================================================================
400
401    // -----------------------------------------------------------------
402    // SessionKey boundary tests
403    // -----------------------------------------------------------------
404
405    #[test]
406    fn test_session_key_whitespace_only() {
407        let result = SessionKey::new("   ");
408        assert!(result.is_err());
409    }
410
411    #[test]
412    fn test_session_key_with_whitespace_prefix_suffix() {
413        let result = SessionKey::new("  YWJjZGVmZ2hpamtsbW5vcA==  ");
414        assert!(result.is_err());
415    }
416
417    #[test]
418    fn test_session_key_control_characters() {
419        let result = SessionKey::new("abc\x00\x01def");
420        assert!(result.is_err());
421    }
422
423    #[test]
424    fn test_session_key_invalid_base64() {
425        let result = SessionKey::new("invalid!!base64!!!");
426        assert!(result.is_err());
427    }
428
429    #[test]
430    fn test_session_key_valid_base64_wrong_length() {
431        // "YWJj" decodes to "abc" (3 bytes) - invalid for AES-128
432        let result = SessionKey::new("YWJj");
433        assert!(result.is_err());
434    }
435
436    // -----------------------------------------------------------------
437    // AppSecret boundary tests
438    // -----------------------------------------------------------------
439
440    #[test]
441    fn test_app_secret_whitespace_only() {
442        let result = AppSecret::new("   \t\n   ");
443        assert!(result.is_err());
444    }
445
446    #[test]
447    fn test_app_secret_control_characters() {
448        let result = AppSecret::new("secret\x00\x01\x02");
449        assert!(result.is_err());
450    }
451
452    // -----------------------------------------------------------------
453    // UnionId boundary tests
454    // -----------------------------------------------------------------
455
456    #[test]
457    fn test_union_id_whitespace_only() {
458        let result = UnionId::new("   ");
459        assert!(result.is_err());
460    }
461
462    #[test]
463    fn test_union_id_control_characters() {
464        let result = UnionId::new("union\x00\x01id");
465        assert!(result.is_err());
466    }
467
468    // -----------------------------------------------------------------
469    // AccessToken boundary tests
470    // -----------------------------------------------------------------
471
472    #[test]
473    fn test_access_token_whitespace_only() {
474        let result = AccessToken::new("   \t\n   ");
475        assert!(result.is_err());
476    }
477
478    #[test]
479    fn test_access_token_control_characters() {
480        let result = AccessToken::new("token\x00\x01value");
481        assert!(result.is_err());
482    }
483
484    #[test]
485    fn test_access_token_with_leading_trailing_whitespace() {
486        let result = AccessToken::new("  token_value_123  ");
487        assert!(result.is_err());
488    }
489
490    // ========================================================================
491    // COMPATIBILITY MATRIX - Validation now hardened
492    // ========================================================================
493    //
494    // | Type         | Valid Input                    | Accepts |
495    // |--------------|--------------------------------|---------|
496    // | SessionKey   | Valid base64, 16 bytes decoded| YES     |
497    // | SessionKey   | Empty/whitespace/invalid      | NO      |
498    // | AppSecret    | Non-empty, no control chars   | YES     |
499    // | AppSecret    | Empty/whitespace-only/ctrl    | NO      |
500    // | UnionId      | Non-empty, no control chars   | YES     |
501    // | UnionId      | Empty/whitespace-only/ctrl    | NO      |
502    // | AccessToken  | Non-empty, no ws/ctrl         | YES     |
503    // | AccessToken  | Empty/whitespace/ctrl/ws-ws   | NO      |
504    // ========================================================================
505
506    // ========================================================================
507    // DISPLAY TRAIT TESTS
508    // ========================================================================
509
510    #[test]
511    fn test_display_app_id() {
512        let id = AppId::new("wx1234567890abcdef").unwrap();
513        assert_eq!(format!("{}", id), "wx1234567890abcdef");
514    }
515
516    #[test]
517    fn test_display_open_id() {
518        let id = OpenId::new("o1234567890123456789").unwrap();
519        assert_eq!(format!("{}", id), "o1234567890123456789");
520    }
521
522    #[test]
523    fn test_display_app_secret_redacted() {
524        let secret = AppSecret::new("my_secret_value").unwrap();
525        assert_eq!(format!("{}", secret), "***");
526    }
527
528    #[test]
529    fn test_display_session_key_redacted() {
530        let key = SessionKey::new("YWJjZGVmZ2hpamtsbW5vcA==").unwrap();
531        assert_eq!(format!("{}", key), "***");
532    }
533
534    #[test]
535    fn test_display_union_id() {
536        let id = UnionId::new("union1234567890").unwrap();
537        assert_eq!(format!("{}", id), "union1234567890");
538    }
539
540    #[test]
541    fn test_display_access_token() {
542        let token = AccessToken::new("token1234567890abcdef").unwrap();
543        assert_eq!(format!("{}", token), "token1234567890abcdef");
544    }
545}