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#[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 #[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#[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#[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#[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#[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#[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 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 #[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 let result = SessionKey::new("YWJj");
433 assert!(result.is_err());
434 }
435
436 #[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 #[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 #[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 #[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}