Skip to main content

lean_ctx_protocol/
identity.rs

1//! Canonical bounded identities used by the additive Context SDK contracts.
2
3use crate::common::{ValidationError, validate_bounded_opaque_identifier};
4use serde::{Deserialize, Deserializer, Serialize, de::Error as DeError};
5
6/// Maximum length for an explicit non-path protocol reference.
7pub const MAX_REFERENCE_LENGTH: usize = 1_024;
8
9/// Exact number of hexadecimal characters in a SHA-256 digest.
10pub const SHA256_HEX_LENGTH: usize = 64;
11
12/// Maximum serialized length for a SemVer identity.
13pub const MAX_SEMVER_LENGTH: usize = 128;
14
15macro_rules! bounded_opaque_identifier {
16    ($name:ident) => {
17        /// Opaque, bounded identity owned by the public protocol.
18        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
19        #[serde(transparent)]
20        pub struct $name(String);
21
22        impl $name {
23            /// Construct an identifier after applying the protocol wire bounds.
24            pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
25                let value = value.into();
26                validate_bounded_opaque_identifier(&value, stringify!($name))?;
27                Ok(Self(value))
28            }
29
30            /// Borrow the opaque protocol identity.
31            pub fn as_str(&self) -> &str {
32                &self.0
33            }
34
35            /// Consume the identifier and return its wire representation.
36            pub fn into_inner(self) -> String {
37                self.0
38            }
39        }
40
41        impl std::str::FromStr for $name {
42            type Err = ValidationError;
43
44            fn from_str(value: &str) -> Result<Self, Self::Err> {
45                Self::new(value)
46            }
47        }
48
49        impl TryFrom<String> for $name {
50            type Error = ValidationError;
51
52            fn try_from(value: String) -> Result<Self, Self::Error> {
53                Self::new(value)
54            }
55        }
56
57        impl TryFrom<&str> for $name {
58            type Error = ValidationError;
59
60            fn try_from(value: &str) -> Result<Self, Self::Error> {
61                Self::new(value)
62            }
63        }
64
65        impl From<$name> for String {
66            fn from(value: $name) -> Self {
67                value.0
68            }
69        }
70
71        impl<'de> Deserialize<'de> for $name {
72            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
73            where
74                D: Deserializer<'de>,
75            {
76                let value = String::deserialize(deserializer)?;
77                Self::new(value).map_err(DeError::custom)
78            }
79        }
80    };
81}
82
83bounded_opaque_identifier!(WorkspaceId);
84bounded_opaque_identifier!(RunId);
85bounded_opaque_identifier!(EventId);
86bounded_opaque_identifier!(ProfileId);
87bounded_opaque_identifier!(KitId);
88bounded_opaque_identifier!(SourceId);
89bounded_opaque_identifier!(ViewId);
90bounded_opaque_identifier!(ProjectContextId);
91bounded_opaque_identifier!(HandoffId);
92bounded_opaque_identifier!(PolicyId);
93bounded_opaque_identifier!(PackageId);
94
95/// A canonical `sha256:<lowercase-hex>` content identity.
96#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
97#[serde(transparent)]
98pub struct Sha256Digest(String);
99
100impl Sha256Digest {
101    /// Parse a canonical SHA-256 digest identity.
102    pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
103        let value = value.into();
104        let Some(hex) = value.strip_prefix("sha256:") else {
105            return Err(ValidationError::new(
106                "Sha256Digest must start with the canonical sha256: prefix",
107            ));
108        };
109        if hex.len() != SHA256_HEX_LENGTH {
110            return Err(ValidationError::new(format!(
111                "Sha256Digest must contain exactly {SHA256_HEX_LENGTH} lowercase hexadecimal characters"
112            )));
113        }
114        if !hex
115            .bytes()
116            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
117        {
118            return Err(ValidationError::new(
119                "Sha256Digest must contain lowercase hexadecimal characters only",
120            ));
121        }
122        Ok(Self(value))
123    }
124
125    /// Borrow the canonical digest including the algorithm prefix.
126    pub fn as_str(&self) -> &str {
127        &self.0
128    }
129
130    /// Borrow the raw lowercase hexadecimal digest bytes.
131    pub fn hex(&self) -> &str {
132        self.0
133            .strip_prefix("sha256:")
134            .expect("Sha256Digest is constructed with a sha256: prefix")
135    }
136
137    /// Consume the digest and return its canonical wire representation.
138    pub fn into_inner(self) -> String {
139        self.0
140    }
141}
142
143impl std::str::FromStr for Sha256Digest {
144    type Err = ValidationError;
145
146    fn from_str(value: &str) -> Result<Self, Self::Err> {
147        Self::new(value)
148    }
149}
150
151impl TryFrom<String> for Sha256Digest {
152    type Error = ValidationError;
153
154    fn try_from(value: String) -> Result<Self, Self::Error> {
155        Self::new(value)
156    }
157}
158
159impl TryFrom<&str> for Sha256Digest {
160    type Error = ValidationError;
161
162    fn try_from(value: &str) -> Result<Self, Self::Error> {
163        Self::new(value)
164    }
165}
166
167impl From<Sha256Digest> for String {
168    fn from(value: Sha256Digest) -> Self {
169        value.0
170    }
171}
172
173impl<'de> Deserialize<'de> for Sha256Digest {
174    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
175    where
176        D: Deserializer<'de>,
177    {
178        let value = String::deserialize(deserializer)?;
179        Self::new(value).map_err(DeError::custom)
180    }
181}
182
183/// A strict SemVer 2.0.0 identity used for pinned protocol artifacts.
184#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
185#[serde(transparent)]
186pub struct SemanticVersion(String);
187
188impl SemanticVersion {
189    /// Parse a bounded SemVer 2.0.0 version without accepting loose aliases.
190    pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
191        let value = value.into();
192        if value.is_empty() || value.len() > MAX_SEMVER_LENGTH || !value.is_ascii() {
193            return Err(ValidationError::new(format!(
194                "SemanticVersion must be nonempty ASCII and at most {MAX_SEMVER_LENGTH} bytes"
195            )));
196        }
197
198        let (without_build, build) = split_optional(&value, '+')?;
199        let (core, prerelease) = split_optional(without_build, '-')?;
200        let core_components = core.split('.').collect::<Vec<_>>();
201        if core_components.len() != 3 || !core_components.iter().all(valid_numeric) {
202            return Err(ValidationError::new(
203                "SemanticVersion must have major.minor.patch numeric core components",
204            ));
205        }
206        if let Some(prerelease) = prerelease {
207            validate_identifier_components(prerelease, true, "prerelease")?;
208        }
209        if let Some(build) = build {
210            validate_identifier_components(build, false, "build metadata")?;
211        }
212        Ok(Self(value))
213    }
214
215    /// Borrow the canonical SemVer representation.
216    pub fn as_str(&self) -> &str {
217        &self.0
218    }
219
220    /// Consume the version and return its wire representation.
221    pub fn into_inner(self) -> String {
222        self.0
223    }
224}
225
226fn split_optional(value: &str, separator: char) -> Result<(&str, Option<&str>), ValidationError> {
227    let Some((before, after)) = value.split_once(separator) else {
228        return Ok((value, None));
229    };
230    if before.is_empty() || after.is_empty() || (separator == '+' && after.contains(separator)) {
231        return Err(ValidationError::new(format!(
232            "SemanticVersion contains an invalid {separator} segment"
233        )));
234    }
235    Ok((before, Some(after)))
236}
237
238fn valid_numeric(value: &&str) -> bool {
239    !value.is_empty()
240        && value.bytes().all(|byte| byte.is_ascii_digit())
241        && (value.len() == 1 || !value.starts_with('0'))
242}
243
244fn validate_identifier_components(
245    value: &str,
246    reject_leading_zero_numeric: bool,
247    field: &str,
248) -> Result<(), ValidationError> {
249    if value.split('.').any(|part| {
250        part.is_empty()
251            || !part
252                .bytes()
253                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
254            || (reject_leading_zero_numeric
255                && part.len() > 1
256                && part.bytes().all(|byte| byte.is_ascii_digit())
257                && part.starts_with('0'))
258    }) {
259        return Err(ValidationError::new(format!(
260            "SemanticVersion {field} must contain dot-separated ASCII alphanumeric or hyphen identifiers"
261        )));
262    }
263    Ok(())
264}
265
266impl std::str::FromStr for SemanticVersion {
267    type Err = ValidationError;
268
269    fn from_str(value: &str) -> Result<Self, Self::Err> {
270        Self::new(value)
271    }
272}
273
274impl TryFrom<String> for SemanticVersion {
275    type Error = ValidationError;
276
277    fn try_from(value: String) -> Result<Self, Self::Error> {
278        Self::new(value)
279    }
280}
281
282impl TryFrom<&str> for SemanticVersion {
283    type Error = ValidationError;
284
285    fn try_from(value: &str) -> Result<Self, Self::Error> {
286        Self::new(value)
287    }
288}
289
290impl From<SemanticVersion> for String {
291    fn from(value: SemanticVersion) -> Self {
292        value.0
293    }
294}
295
296impl<'de> Deserialize<'de> for SemanticVersion {
297    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
298    where
299        D: Deserializer<'de>,
300    {
301        let value = String::deserialize(deserializer)?;
302        Self::new(value).map_err(DeError::custom)
303    }
304}
305
306/// Bounded non-path reference to provenance, activation, or policy material.
307#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
308#[serde(transparent)]
309pub struct ProtocolReference(String);
310
311impl ProtocolReference {
312    /// Construct a bounded, nonempty protocol reference.
313    pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
314        let value = value.into();
315        if value.trim().is_empty() {
316            return Err(ValidationError::new("ProtocolReference must not be empty"));
317        }
318        if value.len() > MAX_REFERENCE_LENGTH {
319            return Err(ValidationError::new(format!(
320                "ProtocolReference exceeds the {MAX_REFERENCE_LENGTH} byte limit"
321            )));
322        }
323        if value.chars().any(char::is_control) {
324            return Err(ValidationError::new(
325                "ProtocolReference must not contain control characters",
326            ));
327        }
328        Ok(Self(value))
329    }
330
331    /// Borrow the wire reference.
332    pub fn as_str(&self) -> &str {
333        &self.0
334    }
335
336    /// Consume the reference and return its wire representation.
337    pub fn into_inner(self) -> String {
338        self.0
339    }
340}
341
342impl std::str::FromStr for ProtocolReference {
343    type Err = ValidationError;
344
345    fn from_str(value: &str) -> Result<Self, Self::Err> {
346        Self::new(value)
347    }
348}
349
350impl TryFrom<String> for ProtocolReference {
351    type Error = ValidationError;
352
353    fn try_from(value: String) -> Result<Self, Self::Error> {
354        Self::new(value)
355    }
356}
357
358impl TryFrom<&str> for ProtocolReference {
359    type Error = ValidationError;
360
361    fn try_from(value: &str) -> Result<Self, Self::Error> {
362        Self::new(value)
363    }
364}
365
366impl From<ProtocolReference> for String {
367    fn from(value: ProtocolReference) -> Self {
368        value.0
369    }
370}
371
372impl<'de> Deserialize<'de> for ProtocolReference {
373    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
374    where
375        D: Deserializer<'de>,
376    {
377        let value = String::deserialize(deserializer)?;
378        Self::new(value).map_err(DeError::custom)
379    }
380}
381
382/// Canonical UTC timestamp with second precision for deterministic session metadata.
383#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
384#[serde(transparent)]
385pub struct UtcTimestamp(String);
386
387impl UtcTimestamp {
388    /// Parse `YYYY-MM-DDTHH:MM:SSZ` and reject invalid calendar values.
389    pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
390        let value = value.into();
391        let bytes = value.as_bytes();
392        if bytes.len() != 20
393            || bytes[4] != b'-'
394            || bytes[7] != b'-'
395            || bytes[10] != b'T'
396            || bytes[13] != b':'
397            || bytes[16] != b':'
398            || bytes[19] != b'Z'
399            || [0..4, 5..7, 8..10, 11..13, 14..16, 17..19]
400                .iter()
401                .any(|range| !bytes[range.clone()].iter().all(u8::is_ascii_digit))
402        {
403            return Err(ValidationError::new(
404                "UtcTimestamp must use canonical YYYY-MM-DDTHH:MM:SSZ syntax",
405            ));
406        }
407        let year = decimal(&bytes[0..4]);
408        let month = decimal(&bytes[5..7]);
409        let day = decimal(&bytes[8..10]);
410        let hour = decimal(&bytes[11..13]);
411        let minute = decimal(&bytes[14..16]);
412        let second = decimal(&bytes[17..19]);
413        if year == 0
414            || !(1..=12).contains(&month)
415            || day == 0
416            || day > days_in_month(year, month)
417            || hour > 23
418            || minute > 59
419            || second > 59
420        {
421            return Err(ValidationError::new(
422                "UtcTimestamp contains an invalid calendar or clock value",
423            ));
424        }
425        Ok(Self(value))
426    }
427
428    /// Borrow the canonical timestamp.
429    pub fn as_str(&self) -> &str {
430        &self.0
431    }
432
433    /// Consume the timestamp and return its wire representation.
434    pub fn into_inner(self) -> String {
435        self.0
436    }
437}
438
439fn decimal(bytes: &[u8]) -> u32 {
440    bytes
441        .iter()
442        .fold(0, |value, byte| value * 10 + u32::from(*byte - b'0'))
443}
444
445fn days_in_month(year: u32, month: u32) -> u32 {
446    match month {
447        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
448        4 | 6 | 9 | 11 => 30,
449        2 if year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) => 29,
450        2 => 28,
451        _ => unreachable!("month range has already been validated"),
452    }
453}
454
455impl std::str::FromStr for UtcTimestamp {
456    type Err = ValidationError;
457
458    fn from_str(value: &str) -> Result<Self, Self::Err> {
459        Self::new(value)
460    }
461}
462
463impl TryFrom<String> for UtcTimestamp {
464    type Error = ValidationError;
465
466    fn try_from(value: String) -> Result<Self, Self::Error> {
467        Self::new(value)
468    }
469}
470
471impl TryFrom<&str> for UtcTimestamp {
472    type Error = ValidationError;
473
474    fn try_from(value: &str) -> Result<Self, Self::Error> {
475        Self::new(value)
476    }
477}
478
479impl From<UtcTimestamp> for String {
480    fn from(value: UtcTimestamp) -> Self {
481        value.0
482    }
483}
484
485impl<'de> Deserialize<'de> for UtcTimestamp {
486    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
487    where
488        D: Deserializer<'de>,
489    {
490        let value = String::deserialize(deserializer)?;
491        Self::new(value).map_err(DeError::custom)
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    const DIGEST: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
500
501    #[test]
502    fn sha256_digest_requires_the_canonical_wire_form() {
503        assert_eq!(
504            Sha256Digest::new(DIGEST).expect("valid digest").hex(),
505            "a".repeat(64)
506        );
507        for invalid in [
508            "sha256:abc",
509            "SHA256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
510            "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
511        ] {
512            assert!(Sha256Digest::new(invalid).is_err(), "{invalid} must fail");
513        }
514    }
515
516    #[test]
517    fn semantic_versions_reject_loose_or_ambiguous_inputs() {
518        for valid in ["1.2.3", "1.2.3-rc.1", "1.2.3+build.7", "1.2.3-rc.1+build.7"] {
519            assert!(SemanticVersion::new(valid).is_ok(), "{valid} must parse");
520        }
521        for invalid in ["v1.2.3", "1.2", "01.2.3", "1.2.3-01", "1.2.3+", "1.2.3++x"] {
522            assert!(
523                SemanticVersion::new(invalid).is_err(),
524                "{invalid} must fail"
525            );
526        }
527    }
528
529    #[test]
530    fn opaque_identifiers_and_references_are_bounded() {
531        assert!(ProfileId::new("profile-1").is_ok());
532        assert!(ProfileId::new("\n").is_err());
533        assert!(ProfileId::new("a".repeat(crate::MAX_IDENTIFIER_LENGTH + 1)).is_err());
534        assert!(ProtocolReference::new("source:fixture").is_ok());
535        assert!(ProtocolReference::new("\u{0000}").is_err());
536    }
537
538    #[test]
539    fn timestamps_are_canonical_and_calendar_checked() {
540        assert!(UtcTimestamp::new("2028-02-29T23:59:59Z").is_ok());
541        assert!(UtcTimestamp::new("2027-02-29T23:59:59Z").is_err());
542        assert!(UtcTimestamp::new("2026-01-01T00:00:00+00:00").is_err());
543    }
544}