Skip to main content

reinhardt_deeplink/
error.rs

1//! Error types for deeplink configuration and handling.
2
3use thiserror::Error;
4
5/// Maximum allowed length for a URL scheme name.
6const MAX_SCHEME_LENGTH: usize = 64;
7
8/// Maximum allowed length for a bundle ID.
9const MAX_BUNDLE_ID_LENGTH: usize = 155;
10
11/// Dangerous URL schemes that must be rejected to prevent XSS and other attacks.
12const DANGEROUS_SCHEMES: &[&str] = &["javascript", "data", "vbscript", "file"];
13
14/// Errors that can occur during deeplink configuration and handling.
15#[derive(Debug, Error)]
16pub enum DeeplinkError {
17	/// Invalid iOS app ID format.
18	///
19	/// App IDs must follow the format `TEAM_ID.bundle_identifier`.
20	#[error("invalid iOS app ID format: {0}. Expected format: TEAM_ID.bundle_identifier")]
21	InvalidAppId(String),
22
23	/// Invalid Android package name format.
24	///
25	/// Package names must follow Java package naming conventions.
26	#[error(
27		"invalid Android package name: {0}. Expected Java package format (e.g., com.example.app)"
28	)]
29	InvalidPackageName(String),
30
31	/// Invalid Android SHA256 fingerprint format.
32	///
33	/// Fingerprints must be 32 colon-separated hex bytes (e.g., `FA:C6:17:...`).
34	#[error("invalid Android fingerprint format: {0}. Expected 32 colon-separated hex bytes")]
35	InvalidFingerprint(String),
36
37	/// Invalid custom URL scheme name.
38	///
39	/// Scheme names must comply with RFC 3986: start with a letter, followed by
40	/// letters, digits, `+`, `-`, or `.`. Dangerous schemes are rejected.
41	#[error("invalid URL scheme name: {0}")]
42	InvalidSchemeName(String),
43
44	/// Invalid bundle ID format.
45	///
46	/// Bundle IDs must follow reverse-domain notation with at least 2 segments.
47	#[error("invalid bundle ID format: {0}")]
48	InvalidBundleId(String),
49
50	/// No paths specified for iOS Universal Links.
51	#[error("no paths specified for iOS Universal Links")]
52	NoPathsSpecified,
53
54	/// Package name is required for Android App Links.
55	#[error("package name required for Android App Links")]
56	MissingPackageName,
57
58	/// At least one SHA256 fingerprint is required for Android.
59	#[error("at least one SHA256 fingerprint required for Android")]
60	MissingFingerprint,
61
62	/// iOS configuration is required but not provided.
63	#[error("iOS configuration required but not provided")]
64	MissingIosConfig,
65
66	/// Android configuration is required but not provided.
67	#[error("Android configuration required but not provided")]
68	MissingAndroidConfig,
69
70	/// JSON serialization failed.
71	#[error("serialization failed: {0}")]
72	Serialization(#[from] serde_json::Error),
73}
74
75/// Validates an iOS app ID format.
76///
77/// Valid format: `TEAM_ID.bundle_identifier` where:
78/// - TEAM_ID is typically 10 alphanumeric characters
79/// - bundle_identifier follows reverse domain notation (validated by [`validate_bundle_id`])
80///
81/// # Errors
82///
83/// Returns `DeeplinkError::InvalidAppId` if the format is invalid.
84pub fn validate_app_id(app_id: &str) -> Result<(), DeeplinkError> {
85	// Split into team ID and bundle ID (requires at least one dot)
86	let Some((team_id, bundle_id)) = app_id.split_once('.') else {
87		return Err(DeeplinkError::InvalidAppId(app_id.to_string()));
88	};
89
90	// Team ID should be alphanumeric (typically 10 characters, but we allow flexibility)
91	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 the bundle ID portion using strict reverse-domain notation
96	validate_bundle_id(bundle_id).map_err(|_| DeeplinkError::InvalidAppId(app_id.to_string()))?;
97
98	Ok(())
99}
100
101/// Validates a bundle ID follows reverse-domain notation.
102///
103/// Valid bundle IDs must:
104/// - Have at least 2 segments separated by dots (e.g., `com.example`)
105/// - Each segment must start with a letter or underscore
106/// - Each segment may contain only ASCII alphanumeric characters, hyphens, or underscores
107/// - Total length must not exceed `MAX_BUNDLE_ID_LENGTH` (155) characters
108///
109/// # Errors
110///
111/// Returns `DeeplinkError::InvalidBundleId` if the format is invalid.
112pub 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	// Must have at least 2 segments (reverse-domain notation)
120	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
133/// Checks if a single bundle ID segment is valid.
134///
135/// A valid segment:
136/// - Is not empty
137/// - Starts with a letter or underscore
138/// - Contains only ASCII alphanumeric characters, hyphens, or underscores
139fn 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
154/// Validates a custom URL scheme name per RFC 3986.
155///
156/// Valid scheme names must:
157/// - Start with an ASCII letter
158/// - Contain only ASCII letters, digits, `+`, `-`, or `.`
159/// - Not be a dangerous scheme (`javascript`, `data`, `vbscript`, `file`)
160/// - Not exceed `MAX_SCHEME_LENGTH` (64) characters
161///
162/// # Errors
163///
164/// Returns `DeeplinkError::InvalidSchemeName` if the scheme is invalid.
165pub 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	// RFC 3986: scheme must start with a letter
171	if !scheme.as_bytes()[0].is_ascii_alphabetic() {
172		return Err(DeeplinkError::InvalidSchemeName(scheme.to_string()));
173	}
174
175	// RFC 3986: followed by letters, digits, +, -, or .
176	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	// Reject dangerous schemes (case-insensitive comparison)
184	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
192/// Validates an Android package name format.
193///
194/// Android package names must follow Java package naming conventions:
195/// - Must contain at least one dot separator
196/// - Each segment must start with a letter
197/// - Only letters, digits, and underscores are allowed in each segment
198/// - Must not be empty
199///
200/// # Errors
201///
202/// Returns `DeeplinkError::InvalidPackageName` if the format is invalid.
203pub fn validate_package_name(name: &str) -> Result<(), DeeplinkError> {
204	if name.is_empty() {
205		return Err(DeeplinkError::InvalidPackageName(name.to_string()));
206	}
207
208	// Must contain at least one dot
209	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		// Each segment must not be empty
216		if segment.is_empty() {
217			return Err(DeeplinkError::InvalidPackageName(name.to_string()));
218		}
219
220		// Each segment must start with a letter
221		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		// Each segment can only contain letters, digits, and underscores
230		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
241/// Validates an Android SHA256 fingerprint format.
242///
243/// Valid format: 32 colon-separated hex bytes (e.g., `FA:C6:17:45:...`).
244///
245/// # Errors
246///
247/// Returns `DeeplinkError::InvalidFingerprint` if the format is invalid.
248pub fn validate_fingerprint(fingerprint: &str) -> Result<(), DeeplinkError> {
249	let parts: Vec<&str> = fingerprint.split(':').collect();
250
251	// Must have exactly 32 bytes
252	if parts.len() != 32 {
253		return Err(DeeplinkError::InvalidFingerprint(fingerprint.to_string()));
254	}
255
256	// Each part must be exactly 2 hex characters
257	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	// -- validate_app_id --
273
274	#[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)] // no dot
281	#[case("", false)] // empty
282	#[case(".com.example", false)] // empty team ID
283	#[case("TEAM.", false)] // empty bundle ID
284	#[case("TEAM.bundle", false)] // single-segment bundle ID (not reverse-domain)
285	#[case("TEAM.com.", false)] // trailing dot creates empty segment
286	#[case("TEAM..com", false)] // empty segment between dots
287	#[case("TEAM.123.app", false)] // segment starting with digit
288	#[case("TEAM.com.app!x", false)] // invalid character in segment
289	fn test_validate_app_id(#[case] app_id: &str, #[case] expected_valid: bool) {
290		// Arrange
291		// (inputs provided by #[case])
292
293		// Act
294		let result = validate_app_id(app_id);
295
296		// Assert
297		assert_eq!(result.is_ok(), expected_valid, "app_id: {}", app_id);
298	}
299
300	// -- validate_bundle_id --
301
302	#[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)] // empty
310	#[case("single", false)] // single segment
311	#[case(".com.example", false)] // leading dot creates empty segment
312	#[case("com.example.", false)] // trailing dot creates empty segment
313	#[case("com..example", false)] // empty segment
314	#[case("123.example", false)] // segment starting with digit
315	#[case("com.123app", false)] // segment starting with digit
316	#[case("com.app!x", false)] // invalid character
317	#[case("com.app x", false)] // space in segment
318	fn test_validate_bundle_id(#[case] bundle_id: &str, #[case] expected_valid: bool) {
319		// Arrange
320		// (inputs provided by #[case])
321
322		// Act
323		let result = validate_bundle_id(bundle_id);
324
325		// Assert
326		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		// Arrange
332		let long_bundle_id = format!("com.{}", "a".repeat(MAX_BUNDLE_ID_LENGTH));
333
334		// Act
335		let result = validate_bundle_id(&long_bundle_id);
336
337		// Assert
338		assert!(
339			result.is_err(),
340			"bundle ID exceeding max length should be rejected"
341		);
342	}
343
344	// -- validate_scheme_name --
345
346	#[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)] // uppercase allowed per RFC 3986
354	#[case("", false)] // empty
355	#[case("1app", false)] // starts with digit
356	#[case("-app", false)] // starts with hyphen
357	#[case(".app", false)] // starts with dot
358	#[case("my app", false)] // space
359	#[case("my_app", false)] // underscore not allowed in scheme
360	#[case("my@app", false)] // special character
361	#[case("javascript", false)] // dangerous scheme
362	#[case("JavaScript", false)] // dangerous scheme (case-insensitive)
363	#[case("data", false)] // dangerous scheme
364	#[case("DATA", false)] // dangerous scheme (case-insensitive)
365	#[case("vbscript", false)] // dangerous scheme
366	#[case("file", false)] // dangerous scheme
367	#[case("FILE", false)] // dangerous scheme (case-insensitive)
368	fn test_validate_scheme_name(#[case] scheme: &str, #[case] expected_valid: bool) {
369		// Arrange
370		// (inputs provided by #[case])
371
372		// Act
373		let result = validate_scheme_name(scheme);
374
375		// Assert
376		assert_eq!(result.is_ok(), expected_valid, "scheme: {}", scheme);
377	}
378
379	#[rstest]
380	fn test_validate_scheme_name_exceeds_max_length() {
381		// Arrange
382		let long_scheme = format!("a{}", "b".repeat(MAX_SCHEME_LENGTH));
383
384		// Act
385		let result = validate_scheme_name(&long_scheme);
386
387		// Assert
388		assert!(
389			result.is_err(),
390			"scheme exceeding max length should be rejected"
391		);
392	}
393
394	// -- validate_fingerprint --
395
396	#[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)] // empty
402	#[case("nopackage", false)] // no dot
403	#[case(".com.example", false)] // starts with dot (empty first segment)
404	#[case("com.example.", false)] // ends with dot (empty last segment)
405	#[case("123.invalid.name", false)] // segment starts with digit
406	#[case("com.123.app", false)] // segment starts with digit
407	#[case("com.exam ple.app", false)] // contains space
408	#[case("com.exam-ple.app", false)] // contains hyphen
409	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		// Arrange
432		// (inputs provided by #[case])
433
434		// Act
435		let result = validate_fingerprint(fingerprint);
436
437		// Assert
438		assert_eq!(
439			result.is_ok(),
440			expected_valid,
441			"fingerprint: {}",
442			fingerprint
443		);
444	}
445}