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