1use thiserror::Error;
4
5const MAX_SCHEME_LENGTH: usize = 64;
7
8const MAX_BUNDLE_ID_LENGTH: usize = 155;
10
11const DANGEROUS_SCHEMES: &[&str] = &["javascript", "data", "vbscript", "file"];
13
14#[derive(Debug, Error)]
16pub enum DeeplinkError {
17 #[error("invalid iOS app ID format: {0}. Expected format: TEAM_ID.bundle_identifier")]
21 InvalidAppId(String),
22
23 #[error(
27 "invalid Android package name: {0}. Expected Java package format (e.g., com.example.app)"
28 )]
29 InvalidPackageName(String),
30
31 #[error("invalid Android fingerprint format: {0}. Expected 32 colon-separated hex bytes")]
35 InvalidFingerprint(String),
36
37 #[error("invalid URL scheme name: {0}")]
42 InvalidSchemeName(String),
43
44 #[error("invalid bundle ID format: {0}")]
48 InvalidBundleId(String),
49
50 #[error("no paths specified for iOS Universal Links")]
52 NoPathsSpecified,
53
54 #[error("package name required for Android App Links")]
56 MissingPackageName,
57
58 #[error("at least one SHA256 fingerprint required for Android")]
60 MissingFingerprint,
61
62 #[error("iOS configuration required but not provided")]
64 MissingIosConfig,
65
66 #[error("Android configuration required but not provided")]
68 MissingAndroidConfig,
69
70 #[error("serialization failed: {0}")]
72 Serialization(#[from] serde_json::Error),
73}
74
75pub fn validate_app_id(app_id: &str) -> Result<(), DeeplinkError> {
85 let Some((team_id, bundle_id)) = app_id.split_once('.') else {
87 return Err(DeeplinkError::InvalidAppId(app_id.to_string()));
88 };
89
90 if team_id.is_empty() || !team_id.chars().all(|c| c.is_ascii_alphanumeric()) {
92 return Err(DeeplinkError::InvalidAppId(app_id.to_string()));
93 }
94
95 validate_bundle_id(bundle_id).map_err(|_| DeeplinkError::InvalidAppId(app_id.to_string()))?;
97
98 Ok(())
99}
100
101pub fn validate_bundle_id(bundle_id: &str) -> Result<(), DeeplinkError> {
113 if bundle_id.is_empty() || bundle_id.len() > MAX_BUNDLE_ID_LENGTH {
114 return Err(DeeplinkError::InvalidBundleId(bundle_id.to_string()));
115 }
116
117 let segments: Vec<&str> = bundle_id.split('.').collect();
118
119 if segments.len() < 2 {
121 return Err(DeeplinkError::InvalidBundleId(bundle_id.to_string()));
122 }
123
124 for segment in &segments {
125 if !is_valid_bundle_segment(segment) {
126 return Err(DeeplinkError::InvalidBundleId(bundle_id.to_string()));
127 }
128 }
129
130 Ok(())
131}
132
133fn is_valid_bundle_segment(segment: &str) -> bool {
140 if segment.is_empty() {
141 return false;
142 }
143
144 let first = segment.as_bytes()[0];
145 if !first.is_ascii_alphabetic() && first != b'_' {
146 return false;
147 }
148
149 segment
150 .bytes()
151 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
152}
153
154pub fn validate_scheme_name(scheme: &str) -> Result<(), DeeplinkError> {
166 if scheme.is_empty() || scheme.len() > MAX_SCHEME_LENGTH {
167 return Err(DeeplinkError::InvalidSchemeName(scheme.to_string()));
168 }
169
170 if !scheme.as_bytes()[0].is_ascii_alphabetic() {
172 return Err(DeeplinkError::InvalidSchemeName(scheme.to_string()));
173 }
174
175 if !scheme
177 .bytes()
178 .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || b == b'.')
179 {
180 return Err(DeeplinkError::InvalidSchemeName(scheme.to_string()));
181 }
182
183 let lower = scheme.to_ascii_lowercase();
185 if DANGEROUS_SCHEMES.contains(&lower.as_str()) {
186 return Err(DeeplinkError::InvalidSchemeName(scheme.to_string()));
187 }
188
189 Ok(())
190}
191
192pub fn validate_package_name(name: &str) -> Result<(), DeeplinkError> {
204 if name.is_empty() {
205 return Err(DeeplinkError::InvalidPackageName(name.to_string()));
206 }
207
208 if !name.contains('.') {
210 return Err(DeeplinkError::InvalidPackageName(name.to_string()));
211 }
212
213 let segments: Vec<&str> = name.split('.').collect();
214 for segment in &segments {
215 if segment.is_empty() {
217 return Err(DeeplinkError::InvalidPackageName(name.to_string()));
218 }
219
220 let first_char = segment
222 .chars()
223 .next()
224 .expect("segment is non-empty after the emptiness check above");
225 if !first_char.is_ascii_alphabetic() {
226 return Err(DeeplinkError::InvalidPackageName(name.to_string()));
227 }
228
229 if !segment
231 .chars()
232 .all(|c| c.is_ascii_alphanumeric() || c == '_')
233 {
234 return Err(DeeplinkError::InvalidPackageName(name.to_string()));
235 }
236 }
237
238 Ok(())
239}
240
241pub fn validate_fingerprint(fingerprint: &str) -> Result<(), DeeplinkError> {
249 let parts: Vec<&str> = fingerprint.split(':').collect();
250
251 if parts.len() != 32 {
253 return Err(DeeplinkError::InvalidFingerprint(fingerprint.to_string()));
254 }
255
256 for part in parts {
258 if part.len() != 2 || !part.chars().all(|c| c.is_ascii_hexdigit()) {
259 return Err(DeeplinkError::InvalidFingerprint(fingerprint.to_string()));
260 }
261 }
262
263 Ok(())
264}
265
266#[cfg(test)]
267mod tests {
268 use rstest::rstest;
269
270 use super::*;
271
272 #[rstest]
275 #[case("TEAM123456.com.example.app", true)]
276 #[case("ABC123XYZ0.com.example.myapp", true)]
277 #[case("TEAM.com.example", true)]
278 #[case("TEAM.com.example-app", true)]
279 #[case("TEAM._private.app", true)]
280 #[case("invalid", false)] #[case("", false)] #[case(".com.example", false)] #[case("TEAM.", false)] #[case("TEAM.bundle", false)] #[case("TEAM.com.", false)] #[case("TEAM..com", false)] #[case("TEAM.123.app", false)] #[case("TEAM.com.app!x", false)] fn test_validate_app_id(#[case] app_id: &str, #[case] expected_valid: bool) {
290 let result = validate_app_id(app_id);
295
296 assert_eq!(result.is_ok(), expected_valid, "app_id: {}", app_id);
298 }
299
300 #[rstest]
303 #[case("com.example", true)]
304 #[case("com.example.app", true)]
305 #[case("io.github.user", true)]
306 #[case("com.my-app.test", true)]
307 #[case("com._private.app", true)]
308 #[case("org.example.my_app", true)]
309 #[case("", false)] #[case("single", false)] #[case(".com.example", false)] #[case("com.example.", false)] #[case("com..example", false)] #[case("123.example", false)] #[case("com.123app", false)] #[case("com.app!x", false)] #[case("com.app x", false)] fn test_validate_bundle_id(#[case] bundle_id: &str, #[case] expected_valid: bool) {
319 let result = validate_bundle_id(bundle_id);
324
325 assert_eq!(result.is_ok(), expected_valid, "bundle_id: {}", bundle_id);
327 }
328
329 #[rstest]
330 fn test_validate_bundle_id_exceeds_max_length() {
331 let long_bundle_id = format!("com.{}", "a".repeat(MAX_BUNDLE_ID_LENGTH));
333
334 let result = validate_bundle_id(&long_bundle_id);
336
337 assert!(
339 result.is_err(),
340 "bundle ID exceeding max length should be rejected"
341 );
342 }
343
344 #[rstest]
347 #[case("myapp", true)]
348 #[case("my-app", true)]
349 #[case("my.app", true)]
350 #[case("my+app", true)]
351 #[case("a123", true)]
352 #[case("x", true)]
353 #[case("MyApp", true)] #[case("", false)] #[case("1app", false)] #[case("-app", false)] #[case(".app", false)] #[case("my app", false)] #[case("my_app", false)] #[case("my@app", false)] #[case("javascript", false)] #[case("JavaScript", false)] #[case("data", false)] #[case("DATA", false)] #[case("vbscript", false)] #[case("file", false)] #[case("FILE", false)] fn test_validate_scheme_name(#[case] scheme: &str, #[case] expected_valid: bool) {
369 let result = validate_scheme_name(scheme);
374
375 assert_eq!(result.is_ok(), expected_valid, "scheme: {}", scheme);
377 }
378
379 #[rstest]
380 fn test_validate_scheme_name_exceeds_max_length() {
381 let long_scheme = format!("a{}", "b".repeat(MAX_SCHEME_LENGTH));
383
384 let result = validate_scheme_name(&long_scheme);
386
387 assert!(
389 result.is_err(),
390 "scheme exceeding max length should be rejected"
391 );
392 }
393
394 #[rstest]
397 #[case("com.example.app", true)]
398 #[case("com.example.myapp", true)]
399 #[case("org.company.product", true)]
400 #[case("com.example.app_v2", true)]
401 #[case("", false)] #[case("nopackage", false)] #[case(".com.example", false)] #[case("com.example.", false)] #[case("123.invalid.name", false)] #[case("com.123.app", false)] #[case("com.exam ple.app", false)] #[case("com.exam-ple.app", false)] fn test_validate_package_name(#[case] name: &str, #[case] expected_valid: bool) {
410 let result = validate_package_name(name);
411 assert_eq!(result.is_ok(), expected_valid, "package_name: {}", name);
412 }
413
414 #[rstest]
415 #[case(
416 "FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C",
417 true
418 )]
419 #[case(
420 "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00",
421 true
422 )]
423 #[case("invalid", false)]
424 #[case("", false)]
425 #[case("FA:C6:17", false)]
426 #[case(
427 "FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:XX",
428 false
429 )]
430 fn test_validate_fingerprint(#[case] fingerprint: &str, #[case] expected_valid: bool) {
431 let result = validate_fingerprint(fingerprint);
436
437 assert_eq!(
439 result.is_ok(),
440 expected_valid,
441 "fingerprint: {}",
442 fingerprint
443 );
444 }
445}