vta_sdk/identifier.rs
1//! Pure validators for caller-supplied identifiers that compose into
2//! store-keyspace keys.
3//!
4//! Several VTA route handlers build keys by formatting caller-supplied
5//! strings directly into templates like `tpl:ctx:{context_id}:{name}`
6//! or `ctx:{id}`. A caller that supplies a string containing the
7//! separator character `:` could collide with or inject into adjacent
8//! namespaces. This module enforces the invariant at the boundary.
9//!
10//! The accepted character class is deliberately narrow:
11//! `[A-Za-z0-9._-]` with a length cap of 64 bytes. If you think you
12//! need `:` or `/` in an identifier, you probably want the identifier
13//! split into two fields instead — collisions are not worth the
14//! convenience.
15//!
16//! ## Why this lives in the SDK
17//! These rules are the canonical, security-relevant gate for what a
18//! context segment may contain. A **client** that constructs context
19//! paths must apply the *same* rules so it only ever sends paths the
20//! VTA will accept. Keeping the validator here (rather than in the
21//! server-only `vti-common`) lets clients share the one source of
22//! truth instead of mirroring it and risking drift. The server
23//! re-exports these through `vti_common::identifier`, mapping the
24//! [`ValidationError`] to its own `AppError` for ergonomics.
25
26/// Maximum length of a validated identifier in bytes. 64 is generous
27/// for slugs, context IDs, template names; anything longer is almost
28/// certainly a mistake or an injection attempt.
29pub const MAX_IDENTIFIER_LEN: usize = 64;
30
31/// A pure validation failure, dependency-light so clients can consume
32/// it without pulling in any server infrastructure.
33///
34/// The server wraps this into its own `AppError::Validation` (the
35/// `Display` string is preserved verbatim), so error messages are
36/// identical regardless of which side ran the validator.
37#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
38#[error("{0}")]
39pub struct ValidationError(pub String);
40
41/// Validate a caller-supplied identifier (context ID, template name,
42/// slug). Accepts ASCII alphanumerics plus `.`, `_`, `-`. Rejects
43/// empty strings, anything over [`MAX_IDENTIFIER_LEN`] bytes, and any
44/// character outside the allowed class — including the separator
45/// characters (`:`, `/`, whitespace) that would let a caller collide
46/// with or traverse into adjacent store-keyspace prefixes.
47///
48/// `label` names the field being validated so the resulting
49/// [`ValidationError`] message is self-describing.
50pub fn validate_identifier(label: &str, value: &str) -> Result<(), ValidationError> {
51 if value.is_empty() {
52 return Err(ValidationError(format!("{label} must not be empty")));
53 }
54 if value.len() > MAX_IDENTIFIER_LEN {
55 return Err(ValidationError(format!(
56 "{label} is {} bytes; maximum is {MAX_IDENTIFIER_LEN}",
57 value.len()
58 )));
59 }
60 for (i, ch) in value.chars().enumerate() {
61 let ok = ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-';
62 if !ok {
63 return Err(ValidationError(format!(
64 "{label} contains an invalid character {ch:?} at position {i}; \
65 allowed: A-Z, a-z, 0-9, '.', '_', '-'"
66 )));
67 }
68 }
69 Ok(())
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75
76 #[test]
77 fn accepts_common_identifier_shapes() {
78 for ok in [
79 "myapp",
80 "My-App_1",
81 "context.v2",
82 "a",
83 "0",
84 "didcomm-mediator",
85 "_private",
86 "CamelCase",
87 ] {
88 validate_identifier("id", ok).unwrap_or_else(|e| panic!("{ok:?} rejected: {e:?}"));
89 }
90 }
91
92 #[test]
93 fn rejects_empty() {
94 let err = validate_identifier("id", "").expect_err("empty must be rejected");
95 assert_eq!(err.0, "id must not be empty");
96 }
97
98 #[test]
99 fn rejects_separator_injection() {
100 // These are the concrete attack shapes: a caller who can inject
101 // `:` can collide with or escape adjacent store namespaces.
102 for bad in [
103 "global:evil", // collides with tpl:global: prefix
104 "../../etc", // path traversal shape
105 "a:b:c", // keyspace separator
106 "my/ctx", // path separator
107 "with space", // whitespace
108 "tab\there", // control chars
109 "with\nnewline", // newline injection
110 "null\0byte", // null byte
111 "unicode:§§", // non-ASCII
112 "quote\"injected", // quote shape
113 ] {
114 validate_identifier("id", bad).expect_err(&format!("{bad:?} must be rejected"));
115 }
116 }
117
118 #[test]
119 fn rejects_too_long() {
120 let long = "a".repeat(MAX_IDENTIFIER_LEN + 1);
121 validate_identifier("id", &long).expect_err("overlong must be rejected");
122 }
123
124 #[test]
125 fn accepts_exactly_at_limit() {
126 let edge = "a".repeat(MAX_IDENTIFIER_LEN);
127 validate_identifier("id", &edge).expect("exactly-at-limit must pass");
128 }
129
130 #[test]
131 fn error_message_names_the_field() {
132 let err = validate_identifier("context_id", "bad:id").expect_err("rejected");
133 assert!(
134 err.0.contains("context_id"),
135 "error must name the field it is validating — got {}",
136 err.0
137 );
138 }
139}