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