1use std::{borrow::Cow, fmt, str::FromStr};
4
5use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7use thiserror::Error;
8
9#[derive(Clone, Debug, Error, Eq, PartialEq)]
11pub enum IdentityError {
12 #[error("invalid agent id")]
13 Agent,
14 #[error("invalid provider id")]
15 Provider,
16 #[error("invalid provider model id")]
17 ProviderModel,
18 #[error("invalid model key")]
19 ModelKey,
20 #[error("invalid variant id")]
21 Variant,
22 #[error("invalid safe code")]
23 SafeCode,
24 #[error("invalid wildcard pattern")]
25 WildcardPattern,
26 #[error("invalid strict identifier")]
27 StrictIdentifier,
28 #[error("invalid revision")]
29 Revision,
30}
31
32macro_rules! string_identity {
33 ($name:ident, $error:ident, $validate:ident, $max:literal, $pattern:literal) => {
34 #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
35 pub struct $name(String);
36
37 impl $name {
38 pub fn new(value: impl Into<String>) -> Result<Self, IdentityError> {
39 let value = value.into();
40 if $validate(&value) {
41 Ok(Self(value))
42 } else {
43 Err(IdentityError::$error)
44 }
45 }
46
47 #[must_use]
48 pub fn as_str(&self) -> &str {
49 &self.0
50 }
51
52 #[must_use]
53 pub fn into_string(self) -> String {
54 self.0
55 }
56 }
57
58 impl fmt::Debug for $name {
59 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60 formatter
61 .debug_tuple(stringify!($name))
62 .field(&self.0)
63 .finish()
64 }
65 }
66
67 impl fmt::Display for $name {
68 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69 formatter.write_str(&self.0)
70 }
71 }
72
73 impl FromStr for $name {
74 type Err = IdentityError;
75
76 fn from_str(value: &str) -> Result<Self, Self::Err> {
77 Self::new(value)
78 }
79 }
80
81 impl Serialize for $name {
82 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
83 where
84 S: Serializer,
85 {
86 serializer.serialize_str(&self.0)
87 }
88 }
89
90 impl<'de> Deserialize<'de> for $name {
91 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
92 where
93 D: Deserializer<'de>,
94 {
95 let value = String::deserialize(deserializer)?;
96 Self::new(value).map_err(de::Error::custom)
97 }
98 }
99
100 impl JsonSchema for $name {
101 fn inline_schema() -> bool {
102 true
103 }
104
105 fn schema_name() -> Cow<'static, str> {
106 stringify!($name).into()
107 }
108
109 fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
110 json_schema!({
111 "type": "string",
112 "minLength": 1,
113 "maxLength": $max,
114 "pattern": $pattern
115 })
116 }
117 }
118 };
119}
120
121string_identity!(
122 AgentId,
123 Agent,
124 valid_agent_id,
125 64,
126 "^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*$"
127);
128
129#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
131pub struct SafeCode(String);
132
133impl SafeCode {
134 pub const MAX_BYTES: usize = 128;
135 pub const JSON_SCHEMA_PATTERN: &'static str = "^[a-z0-9][a-z0-9._-]*$";
136
137 pub fn new(value: impl Into<String>) -> Result<Self, IdentityError> {
138 let value = value.into();
139 if (1..=Self::MAX_BYTES).contains(&value.len())
140 && value.bytes().enumerate().all(|(index, byte)| {
141 byte.is_ascii_lowercase()
142 || byte.is_ascii_digit()
143 || (index > 0 && matches!(byte, b'.' | b'_' | b'-'))
144 })
145 {
146 Ok(Self(value))
147 } else {
148 Err(IdentityError::SafeCode)
149 }
150 }
151
152 #[must_use]
153 pub fn as_str(&self) -> &str {
154 &self.0
155 }
156}
157
158#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
160pub struct WildcardPattern(String);
161
162impl WildcardPattern {
163 pub const MAX_BYTES: usize = 4096;
164 pub const WORKSPACE_DIR_EXPRESSION: &'static str = "${workspace_dir}";
165 pub const JSON_SCHEMA_MAX_UTF8_BYTES_EXTENSION: &'static str = "x-cookie-agent-maxUtf8Bytes";
166 pub const JSON_SCHEMA_PATTERN: &'static str =
167 "^(?:\\$\\{workspace_dir\\}|[^\\u0000-\\u001f\\u007f-\\u009f\\\\\\[\\]\\{\\}])+$";
168
169 pub fn new(value: impl Into<String>) -> Result<Self, IdentityError> {
170 let value = value.into();
171 let without_workspace_expression = value.replace(Self::WORKSPACE_DIR_EXPRESSION, "");
172 if (1..=Self::MAX_BYTES).contains(&value.len())
173 && !value.chars().any(char::is_control)
174 && !without_workspace_expression
175 .chars()
176 .any(|character| matches!(character, '\\' | '[' | ']' | '{' | '}'))
177 {
178 Ok(Self(value))
179 } else {
180 Err(IdentityError::WildcardPattern)
181 }
182 }
183
184 #[must_use]
185 pub fn as_str(&self) -> &str {
186 &self.0
187 }
188}
189
190macro_rules! shared_permission_string_impl {
191 ($name:ident, $schema:expr) => {
192 impl fmt::Display for $name {
193 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
194 formatter.write_str(&self.0)
195 }
196 }
197
198 impl FromStr for $name {
199 type Err = IdentityError;
200
201 fn from_str(value: &str) -> Result<Self, Self::Err> {
202 Self::new(value)
203 }
204 }
205
206 impl Serialize for $name {
207 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
208 where
209 S: Serializer,
210 {
211 serializer.serialize_str(&self.0)
212 }
213 }
214
215 impl<'de> Deserialize<'de> for $name {
216 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
217 where
218 D: Deserializer<'de>,
219 {
220 Self::new(String::deserialize(deserializer)?).map_err(de::Error::custom)
221 }
222 }
223
224 impl JsonSchema for $name {
225 fn inline_schema() -> bool {
226 true
227 }
228
229 fn schema_name() -> Cow<'static, str> {
230 stringify!($name).into()
231 }
232
233 fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
234 $schema
235 }
236 }
237 };
238}
239
240shared_permission_string_impl!(
241 SafeCode,
242 json_schema!({
243 "type": "string",
244 "minLength": 1,
245 "maxLength": SafeCode::MAX_BYTES,
246 "pattern": SafeCode::JSON_SCHEMA_PATTERN,
247 "description": "Stable lowercase machine-readable code."
248 })
249);
250shared_permission_string_impl!(
251 WildcardPattern,
252 json_schema!({
253 "type": "string",
254 "minLength": 1,
255 "pattern": WildcardPattern::JSON_SCHEMA_PATTERN,
256 "x-cookie-agent-maxUtf8Bytes": WildcardPattern::MAX_BYTES,
257 "description": "Current wildcard grammar: '*' matches any characters, '?' matches one character, and the exact '${workspace_dir}' expression is allowed; controls, globstar, escapes, classes, and every other brace form are forbidden. Runtime deserialization additionally enforces a maximum of 4096 UTF-8 bytes; x-cookie-agent-maxUtf8Bytes records that byte limit because JSON Schema maxLength counts Unicode code points."
258 })
259);
260string_identity!(
261 ProviderId,
262 Provider,
263 valid_provider_id,
264 128,
265 "^[a-z0-9][a-z0-9._-]{0,127}$"
266);
267string_identity!(
268 ProviderModelId,
269 ProviderModel,
270 valid_provider_model_id,
271 384,
272 "^[^/\\s\\u0000-\\u001f\\u007f-\\u009f\\[\\]]+(?:/[^/\\s\\u0000-\\u001f\\u007f-\\u009f\\[\\]]+)*$"
273);
274string_identity!(
275 VariantId,
276 Variant,
277 valid_variant_id,
278 64,
279 "^(?!base$)[a-z0-9][a-z0-9._-]{0,63}$"
280);
281
282macro_rules! strict_identifier {
283 ($($name:ident),+ $(,)?) => {$(
284 string_identity!(
285 $name,
286 StrictIdentifier,
287 valid_strict_identifier,
288 128,
289 "^[a-z0-9][a-z0-9._-]{0,127}$"
290 );
291 )+};
292}
293
294strict_identifier!(
295 AuthMethodId,
296 AdapterId,
297 ProviderRecipeId,
298 ProtocolRecipeId,
299 ProviderSetupRecipeId,
300 AuthRecipeId,
301 RecipeCompilerVersion,
302 CacheEntryId,
303 StoreEntryId,
304 ManifestEntryId,
305);
306
307macro_rules! field_identifier {
308 ($($name:ident),+ $(,)?) => {$(
309 string_identity!(
310 $name,
311 StrictIdentifier,
312 valid_field_identifier,
313 128,
314 "^[a-z][a-z0-9_]{0,127}$"
315 );
316 )+};
317}
318
319field_identifier!(SetupFieldId, AuthParameterId, AuthFieldName);
320
321string_identity!(
322 CanonicalModelId,
323 StrictIdentifier,
324 valid_provider_model_id,
325 384,
326 "^[^/\\s\\u0000-\\u001f\\u007f-\\u009f\\[\\]]+(?:/[^/\\s\\u0000-\\u001f\\u007f-\\u009f\\[\\]]+)*$"
327);
328
329macro_rules! revision_identity {
330 ($($name:ident),+ $(,)?) => {$(
331 #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
332 pub struct $name(String);
333
334 impl $name {
335 pub fn new(value: impl Into<String>) -> Result<Self, IdentityError> {
336 let value = value.into();
337 if valid_revision(&value) { Ok(Self(value)) } else { Err(IdentityError::Revision) }
338 }
339 #[must_use]
340 pub fn as_str(&self) -> &str { &self.0 }
341 #[must_use]
342 pub fn into_string(self) -> String { self.0 }
343 }
344 impl fmt::Debug for $name {
345 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
346 f.debug_tuple(stringify!($name)).field(&self.0).finish()
347 }
348 }
349 impl fmt::Display for $name {
350 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) }
351 }
352 impl FromStr for $name {
353 type Err = IdentityError;
354 fn from_str(value: &str) -> Result<Self, Self::Err> { Self::new(value) }
355 }
356 impl Serialize for $name {
357 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
358 where S: Serializer { serializer.serialize_str(&self.0) }
359 }
360 impl<'de> Deserialize<'de> for $name {
361 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
362 where D: Deserializer<'de> {
363 Self::new(String::deserialize(deserializer)?).map_err(de::Error::custom)
364 }
365 }
366 impl JsonSchema for $name {
367 fn inline_schema() -> bool { true }
368 fn schema_name() -> Cow<'static, str> { stringify!($name).into() }
369 fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
370 json_schema!({
371 "type": "string",
372 "pattern": "^sha256:[0-9a-f]{64}$",
373 "minLength": 71,
374 "maxLength": 71
375 })
376 }
377 }
378 )+};
379}
380
381revision_identity!(
382 CatalogRevision,
383 ModelRevision,
384 RecipeRegistryRevision,
385 ProviderStoreRevision,
386 ProviderStateRevision,
387 ModelSnapshotRevision,
388 RuntimeRevision,
389 AgentRevision,
390 ManifestRevision,
391 CacheRevision,
392);
393
394fn valid_agent_id(value: &str) -> bool {
395 (1..=64).contains(&value.len())
396 && value
397 .bytes()
398 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
399 && value
400 .as_bytes()
401 .first()
402 .is_some_and(u8::is_ascii_alphanumeric)
403 && value
404 .as_bytes()
405 .last()
406 .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
407 && !value.as_bytes().windows(2).any(|pair| pair == b"--")
408}
409
410fn valid_provider_id(value: &str) -> bool {
411 (1..=128).contains(&value.len())
412 && value
413 .as_bytes()
414 .first()
415 .is_some_and(u8::is_ascii_alphanumeric)
416 && value.bytes().all(|byte| {
417 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
418 })
419}
420
421fn valid_provider_model_id(value: &str) -> bool {
422 (1..=384).contains(&value.len())
423 && !value.contains(['[', ']'])
424 && !value.split('/').any(|segment| segment.is_empty())
425 && !value
426 .chars()
427 .any(|character| character.is_control() || character.is_whitespace())
428}
429
430fn valid_variant_id(value: &str) -> bool {
431 value != "base"
432 && (1..=64).contains(&value.len())
433 && value
434 .as_bytes()
435 .first()
436 .is_some_and(u8::is_ascii_alphanumeric)
437 && value.bytes().all(|byte| {
438 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
439 })
440}
441
442fn valid_strict_identifier(value: &str) -> bool {
443 (1..=128).contains(&value.len())
444 && value
445 .as_bytes()
446 .first()
447 .is_some_and(u8::is_ascii_alphanumeric)
448 && value.bytes().all(|byte| {
449 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
450 })
451}
452
453fn valid_field_identifier(value: &str) -> bool {
454 (1..=128).contains(&value.len())
455 && value.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
456 && value
457 .bytes()
458 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
459}
460
461fn valid_revision(value: &str) -> bool {
462 value.len() == 71
463 && value.starts_with("sha256:")
464 && value[7..]
465 .bytes()
466 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
467}
468
469#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
471pub struct ModelKey(String);
472
473impl ModelKey {
474 pub fn new(provider: ProviderId, model: ProviderModelId) -> Result<Self, IdentityError> {
475 Self::from_parts(&provider, &model)
476 }
477
478 fn from_parts(provider: &ProviderId, model: &ProviderModelId) -> Result<Self, IdentityError> {
479 let value = format!("{provider}/{model}");
480 if value.len() <= 512 {
481 Ok(Self(value))
482 } else {
483 Err(IdentityError::ModelKey)
484 }
485 }
486
487 #[must_use]
488 pub fn as_str(&self) -> &str {
489 &self.0
490 }
491
492 #[must_use]
493 pub fn provider_id(&self) -> ProviderId {
494 ProviderId::new(self.0.split_once('/').expect("validated model key").0)
495 .expect("validated provider id")
496 }
497
498 #[must_use]
499 pub fn model_id(&self) -> ProviderModelId {
500 ProviderModelId::new(self.0.split_once('/').expect("validated model key").1)
501 .expect("validated model id")
502 }
503}
504
505impl FromStr for ModelKey {
506 type Err = IdentityError;
507
508 fn from_str(value: &str) -> Result<Self, Self::Err> {
509 if value.len() > 512 {
510 return Err(IdentityError::ModelKey);
511 }
512 let (provider, model) = value.split_once('/').ok_or(IdentityError::ModelKey)?;
513 Self::from_parts(&ProviderId::new(provider)?, &ProviderModelId::new(model)?)
514 }
515}
516
517impl fmt::Debug for ModelKey {
518 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
519 formatter.debug_tuple("ModelKey").field(&self.0).finish()
520 }
521}
522
523impl fmt::Display for ModelKey {
524 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
525 formatter.write_str(&self.0)
526 }
527}
528
529impl Serialize for ModelKey {
530 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
531 where
532 S: Serializer,
533 {
534 serializer.serialize_str(&self.0)
535 }
536}
537
538impl<'de> Deserialize<'de> for ModelKey {
539 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
540 where
541 D: Deserializer<'de>,
542 {
543 String::deserialize(deserializer)?
544 .parse()
545 .map_err(de::Error::custom)
546 }
547}
548
549impl JsonSchema for ModelKey {
550 fn inline_schema() -> bool {
551 true
552 }
553
554 fn schema_name() -> Cow<'static, str> {
555 "ModelKey".into()
556 }
557
558 fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
559 json_schema!({
560 "type": "string",
561 "minLength": 3,
562 "maxLength": 512,
563 "pattern": "^[a-z0-9][a-z0-9._-]{0,127}/[^/\\s\\u0000-\\u001f\\u007f-\\u009f\\[\\]]+(?:/[^/\\s\\u0000-\\u001f\\u007f-\\u009f\\[\\]]+)*$"
564 })
565 }
566}
567
568#[derive(
570 Clone, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
571)]
572#[serde(deny_unknown_fields)]
573pub struct ModelSelection {
574 pub model: ModelKey,
575 pub variant: Option<VariantId>,
576}
577
578impl fmt::Display for ModelSelection {
579 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
580 write!(formatter, "{}[", self.model)?;
581 match &self.variant {
582 Some(variant) => variant.fmt(formatter)?,
583 None => formatter.write_str("base")?,
584 }
585 formatter.write_str("]")
586 }
587}
588
589#[derive(Clone, Debug, Eq, PartialEq)]
591pub enum ConfiguredVariantRef {
592 Base,
593 Named(VariantId),
594}
595
596#[derive(Clone, Debug, Eq, PartialEq)]
598pub enum ConfiguredModelDefault {
599 Base,
600 Named(VariantId),
601}
602
603macro_rules! variant_reference_serde {
604 ($name:ident) => {
605 impl Serialize for $name {
606 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
607 where
608 S: Serializer,
609 {
610 match self {
611 Self::Base => serializer.serialize_str("base"),
612 Self::Named(id) => serializer.serialize_str(id.as_str()),
613 }
614 }
615 }
616
617 impl<'de> Deserialize<'de> for $name {
618 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
619 where
620 D: Deserializer<'de>,
621 {
622 let value = String::deserialize(deserializer)?;
623 if value == "base" {
624 Ok(Self::Base)
625 } else {
626 VariantId::new(value)
627 .map(Self::Named)
628 .map_err(de::Error::custom)
629 }
630 }
631 }
632 };
633}
634
635variant_reference_serde!(ConfiguredVariantRef);
636variant_reference_serde!(ConfiguredModelDefault);
637
638macro_rules! variant_reference_schema {
639 ($name:ident) => {
640 impl JsonSchema for $name {
641 fn inline_schema() -> bool {
642 true
643 }
644
645 fn schema_name() -> Cow<'static, str> {
646 stringify!($name).into()
647 }
648
649 fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
650 json_schema!({
651 "type": "string",
652 "minLength": 1,
653 "maxLength": 64,
654 "pattern": "^[a-z0-9._-]+$"
655 })
656 }
657 }
658 };
659}
660
661variant_reference_schema!(ConfiguredVariantRef);
662variant_reference_schema!(ConfiguredModelDefault);
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667
668 #[test]
669 fn identities_are_strict_and_model_keys_split_once() {
670 assert!(AgentId::new("worker-one").is_ok());
671 assert!(AgentId::new("Worker").is_err());
672 assert!(ProviderId::new("openai.compat").is_ok());
673 assert!(ProviderModelId::new("model/child").is_ok());
674 assert!(VariantId::new("base").is_err());
675 let key: ModelKey = "openai/gpt-5.6-sol".parse().unwrap();
676 assert_eq!(key.provider_id().as_str(), "openai");
677 assert_eq!(key.model_id().as_str(), "gpt-5.6-sol");
678 assert_eq!(
679 "openai/group/model"
680 .parse::<ModelKey>()
681 .unwrap()
682 .model_id()
683 .as_str(),
684 "group/model"
685 );
686 }
687
688 #[test]
689 fn base_and_named_default_are_not_conflated() {
690 assert_eq!(
691 serde_json::from_str::<ConfiguredVariantRef>("\"base\"").unwrap(),
692 ConfiguredVariantRef::Base
693 );
694 assert_eq!(
695 serde_json::from_str::<ConfiguredVariantRef>("\"default\"").unwrap(),
696 ConfiguredVariantRef::Named(VariantId::new("default").unwrap())
697 );
698 }
699
700 #[test]
701 fn model_selection_format_is_exact_and_serialization_is_unchanged() {
702 let base = ModelSelection {
703 model: "openai/gpt-5.6-sol".parse().unwrap(),
704 variant: None,
705 };
706 let named_default = ModelSelection {
707 model: "openai/gpt-5.6-sol".parse().unwrap(),
708 variant: Some(VariantId::new("default").unwrap()),
709 };
710 let high = ModelSelection {
711 model: "anthropic/claude-sonnet-4-6".parse().unwrap(),
712 variant: Some(VariantId::new("high").unwrap()),
713 };
714
715 assert_eq!(base.to_string(), "openai/gpt-5.6-sol[base]");
716 assert_eq!(named_default.to_string(), "openai/gpt-5.6-sol[default]");
717 assert_eq!(high.to_string(), "anthropic/claude-sonnet-4-6[high]");
718 assert_eq!(
719 serde_json::to_value(&base).unwrap(),
720 serde_json::json!({"model": "openai/gpt-5.6-sol", "variant": null})
721 );
722 assert_eq!(
723 serde_json::to_value(&named_default).unwrap(),
724 serde_json::json!({"model": "openai/gpt-5.6-sol", "variant": "default"})
725 );
726 }
727
728 #[test]
729 fn agent_schema_exposes_runtime_bounds() {
730 let schema = serde_json::to_value(schemars::schema_for!(AgentId)).unwrap();
731 assert_eq!(schema["maxLength"], 64);
732 assert_eq!(schema["pattern"], "^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*$");
733 }
734
735 #[test]
736 fn provider_schema_exposes_runtime_bounds() {
737 let schema = serde_json::to_value(schemars::schema_for!(ProviderId)).unwrap();
738 assert_eq!(schema["maxLength"], 128);
739 assert_eq!(schema["pattern"], "^[a-z0-9][a-z0-9._-]{0,127}$");
740 }
741
742 #[test]
743 fn provider_model_schema_exposes_runtime_bounds() {
744 let schema = serde_json::to_value(schemars::schema_for!(ProviderModelId)).unwrap();
745 assert_eq!(schema["maxLength"], 384);
746 assert!(schema["pattern"].as_str().unwrap().contains("\\s"));
747 }
748
749 #[test]
750 fn variant_schema_excludes_reserved_base() {
751 let schema = serde_json::to_value(schemars::schema_for!(VariantId)).unwrap();
752 assert_eq!(schema["maxLength"], 64);
753 assert!(schema["pattern"].as_str().unwrap().contains("?!base$"));
754 }
755
756 #[test]
757 fn model_key_schema_exposes_combined_bound_and_separator() {
758 let schema = serde_json::to_value(schemars::schema_for!(ModelKey)).unwrap();
759 assert_eq!(schema["maxLength"], 512);
760 assert!(schema["pattern"].as_str().unwrap().contains('/'));
761 }
762
763 #[test]
764 fn configured_variant_schema_includes_base_spelling() {
765 let schema = serde_json::to_value(schemars::schema_for!(ConfiguredVariantRef)).unwrap();
766 assert_eq!(schema["type"], "string");
767 assert!(schema["pattern"].as_str().unwrap().contains("a-z"));
768 }
769
770 #[test]
771 fn permission_values_enforce_current_shared_grammar() {
772 assert!(SafeCode::new("allow-read_1.test").is_ok());
773 assert!(SafeCode::new("Allow-read").is_err());
774 assert!(SafeCode::new("-allow-read").is_err());
775 assert!(SafeCode::new("a".repeat(128)).is_ok());
776 assert!(SafeCode::new("a".repeat(129)).is_err());
777
778 for pattern in [
779 "*",
780 "file?.rs",
781 "literal(value)",
782 "資料/?",
783 "${workspace_dir}/src/*",
784 "**",
785 "a**b",
786 "src/**",
787 ] {
788 assert!(WildcardPattern::new(pattern).is_ok(), "{pattern:?}");
789 }
790 for pattern in [
791 "",
792 r"a\\*",
793 "[ab]",
794 "{a,b}",
795 "${foo}/src/*",
796 "${workspace_dir}/src/{x}",
797 "a\n",
798 ] {
799 assert!(WildcardPattern::new(pattern).is_err(), "{pattern:?}");
800 }
801 assert!(WildcardPattern::new("a".repeat(4096)).is_ok());
802 assert!(WildcardPattern::new("a".repeat(4097)).is_err());
803 assert!(WildcardPattern::new("界".repeat(1365)).is_ok());
804 assert!(WildcardPattern::new("界".repeat(1366)).is_err());
805 assert!(WildcardPattern::new("😀".repeat(1024)).is_ok());
806 assert!(WildcardPattern::new("😀".repeat(1025)).is_err());
807 }
808
809 #[test]
810 fn permission_value_schemas_expose_runtime_constraints() {
811 let code = serde_json::to_value(schemars::schema_for!(SafeCode)).unwrap();
812 assert_eq!(code["maxLength"], SafeCode::MAX_BYTES);
813 assert_eq!(code["pattern"], SafeCode::JSON_SCHEMA_PATTERN);
814
815 let wildcard = serde_json::to_value(schemars::schema_for!(WildcardPattern)).unwrap();
816 assert!(wildcard.get("maxLength").is_none());
817 assert_eq!(
818 wildcard[WildcardPattern::JSON_SCHEMA_MAX_UTF8_BYTES_EXTENSION],
819 WildcardPattern::MAX_BYTES
820 );
821 assert_eq!(wildcard["pattern"], WildcardPattern::JSON_SCHEMA_PATTERN);
822 let description = wildcard["description"].as_str().unwrap();
823 assert!(description.contains("globstar"));
824 assert!(description.contains("escapes"));
825 assert!(description.contains("UTF-8 bytes"));
826 assert!(description.contains("maxLength counts Unicode code points"));
827 }
828
829 #[test]
830 fn agent_id_accepts_exact_maximum_length() {
831 assert!(AgentId::new("a".repeat(64)).is_ok());
832 assert!(AgentId::new("a".repeat(65)).is_err());
833 }
834
835 #[test]
836 fn provider_id_accepts_exact_maximum_length() {
837 assert!(ProviderId::new("p".repeat(128)).is_ok());
838 assert!(ProviderId::new("p".repeat(129)).is_err());
839 }
840
841 #[test]
842 fn provider_model_id_rejects_trimmed_and_control_forms() {
843 assert!(ProviderModelId::new(" model").is_err());
844 assert!(ProviderModelId::new("model\n").is_err());
845 }
846
847 #[test]
848 fn variant_id_accepts_named_default_but_not_base() {
849 assert!(VariantId::new("default").is_ok());
850 assert!(VariantId::new("base").is_err());
851 }
852}