Skip to main content

cookie_agent_protocol/
identity.rs

1use std::{borrow::Cow, fmt, str::FromStr};
2
3use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7#[derive(Clone, Debug, Eq, PartialEq)]
8pub enum WireStringError {
9    Empty,
10    TooLong { found: usize, maximum: usize },
11    Invalid(&'static str),
12}
13
14impl fmt::Display for WireStringError {
15    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
16        match self {
17            Self::Empty => formatter.write_str("value must not be empty"),
18            Self::TooLong { found, maximum } => write!(
19                formatter,
20                "value is {found} bytes, exceeding the {maximum}-byte limit"
21            ),
22            Self::Invalid(requirement) => write!(formatter, "value {requirement}"),
23        }
24    }
25}
26
27impl std::error::Error for WireStringError {}
28
29fn validate_bounded(value: &str, maximum: usize) -> Result<(), WireStringError> {
30    if value.is_empty() {
31        return Err(WireStringError::Empty);
32    }
33    if value.len() > maximum {
34        return Err(WireStringError::TooLong {
35            found: value.len(),
36            maximum,
37        });
38    }
39    Ok(())
40}
41
42fn validate_control_free(value: &str, maximum: usize) -> Result<(), WireStringError> {
43    validate_bounded(value, maximum)?;
44    if value.chars().any(char::is_control) {
45        return Err(WireStringError::Invalid(
46            "must not contain control characters",
47        ));
48    }
49    Ok(())
50}
51
52macro_rules! string_wire_impl {
53    ($name:ident, $max:expr, $pattern:literal, $description:literal, $validator:expr) => {
54        impl $name {
55            pub const MAX_BYTES: usize = $max;
56
57            pub fn new(value: impl Into<String>) -> Result<Self, WireStringError> {
58                let value = value.into();
59                ($validator)(&value)?;
60                Ok(Self(value))
61            }
62
63            #[must_use]
64            pub fn as_str(&self) -> &str {
65                &self.0
66            }
67        }
68
69        impl fmt::Display for $name {
70            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71                self.0.fmt(formatter)
72            }
73        }
74
75        impl Serialize for $name {
76            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
77            where
78                S: serde::Serializer,
79            {
80                serializer.serialize_str(&self.0)
81            }
82        }
83
84        impl<'de> Deserialize<'de> for $name {
85            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
86            where
87                D: serde::Deserializer<'de>,
88            {
89                Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
90            }
91        }
92
93        impl JsonSchema for $name {
94            fn inline_schema() -> bool {
95                true
96            }
97
98            fn schema_name() -> Cow<'static, str> {
99                Cow::Borrowed(stringify!($name))
100            }
101
102            fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
103                json_schema!({
104                    "type": "string",
105                    "minLength": 1,
106                    "maxLength": $max,
107                    "pattern": $pattern,
108                    "description": $description
109                })
110            }
111        }
112    };
113}
114
115macro_rules! bounded_control_free_type {
116    ($name:ident, $max:expr, $description:literal) => {
117        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
118        pub struct $name(String);
119        string_wire_impl!(
120            $name,
121            $max,
122            "^[^\\p{Cc}\\p{Cf}]+$",
123            $description,
124            |value: &str| validate_control_free(value, $name::MAX_BYTES)
125        );
126    };
127}
128
129bounded_control_free_type!(ClientRunId, 256, "Bounded run idempotency key.");
130bounded_control_free_type!(
131    ProducerIdempotencyKey,
132    256,
133    "Sender-chosen idempotency key scoped to a session and stable producer owner."
134);
135bounded_control_free_type!(
136    ClientResponseId,
137    256,
138    "Bounded approval response idempotency key."
139);
140bounded_control_free_type!(
141    ClientConnectId,
142    256,
143    "Bounded provider connection idempotency key."
144);
145bounded_control_free_type!(
146    ClientRequestId,
147    256,
148    "Bounded provider mutation idempotency key."
149);
150bounded_control_free_type!(
151    ClientRenameId,
152    256,
153    "Bounded title mutation idempotency key."
154);
155bounded_control_free_type!(
156    ModelCallId,
157    512,
158    "Bounded semantic model tool-call identity."
159);
160bounded_control_free_type!(ProviderItemId, 512, "Bounded provider item identity.");
161bounded_control_free_type!(CwdIdentity, 4096, "Opaque canonical workspace identity.");
162#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
163pub struct SafeDisplayText(String);
164string_wire_impl!(
165    SafeDisplayText,
166    1024,
167    "^[^\\p{Cc}\\p{Cf}]+$",
168    "Control-free bounded presentation text.",
169    |value: &str| validate_control_free(value, SafeDisplayText::MAX_BYTES)
170);
171#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
172pub struct BoundedDisplayText(String);
173string_wire_impl!(
174    BoundedDisplayText,
175    1024,
176    "^[\\s\\S]+$",
177    "Bounded presentation text; byte-capped only, clients sanitize control characters.",
178    |value: &str| validate_bounded(value, BoundedDisplayText::MAX_BYTES)
179);
180bounded_control_free_type!(
181    SafeErrorMessage,
182    4096,
183    "Control-free bounded safe error text."
184);
185bounded_control_free_type!(CatalogIdentifier, 1024, "Bounded catalog identity.");
186#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
187pub struct DiagnosticText(String);
188string_wire_impl!(
189    DiagnosticText,
190    4096,
191    "^(?:[^\\p{Cc}\\p{Cf}]|\\n|\\t)+$",
192    "Bounded diagnostic text permitting newlines and tabs.",
193    |value: &str| {
194        validate_bounded(value, DiagnosticText::MAX_BYTES)?;
195        static CONTROLS: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
196            regex::Regex::new(r"[\p{Cc}\p{Cf}&&[^\n\t]]").expect("control pattern")
197        });
198        if CONTROLS.is_match(value) {
199            return Err(WireStringError::Invalid(
200                "must not contain terminal controls",
201            ));
202        }
203        Ok(())
204    }
205);
206
207#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
208pub struct CatalogText(String);
209string_wire_impl!(
210    CatalogText,
211    16_384,
212    "^\\S(?:.*\\S)?$",
213    "Trimmed bounded catalog display text.",
214    |value: &str| {
215        validate_control_free(value, CatalogText::MAX_BYTES)?;
216        if value.trim() != value {
217            return Err(WireStringError::Invalid("must be trimmed and nonblank"));
218        }
219        Ok(())
220    }
221);
222
223#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
224pub struct CredentialFieldName(String);
225
226string_wire_impl!(
227    CredentialFieldName,
228    1024,
229    "^[A-Z0-9_]+$",
230    "Strict provider credential field name.",
231    |value: &str| {
232        validate_bounded(value, CredentialFieldName::MAX_BYTES)?;
233        if !value
234            .bytes()
235            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
236        {
237            return Err(WireStringError::Invalid(
238                "must use only uppercase ASCII letters, digits, or '_'",
239            ));
240        }
241        Ok(())
242    }
243);
244
245#[derive(Clone, Deserialize, Serialize)]
246#[serde(transparent)]
247struct TransparentUuid(Uuid);
248
249macro_rules! uuid_id {
250    ($name:ident) => {
251        #[derive(Clone, Copy, Debug, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd)]
252        #[schemars(transparent)]
253        pub struct $name(pub Uuid);
254
255        impl Serialize for $name {
256            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
257            where
258                S: serde::Serializer,
259            {
260                TransparentUuid(self.0).serialize(serializer)
261            }
262        }
263
264        impl<'de> Deserialize<'de> for $name {
265            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
266            where
267                D: serde::Deserializer<'de>,
268            {
269                TransparentUuid::deserialize(deserializer).map(|uuid| Self(uuid.0))
270            }
271        }
272
273        impl $name {
274            #[must_use]
275            pub fn new_v7() -> Self {
276                Self(Uuid::now_v7())
277            }
278        }
279
280        impl fmt::Display for $name {
281            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
282                self.0.fmt(formatter)
283            }
284        }
285
286        impl FromStr for $name {
287            type Err = uuid::Error;
288            fn from_str(value: &str) -> Result<Self, Self::Err> {
289                value.parse().map(Self)
290            }
291        }
292    };
293}
294
295uuid_id!(SessionId);
296uuid_id!(RunId);
297uuid_id!(AttemptId);
298uuid_id!(ToolCallId);
299uuid_id!(InvocationId);
300uuid_id!(InternalAgentInvocationId);
301uuid_id!(InternalAgentRunId);
302uuid_id!(ApprovalId);
303uuid_id!(TreeApprovalGrantId);
304uuid_id!(DurableProviderReceiptId);
305uuid_id!(GoalId);
306uuid_id!(ProducerId);
307uuid_id!(ProducerMessageId);
308
309#[derive(Clone, Eq, Hash, PartialEq)]
310pub struct Sha256Digest(String);
311
312impl Sha256Digest {
313    pub const HEX_LENGTH: usize = 64;
314
315    pub fn new(value: impl Into<String>) -> Result<Self, WireStringError> {
316        let value = value.into();
317        if value.len() != Self::HEX_LENGTH
318            || !value
319                .bytes()
320                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
321        {
322            return Err(WireStringError::Invalid(
323                "must be exactly 64 lowercase hexadecimal characters",
324            ));
325        }
326        Ok(Self(value))
327    }
328
329    #[must_use]
330    pub fn as_str(&self) -> &str {
331        &self.0
332    }
333
334    #[must_use]
335    pub fn of_bytes(bytes: &[u8]) -> Self {
336        Self(sha256_hex(bytes))
337    }
338
339    #[must_use]
340    pub(crate) fn hash_domain_separated(domain: &[u8], bytes: &[u8]) -> Self {
341        let mut input = Vec::with_capacity(domain.len() + 8 + bytes.len());
342        input.extend_from_slice(domain);
343        input.extend_from_slice(&(bytes.len() as u64).to_be_bytes());
344        input.extend_from_slice(bytes);
345        Self(sha256_hex(&input))
346    }
347}
348
349impl fmt::Debug for Sha256Digest {
350    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
351        self.0.fmt(formatter)
352    }
353}
354impl fmt::Display for Sha256Digest {
355    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
356        self.0.fmt(formatter)
357    }
358}
359impl Serialize for Sha256Digest {
360    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
361    where
362        S: serde::Serializer,
363    {
364        serializer.serialize_str(&self.0)
365    }
366}
367impl<'de> Deserialize<'de> for Sha256Digest {
368    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
369    where
370        D: serde::Deserializer<'de>,
371    {
372        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
373    }
374}
375impl JsonSchema for Sha256Digest {
376    fn inline_schema() -> bool {
377        true
378    }
379    fn schema_name() -> Cow<'static, str> {
380        Cow::Borrowed("Sha256Digest")
381    }
382    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
383        json_schema!({"type":"string","pattern":"^[0-9a-f]{64}$","minLength":64,"maxLength":64})
384    }
385}
386
387fn sha256_hex(input: &[u8]) -> String {
388    use sha2::{Digest, Sha256};
389    let digest = Sha256::digest(input);
390    let mut output = String::with_capacity(64);
391    for byte in digest {
392        use std::fmt::Write as _;
393        write!(&mut output, "{byte:02x}").expect("writing to a string cannot fail");
394    }
395    output
396}