1use crate::common::{ValidationError, validate_bounded_opaque_identifier};
4use serde::{Deserialize, Deserializer, Serialize, de::Error as DeError};
5
6pub const MAX_REFERENCE_LENGTH: usize = 1_024;
8
9pub const SHA256_HEX_LENGTH: usize = 64;
11
12pub const MAX_SEMVER_LENGTH: usize = 128;
14
15macro_rules! bounded_opaque_identifier {
16 ($name:ident) => {
17 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
19 #[serde(transparent)]
20 pub struct $name(String);
21
22 impl $name {
23 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 pub fn as_str(&self) -> &str {
32 &self.0
33 }
34
35 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
97#[serde(transparent)]
98pub struct Sha256Digest(String);
99
100impl Sha256Digest {
101 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 pub fn as_str(&self) -> &str {
127 &self.0
128 }
129
130 pub fn hex(&self) -> &str {
132 self.0
133 .strip_prefix("sha256:")
134 .expect("Sha256Digest is constructed with a sha256: prefix")
135 }
136
137 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
185#[serde(transparent)]
186pub struct SemanticVersion(String);
187
188impl SemanticVersion {
189 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 pub fn as_str(&self) -> &str {
217 &self.0
218 }
219
220 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
308#[serde(transparent)]
309pub struct ProtocolReference(String);
310
311impl ProtocolReference {
312 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 pub fn as_str(&self) -> &str {
333 &self.0
334 }
335
336 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
384#[serde(transparent)]
385pub struct UtcTimestamp(String);
386
387impl UtcTimestamp {
388 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 pub fn as_str(&self) -> &str {
430 &self.0
431 }
432
433 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}