1use std::{collections::HashSet, fmt};
10
11use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
12use serde_json::Value;
13
14use crate::PROTOCOL_VERSION;
15
16#[derive(Serialize, Debug, Clone, PartialEq)]
23#[non_exhaustive]
24pub struct ModuleManifest {
25 pub module_id: String,
26 pub module_version: String,
27 pub protocol_ver: u8,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub trust_tier: Option<TrustTier>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub ready: Option<bool>,
38 pub provides: Vec<ProviderRole>,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub consumes: Vec<ConsumerRole>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub bindings: Option<Bindings>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub capabilities: Option<CapabilityDeclarations>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub events: Option<Vec<EventDeclaration>>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub self_signals: Option<Vec<SelfSignalDeclaration>>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub provenance: Option<ManifestProvenance>,
105}
106
107#[derive(Debug, Clone)]
109pub struct ModuleManifestBuilder {
110 module_id: String,
111 module_version: String,
112 protocol_ver: u8,
113 trust_tier: Option<TrustTier>,
114 ready: Option<bool>,
115 provides: Vec<ProviderRole>,
116 consumes: Vec<ConsumerRole>,
117 bindings: Option<Bindings>,
118 capabilities: Option<CapabilityDeclarations>,
119 events: Option<Vec<EventDeclaration>>,
120 self_signals: Option<Vec<SelfSignalDeclaration>>,
121 provenance: Option<ManifestProvenance>,
122}
123
124impl ModuleManifest {
125 pub fn builder(
133 module_id: impl Into<String>,
134 module_version: impl Into<String>,
135 ) -> ModuleManifestBuilder {
136 ModuleManifestBuilder {
137 module_id: module_id.into(),
138 module_version: module_version.into(),
139 protocol_ver: PROTOCOL_VERSION,
140 trust_tier: None,
141 ready: None,
142 provides: Vec::new(),
143 consumes: Vec::new(),
144 bindings: None,
145 capabilities: None,
146 events: None,
147 self_signals: None,
148 provenance: None,
149 }
150 }
151}
152
153impl ModuleManifestBuilder {
154 pub fn protocol_ver(mut self, protocol_ver: u8) -> Self {
156 self.protocol_ver = protocol_ver;
157 self
158 }
159
160 pub fn trust_tier(mut self, trust_tier: Option<TrustTier>) -> Self {
164 self.trust_tier = trust_tier;
165 self
166 }
167
168 pub fn ready(mut self, ready: bool) -> Self {
170 self.ready = Some(ready);
171 self
172 }
173
174 pub fn provides(mut self, provides: Vec<ProviderRole>) -> Self {
176 self.provides = provides;
177 self
178 }
179
180 pub fn consumes(mut self, consumes: Vec<ConsumerRole>) -> Self {
182 self.consumes = consumes;
183 self
184 }
185
186 pub fn bindings(mut self, bindings: Option<Bindings>) -> Self {
190 self.bindings = bindings;
191 self
192 }
193
194 pub fn capabilities(mut self, capabilities: Option<CapabilityDeclarations>) -> Self {
196 self.capabilities = capabilities;
197 self
198 }
199
200 pub fn events(mut self, events: Option<Vec<EventDeclaration>>) -> Self {
202 self.events = events;
203 self
204 }
205
206 pub fn self_signals(mut self, self_signals: Option<Vec<SelfSignalDeclaration>>) -> Self {
208 self.self_signals = self_signals;
209 self
210 }
211
212 pub fn provenance(mut self, provenance: Option<ManifestProvenance>) -> Self {
214 self.provenance = provenance;
215 self
216 }
217
218 pub fn build(self) -> ModuleManifest {
220 ModuleManifest {
221 module_id: self.module_id,
222 module_version: self.module_version,
223 protocol_ver: self.protocol_ver,
224 trust_tier: self.trust_tier,
225 ready: self.ready,
226 provides: self.provides,
227 consumes: self.consumes,
228 bindings: self.bindings,
229 capabilities: self.capabilities,
230 events: self.events,
231 self_signals: self.self_signals,
232 provenance: self.provenance,
233 }
234 }
235}
236
237#[derive(Deserialize)]
255struct ModuleManifestWire {
256 module_id: String,
257 module_version: String,
258 protocol_ver: u8,
259 #[serde(default)]
260 trust_tier: Option<TrustTier>,
261 #[serde(default)]
262 ready: Option<bool>,
263 provides: Vec<ProviderRole>,
264 #[serde(default)]
265 consumes: Vec<ConsumerRole>,
266 #[serde(default)]
267 bindings: Option<Bindings>,
268 #[serde(default)]
269 capabilities: Option<CapabilityDeclarations>,
270 #[serde(default)]
271 events: Option<Value>,
272 #[serde(default)]
273 self_signals: Option<Vec<SelfSignalDeclaration>>,
274 #[serde(default)]
275 provenance: Option<ManifestProvenance>,
276 #[serde(default)]
280 runtime_computed: Option<Value>,
281}
282
283impl<'de> Deserialize<'de> for ModuleManifest {
284 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
285 where
286 D: Deserializer<'de>,
287 {
288 let wire = ModuleManifestWire::deserialize(deserializer)?;
289 validate_runtime_computed(wire.runtime_computed.as_ref(), "runtime_computed")
290 .map_err(D::Error::custom)?;
291 validate_events_value(wire.events.as_ref()).map_err(D::Error::custom)?;
292 let events = wire
293 .events
294 .map(serde_json::from_value)
295 .transpose()
296 .map_err(D::Error::custom)?;
297 let mut builder = Self::builder(wire.module_id, wire.module_version)
298 .protocol_ver(wire.protocol_ver)
299 .trust_tier(wire.trust_tier);
300 if let Some(ready) = wire.ready {
301 builder = builder.ready(ready);
302 }
303 let manifest = builder
304 .provides(wire.provides)
305 .consumes(wire.consumes)
306 .bindings(wire.bindings)
307 .capabilities(wire.capabilities)
308 .events(events)
309 .self_signals(wire.self_signals)
310 .provenance(wire.provenance)
311 .build();
312 manifest
313 .validate_capability_grammar()
314 .map_err(D::Error::custom)?;
315 Ok(manifest)
316 }
317}
318
319#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
321#[non_exhaustive]
322pub struct EventDeclaration {
323 pub name: String,
328 pub version: u32,
330 pub headers: Vec<String>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub summary: Option<String>,
336}
337
338impl EventDeclaration {
339 pub fn new(name: impl Into<String>, version: u32) -> Self {
342 Self {
343 name: name.into(),
344 version,
345 headers: Vec::new(),
346 summary: None,
347 }
348 }
349
350 pub fn with_headers(mut self, headers: Vec<String>) -> Self {
352 self.headers = headers;
353 self
354 }
355
356 pub fn with_summary(mut self, summary: Option<String>) -> Self {
358 self.summary = summary;
359 self
360 }
361}
362
363#[derive(Debug, Clone, PartialEq, Eq)]
365#[non_exhaustive]
366pub struct EventDeclarationError {
367 field: String,
368 reason: &'static str,
369}
370
371impl EventDeclarationError {
372 fn new(field: impl Into<String>, reason: &'static str) -> Self {
373 Self {
374 field: field.into(),
375 reason,
376 }
377 }
378
379 pub fn field(&self) -> &str {
381 &self.field
382 }
383}
384
385impl fmt::Display for EventDeclarationError {
386 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
387 write!(
388 f,
389 "invalid event declaration: field {} {}",
390 self.field, self.reason
391 )
392 }
393}
394
395impl std::error::Error for EventDeclarationError {}
396
397pub fn validate_hello_event_declarations(hello: &Value) -> Result<(), EventDeclarationError> {
399 validate_events_value(
400 hello
401 .get("manifest")
402 .and_then(|manifest| manifest.get("events")),
403 )
404}
405
406fn is_valid_event_name(name: &str) -> bool {
407 (1..=63).contains(&name.len())
408 && name.as_bytes()[0].is_ascii_alphanumeric()
409 && name
410 .bytes()
411 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
412}
413
414fn validate_events_value(value: Option<&Value>) -> Result<(), EventDeclarationError> {
415 let Some(value) = value.filter(|value| !value.is_null()) else {
416 return Ok(());
417 };
418 let entries = value
419 .as_array()
420 .ok_or_else(|| EventDeclarationError::new("events", "must be a list"))?;
421 if entries.len() > 64 {
422 return Err(EventDeclarationError::new(
423 "events",
424 "must contain at most 64 declarations",
425 ));
426 }
427 let mut pairs = HashSet::new();
428 for (index, entry) in entries.iter().enumerate() {
429 let field = format!("events[{index}]");
430 let entry = entry
431 .as_object()
432 .ok_or_else(|| EventDeclarationError::new(&field, "must be an object"))?;
433 let name_field = format!("{field}.name");
434 let name = entry
435 .get("name")
436 .and_then(Value::as_str)
437 .ok_or_else(|| EventDeclarationError::new(&name_field, "must be a string"))?;
438 if !is_valid_event_name(name) {
439 return Err(EventDeclarationError::new(
440 name_field,
441 "must match [a-z0-9][a-z0-9_]{0,62}",
442 ));
443 }
444 let version = entry
445 .get("version")
446 .and_then(Value::as_u64)
447 .filter(|version| (1..=u32::MAX as u64).contains(version))
448 .ok_or_else(|| {
449 EventDeclarationError::new(
450 format!("{field}.version"),
451 "must be an integer between 1 and u32::MAX",
452 )
453 })?;
454 if !pairs.insert((name, version)) {
455 return Err(EventDeclarationError::new(
456 &field,
457 "duplicates an event (name, version) pair",
458 ));
459 }
460 let headers_field = format!("{field}.headers");
461 let headers = entry
462 .get("headers")
463 .and_then(Value::as_array)
464 .ok_or_else(|| EventDeclarationError::new(&headers_field, "must be a list"))?;
465 if headers.len() > 16 {
466 return Err(EventDeclarationError::new(
467 headers_field,
468 "must contain at most 16 headers",
469 ));
470 }
471 let mut names = HashSet::new();
472 for (header_index, header) in headers.iter().enumerate() {
473 let header_field = format!("{headers_field}[{header_index}]");
474 let header = header
475 .as_str()
476 .ok_or_else(|| EventDeclarationError::new(&header_field, "must be a string"))?;
477 if !(1..=32).contains(&header.len())
478 || !header.as_bytes()[0].is_ascii_lowercase()
479 || !header
480 .bytes()
481 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
482 {
483 return Err(EventDeclarationError::new(
484 header_field,
485 "must match [a-z][a-z0-9_]{0,31}",
486 ));
487 }
488 if !names.insert(header) {
489 return Err(EventDeclarationError::new(
490 header_field,
491 "duplicates a header name",
492 ));
493 }
494 }
495 if let Some(summary) = entry.get("summary").filter(|value| !value.is_null()) {
496 let summary_field = format!("{field}.summary");
497 let summary = summary
498 .as_str()
499 .ok_or_else(|| EventDeclarationError::new(&summary_field, "must be a string"))?;
500 if summary.chars().count() > 200 || summary.chars().any(char::is_control) {
501 return Err(EventDeclarationError::new(
502 summary_field,
503 "must contain at most 200 characters and no control characters",
504 ));
505 }
506 }
507 }
508 Ok(())
509}
510
511#[derive(Debug, Clone, PartialEq, Eq)]
513pub struct SelfSignalDeclarationError {
514 module_id: String,
515 entry_index: usize,
516 field: &'static str,
517}
518
519impl fmt::Display for SelfSignalDeclarationError {
520 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
521 write!(
522 f,
523 "module_id '{}' self_signals[{}] is missing required field '{}'",
524 self.module_id.escape_debug(),
525 self.entry_index,
526 self.field
527 )
528 }
529}
530
531pub fn validate_hello_self_signal_declarations(
538 hello: &Value,
539) -> Result<(), SelfSignalDeclarationError> {
540 let Some(manifest) = hello.get("manifest").and_then(Value::as_object) else {
541 return Ok(());
542 };
543 let module_id = manifest
544 .get("module_id")
545 .and_then(Value::as_str)
546 .unwrap_or("<unknown>");
547 let Some(entries) = manifest.get("self_signals").and_then(Value::as_array) else {
548 return Ok(());
549 };
550
551 for (entry_index, entry) in entries.iter().enumerate() {
552 let Some(entry) = entry.as_object() else {
553 continue;
554 };
555 for field in ["effect", "anchored_to"] {
556 if !entry.contains_key(field) {
557 return Err(SelfSignalDeclarationError {
558 module_id: module_id.to_string(),
559 entry_index,
560 field,
561 });
562 }
563 }
564 }
565 Ok(())
566}
567
568#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
570#[serde(deny_unknown_fields)]
571pub struct CapabilityDeclarations {
572 #[serde(default)]
573 pub provides: Vec<String>,
574 #[serde(default)]
575 pub requires: Vec<CapabilityRequirement>,
576 #[serde(default)]
577 pub must_never_reach: Vec<String>,
578}
579
580#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
582pub struct SelfSignalDeclaration {
583 pub name: String,
585 pub kind: SelfSignalKind,
613 pub effect: SelfSignalEffect,
615 pub anchored_to: SignalAnchor,
617 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub cadence: Option<SignalCadence>,
625 #[serde(default, skip_serializing_if = "Option::is_none")]
627 pub domain: Option<String>,
628 #[serde(default, skip_serializing_if = "Option::is_none")]
629 pub note: Option<String>,
630}
631
632#[derive(Debug, Clone, PartialEq, Eq)]
634pub enum SelfSignalKind {
635 Keepalive,
636 Busy,
638 Poller,
639 Cron,
640 Sweep,
641 Watchdog,
642 Heartbeat,
643 Other(String),
644}
645
646impl SelfSignalKind {
647 fn wire_name(&self) -> &str {
648 match self {
649 Self::Keepalive => "keepalive",
650 Self::Busy => "busy",
651 Self::Poller => "poller",
652 Self::Cron => "cron",
653 Self::Sweep => "sweep",
654 Self::Watchdog => "watchdog",
655 Self::Heartbeat => "heartbeat",
656 Self::Other(value) => value,
657 }
658 }
659}
660
661impl Serialize for SelfSignalKind {
662 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
663 where
664 S: serde::Serializer,
665 {
666 serializer.serialize_str(self.wire_name())
667 }
668}
669
670impl<'de> Deserialize<'de> for SelfSignalKind {
671 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
672 where
673 D: Deserializer<'de>,
674 {
675 let value = String::deserialize(deserializer)?;
676 Ok(match value.as_str() {
677 "keepalive" => Self::Keepalive,
678 "busy" => Self::Busy,
679 "poller" => Self::Poller,
680 "cron" => Self::Cron,
681 "sweep" => Self::Sweep,
682 "watchdog" => Self::Watchdog,
683 "heartbeat" => Self::Heartbeat,
684 _ => Self::Other(value),
685 })
686 }
687}
688
689#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
691#[serde(rename_all = "lowercase")]
692pub enum SelfSignalEffect {
693 Observe,
694 Mutate,
695}
696
697#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
699#[serde(rename_all = "snake_case")]
700pub enum SignalAnchor {
701 FixedInterval,
704 Event { event: String },
707 HealthGauges { gauges: Vec<String> },
709}
710
711#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
713#[serde(rename_all = "snake_case")]
714pub enum SignalCadence {
715 Literal { interval_ms: u64 },
716 Derived { source: String },
717}
718
719#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq)]
796#[non_exhaustive]
797pub struct ManifestProvenance {
798 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub build_git_sha: Option<String>,
800 #[serde(default, skip_serializing_if = "Option::is_none")]
804 pub build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
805 #[serde(default, skip_serializing_if = "Option::is_none")]
806 pub build_lock_digest: Option<String>,
807 #[serde(default, skip_serializing_if = "Option::is_none")]
816 pub wire_crate_version: Option<String>,
817 #[serde(default, skip_serializing_if = "Option::is_none")]
818 pub store_schema_version: Option<String>,
819 #[serde(default, skip_serializing_if = "Option::is_none")]
824 pub launch_nonce_source: Option<LaunchNonceSource>,
825}
826
827#[derive(Debug, Clone, PartialEq, Eq)]
834#[non_exhaustive]
835pub enum LaunchNonceSource {
836 Fd,
838 Env,
840 ForwardCompatibleUnknown(String),
841}
842
843impl LaunchNonceSource {
844 pub fn wire_name(&self) -> &str {
846 match self {
847 Self::Fd => "fd",
848 Self::Env => "env",
849 Self::ForwardCompatibleUnknown(value) => value,
850 }
851 }
852
853 pub fn from_wire_name(value: &str) -> Self {
855 match value {
856 "fd" => Self::Fd,
857 "env" => Self::Env,
858 _ => Self::ForwardCompatibleUnknown(value.to_string()),
859 }
860 }
861}
862
863impl Serialize for LaunchNonceSource {
864 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
865 where
866 S: serde::Serializer,
867 {
868 serializer.serialize_str(self.wire_name())
869 }
870}
871
872impl<'de> Deserialize<'de> for LaunchNonceSource {
873 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
874 where
875 D: serde::Deserializer<'de>,
876 {
877 let value = String::deserialize(deserializer)?;
878 Ok(Self::from_wire_name(&value))
879 }
880}
881
882#[derive(Debug, Clone, PartialEq, Eq)]
887pub enum BuildGitShaAbsenceReason {
888 DeclinedDirty,
889 NeverDerived,
890 NoGitDir,
891 ForwardCompatibleUnknown(String),
892}
893
894impl BuildGitShaAbsenceReason {
895 fn wire_name(&self) -> &str {
896 match self {
897 Self::DeclinedDirty => "declined_dirty",
898 Self::NeverDerived => "never_derived",
899 Self::NoGitDir => "no_git_dir",
900 Self::ForwardCompatibleUnknown(value) => value,
901 }
902 }
903}
904
905impl Serialize for BuildGitShaAbsenceReason {
906 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
907 where
908 S: serde::Serializer,
909 {
910 serializer.serialize_str(self.wire_name())
911 }
912}
913
914impl<'de> Deserialize<'de> for BuildGitShaAbsenceReason {
915 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
916 where
917 D: serde::Deserializer<'de>,
918 {
919 let value = String::deserialize(deserializer)?;
920 Ok(match value.as_str() {
921 "declined_dirty" => Self::DeclinedDirty,
922 "never_derived" => Self::NeverDerived,
923 "no_git_dir" => Self::NoGitDir,
924 _ => Self::ForwardCompatibleUnknown(value),
925 })
926 }
927}
928
929#[derive(Debug, Clone, Copy, PartialEq, Eq)]
931pub enum GitTreeState {
932 Clean,
933 Dirty,
934}
935
936#[derive(Debug, Clone, Copy, PartialEq, Eq)]
941pub enum BuildGitShaSource<'a> {
942 Git {
943 revision: &'a str,
944 tree_state: GitTreeState,
945 },
946 NeverDerived,
947 NoGitDir,
948}
949
950pub fn attestable_commit(revision: &str, tree_state: GitTreeState) -> Option<&str> {
954 match tree_state {
955 GitTreeState::Clean => Some(revision),
956 GitTreeState::Dirty => None,
957 }
958}
959
960const MAX_PROVENANCE_VALUE_BYTES: usize = 128;
961const BUILD_GIT_SHA_CANONICAL_FORM: &str = "exactly 40 lowercase hexadecimal characters";
962const BUILD_LOCK_DIGEST_CANONICAL_FORM: &str = "exactly 64 lowercase hexadecimal characters";
963
964#[derive(Deserialize)]
965struct ManifestProvenanceWire {
966 #[serde(default)]
967 build_git_sha: Option<String>,
968 #[serde(default)]
969 build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
970 #[serde(default)]
971 build_lock_digest: Option<String>,
972 #[serde(default)]
973 wire_crate_version: Option<String>,
974 #[serde(default)]
975 store_schema_version: Option<String>,
976 #[serde(default)]
977 launch_nonce_source: Option<LaunchNonceSource>,
978}
979
980impl<'de> Deserialize<'de> for ManifestProvenance {
981 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
982 where
983 D: Deserializer<'de>,
984 {
985 let wire = ManifestProvenanceWire::deserialize(deserializer)?;
986 let provenance = Self {
987 build_git_sha: wire.build_git_sha,
988 build_git_sha_absence_reason: wire.build_git_sha_absence_reason,
989 build_lock_digest: wire.build_lock_digest,
990 wire_crate_version: wire.wire_crate_version,
991 store_schema_version: wire.store_schema_version,
992 launch_nonce_source: wire.launch_nonce_source,
993 };
994 provenance.validate().map_err(D::Error::custom)?;
995 Ok(provenance)
996 }
997}
998
999#[derive(Debug, Clone, PartialEq, Eq)]
1001pub struct ProvenanceFormError {
1002 field: &'static str,
1003 length: usize,
1004 canonical_form: &'static str,
1005}
1006
1007impl ProvenanceFormError {
1008 fn new(field: &'static str, length: usize, canonical_form: &'static str) -> Self {
1009 Self {
1010 field,
1011 length,
1012 canonical_form,
1013 }
1014 }
1015
1016 pub fn field(&self) -> &str {
1018 self.field
1019 }
1020
1021 pub fn length(&self) -> usize {
1023 self.length
1024 }
1025
1026 pub fn canonical_form(&self) -> &str {
1028 self.canonical_form
1029 }
1030}
1031
1032impl fmt::Display for ProvenanceFormError {
1033 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1034 write!(
1035 f,
1036 "invalid manifest provenance form: field {} has length {}; canonical form is {}",
1037 self.field, self.length, self.canonical_form
1038 )
1039 }
1040}
1041
1042impl std::error::Error for ProvenanceFormError {}
1043
1044#[derive(Debug, Clone, PartialEq, Eq)]
1045pub struct ManifestProvenanceError {
1046 field: String,
1047 value: String,
1048 reason: &'static str,
1049}
1050
1051impl ManifestProvenanceError {
1052 fn new(field: &str, value: &str, reason: &'static str) -> Self {
1053 Self {
1054 field: field.to_string(),
1055 value: safe_error_value(value),
1056 reason,
1057 }
1058 }
1059
1060 pub fn field(&self) -> &str {
1061 &self.field
1062 }
1063}
1064
1065impl fmt::Display for ManifestProvenanceError {
1066 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1067 write!(
1068 f,
1069 "invalid manifest provenance: field {} has {} (value {:?})",
1070 self.field, self.reason, self.value
1071 )
1072 }
1073}
1074
1075impl std::error::Error for ManifestProvenanceError {}
1076
1077impl ManifestProvenance {
1078 pub fn new() -> Self {
1080 Self::default()
1081 }
1082
1083 pub fn with_build_git_sha(mut self, value: Option<String>) -> Self {
1084 self.build_git_sha = value;
1085 self
1086 }
1087
1088 pub fn with_build_git_sha_absence_reason(
1089 mut self,
1090 value: Option<BuildGitShaAbsenceReason>,
1091 ) -> Self {
1092 self.build_git_sha_absence_reason = value;
1093 self
1094 }
1095
1096 pub fn with_build_lock_digest(mut self, value: Option<String>) -> Self {
1097 self.build_lock_digest = value;
1098 self
1099 }
1100
1101 pub fn with_wire_crate_version(mut self, value: Option<String>) -> Self {
1102 self.wire_crate_version = value;
1103 self
1104 }
1105
1106 pub fn with_store_schema_version(mut self, value: Option<String>) -> Self {
1107 self.store_schema_version = value;
1108 self
1109 }
1110
1111 pub fn with_launch_nonce_source(mut self, value: Option<LaunchNonceSource>) -> Self {
1116 self.launch_nonce_source = value;
1117 self
1118 }
1119
1120 pub fn validate(&self) -> Result<(), ManifestProvenanceError> {
1121 if let (Some(_), Some(reason)) = (
1122 self.build_git_sha.as_ref(),
1123 self.build_git_sha_absence_reason.as_ref(),
1124 ) {
1125 return Err(ManifestProvenanceError::new(
1126 "build_git_sha_absence_reason",
1127 reason.wire_name(),
1128 "must be omitted when build_git_sha is present",
1129 ));
1130 }
1131 for (field, value) in [
1132 ("build_git_sha", self.build_git_sha.as_deref()),
1133 (
1134 "build_git_sha_absence_reason",
1135 self.build_git_sha_absence_reason
1136 .as_ref()
1137 .map(|reason| reason.wire_name()),
1138 ),
1139 ("build_lock_digest", self.build_lock_digest.as_deref()),
1140 ("wire_crate_version", self.wire_crate_version.as_deref()),
1141 ("store_schema_version", self.store_schema_version.as_deref()),
1142 (
1143 "launch_nonce_source",
1144 self.launch_nonce_source
1145 .as_ref()
1146 .map(|source| source.wire_name()),
1147 ),
1148 ] {
1149 let Some(value) = value else { continue };
1150 if value.is_empty() {
1151 return Err(ManifestProvenanceError::new(
1152 field,
1153 value,
1154 "must not be empty",
1155 ));
1156 }
1157 if value.len() > MAX_PROVENANCE_VALUE_BYTES {
1162 return Err(ManifestProvenanceError::new(
1163 field,
1164 value,
1165 "exceeds the 128-byte maximum",
1166 ));
1167 }
1168 if value.bytes().any(|byte| !(0x20..=0x7e).contains(&byte)) {
1169 return Err(ManifestProvenanceError::new(
1170 field,
1171 value,
1172 "contains non-printable ASCII",
1173 ));
1174 }
1175 }
1176 Ok(())
1177 }
1178}
1179
1180pub fn build_provenance(
1198 build_git_sha: Option<&str>,
1199 build_lock_digest: Option<&str>,
1200 store_schema_version: Option<&str>,
1201) -> Result<ManifestProvenance, ProvenanceFormError> {
1202 let build_git_sha = normalize_and_validate_build_git_sha(build_git_sha)?;
1203 build_provenance_with_build_git_sha(
1204 build_git_sha,
1205 None,
1206 build_lock_digest,
1207 store_schema_version,
1208 )
1209}
1210
1211pub fn build_provenance_from_source(
1227 build_git_sha_source: BuildGitShaSource<'_>,
1228 build_lock_digest: Option<&str>,
1229 store_schema_version: Option<&str>,
1230) -> Result<ManifestProvenance, ProvenanceFormError> {
1231 let (raw_build_git_sha, mut build_git_sha_absence_reason) = match build_git_sha_source {
1232 BuildGitShaSource::Git {
1233 revision,
1234 tree_state,
1235 } => match attestable_commit(revision, tree_state) {
1236 Some(revision) => (Some(revision), None),
1237 None => (None, Some(BuildGitShaAbsenceReason::DeclinedDirty)),
1238 },
1239 BuildGitShaSource::NeverDerived => (None, Some(BuildGitShaAbsenceReason::NeverDerived)),
1240 BuildGitShaSource::NoGitDir => (None, Some(BuildGitShaAbsenceReason::NoGitDir)),
1241 };
1242 let build_git_sha = normalize_and_validate_build_git_sha(raw_build_git_sha)?;
1243 if build_git_sha.is_none() {
1244 build_git_sha_absence_reason.get_or_insert(BuildGitShaAbsenceReason::NeverDerived);
1245 }
1246 build_provenance_with_build_git_sha(
1247 build_git_sha,
1248 build_git_sha_absence_reason,
1249 build_lock_digest,
1250 store_schema_version,
1251 )
1252}
1253
1254fn normalize_and_validate_build_git_sha(
1255 build_git_sha: Option<&str>,
1256) -> Result<Option<String>, ProvenanceFormError> {
1257 let build_git_sha = normalize_provenance_fact(build_git_sha);
1258 validate_provenance_form(
1259 "build_git_sha",
1260 build_git_sha.as_deref(),
1261 BUILD_GIT_SHA_CANONICAL_FORM,
1262 40,
1263 )?;
1264 Ok(build_git_sha)
1265}
1266
1267fn build_provenance_with_build_git_sha(
1268 build_git_sha: Option<String>,
1269 build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
1270 build_lock_digest: Option<&str>,
1271 store_schema_version: Option<&str>,
1272) -> Result<ManifestProvenance, ProvenanceFormError> {
1273 let build_lock_digest = normalize_provenance_fact(build_lock_digest);
1274 validate_provenance_form(
1275 "build_lock_digest",
1276 build_lock_digest.as_deref(),
1277 BUILD_LOCK_DIGEST_CANONICAL_FORM,
1278 64,
1279 )?;
1280
1281 Ok(ManifestProvenance {
1282 build_git_sha,
1283 build_git_sha_absence_reason,
1284 build_lock_digest,
1285 wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
1286 store_schema_version: normalize_provenance_fact(store_schema_version),
1287 launch_nonce_source: None,
1288 })
1289}
1290
1291fn validate_provenance_form(
1292 field: &'static str,
1293 value: Option<&str>,
1294 canonical_form: &'static str,
1295 expected_length: usize,
1296) -> Result<(), ProvenanceFormError> {
1297 let Some(value) = value else { return Ok(()) };
1298 if value.len() != expected_length
1299 || !value
1300 .bytes()
1301 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
1302 {
1303 return Err(ProvenanceFormError::new(field, value.len(), canonical_form));
1304 }
1305 Ok(())
1306}
1307
1308pub const PROVENANCE_SENTINELS: [&str; 3] = ["unknown", "unavailable", "none"];
1316
1317fn normalize_provenance_fact(value: Option<&str>) -> Option<String> {
1318 let value = value?.trim();
1319 if value.is_empty() {
1320 return None;
1321 }
1322 let lowered = value.to_ascii_lowercase();
1323 if PROVENANCE_SENTINELS.contains(&lowered.as_str()) {
1324 return None;
1325 }
1326 Some(value.to_string())
1327}
1328
1329#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1331#[serde(deny_unknown_fields)]
1332pub struct CapabilityRequirement {
1333 pub capability: String,
1334 pub need: CapabilityNeed,
1335}
1336
1337#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
1339#[serde(rename_all = "snake_case")]
1340pub enum CapabilityNeed {
1341 Required,
1342 Optional,
1343}
1344
1345#[derive(Debug, Clone, PartialEq, Eq)]
1347pub struct CapabilityGrammarError {
1348 field: String,
1349 value: String,
1350}
1351
1352impl CapabilityGrammarError {
1353 fn new(field: impl Into<String>, value: impl AsRef<str>) -> Self {
1354 Self {
1355 field: field.into(),
1356 value: safe_error_value(value.as_ref()),
1357 }
1358 }
1359
1360 pub fn field(&self) -> &str {
1362 &self.field
1363 }
1364
1365 pub fn value(&self) -> &str {
1367 &self.value
1368 }
1369}
1370
1371impl fmt::Display for CapabilityGrammarError {
1372 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1373 write!(
1374 f,
1375 "invalid capability grammar: field {} has offending value {:?}",
1376 self.field, self.value
1377 )
1378 }
1379}
1380
1381impl std::error::Error for CapabilityGrammarError {}
1382
1383impl ModuleManifest {
1384 pub fn validate_capability_grammar(&self) -> Result<(), CapabilityGrammarError> {
1386 let Some(capabilities) = &self.capabilities else {
1387 return Ok(());
1388 };
1389
1390 validate_capability_list("capabilities.provides", &capabilities.provides)?;
1391 validate_requires(&capabilities.requires)?;
1392 validate_capability_list(
1393 "capabilities.must_never_reach",
1394 &capabilities.must_never_reach,
1395 )
1396 }
1397}
1398
1399pub fn validate_manifest_capability_grammar(
1404 manifest: &Value,
1405) -> Result<(), CapabilityGrammarError> {
1406 let Some(object) = manifest.as_object() else {
1407 return Ok(());
1408 };
1409
1410 validate_capabilities_value(object.get("capabilities"))?;
1411 validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
1412}
1413
1414pub fn validate_hello_capability_grammar(hello: &Value) -> Result<(), CapabilityGrammarError> {
1420 let Some(object) = hello.as_object() else {
1421 return Ok(());
1422 };
1423 if let Some(manifest) = object.get("manifest") {
1424 validate_manifest_capability_grammar(manifest)?;
1425 }
1426 validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
1427}
1428
1429pub fn is_valid_capability_identifier(identifier: &str) -> bool {
1431 if identifier.chars().any(char::is_whitespace) {
1432 return false;
1433 }
1434 let Some((name, version)) = identifier.split_once("/v") else {
1435 return false;
1436 };
1437 if name.is_empty() || name.len() > 64 || version.is_empty() {
1438 return false;
1439 }
1440
1441 let name_bytes = name.as_bytes();
1442 if !name_bytes[0].is_ascii_lowercase()
1443 || (name.len() > 1
1444 && !name_bytes[name.len() - 1].is_ascii_lowercase()
1445 && !name_bytes[name.len() - 1].is_ascii_digit())
1446 || name_bytes.windows(2).any(|pair| pair == b"--")
1447 {
1448 return false;
1449 }
1450 if !name_bytes
1451 .iter()
1452 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
1453 {
1454 return false;
1455 }
1456
1457 if version.len() > 1 && version.starts_with('0')
1458 || !version.bytes().all(|byte| byte.is_ascii_digit())
1459 {
1460 return false;
1461 }
1462 matches!(
1463 version.parse::<u64>(),
1464 Ok(value) if (1..=u64::from(u32::MAX)).contains(&value)
1465 )
1466}
1467
1468fn validate_capabilities_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
1469 let Some(value) = value else {
1470 return Ok(());
1471 };
1472 let Some(object) = value.as_object() else {
1473 return Err(CapabilityGrammarError::new(
1474 "capabilities",
1475 value_description(value),
1476 ));
1477 };
1478
1479 for (key, value) in object {
1480 if !matches!(key.as_str(), "provides" | "requires" | "must_never_reach") {
1481 return Err(CapabilityGrammarError::new(
1482 field_child("capabilities", key),
1483 value_description(value),
1484 ));
1485 }
1486 }
1487
1488 validate_capability_list_value("capabilities.provides", object.get("provides"))?;
1489 validate_requires_value(object.get("requires"))?;
1490 validate_capability_list_value(
1491 "capabilities.must_never_reach",
1492 object.get("must_never_reach"),
1493 )
1494}
1495
1496fn validate_capability_list_value(
1497 field: &str,
1498 value: Option<&Value>,
1499) -> Result<(), CapabilityGrammarError> {
1500 let Some(value) = value else {
1501 return Ok(());
1502 };
1503 let Some(values) = value.as_array() else {
1504 return Err(CapabilityGrammarError::new(field, value_description(value)));
1505 };
1506
1507 let mut seen = HashSet::new();
1508 for (index, value) in values.iter().enumerate() {
1509 let field = format!("{field}[{index}]");
1510 let Some(identifier) = value.as_str() else {
1511 return Err(CapabilityGrammarError::new(field, value_description(value)));
1512 };
1513 validate_capability_identifier(&field, identifier)?;
1514 if !seen.insert(identifier) {
1515 return Err(CapabilityGrammarError::new(field, identifier));
1516 }
1517 }
1518 Ok(())
1519}
1520
1521fn validate_requires_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
1522 let Some(value) = value else {
1523 return Ok(());
1524 };
1525 let Some(values) = value.as_array() else {
1526 return Err(CapabilityGrammarError::new(
1527 "capabilities.requires",
1528 value_description(value),
1529 ));
1530 };
1531
1532 let mut seen = HashSet::new();
1533 for (index, value) in values.iter().enumerate() {
1534 let entry_field = format!("capabilities.requires[{index}]");
1535 let Some(object) = value.as_object() else {
1536 return Err(CapabilityGrammarError::new(
1537 entry_field,
1538 value_description(value),
1539 ));
1540 };
1541 for (key, value) in object {
1542 if !matches!(key.as_str(), "capability" | "need") {
1543 return Err(CapabilityGrammarError::new(
1544 field_child(&entry_field, key),
1545 value_description(value),
1546 ));
1547 }
1548 }
1549 let capability_field = format!("{entry_field}.capability");
1550 let Some(capability) = object.get("capability").and_then(Value::as_str) else {
1551 return Err(CapabilityGrammarError::new(
1552 capability_field,
1553 object
1554 .get("capability")
1555 .map_or("<missing>".to_string(), value_description),
1556 ));
1557 };
1558 validate_capability_identifier(&capability_field, capability)?;
1559
1560 let need_field = format!("{entry_field}.need");
1561 let Some(need) = object.get("need").and_then(Value::as_str) else {
1562 return Err(CapabilityGrammarError::new(
1563 need_field,
1564 object
1565 .get("need")
1566 .map_or("<missing>".to_string(), value_description),
1567 ));
1568 };
1569 if !matches!(need, "required" | "optional") {
1570 return Err(CapabilityGrammarError::new(need_field, need));
1571 }
1572 if !seen.insert(capability) {
1573 return Err(CapabilityGrammarError::new(entry_field, capability));
1574 }
1575 }
1576 Ok(())
1577}
1578
1579fn validate_capability_list(field: &str, values: &[String]) -> Result<(), CapabilityGrammarError> {
1580 let mut seen = HashSet::new();
1581 for (index, identifier) in values.iter().enumerate() {
1582 let field = format!("{field}[{index}]");
1583 validate_capability_identifier(&field, identifier)?;
1584 if !seen.insert(identifier) {
1585 return Err(CapabilityGrammarError::new(field, identifier));
1586 }
1587 }
1588 Ok(())
1589}
1590
1591fn validate_requires(values: &[CapabilityRequirement]) -> Result<(), CapabilityGrammarError> {
1592 let mut seen = HashSet::new();
1593 for (index, requirement) in values.iter().enumerate() {
1594 let field = format!("capabilities.requires[{index}].capability");
1595 validate_capability_identifier(&field, &requirement.capability)?;
1596 if !seen.insert(&requirement.capability) {
1597 return Err(CapabilityGrammarError::new(
1598 format!("capabilities.requires[{index}]"),
1599 &requirement.capability,
1600 ));
1601 }
1602 }
1603 Ok(())
1604}
1605
1606fn validate_capability_identifier(
1607 field: &str,
1608 identifier: &str,
1609) -> Result<(), CapabilityGrammarError> {
1610 if is_valid_capability_identifier(identifier) {
1611 Ok(())
1612 } else {
1613 Err(CapabilityGrammarError::new(field, identifier))
1614 }
1615}
1616
1617fn validate_runtime_computed(
1618 value: Option<&Value>,
1619 field: &str,
1620) -> Result<(), CapabilityGrammarError> {
1621 let Some(value) = value else {
1622 return Ok(());
1623 };
1624 let Some(pointers) = value.as_array() else {
1625 return Err(CapabilityGrammarError::new(field, value_description(value)));
1626 };
1627
1628 for (index, pointer) in pointers.iter().enumerate() {
1629 let field = format!("{field}[{index}]");
1630 let Some(pointer) = pointer.as_str() else {
1631 return Err(CapabilityGrammarError::new(
1632 field,
1633 value_description(pointer),
1634 ));
1635 };
1636 let Some(tokens) = parse_json_pointer(pointer) else {
1637 return Err(CapabilityGrammarError::new(field, pointer));
1638 };
1639 if tokens.first().is_some_and(|token| token == "capabilities") {
1640 return Err(CapabilityGrammarError::new(field, pointer));
1641 }
1642 }
1643 Ok(())
1644}
1645
1646fn parse_json_pointer(pointer: &str) -> Option<Vec<String>> {
1647 if pointer.is_empty() {
1648 return Some(Vec::new());
1649 }
1650 let raw_tokens = pointer.strip_prefix('/')?;
1651 raw_tokens
1652 .split('/')
1653 .map(unescape_json_pointer_token)
1654 .collect()
1655}
1656
1657fn unescape_json_pointer_token(token: &str) -> Option<String> {
1658 let mut output = String::with_capacity(token.len());
1659 let mut characters = token.chars();
1660 while let Some(character) = characters.next() {
1661 if character != '~' {
1662 output.push(character);
1663 continue;
1664 }
1665 match characters.next()? {
1666 '0' => output.push('~'),
1667 '1' => output.push('/'),
1668 _ => return None,
1669 }
1670 }
1671 Some(output)
1672}
1673
1674fn field_child(parent: &str, child: &str) -> String {
1675 let child = safe_error_value(child);
1676 format!("{parent}.{child}")
1677}
1678
1679fn value_description(value: &Value) -> String {
1680 match value {
1681 Value::String(value) => safe_error_value(value),
1682 Value::Null => "null".to_string(),
1683 Value::Bool(value) => value.to_string(),
1684 Value::Number(value) => value.to_string(),
1685 Value::Array(_) => "<array>".to_string(),
1686 Value::Object(_) => "<object>".to_string(),
1687 }
1688}
1689
1690fn safe_error_value(value: &str) -> String {
1691 let lower = value.to_ascii_lowercase();
1692 if ["secret", "password", "api_key"]
1693 .iter()
1694 .any(|marker| lower.contains(marker))
1695 || lower.starts_with("sk-")
1696 || lower.starts_with("akia")
1697 || lower.starts_with("bearer ")
1698 || lower.starts_with("token=")
1699 || lower.starts_with("credential=")
1700 {
1701 "<redacted>".to_string()
1702 } else {
1703 value.to_string()
1704 }
1705}
1706
1707#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1713#[serde(rename_all = "snake_case")]
1714pub enum TrustTier {
1715 FirstParty,
1716 Reviewed,
1717 Untrusted,
1718}
1719
1720#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1724#[serde(tag = "role", rename_all = "snake_case")]
1725pub enum ProviderRole {
1726 ToolProvider {
1727 tools: Vec<Tool>,
1728 identity_scope: Vec<IdentityScope>,
1736 concurrency: Concurrency,
1737 emits_push: bool,
1738 sub_supervises: bool,
1739 },
1740 PipelineStage {
1741 stage: PipelineStageKind,
1742 applies_to: PipelineAppliesTo,
1743 interface: String,
1744 declares_frozen_floor: bool,
1745 needs_signals: Vec<String>,
1746 conformance_class: String,
1747 },
1748 ManagementSurface {
1749 operations: Vec<ManagementOperation>,
1750 config_schema: Value,
1751 observability: Vec<ObservabilitySurface>,
1752 identity_scope: Vec<IdentityScope>,
1756 #[serde(default)]
1757 concurrency: Concurrency,
1758 },
1759 InternalService {
1760 service_id: String,
1761 transport: InternalTransport,
1762 agent_facing: bool,
1763 operations: Vec<String>,
1764 },
1765}
1766
1767#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
1780#[serde(rename_all = "snake_case")]
1781pub enum ExecutionMode {
1782 Pure,
1783 Mutating,
1784 Unfenceable,
1785}
1786
1787#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1789pub struct Tool {
1790 pub name: String,
1791 #[serde(default, skip_serializing_if = "Option::is_none")]
1792 pub description: Option<String>,
1793 pub execution_mode: ExecutionMode,
1798 pub schema: Value,
1799}
1800
1801#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1807#[serde(rename_all = "snake_case")]
1808pub enum Concurrency {
1809 Serial,
1811 ModuleManaged,
1814 StatelessParallel,
1817}
1818
1819#[allow(clippy::derivable_impls)]
1820impl Default for Concurrency {
1831 fn default() -> Self {
1832 Self::ModuleManaged
1833 }
1834}
1835
1836#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1843#[serde(rename_all = "snake_case")]
1844pub enum IdentityScope {
1845 Session,
1846 Project,
1847}
1848
1849#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1851#[serde(rename_all = "snake_case")]
1852pub enum PipelineStageKind {
1853 Transform,
1854 Codec,
1855 Auth,
1856}
1857
1858#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1860pub struct PipelineAppliesTo {
1861 pub provider: String,
1862 pub model: String,
1863}
1864
1865#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1867pub struct ManagementOperation {
1868 pub name: String,
1869 pub kind: ManagementOperationKind,
1870 #[serde(default, skip_serializing_if = "Option::is_none")]
1871 pub description: Option<String>,
1872}
1873
1874#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1875#[serde(rename_all = "snake_case")]
1876pub enum ManagementOperationKind {
1877 Query,
1878 Mutate,
1879}
1880
1881#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1883pub struct ObservabilitySurface {
1884 pub name: String,
1885 pub kind: ObservabilityKind,
1886}
1887
1888#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1889#[serde(rename_all = "snake_case")]
1890pub enum ObservabilityKind {
1891 Snapshot,
1892 Stream,
1893}
1894
1895#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1896#[serde(rename_all = "snake_case")]
1897pub enum InternalTransport {
1898 Bulk,
1899}
1900
1901#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1903#[serde(tag = "role", rename_all = "snake_case")]
1904pub enum ConsumerRole {
1905 ToolClient { of: Vec<String> },
1906 LlmClient { via: String, auth: String },
1907 ServiceClient { of: Vec<String> },
1908}
1909
1910#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1912pub struct Bindings {
1913 pub storage: StorageBinding,
1914 pub vault_grants: Vec<VaultGrant>,
1915 pub identity: IdentityBinding,
1916}
1917
1918#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1920pub struct StorageBinding {
1921 pub kind: StorageKind,
1922 pub scope: StorageScope,
1923 pub owns_schema: bool,
1924}
1925
1926#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1927#[serde(rename_all = "snake_case")]
1928pub enum StorageKind {
1929 Sqlite,
1930}
1931
1932#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1933#[serde(rename_all = "snake_case")]
1934pub enum StorageScope {
1935 Project,
1936}
1937
1938#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1939pub struct VaultGrant {
1940 pub secret: String,
1941 pub reason: String,
1942}
1943
1944#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1945pub struct IdentityBinding {
1946 pub requires: Vec<IdentityScope>,
1947 pub optional: Vec<IdentityScope>,
1948}
1949
1950#[cfg(test)]
1951mod tests {
1952 use super::*;
1953 use serde_json::json;
1954
1955 fn aft_manifest_fixture() -> ModuleManifest {
1956 ModuleManifest::builder("aft", "0.39.2")
1957 .trust_tier(Some(TrustTier::FirstParty))
1958 .bindings(Some(Bindings {
1959 storage: StorageBinding {
1960 kind: StorageKind::Sqlite,
1961 scope: StorageScope::Project,
1962 owns_schema: true,
1963 },
1964 vault_grants: vec![VaultGrant {
1965 secret: "provider_api_key".to_string(),
1966 reason: "cortexkit_native auth".to_string(),
1967 }],
1968 identity: IdentityBinding {
1969 requires: vec![IdentityScope::Project],
1970 optional: vec![IdentityScope::Session],
1971 },
1972 }))
1973 .protocol_ver(1)
1974 .provides(vec![ProviderRole::ToolProvider {
1975 tools: vec![
1976 Tool {
1977 name: "read".to_string(),
1978 description: None,
1979 execution_mode: ExecutionMode::Pure,
1980 schema: json!({"type": "object"}),
1981 },
1982 Tool {
1983 name: "grep".to_string(),
1984 description: None,
1985 execution_mode: ExecutionMode::Pure,
1986 schema: json!({"type": "object"}),
1987 },
1988 Tool {
1989 name: "outline".to_string(),
1990 description: None,
1991 execution_mode: ExecutionMode::Pure,
1992 schema: json!({"type": "object"}),
1993 },
1994 Tool {
1995 name: "semantic_search".to_string(),
1996 description: None,
1997 execution_mode: ExecutionMode::Pure,
1998 schema: json!({"type": "object"}),
1999 },
2000 Tool {
2001 name: "edit".to_string(),
2002 description: None,
2003 execution_mode: ExecutionMode::Mutating,
2004 schema: json!({"type": "object"}),
2005 },
2006 Tool {
2007 name: "write".to_string(),
2008 description: None,
2009 execution_mode: ExecutionMode::Mutating,
2010 schema: json!({"type": "object"}),
2011 },
2012 Tool {
2013 name: "bash".to_string(),
2014 description: None,
2015 execution_mode: ExecutionMode::Unfenceable,
2016 schema: json!({"type": "object"}),
2017 },
2018 ],
2019 identity_scope: vec![IdentityScope::Session, IdentityScope::Project],
2020 concurrency: Concurrency::ModuleManaged,
2021 emits_push: true,
2022 sub_supervises: true,
2023 }])
2024 .consumes(vec![ConsumerRole::ServiceClient {
2025 of: vec!["embedding.v2".to_string()],
2026 }])
2027 .build()
2028 }
2029
2030 #[test]
2031 fn serde_round_trips_representative_manifest() {
2032 let manifest = aft_manifest_fixture();
2033 let serialized = serde_json::to_string_pretty(&manifest).unwrap();
2034 let decoded: ModuleManifest = serde_json::from_str(&serialized).unwrap();
2035
2036 assert_eq!(manifest, decoded);
2037 }
2038
2039 #[test]
2040 fn builder_defaults_additions_to_honest_absence_and_round_trips() {
2041 let manifest = ModuleManifest::builder("builder-defaults", "2.0.0").build();
2042
2043 assert_eq!(manifest.module_id, "builder-defaults");
2044 assert_eq!(manifest.module_version, "2.0.0");
2045 assert_eq!(manifest.protocol_ver, PROTOCOL_VERSION);
2046 assert_eq!(manifest.trust_tier, None);
2047 assert!(manifest.provides.is_empty());
2048 assert!(manifest.consumes.is_empty());
2049 assert_eq!(manifest.bindings, None);
2050 assert_eq!(manifest.capabilities, None);
2051 assert_eq!(manifest.self_signals, None);
2052 assert_eq!(manifest.provenance, None);
2053
2054 let encoded = serde_json::to_value(&manifest).expect("builder manifest serializes");
2055 for optional in [
2056 "trust_tier",
2057 "consumes",
2058 "bindings",
2059 "capabilities",
2060 "self_signals",
2061 "provenance",
2062 ] {
2063 assert!(
2064 encoded.get(optional).is_none(),
2065 "an absent {optional} declaration must stay absent on the wire"
2066 );
2067 }
2068 let decoded: ModuleManifest =
2069 serde_json::from_value(encoded).expect("builder manifest round-trips");
2070 assert_eq!(decoded, manifest);
2071 }
2072
2073 #[test]
2074 fn fully_populated_builder_manifest_matches_the_literal_wire_golden() {
2075 let manifest = ModuleManifest::builder("full-builder", "2.0.0")
2076 .trust_tier(Some(TrustTier::Reviewed))
2077 .bindings(Some(Bindings {
2078 storage: StorageBinding {
2079 kind: StorageKind::Sqlite,
2080 scope: StorageScope::Project,
2081 owns_schema: false,
2082 },
2083 vault_grants: Vec::new(),
2084 identity: IdentityBinding {
2085 requires: vec![IdentityScope::Project],
2086 optional: Vec::new(),
2087 },
2088 }))
2089 .provides(vec![ProviderRole::ToolProvider {
2090 tools: vec![Tool {
2091 name: "read".to_string(),
2092 description: None,
2093 execution_mode: ExecutionMode::Pure,
2094 schema: json!({"type": "object"}),
2095 }],
2096 identity_scope: vec![IdentityScope::Project],
2097 concurrency: Concurrency::Serial,
2098 emits_push: false,
2099 sub_supervises: false,
2100 }])
2101 .consumes(vec![ConsumerRole::ServiceClient {
2102 of: vec!["embedding.v2".to_string()],
2103 }])
2104 .capabilities(Some(CapabilityDeclarations {
2105 provides: vec!["embedding/v2".to_string()],
2106 requires: Vec::new(),
2107 must_never_reach: Vec::new(),
2108 }))
2109 .self_signals(Some(vec![SelfSignalDeclaration {
2110 name: "usage_poller".to_string(),
2111 kind: SelfSignalKind::Poller,
2112 effect: SelfSignalEffect::Observe,
2113 anchored_to: SignalAnchor::FixedInterval,
2114 cadence: Some(SignalCadence::Literal {
2115 interval_ms: 60_000,
2116 }),
2117 domain: Some("provider-usage".to_string()),
2118 note: None,
2119 }]))
2120 .provenance(Some(ManifestProvenance {
2121 build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
2122 build_git_sha_absence_reason: None,
2123 build_lock_digest: Some(
2124 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
2125 ),
2126 wire_crate_version: Some("0.16.0".to_string()),
2127 store_schema_version: Some("42".to_string()),
2128 launch_nonce_source: None,
2129 }))
2130 .build();
2131
2132 assert_eq!(
2133 serde_json::to_vec(&manifest).expect("builder manifest serializes"),
2134 include_bytes!("../tests/golden/module_manifest_builder_full.json"),
2135 "the builder must preserve the prior fully populated literal wire bytes"
2136 );
2137 }
2138
2139 #[test]
2140 fn old_manifest_with_unread_fields_decodes_and_round_trips_verbatim() {
2141 let raw = include_bytes!("../tests/golden/module_manifest_builder_full.json");
2142 let decoded: ModuleManifest =
2143 serde_json::from_slice(raw).expect("old manifest with all unread fields decodes");
2144
2145 assert_eq!(decoded.trust_tier, Some(TrustTier::Reviewed));
2146 assert!(!decoded.consumes.is_empty());
2147 assert!(decoded.bindings.is_some());
2148
2149 let reencoded = serde_json::to_vec(&decoded).expect("re-encode succeeds");
2150 assert_eq!(
2151 reencoded, raw,
2152 "old manifest relay stays byte-for-byte verbatim"
2153 );
2154 }
2155
2156 #[test]
2157 fn new_manifest_omits_unread_fields_on_wire_and_decodes_cleanly() {
2158 let raw = include_bytes!("../tests/golden/module_manifest_diet.json");
2159 let decoded: ModuleManifest =
2160 serde_json::from_slice(raw).expect("new manifest omitting unread fields decodes");
2161
2162 assert_eq!(decoded.trust_tier, None);
2163 assert!(decoded.consumes.is_empty());
2164 assert_eq!(decoded.bindings, None);
2165
2166 let pretty = format!("{}\n", serde_json::to_string_pretty(&decoded).unwrap());
2167 assert_eq!(
2168 pretty.as_bytes(),
2169 raw,
2170 "new manifest matches golden byte-for-byte without unread keys"
2171 );
2172
2173 let as_val: serde_json::Value = serde_json::to_value(&decoded).unwrap();
2174 assert!(
2175 as_val.get("trust_tier").is_none(),
2176 "no trust_tier on wire for new manifest"
2177 );
2178 assert!(
2179 as_val.get("consumes").is_none(),
2180 "no consumes on wire for empty consumes"
2181 );
2182 assert!(
2183 as_val.get("bindings").is_none(),
2184 "no bindings on wire for new manifest"
2185 );
2186 }
2187
2188 #[test]
2189 fn aft_manifest_fixture_matches_v1_contract() {
2190 let manifest = aft_manifest_fixture();
2191
2192 assert_eq!(manifest.module_id, "aft");
2193 let ProviderRole::ToolProvider {
2194 tools,
2195 identity_scope,
2196 concurrency,
2197 emits_push,
2198 sub_supervises,
2199 } = &manifest.provides[0]
2200 else {
2201 panic!("AFT fixture must expose one tool_provider role");
2202 };
2203
2204 assert_eq!(*concurrency, Concurrency::ModuleManaged);
2205 assert!(*emits_push);
2206 assert!(*sub_supervises);
2207 assert_eq!(
2208 identity_scope,
2209 &vec![IdentityScope::Session, IdentityScope::Project]
2210 );
2211 assert_eq!(
2212 tools
2213 .iter()
2214 .map(|tool| (tool.name.as_str(), tool.execution_mode))
2215 .collect::<Vec<_>>(),
2216 vec![
2217 ("read", ExecutionMode::Pure),
2218 ("grep", ExecutionMode::Pure),
2219 ("outline", ExecutionMode::Pure),
2220 ("semantic_search", ExecutionMode::Pure),
2221 ("edit", ExecutionMode::Mutating),
2222 ("write", ExecutionMode::Mutating),
2223 ("bash", ExecutionMode::Unfenceable),
2224 ]
2225 );
2226 }
2227
2228 #[test]
2229 fn tool_provider_role_tag_serializes_as_snake_case() {
2230 let manifest = aft_manifest_fixture();
2231 let value = serde_json::to_value(&manifest).unwrap();
2232
2233 assert_eq!(value["provides"][0]["role"], "tool_provider");
2234 }
2235
2236 #[test]
2237 fn manifest_without_capabilities_preserves_the_existing_wire_shape() {
2238 let manifest = aft_manifest_fixture();
2239 let encoded = serde_json::to_value(&manifest).expect("manifest serializes");
2240 assert!(encoded.get("capabilities").is_none());
2241
2242 let decoded: ModuleManifest =
2243 serde_json::from_value(encoded).expect("legacy manifest parses");
2244 assert_eq!(decoded.capabilities, None);
2245 }
2246
2247 #[test]
2248 fn capability_identifier_lexical_grammar_accepts_only_pinned_forms() {
2249 for identifier in [
2250 "a/v1",
2251 "credentials-provider/v1",
2252 "a1-b2/v4294967295",
2253 "a123456789012345678901234567890123456789012345678901234567890123/v1",
2254 ] {
2255 assert!(
2256 is_valid_capability_identifier(identifier),
2257 "identifier must be accepted: {identifier}"
2258 );
2259 }
2260
2261 for identifier in [
2262 "credentials-Provider/v1",
2263 "credentials-provider/v01",
2264 "credentials-provider-/v1",
2265 "credentials--provider/v1",
2266 "Credentials-provider/v1",
2267 "credentials-provider/1",
2268 "credentials provider/v1",
2269 "credentials-provider/v0",
2270 "credentials-provider/v4294967296",
2271 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/v1",
2272 ] {
2273 assert!(
2274 !is_valid_capability_identifier(identifier),
2275 "identifier must be rejected: {identifier}"
2276 );
2277 }
2278 }
2279
2280 #[test]
2281 fn capability_grammar_errors_redact_secret_shaped_values() {
2282 let error = validate_manifest_capability_grammar(&json!({
2283 "capabilities": { "provides": ["sk-secret-value/v0"] }
2284 }))
2285 .expect_err("secret-shaped capability identifier is malformed");
2286 assert_eq!(error.field(), "capabilities.provides[0]");
2287 assert_eq!(error.value(), "<redacted>");
2288 assert!(!error.to_string().contains("sk-secret-value"));
2289 }
2290
2291 #[test]
2298 fn provenance_builder_sentinels_become_field_omission() {
2299 for sentinel in [
2300 "unknown",
2301 "UNKNOWN",
2302 "Unknown",
2303 "unavailable",
2304 "none",
2305 "None",
2306 " unknown ",
2307 "",
2308 ] {
2309 let p = build_provenance_from_source(
2310 BuildGitShaSource::Git {
2311 revision: sentinel,
2312 tree_state: GitTreeState::Clean,
2313 },
2314 Some(sentinel),
2315 Some(sentinel),
2316 )
2317 .expect("sentinels are omitted before form validation");
2318 assert_eq!(
2319 (
2320 p.build_git_sha,
2321 p.build_git_sha_absence_reason,
2322 p.build_lock_digest,
2323 p.store_schema_version,
2324 ),
2325 (
2326 None,
2327 Some(BuildGitShaAbsenceReason::NeverDerived),
2328 None,
2329 None,
2330 ),
2331 "sentinel {sentinel:?} must be omitted, not published"
2332 );
2333 }
2334 let real = build_provenance_from_source(
2335 BuildGitShaSource::Git {
2336 revision: "0123456789abcdef0123456789abcdef01234567",
2337 tree_state: GitTreeState::Clean,
2338 },
2339 None,
2340 Some("9"),
2341 )
2342 .expect("canonical build revision is accepted");
2343 assert_eq!(
2344 real.build_git_sha.as_deref(),
2345 Some("0123456789abcdef0123456789abcdef01234567")
2346 );
2347 assert_eq!(real.store_schema_version.as_deref(), Some("9"));
2348 assert_eq!(
2352 real.wire_crate_version.as_deref(),
2353 Some(crate::SUBC_PROTOCOL_CRATE_VERSION)
2354 );
2355 }
2356
2357 #[test]
2358 fn build_provenance_accepts_canonical_sha_and_lock_digest() {
2359 let provenance = build_provenance_from_source(
2360 BuildGitShaSource::Git {
2361 revision: " 0123456789abcdef0123456789abcdef01234567 ",
2362 tree_state: GitTreeState::Clean,
2363 },
2364 Some(" abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 "),
2365 Some(" schema-v3 "),
2366 )
2367 .expect("canonical build facts are accepted");
2368
2369 assert_eq!(
2370 provenance,
2371 ManifestProvenance {
2372 build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
2373 build_git_sha_absence_reason: None,
2374 build_lock_digest: Some(
2375 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
2376 ),
2377 wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
2378 store_schema_version: Some("schema-v3".to_string()),
2379 launch_nonce_source: None,
2380 }
2381 );
2382 }
2383
2384 #[test]
2385 fn build_provenance_refuses_an_abbreviated_git_sha() {
2386 let error = build_provenance_from_source(
2387 BuildGitShaSource::Git {
2388 revision: "0123456789ab",
2389 tree_state: GitTreeState::Clean,
2390 },
2391 None,
2392 None,
2393 )
2394 .expect_err("a 12-character abbreviation is not canonical");
2395
2396 assert_eq!(error.field(), "build_git_sha");
2397 assert_eq!(error.length(), 12);
2398 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2399 assert_eq!(
2400 error.to_string(),
2401 "invalid manifest provenance form: field build_git_sha has length 12; canonical form is exactly 40 lowercase hexadecimal characters"
2402 );
2403 }
2404
2405 #[test]
2406 fn build_provenance_refuses_an_abbreviated_lock_digest() {
2407 let error = build_provenance_from_source(
2408 BuildGitShaSource::NeverDerived,
2409 Some("0123456789abcdef"),
2410 None,
2411 )
2412 .expect_err("a 16-character digest is not canonical");
2413
2414 assert_eq!(error.field(), "build_lock_digest");
2415 assert_eq!(error.length(), 16);
2416 assert_eq!(error.canonical_form(), BUILD_LOCK_DIGEST_CANONICAL_FORM);
2417 }
2418
2419 #[test]
2420 fn build_provenance_refuses_uppercase_hex() {
2421 let uppercase_sha = "A".repeat(40);
2422 let error = build_provenance_from_source(
2423 BuildGitShaSource::Git {
2424 revision: &uppercase_sha,
2425 tree_state: GitTreeState::Clean,
2426 },
2427 None,
2428 None,
2429 )
2430 .expect_err("uppercase hexadecimal is not canonical");
2431
2432 assert_eq!(error.field(), "build_git_sha");
2433 assert_eq!(error.length(), 40);
2434 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2435 }
2436
2437 #[test]
2438 fn build_provenance_refuses_dirty_revision_stamp_claimed_clean() {
2439 let error = build_provenance_from_source(
2440 BuildGitShaSource::Git {
2441 revision: "0123456789abcdef0123456789abcdef01234567-dirty",
2442 tree_state: GitTreeState::Clean,
2443 },
2444 None,
2445 None,
2446 )
2447 .expect_err("a dirty stamp is not a canonical build revision");
2448
2449 assert_eq!(error.field(), "build_git_sha");
2450 assert_eq!(error.length(), 46);
2451 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2452 }
2453
2454 #[test]
2455 fn build_provenance_keeps_a_lock_digest_when_identity_is_unavailable() {
2456 let provenance = build_provenance_from_source(
2457 BuildGitShaSource::Git {
2458 revision: "unavailable",
2459 tree_state: GitTreeState::Clean,
2460 },
2461 Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"),
2462 None,
2463 )
2464 .expect("sentinel SHA is omitted before the valid lock digest is checked");
2465
2466 assert_eq!(provenance.build_git_sha, None);
2467 assert_eq!(
2468 provenance.build_lock_digest,
2469 Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string())
2470 );
2471 assert_eq!(
2472 provenance.wire_crate_version,
2473 Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
2474 );
2475 }
2476
2477 #[test]
2478 fn build_provenance_omits_fully_unavailable_inputs() {
2479 let provenance = build_provenance_from_source(
2480 BuildGitShaSource::NeverDerived,
2481 Some(" unavailable "),
2482 Some(" "),
2483 )
2484 .expect("omitted and sentinel inputs are not form errors");
2485
2486 assert_eq!(provenance.build_git_sha, None);
2487 assert_eq!(provenance.build_lock_digest, None);
2488 assert_eq!(provenance.store_schema_version, None);
2489 assert_eq!(
2490 provenance.wire_crate_version,
2491 Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
2492 );
2493 }
2494
2495 #[test]
2496 fn legacy_build_provenance_keeps_master_wire_bytes_without_an_absence_reason() {
2497 let revision = "0123456789abcdef0123456789abcdef01234567";
2498 for (input, expected) in [
2499 (
2500 Some(revision),
2501 format!(
2502 r#"{{"build_git_sha":"{revision}","wire_crate_version":"{}"}}"#,
2503 crate::SUBC_PROTOCOL_CRATE_VERSION
2504 ),
2505 ),
2506 (
2507 None,
2508 format!(
2509 r#"{{"wire_crate_version":"{}"}}"#,
2510 crate::SUBC_PROTOCOL_CRATE_VERSION
2511 ),
2512 ),
2513 (
2514 Some("unknown"),
2515 format!(
2516 r#"{{"wire_crate_version":"{}"}}"#,
2517 crate::SUBC_PROTOCOL_CRATE_VERSION
2518 ),
2519 ),
2520 ] {
2521 let provenance = build_provenance(input, None, None)
2522 .expect("the legacy build facts remain constructible");
2523 assert_eq!(provenance.build_git_sha_absence_reason, None);
2524 assert_eq!(
2525 serde_json::to_string(&provenance).expect("legacy provenance serializes"),
2526 expected
2527 );
2528 }
2529 }
2530
2531 #[test]
2532 fn build_provenance_derives_git_sha_absence_from_the_stamping_inputs() {
2533 let revision = "0123456789abcdef0123456789abcdef01234567";
2534 let cases = [
2535 (
2536 BuildGitShaSource::Git {
2537 revision,
2538 tree_state: GitTreeState::Clean,
2539 },
2540 Some(revision),
2541 None,
2542 ),
2543 (
2544 BuildGitShaSource::Git {
2545 revision,
2546 tree_state: GitTreeState::Dirty,
2547 },
2548 None,
2549 Some(BuildGitShaAbsenceReason::DeclinedDirty),
2550 ),
2551 (
2552 BuildGitShaSource::NeverDerived,
2553 None,
2554 Some(BuildGitShaAbsenceReason::NeverDerived),
2555 ),
2556 (
2557 BuildGitShaSource::NoGitDir,
2558 None,
2559 Some(BuildGitShaAbsenceReason::NoGitDir),
2560 ),
2561 ];
2562
2563 for (source, expected_sha, expected_reason) in cases {
2564 let provenance = build_provenance_from_source(source, None, None)
2565 .expect("every stamping state constructs honest provenance");
2566 assert_eq!(provenance.build_git_sha.as_deref(), expected_sha);
2567 assert_eq!(provenance.build_git_sha_absence_reason, expected_reason);
2568 }
2569 }
2570
2571 #[test]
2572 fn unknown_git_sha_absence_reason_round_trips_byte_faithfully() {
2573 let wire = format!(
2574 r#"{{"build_git_sha_absence_reason":"future_stamper_state","wire_crate_version":"{}"}}"#,
2575 crate::SUBC_PROTOCOL_CRATE_VERSION
2576 );
2577 let provenance: ManifestProvenance =
2578 serde_json::from_str(&wire).expect("future absence reasons remain readable");
2579
2580 assert_eq!(
2581 provenance.build_git_sha_absence_reason,
2582 Some(BuildGitShaAbsenceReason::ForwardCompatibleUnknown(
2583 "future_stamper_state".to_string()
2584 ))
2585 );
2586 assert_eq!(
2587 serde_json::to_string(&provenance).expect("future absence reason reserializes"),
2588 wire
2589 );
2590 }
2591
2592 #[test]
2593 fn provenance_rejects_an_absence_reason_beside_a_declared_commit() {
2594 let error = serde_json::from_value::<ManifestProvenance>(json!({
2595 "build_git_sha": "0123456789abcdef0123456789abcdef01234567",
2596 "build_git_sha_absence_reason": "declined_dirty"
2597 }))
2598 .expect_err("a declared commit cannot also claim an absence reason");
2599
2600 assert!(error.to_string().contains(
2601 "build_git_sha_absence_reason has must be omitted when build_git_sha is present"
2602 ));
2603 }
2604}