Skip to main content

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}