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}