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")]
832 pub launch_nonce_source: Option<LaunchNonceSource>,
833}
834
835#[derive(Debug, Clone, PartialEq, Eq)]
842#[non_exhaustive]
843pub enum LaunchNonceSource {
844 Fd,
846 Pipe,
848 Env,
850 ForwardCompatibleUnknown(String),
851}
852
853impl LaunchNonceSource {
854 pub fn wire_name(&self) -> &str {
856 match self {
857 Self::Fd => "fd",
858 Self::Pipe => "pipe",
859 Self::Env => "env",
860 Self::ForwardCompatibleUnknown(value) => value,
861 }
862 }
863
864 pub fn from_wire_name(value: &str) -> Self {
866 match value {
867 "fd" => Self::Fd,
868 "pipe" => Self::Pipe,
869 "env" => Self::Env,
870 _ => Self::ForwardCompatibleUnknown(value.to_string()),
871 }
872 }
873}
874
875impl Serialize for LaunchNonceSource {
876 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
877 where
878 S: serde::Serializer,
879 {
880 serializer.serialize_str(self.wire_name())
881 }
882}
883
884impl<'de> Deserialize<'de> for LaunchNonceSource {
885 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
886 where
887 D: serde::Deserializer<'de>,
888 {
889 let value = String::deserialize(deserializer)?;
890 Ok(Self::from_wire_name(&value))
891 }
892}
893
894#[derive(Debug, Clone, PartialEq, Eq)]
899pub enum BuildGitShaAbsenceReason {
900 DeclinedDirty,
901 NeverDerived,
902 NoGitDir,
903 ForwardCompatibleUnknown(String),
904}
905
906impl BuildGitShaAbsenceReason {
907 fn wire_name(&self) -> &str {
908 match self {
909 Self::DeclinedDirty => "declined_dirty",
910 Self::NeverDerived => "never_derived",
911 Self::NoGitDir => "no_git_dir",
912 Self::ForwardCompatibleUnknown(value) => value,
913 }
914 }
915}
916
917impl Serialize for BuildGitShaAbsenceReason {
918 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
919 where
920 S: serde::Serializer,
921 {
922 serializer.serialize_str(self.wire_name())
923 }
924}
925
926impl<'de> Deserialize<'de> for BuildGitShaAbsenceReason {
927 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
928 where
929 D: serde::Deserializer<'de>,
930 {
931 let value = String::deserialize(deserializer)?;
932 Ok(match value.as_str() {
933 "declined_dirty" => Self::DeclinedDirty,
934 "never_derived" => Self::NeverDerived,
935 "no_git_dir" => Self::NoGitDir,
936 _ => Self::ForwardCompatibleUnknown(value),
937 })
938 }
939}
940
941#[derive(Debug, Clone, Copy, PartialEq, Eq)]
943pub enum GitTreeState {
944 Clean,
945 Dirty,
946}
947
948#[derive(Debug, Clone, Copy, PartialEq, Eq)]
953pub enum BuildGitShaSource<'a> {
954 Git {
955 revision: &'a str,
956 tree_state: GitTreeState,
957 },
958 NeverDerived,
959 NoGitDir,
960}
961
962pub fn attestable_commit(revision: &str, tree_state: GitTreeState) -> Option<&str> {
966 match tree_state {
967 GitTreeState::Clean => Some(revision),
968 GitTreeState::Dirty => None,
969 }
970}
971
972const MAX_PROVENANCE_VALUE_BYTES: usize = 128;
973const BUILD_GIT_SHA_CANONICAL_FORM: &str = "exactly 40 lowercase hexadecimal characters";
974const BUILD_LOCK_DIGEST_CANONICAL_FORM: &str = "exactly 64 lowercase hexadecimal characters";
975
976#[derive(Deserialize)]
977struct ManifestProvenanceWire {
978 #[serde(default)]
979 build_git_sha: Option<String>,
980 #[serde(default)]
981 build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
982 #[serde(default)]
983 build_lock_digest: Option<String>,
984 #[serde(default)]
985 wire_crate_version: Option<String>,
986 #[serde(default)]
987 store_schema_version: Option<String>,
988 #[serde(default)]
989 launch_nonce_source: Option<LaunchNonceSource>,
990}
991
992impl<'de> Deserialize<'de> for ManifestProvenance {
993 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
994 where
995 D: Deserializer<'de>,
996 {
997 let wire = ManifestProvenanceWire::deserialize(deserializer)?;
998 let provenance = Self {
999 build_git_sha: wire.build_git_sha,
1000 build_git_sha_absence_reason: wire.build_git_sha_absence_reason,
1001 build_lock_digest: wire.build_lock_digest,
1002 wire_crate_version: wire.wire_crate_version,
1003 store_schema_version: wire.store_schema_version,
1004 launch_nonce_source: wire.launch_nonce_source,
1005 };
1006 provenance.validate().map_err(D::Error::custom)?;
1007 Ok(provenance)
1008 }
1009}
1010
1011#[derive(Debug, Clone, PartialEq, Eq)]
1013pub struct ProvenanceFormError {
1014 field: &'static str,
1015 length: usize,
1016 canonical_form: &'static str,
1017}
1018
1019impl ProvenanceFormError {
1020 fn new(field: &'static str, length: usize, canonical_form: &'static str) -> Self {
1021 Self {
1022 field,
1023 length,
1024 canonical_form,
1025 }
1026 }
1027
1028 pub fn field(&self) -> &str {
1030 self.field
1031 }
1032
1033 pub fn length(&self) -> usize {
1035 self.length
1036 }
1037
1038 pub fn canonical_form(&self) -> &str {
1040 self.canonical_form
1041 }
1042}
1043
1044impl fmt::Display for ProvenanceFormError {
1045 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1046 write!(
1047 f,
1048 "invalid manifest provenance form: field {} has length {}; canonical form is {}",
1049 self.field, self.length, self.canonical_form
1050 )
1051 }
1052}
1053
1054impl std::error::Error for ProvenanceFormError {}
1055
1056#[derive(Debug, Clone, PartialEq, Eq)]
1057pub struct ManifestProvenanceError {
1058 field: String,
1059 value: String,
1060 reason: &'static str,
1061}
1062
1063impl ManifestProvenanceError {
1064 fn new(field: &str, value: &str, reason: &'static str) -> Self {
1065 Self {
1066 field: field.to_string(),
1067 value: safe_error_value(value),
1068 reason,
1069 }
1070 }
1071
1072 pub fn field(&self) -> &str {
1073 &self.field
1074 }
1075}
1076
1077impl fmt::Display for ManifestProvenanceError {
1078 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1079 write!(
1080 f,
1081 "invalid manifest provenance: field {} has {} (value {:?})",
1082 self.field, self.reason, self.value
1083 )
1084 }
1085}
1086
1087impl std::error::Error for ManifestProvenanceError {}
1088
1089impl ManifestProvenance {
1090 pub fn new() -> Self {
1092 Self::default()
1093 }
1094
1095 pub fn with_build_git_sha(mut self, value: Option<String>) -> Self {
1096 self.build_git_sha = value;
1097 self
1098 }
1099
1100 pub fn with_build_git_sha_absence_reason(
1101 mut self,
1102 value: Option<BuildGitShaAbsenceReason>,
1103 ) -> Self {
1104 self.build_git_sha_absence_reason = value;
1105 self
1106 }
1107
1108 pub fn with_build_lock_digest(mut self, value: Option<String>) -> Self {
1109 self.build_lock_digest = value;
1110 self
1111 }
1112
1113 pub fn with_wire_crate_version(mut self, value: Option<String>) -> Self {
1114 self.wire_crate_version = value;
1115 self
1116 }
1117
1118 pub fn with_store_schema_version(mut self, value: Option<String>) -> Self {
1119 self.store_schema_version = value;
1120 self
1121 }
1122
1123 pub fn with_launch_nonce_source(mut self, value: Option<LaunchNonceSource>) -> Self {
1128 self.launch_nonce_source = value;
1129 self
1130 }
1131
1132 pub fn validate(&self) -> Result<(), ManifestProvenanceError> {
1133 if let (Some(_), Some(reason)) = (
1134 self.build_git_sha.as_ref(),
1135 self.build_git_sha_absence_reason.as_ref(),
1136 ) {
1137 return Err(ManifestProvenanceError::new(
1138 "build_git_sha_absence_reason",
1139 reason.wire_name(),
1140 "must be omitted when build_git_sha is present",
1141 ));
1142 }
1143 for (field, value) in [
1144 ("build_git_sha", self.build_git_sha.as_deref()),
1145 (
1146 "build_git_sha_absence_reason",
1147 self.build_git_sha_absence_reason
1148 .as_ref()
1149 .map(|reason| reason.wire_name()),
1150 ),
1151 ("build_lock_digest", self.build_lock_digest.as_deref()),
1152 ("wire_crate_version", self.wire_crate_version.as_deref()),
1153 ("store_schema_version", self.store_schema_version.as_deref()),
1154 (
1155 "launch_nonce_source",
1156 self.launch_nonce_source
1157 .as_ref()
1158 .map(|source| source.wire_name()),
1159 ),
1160 ] {
1161 let Some(value) = value else { continue };
1162 if value.is_empty() {
1163 return Err(ManifestProvenanceError::new(
1164 field,
1165 value,
1166 "must not be empty",
1167 ));
1168 }
1169 if value.len() > MAX_PROVENANCE_VALUE_BYTES {
1174 return Err(ManifestProvenanceError::new(
1175 field,
1176 value,
1177 "exceeds the 128-byte maximum",
1178 ));
1179 }
1180 if value.bytes().any(|byte| !(0x20..=0x7e).contains(&byte)) {
1181 return Err(ManifestProvenanceError::new(
1182 field,
1183 value,
1184 "contains non-printable ASCII",
1185 ));
1186 }
1187 }
1188 Ok(())
1189 }
1190}
1191
1192pub fn build_provenance(
1210 build_git_sha: Option<&str>,
1211 build_lock_digest: Option<&str>,
1212 store_schema_version: Option<&str>,
1213) -> Result<ManifestProvenance, ProvenanceFormError> {
1214 let build_git_sha = normalize_and_validate_build_git_sha(build_git_sha)?;
1215 build_provenance_with_build_git_sha(
1216 build_git_sha,
1217 None,
1218 build_lock_digest,
1219 store_schema_version,
1220 )
1221}
1222
1223pub fn build_provenance_from_source(
1239 build_git_sha_source: BuildGitShaSource<'_>,
1240 build_lock_digest: Option<&str>,
1241 store_schema_version: Option<&str>,
1242) -> Result<ManifestProvenance, ProvenanceFormError> {
1243 let (raw_build_git_sha, mut build_git_sha_absence_reason) = match build_git_sha_source {
1244 BuildGitShaSource::Git {
1245 revision,
1246 tree_state,
1247 } => match attestable_commit(revision, tree_state) {
1248 Some(revision) => (Some(revision), None),
1249 None => (None, Some(BuildGitShaAbsenceReason::DeclinedDirty)),
1250 },
1251 BuildGitShaSource::NeverDerived => (None, Some(BuildGitShaAbsenceReason::NeverDerived)),
1252 BuildGitShaSource::NoGitDir => (None, Some(BuildGitShaAbsenceReason::NoGitDir)),
1253 };
1254 let build_git_sha = normalize_and_validate_build_git_sha(raw_build_git_sha)?;
1255 if build_git_sha.is_none() {
1256 build_git_sha_absence_reason.get_or_insert(BuildGitShaAbsenceReason::NeverDerived);
1257 }
1258 build_provenance_with_build_git_sha(
1259 build_git_sha,
1260 build_git_sha_absence_reason,
1261 build_lock_digest,
1262 store_schema_version,
1263 )
1264}
1265
1266fn normalize_and_validate_build_git_sha(
1267 build_git_sha: Option<&str>,
1268) -> Result<Option<String>, ProvenanceFormError> {
1269 let build_git_sha = normalize_provenance_fact(build_git_sha);
1270 validate_provenance_form(
1271 "build_git_sha",
1272 build_git_sha.as_deref(),
1273 BUILD_GIT_SHA_CANONICAL_FORM,
1274 40,
1275 )?;
1276 Ok(build_git_sha)
1277}
1278
1279fn build_provenance_with_build_git_sha(
1280 build_git_sha: Option<String>,
1281 build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
1282 build_lock_digest: Option<&str>,
1283 store_schema_version: Option<&str>,
1284) -> Result<ManifestProvenance, ProvenanceFormError> {
1285 let build_lock_digest = normalize_provenance_fact(build_lock_digest);
1286 validate_provenance_form(
1287 "build_lock_digest",
1288 build_lock_digest.as_deref(),
1289 BUILD_LOCK_DIGEST_CANONICAL_FORM,
1290 64,
1291 )?;
1292
1293 Ok(ManifestProvenance {
1294 build_git_sha,
1295 build_git_sha_absence_reason,
1296 build_lock_digest,
1297 wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
1298 store_schema_version: normalize_provenance_fact(store_schema_version),
1299 launch_nonce_source: None,
1300 })
1301}
1302
1303fn validate_provenance_form(
1304 field: &'static str,
1305 value: Option<&str>,
1306 canonical_form: &'static str,
1307 expected_length: usize,
1308) -> Result<(), ProvenanceFormError> {
1309 let Some(value) = value else { return Ok(()) };
1310 if value.len() != expected_length
1311 || !value
1312 .bytes()
1313 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
1314 {
1315 return Err(ProvenanceFormError::new(field, value.len(), canonical_form));
1316 }
1317 Ok(())
1318}
1319
1320pub const PROVENANCE_SENTINELS: [&str; 3] = ["unknown", "unavailable", "none"];
1328
1329fn normalize_provenance_fact(value: Option<&str>) -> Option<String> {
1330 let value = value?.trim();
1331 if value.is_empty() {
1332 return None;
1333 }
1334 let lowered = value.to_ascii_lowercase();
1335 if PROVENANCE_SENTINELS.contains(&lowered.as_str()) {
1336 return None;
1337 }
1338 Some(value.to_string())
1339}
1340
1341#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1343#[serde(deny_unknown_fields)]
1344pub struct CapabilityRequirement {
1345 pub capability: String,
1346 pub need: CapabilityNeed,
1347}
1348
1349#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
1351#[serde(rename_all = "snake_case")]
1352pub enum CapabilityNeed {
1353 Required,
1354 Optional,
1355}
1356
1357#[derive(Debug, Clone, PartialEq, Eq)]
1359pub struct CapabilityGrammarError {
1360 field: String,
1361 value: String,
1362}
1363
1364impl CapabilityGrammarError {
1365 fn new(field: impl Into<String>, value: impl AsRef<str>) -> Self {
1366 Self {
1367 field: field.into(),
1368 value: safe_error_value(value.as_ref()),
1369 }
1370 }
1371
1372 pub fn field(&self) -> &str {
1374 &self.field
1375 }
1376
1377 pub fn value(&self) -> &str {
1379 &self.value
1380 }
1381}
1382
1383impl fmt::Display for CapabilityGrammarError {
1384 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1385 write!(
1386 f,
1387 "invalid capability grammar: field {} has offending value {:?}",
1388 self.field, self.value
1389 )
1390 }
1391}
1392
1393impl std::error::Error for CapabilityGrammarError {}
1394
1395impl ModuleManifest {
1396 pub fn validate_capability_grammar(&self) -> Result<(), CapabilityGrammarError> {
1398 let Some(capabilities) = &self.capabilities else {
1399 return Ok(());
1400 };
1401
1402 validate_capability_list("capabilities.provides", &capabilities.provides)?;
1403 validate_requires(&capabilities.requires)?;
1404 validate_capability_list(
1405 "capabilities.must_never_reach",
1406 &capabilities.must_never_reach,
1407 )
1408 }
1409}
1410
1411pub fn validate_manifest_capability_grammar(
1416 manifest: &Value,
1417) -> Result<(), CapabilityGrammarError> {
1418 let Some(object) = manifest.as_object() else {
1419 return Ok(());
1420 };
1421
1422 validate_capabilities_value(object.get("capabilities"))?;
1423 validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
1424}
1425
1426pub fn validate_hello_capability_grammar(hello: &Value) -> Result<(), CapabilityGrammarError> {
1432 let Some(object) = hello.as_object() else {
1433 return Ok(());
1434 };
1435 if let Some(manifest) = object.get("manifest") {
1436 validate_manifest_capability_grammar(manifest)?;
1437 }
1438 validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
1439}
1440
1441pub fn is_valid_capability_identifier(identifier: &str) -> bool {
1443 if identifier.chars().any(char::is_whitespace) {
1444 return false;
1445 }
1446 let Some((name, version)) = identifier.split_once("/v") else {
1447 return false;
1448 };
1449 if name.is_empty() || name.len() > 64 || version.is_empty() {
1450 return false;
1451 }
1452
1453 let name_bytes = name.as_bytes();
1454 if !name_bytes[0].is_ascii_lowercase()
1455 || (name.len() > 1
1456 && !name_bytes[name.len() - 1].is_ascii_lowercase()
1457 && !name_bytes[name.len() - 1].is_ascii_digit())
1458 || name_bytes.windows(2).any(|pair| pair == b"--")
1459 {
1460 return false;
1461 }
1462 if !name_bytes
1463 .iter()
1464 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
1465 {
1466 return false;
1467 }
1468
1469 if version.len() > 1 && version.starts_with('0')
1470 || !version.bytes().all(|byte| byte.is_ascii_digit())
1471 {
1472 return false;
1473 }
1474 matches!(
1475 version.parse::<u64>(),
1476 Ok(value) if (1..=u64::from(u32::MAX)).contains(&value)
1477 )
1478}
1479
1480fn validate_capabilities_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
1481 let Some(value) = value else {
1482 return Ok(());
1483 };
1484 let Some(object) = value.as_object() else {
1485 return Err(CapabilityGrammarError::new(
1486 "capabilities",
1487 value_description(value),
1488 ));
1489 };
1490
1491 for (key, value) in object {
1492 if !matches!(key.as_str(), "provides" | "requires" | "must_never_reach") {
1493 return Err(CapabilityGrammarError::new(
1494 field_child("capabilities", key),
1495 value_description(value),
1496 ));
1497 }
1498 }
1499
1500 validate_capability_list_value("capabilities.provides", object.get("provides"))?;
1501 validate_requires_value(object.get("requires"))?;
1502 validate_capability_list_value(
1503 "capabilities.must_never_reach",
1504 object.get("must_never_reach"),
1505 )
1506}
1507
1508fn validate_capability_list_value(
1509 field: &str,
1510 value: Option<&Value>,
1511) -> Result<(), CapabilityGrammarError> {
1512 let Some(value) = value else {
1513 return Ok(());
1514 };
1515 let Some(values) = value.as_array() else {
1516 return Err(CapabilityGrammarError::new(field, value_description(value)));
1517 };
1518
1519 let mut seen = HashSet::new();
1520 for (index, value) in values.iter().enumerate() {
1521 let field = format!("{field}[{index}]");
1522 let Some(identifier) = value.as_str() else {
1523 return Err(CapabilityGrammarError::new(field, value_description(value)));
1524 };
1525 validate_capability_identifier(&field, identifier)?;
1526 if !seen.insert(identifier) {
1527 return Err(CapabilityGrammarError::new(field, identifier));
1528 }
1529 }
1530 Ok(())
1531}
1532
1533fn validate_requires_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
1534 let Some(value) = value else {
1535 return Ok(());
1536 };
1537 let Some(values) = value.as_array() else {
1538 return Err(CapabilityGrammarError::new(
1539 "capabilities.requires",
1540 value_description(value),
1541 ));
1542 };
1543
1544 let mut seen = HashSet::new();
1545 for (index, value) in values.iter().enumerate() {
1546 let entry_field = format!("capabilities.requires[{index}]");
1547 let Some(object) = value.as_object() else {
1548 return Err(CapabilityGrammarError::new(
1549 entry_field,
1550 value_description(value),
1551 ));
1552 };
1553 for (key, value) in object {
1554 if !matches!(key.as_str(), "capability" | "need") {
1555 return Err(CapabilityGrammarError::new(
1556 field_child(&entry_field, key),
1557 value_description(value),
1558 ));
1559 }
1560 }
1561 let capability_field = format!("{entry_field}.capability");
1562 let Some(capability) = object.get("capability").and_then(Value::as_str) else {
1563 return Err(CapabilityGrammarError::new(
1564 capability_field,
1565 object
1566 .get("capability")
1567 .map_or("<missing>".to_string(), value_description),
1568 ));
1569 };
1570 validate_capability_identifier(&capability_field, capability)?;
1571
1572 let need_field = format!("{entry_field}.need");
1573 let Some(need) = object.get("need").and_then(Value::as_str) else {
1574 return Err(CapabilityGrammarError::new(
1575 need_field,
1576 object
1577 .get("need")
1578 .map_or("<missing>".to_string(), value_description),
1579 ));
1580 };
1581 if !matches!(need, "required" | "optional") {
1582 return Err(CapabilityGrammarError::new(need_field, need));
1583 }
1584 if !seen.insert(capability) {
1585 return Err(CapabilityGrammarError::new(entry_field, capability));
1586 }
1587 }
1588 Ok(())
1589}
1590
1591fn validate_capability_list(field: &str, values: &[String]) -> Result<(), CapabilityGrammarError> {
1592 let mut seen = HashSet::new();
1593 for (index, identifier) in values.iter().enumerate() {
1594 let field = format!("{field}[{index}]");
1595 validate_capability_identifier(&field, identifier)?;
1596 if !seen.insert(identifier) {
1597 return Err(CapabilityGrammarError::new(field, identifier));
1598 }
1599 }
1600 Ok(())
1601}
1602
1603fn validate_requires(values: &[CapabilityRequirement]) -> Result<(), CapabilityGrammarError> {
1604 let mut seen = HashSet::new();
1605 for (index, requirement) in values.iter().enumerate() {
1606 let field = format!("capabilities.requires[{index}].capability");
1607 validate_capability_identifier(&field, &requirement.capability)?;
1608 if !seen.insert(&requirement.capability) {
1609 return Err(CapabilityGrammarError::new(
1610 format!("capabilities.requires[{index}]"),
1611 &requirement.capability,
1612 ));
1613 }
1614 }
1615 Ok(())
1616}
1617
1618fn validate_capability_identifier(
1619 field: &str,
1620 identifier: &str,
1621) -> Result<(), CapabilityGrammarError> {
1622 if is_valid_capability_identifier(identifier) {
1623 Ok(())
1624 } else {
1625 Err(CapabilityGrammarError::new(field, identifier))
1626 }
1627}
1628
1629fn validate_runtime_computed(
1630 value: Option<&Value>,
1631 field: &str,
1632) -> Result<(), CapabilityGrammarError> {
1633 let Some(value) = value else {
1634 return Ok(());
1635 };
1636 let Some(pointers) = value.as_array() else {
1637 return Err(CapabilityGrammarError::new(field, value_description(value)));
1638 };
1639
1640 for (index, pointer) in pointers.iter().enumerate() {
1641 let field = format!("{field}[{index}]");
1642 let Some(pointer) = pointer.as_str() else {
1643 return Err(CapabilityGrammarError::new(
1644 field,
1645 value_description(pointer),
1646 ));
1647 };
1648 let Some(tokens) = parse_json_pointer(pointer) else {
1649 return Err(CapabilityGrammarError::new(field, pointer));
1650 };
1651 if tokens.first().is_some_and(|token| token == "capabilities") {
1652 return Err(CapabilityGrammarError::new(field, pointer));
1653 }
1654 }
1655 Ok(())
1656}
1657
1658fn parse_json_pointer(pointer: &str) -> Option<Vec<String>> {
1659 if pointer.is_empty() {
1660 return Some(Vec::new());
1661 }
1662 let raw_tokens = pointer.strip_prefix('/')?;
1663 raw_tokens
1664 .split('/')
1665 .map(unescape_json_pointer_token)
1666 .collect()
1667}
1668
1669fn unescape_json_pointer_token(token: &str) -> Option<String> {
1670 let mut output = String::with_capacity(token.len());
1671 let mut characters = token.chars();
1672 while let Some(character) = characters.next() {
1673 if character != '~' {
1674 output.push(character);
1675 continue;
1676 }
1677 match characters.next()? {
1678 '0' => output.push('~'),
1679 '1' => output.push('/'),
1680 _ => return None,
1681 }
1682 }
1683 Some(output)
1684}
1685
1686fn field_child(parent: &str, child: &str) -> String {
1687 let child = safe_error_value(child);
1688 format!("{parent}.{child}")
1689}
1690
1691fn value_description(value: &Value) -> String {
1692 match value {
1693 Value::String(value) => safe_error_value(value),
1694 Value::Null => "null".to_string(),
1695 Value::Bool(value) => value.to_string(),
1696 Value::Number(value) => value.to_string(),
1697 Value::Array(_) => "<array>".to_string(),
1698 Value::Object(_) => "<object>".to_string(),
1699 }
1700}
1701
1702fn safe_error_value(value: &str) -> String {
1703 let lower = value.to_ascii_lowercase();
1704 if ["secret", "password", "api_key"]
1705 .iter()
1706 .any(|marker| lower.contains(marker))
1707 || lower.starts_with("sk-")
1708 || lower.starts_with("akia")
1709 || lower.starts_with("bearer ")
1710 || lower.starts_with("token=")
1711 || lower.starts_with("credential=")
1712 {
1713 "<redacted>".to_string()
1714 } else {
1715 value.to_string()
1716 }
1717}
1718
1719#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1725#[serde(rename_all = "snake_case")]
1726pub enum TrustTier {
1727 FirstParty,
1728 Reviewed,
1729 Untrusted,
1730}
1731
1732#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1736#[serde(tag = "role", rename_all = "snake_case")]
1737pub enum ProviderRole {
1738 ToolProvider {
1739 tools: Vec<Tool>,
1740 identity_scope: Vec<IdentityScope>,
1748 concurrency: Concurrency,
1749 emits_push: bool,
1750 sub_supervises: bool,
1751 },
1752 PipelineStage {
1753 stage: PipelineStageKind,
1754 applies_to: PipelineAppliesTo,
1755 interface: String,
1756 declares_frozen_floor: bool,
1757 needs_signals: Vec<String>,
1758 conformance_class: String,
1759 },
1760 ManagementSurface {
1761 operations: Vec<ManagementOperation>,
1762 config_schema: Value,
1763 observability: Vec<ObservabilitySurface>,
1764 identity_scope: Vec<IdentityScope>,
1768 #[serde(default)]
1769 concurrency: Concurrency,
1770 },
1771 InternalService {
1772 service_id: String,
1773 transport: InternalTransport,
1774 agent_facing: bool,
1775 operations: Vec<String>,
1776 },
1777}
1778
1779#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
1792#[serde(rename_all = "snake_case")]
1793pub enum ExecutionMode {
1794 Pure,
1795 Mutating,
1796 Unfenceable,
1797}
1798
1799#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1801pub struct Tool {
1802 pub name: String,
1803 #[serde(default, skip_serializing_if = "Option::is_none")]
1804 pub description: Option<String>,
1805 pub execution_mode: ExecutionMode,
1810 pub schema: Value,
1811}
1812
1813#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1819#[serde(rename_all = "snake_case")]
1820pub enum Concurrency {
1821 Serial,
1823 ModuleManaged,
1826 StatelessParallel,
1829}
1830
1831#[allow(clippy::derivable_impls)]
1832impl Default for Concurrency {
1843 fn default() -> Self {
1844 Self::ModuleManaged
1845 }
1846}
1847
1848#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1855#[serde(rename_all = "snake_case")]
1856pub enum IdentityScope {
1857 Session,
1858 Project,
1859}
1860
1861#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1863#[serde(rename_all = "snake_case")]
1864pub enum PipelineStageKind {
1865 Transform,
1866 Codec,
1867 Auth,
1868}
1869
1870#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1872pub struct PipelineAppliesTo {
1873 pub provider: String,
1874 pub model: String,
1875}
1876
1877#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1879pub struct ManagementOperation {
1880 pub name: String,
1881 pub kind: ManagementOperationKind,
1882 #[serde(default, skip_serializing_if = "Option::is_none")]
1883 pub description: Option<String>,
1884}
1885
1886#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1887#[serde(rename_all = "snake_case")]
1888pub enum ManagementOperationKind {
1889 Query,
1890 Mutate,
1891}
1892
1893#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1895pub struct ObservabilitySurface {
1896 pub name: String,
1897 pub kind: ObservabilityKind,
1898}
1899
1900#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1901#[serde(rename_all = "snake_case")]
1902pub enum ObservabilityKind {
1903 Snapshot,
1904 Stream,
1905}
1906
1907#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1908#[serde(rename_all = "snake_case")]
1909pub enum InternalTransport {
1910 Bulk,
1911}
1912
1913#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1915#[serde(tag = "role", rename_all = "snake_case")]
1916pub enum ConsumerRole {
1917 ToolClient { of: Vec<String> },
1918 LlmClient { via: String, auth: String },
1919 ServiceClient { of: Vec<String> },
1920}
1921
1922#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1924pub struct Bindings {
1925 pub storage: StorageBinding,
1926 pub vault_grants: Vec<VaultGrant>,
1927 pub identity: IdentityBinding,
1928}
1929
1930#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1932pub struct StorageBinding {
1933 pub kind: StorageKind,
1934 pub scope: StorageScope,
1935 pub owns_schema: bool,
1936}
1937
1938#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1939#[serde(rename_all = "snake_case")]
1940pub enum StorageKind {
1941 Sqlite,
1942}
1943
1944#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1945#[serde(rename_all = "snake_case")]
1946pub enum StorageScope {
1947 Project,
1948}
1949
1950#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1951pub struct VaultGrant {
1952 pub secret: String,
1953 pub reason: String,
1954}
1955
1956#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1957pub struct IdentityBinding {
1958 pub requires: Vec<IdentityScope>,
1959 pub optional: Vec<IdentityScope>,
1960}
1961
1962#[cfg(test)]
1963mod tests {
1964 use super::*;
1965 use serde_json::json;
1966
1967 fn aft_manifest_fixture() -> ModuleManifest {
1968 ModuleManifest::builder("aft", "0.39.2")
1969 .trust_tier(Some(TrustTier::FirstParty))
1970 .bindings(Some(Bindings {
1971 storage: StorageBinding {
1972 kind: StorageKind::Sqlite,
1973 scope: StorageScope::Project,
1974 owns_schema: true,
1975 },
1976 vault_grants: vec![VaultGrant {
1977 secret: "provider_api_key".to_string(),
1978 reason: "cortexkit_native auth".to_string(),
1979 }],
1980 identity: IdentityBinding {
1981 requires: vec![IdentityScope::Project],
1982 optional: vec![IdentityScope::Session],
1983 },
1984 }))
1985 .protocol_ver(1)
1986 .provides(vec![ProviderRole::ToolProvider {
1987 tools: vec![
1988 Tool {
1989 name: "read".to_string(),
1990 description: None,
1991 execution_mode: ExecutionMode::Pure,
1992 schema: json!({"type": "object"}),
1993 },
1994 Tool {
1995 name: "grep".to_string(),
1996 description: None,
1997 execution_mode: ExecutionMode::Pure,
1998 schema: json!({"type": "object"}),
1999 },
2000 Tool {
2001 name: "outline".to_string(),
2002 description: None,
2003 execution_mode: ExecutionMode::Pure,
2004 schema: json!({"type": "object"}),
2005 },
2006 Tool {
2007 name: "semantic_search".to_string(),
2008 description: None,
2009 execution_mode: ExecutionMode::Pure,
2010 schema: json!({"type": "object"}),
2011 },
2012 Tool {
2013 name: "edit".to_string(),
2014 description: None,
2015 execution_mode: ExecutionMode::Mutating,
2016 schema: json!({"type": "object"}),
2017 },
2018 Tool {
2019 name: "write".to_string(),
2020 description: None,
2021 execution_mode: ExecutionMode::Mutating,
2022 schema: json!({"type": "object"}),
2023 },
2024 Tool {
2025 name: "bash".to_string(),
2026 description: None,
2027 execution_mode: ExecutionMode::Unfenceable,
2028 schema: json!({"type": "object"}),
2029 },
2030 ],
2031 identity_scope: vec![IdentityScope::Session, IdentityScope::Project],
2032 concurrency: Concurrency::ModuleManaged,
2033 emits_push: true,
2034 sub_supervises: true,
2035 }])
2036 .consumes(vec![ConsumerRole::ServiceClient {
2037 of: vec!["embedding.v2".to_string()],
2038 }])
2039 .build()
2040 }
2041
2042 #[test]
2043 fn serde_round_trips_representative_manifest() {
2044 let manifest = aft_manifest_fixture();
2045 let serialized = serde_json::to_string_pretty(&manifest).unwrap();
2046 let decoded: ModuleManifest = serde_json::from_str(&serialized).unwrap();
2047
2048 assert_eq!(manifest, decoded);
2049 }
2050
2051 #[test]
2052 fn builder_defaults_additions_to_honest_absence_and_round_trips() {
2053 let manifest = ModuleManifest::builder("builder-defaults", "2.0.0").build();
2054
2055 assert_eq!(manifest.module_id, "builder-defaults");
2056 assert_eq!(manifest.module_version, "2.0.0");
2057 assert_eq!(manifest.protocol_ver, PROTOCOL_VERSION);
2058 assert_eq!(manifest.trust_tier, None);
2059 assert!(manifest.provides.is_empty());
2060 assert!(manifest.consumes.is_empty());
2061 assert_eq!(manifest.bindings, None);
2062 assert_eq!(manifest.capabilities, None);
2063 assert_eq!(manifest.self_signals, None);
2064 assert_eq!(manifest.provenance, None);
2065
2066 let encoded = serde_json::to_value(&manifest).expect("builder manifest serializes");
2067 for optional in [
2068 "trust_tier",
2069 "consumes",
2070 "bindings",
2071 "capabilities",
2072 "self_signals",
2073 "provenance",
2074 ] {
2075 assert!(
2076 encoded.get(optional).is_none(),
2077 "an absent {optional} declaration must stay absent on the wire"
2078 );
2079 }
2080 let decoded: ModuleManifest =
2081 serde_json::from_value(encoded).expect("builder manifest round-trips");
2082 assert_eq!(decoded, manifest);
2083 }
2084
2085 #[test]
2086 fn fully_populated_builder_manifest_matches_the_literal_wire_golden() {
2087 let manifest = ModuleManifest::builder("full-builder", "2.0.0")
2088 .trust_tier(Some(TrustTier::Reviewed))
2089 .bindings(Some(Bindings {
2090 storage: StorageBinding {
2091 kind: StorageKind::Sqlite,
2092 scope: StorageScope::Project,
2093 owns_schema: false,
2094 },
2095 vault_grants: Vec::new(),
2096 identity: IdentityBinding {
2097 requires: vec![IdentityScope::Project],
2098 optional: Vec::new(),
2099 },
2100 }))
2101 .provides(vec![ProviderRole::ToolProvider {
2102 tools: vec![Tool {
2103 name: "read".to_string(),
2104 description: None,
2105 execution_mode: ExecutionMode::Pure,
2106 schema: json!({"type": "object"}),
2107 }],
2108 identity_scope: vec![IdentityScope::Project],
2109 concurrency: Concurrency::Serial,
2110 emits_push: false,
2111 sub_supervises: false,
2112 }])
2113 .consumes(vec![ConsumerRole::ServiceClient {
2114 of: vec!["embedding.v2".to_string()],
2115 }])
2116 .capabilities(Some(CapabilityDeclarations {
2117 provides: vec!["embedding/v2".to_string()],
2118 requires: Vec::new(),
2119 must_never_reach: Vec::new(),
2120 }))
2121 .self_signals(Some(vec![SelfSignalDeclaration {
2122 name: "usage_poller".to_string(),
2123 kind: SelfSignalKind::Poller,
2124 effect: SelfSignalEffect::Observe,
2125 anchored_to: SignalAnchor::FixedInterval,
2126 cadence: Some(SignalCadence::Literal {
2127 interval_ms: 60_000,
2128 }),
2129 domain: Some("provider-usage".to_string()),
2130 note: None,
2131 }]))
2132 .provenance(Some(ManifestProvenance {
2133 build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
2134 build_git_sha_absence_reason: None,
2135 build_lock_digest: Some(
2136 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
2137 ),
2138 wire_crate_version: Some("0.16.0".to_string()),
2139 store_schema_version: Some("42".to_string()),
2140 launch_nonce_source: None,
2141 }))
2142 .build();
2143
2144 assert_eq!(
2145 serde_json::to_vec(&manifest).expect("builder manifest serializes"),
2146 include_bytes!("../tests/golden/module_manifest_builder_full.json"),
2147 "the builder must preserve the prior fully populated literal wire bytes"
2148 );
2149 }
2150
2151 #[test]
2152 fn old_manifest_with_unread_fields_decodes_and_round_trips_verbatim() {
2153 let raw = include_bytes!("../tests/golden/module_manifest_builder_full.json");
2154 let decoded: ModuleManifest =
2155 serde_json::from_slice(raw).expect("old manifest with all unread fields decodes");
2156
2157 assert_eq!(decoded.trust_tier, Some(TrustTier::Reviewed));
2158 assert!(!decoded.consumes.is_empty());
2159 assert!(decoded.bindings.is_some());
2160
2161 let reencoded = serde_json::to_vec(&decoded).expect("re-encode succeeds");
2162 assert_eq!(
2163 reencoded, raw,
2164 "old manifest relay stays byte-for-byte verbatim"
2165 );
2166 }
2167
2168 #[test]
2169 fn new_manifest_omits_unread_fields_on_wire_and_decodes_cleanly() {
2170 let raw = include_bytes!("../tests/golden/module_manifest_diet.json");
2171 let decoded: ModuleManifest =
2172 serde_json::from_slice(raw).expect("new manifest omitting unread fields decodes");
2173
2174 assert_eq!(decoded.trust_tier, None);
2175 assert!(decoded.consumes.is_empty());
2176 assert_eq!(decoded.bindings, None);
2177
2178 let pretty = format!("{}\n", serde_json::to_string_pretty(&decoded).unwrap());
2179 assert_eq!(
2180 pretty.as_bytes(),
2181 raw,
2182 "new manifest matches golden byte-for-byte without unread keys"
2183 );
2184
2185 let as_val: serde_json::Value = serde_json::to_value(&decoded).unwrap();
2186 assert!(
2187 as_val.get("trust_tier").is_none(),
2188 "no trust_tier on wire for new manifest"
2189 );
2190 assert!(
2191 as_val.get("consumes").is_none(),
2192 "no consumes on wire for empty consumes"
2193 );
2194 assert!(
2195 as_val.get("bindings").is_none(),
2196 "no bindings on wire for new manifest"
2197 );
2198 }
2199
2200 #[test]
2201 fn aft_manifest_fixture_matches_v1_contract() {
2202 let manifest = aft_manifest_fixture();
2203
2204 assert_eq!(manifest.module_id, "aft");
2205 let ProviderRole::ToolProvider {
2206 tools,
2207 identity_scope,
2208 concurrency,
2209 emits_push,
2210 sub_supervises,
2211 } = &manifest.provides[0]
2212 else {
2213 panic!("AFT fixture must expose one tool_provider role");
2214 };
2215
2216 assert_eq!(*concurrency, Concurrency::ModuleManaged);
2217 assert!(*emits_push);
2218 assert!(*sub_supervises);
2219 assert_eq!(
2220 identity_scope,
2221 &vec![IdentityScope::Session, IdentityScope::Project]
2222 );
2223 assert_eq!(
2224 tools
2225 .iter()
2226 .map(|tool| (tool.name.as_str(), tool.execution_mode))
2227 .collect::<Vec<_>>(),
2228 vec![
2229 ("read", ExecutionMode::Pure),
2230 ("grep", ExecutionMode::Pure),
2231 ("outline", ExecutionMode::Pure),
2232 ("semantic_search", ExecutionMode::Pure),
2233 ("edit", ExecutionMode::Mutating),
2234 ("write", ExecutionMode::Mutating),
2235 ("bash", ExecutionMode::Unfenceable),
2236 ]
2237 );
2238 }
2239
2240 #[test]
2241 fn tool_provider_role_tag_serializes_as_snake_case() {
2242 let manifest = aft_manifest_fixture();
2243 let value = serde_json::to_value(&manifest).unwrap();
2244
2245 assert_eq!(value["provides"][0]["role"], "tool_provider");
2246 }
2247
2248 #[test]
2249 fn manifest_without_capabilities_preserves_the_existing_wire_shape() {
2250 let manifest = aft_manifest_fixture();
2251 let encoded = serde_json::to_value(&manifest).expect("manifest serializes");
2252 assert!(encoded.get("capabilities").is_none());
2253
2254 let decoded: ModuleManifest =
2255 serde_json::from_value(encoded).expect("legacy manifest parses");
2256 assert_eq!(decoded.capabilities, None);
2257 }
2258
2259 #[test]
2260 fn capability_identifier_lexical_grammar_accepts_only_pinned_forms() {
2261 for identifier in [
2262 "a/v1",
2263 "credentials-provider/v1",
2264 "a1-b2/v4294967295",
2265 "a123456789012345678901234567890123456789012345678901234567890123/v1",
2266 ] {
2267 assert!(
2268 is_valid_capability_identifier(identifier),
2269 "identifier must be accepted: {identifier}"
2270 );
2271 }
2272
2273 for identifier in [
2274 "credentials-Provider/v1",
2275 "credentials-provider/v01",
2276 "credentials-provider-/v1",
2277 "credentials--provider/v1",
2278 "Credentials-provider/v1",
2279 "credentials-provider/1",
2280 "credentials provider/v1",
2281 "credentials-provider/v0",
2282 "credentials-provider/v4294967296",
2283 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/v1",
2284 ] {
2285 assert!(
2286 !is_valid_capability_identifier(identifier),
2287 "identifier must be rejected: {identifier}"
2288 );
2289 }
2290 }
2291
2292 #[test]
2293 fn capability_grammar_errors_redact_secret_shaped_values() {
2294 let error = validate_manifest_capability_grammar(&json!({
2295 "capabilities": { "provides": ["sk-secret-value/v0"] }
2296 }))
2297 .expect_err("secret-shaped capability identifier is malformed");
2298 assert_eq!(error.field(), "capabilities.provides[0]");
2299 assert_eq!(error.value(), "<redacted>");
2300 assert!(!error.to_string().contains("sk-secret-value"));
2301 }
2302
2303 #[test]
2310 fn provenance_builder_sentinels_become_field_omission() {
2311 for sentinel in [
2312 "unknown",
2313 "UNKNOWN",
2314 "Unknown",
2315 "unavailable",
2316 "none",
2317 "None",
2318 " unknown ",
2319 "",
2320 ] {
2321 let p = build_provenance_from_source(
2322 BuildGitShaSource::Git {
2323 revision: sentinel,
2324 tree_state: GitTreeState::Clean,
2325 },
2326 Some(sentinel),
2327 Some(sentinel),
2328 )
2329 .expect("sentinels are omitted before form validation");
2330 assert_eq!(
2331 (
2332 p.build_git_sha,
2333 p.build_git_sha_absence_reason,
2334 p.build_lock_digest,
2335 p.store_schema_version,
2336 ),
2337 (
2338 None,
2339 Some(BuildGitShaAbsenceReason::NeverDerived),
2340 None,
2341 None,
2342 ),
2343 "sentinel {sentinel:?} must be omitted, not published"
2344 );
2345 }
2346 let real = build_provenance_from_source(
2347 BuildGitShaSource::Git {
2348 revision: "0123456789abcdef0123456789abcdef01234567",
2349 tree_state: GitTreeState::Clean,
2350 },
2351 None,
2352 Some("9"),
2353 )
2354 .expect("canonical build revision is accepted");
2355 assert_eq!(
2356 real.build_git_sha.as_deref(),
2357 Some("0123456789abcdef0123456789abcdef01234567")
2358 );
2359 assert_eq!(real.store_schema_version.as_deref(), Some("9"));
2360 assert_eq!(
2364 real.wire_crate_version.as_deref(),
2365 Some(crate::SUBC_PROTOCOL_CRATE_VERSION)
2366 );
2367 }
2368
2369 #[test]
2370 fn build_provenance_accepts_canonical_sha_and_lock_digest() {
2371 let provenance = build_provenance_from_source(
2372 BuildGitShaSource::Git {
2373 revision: " 0123456789abcdef0123456789abcdef01234567 ",
2374 tree_state: GitTreeState::Clean,
2375 },
2376 Some(" abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 "),
2377 Some(" schema-v3 "),
2378 )
2379 .expect("canonical build facts are accepted");
2380
2381 assert_eq!(
2382 provenance,
2383 ManifestProvenance {
2384 build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
2385 build_git_sha_absence_reason: None,
2386 build_lock_digest: Some(
2387 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
2388 ),
2389 wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
2390 store_schema_version: Some("schema-v3".to_string()),
2391 launch_nonce_source: None,
2392 }
2393 );
2394 }
2395
2396 #[test]
2397 fn build_provenance_refuses_an_abbreviated_git_sha() {
2398 let error = build_provenance_from_source(
2399 BuildGitShaSource::Git {
2400 revision: "0123456789ab",
2401 tree_state: GitTreeState::Clean,
2402 },
2403 None,
2404 None,
2405 )
2406 .expect_err("a 12-character abbreviation is not canonical");
2407
2408 assert_eq!(error.field(), "build_git_sha");
2409 assert_eq!(error.length(), 12);
2410 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2411 assert_eq!(
2412 error.to_string(),
2413 "invalid manifest provenance form: field build_git_sha has length 12; canonical form is exactly 40 lowercase hexadecimal characters"
2414 );
2415 }
2416
2417 #[test]
2418 fn build_provenance_refuses_an_abbreviated_lock_digest() {
2419 let error = build_provenance_from_source(
2420 BuildGitShaSource::NeverDerived,
2421 Some("0123456789abcdef"),
2422 None,
2423 )
2424 .expect_err("a 16-character digest is not canonical");
2425
2426 assert_eq!(error.field(), "build_lock_digest");
2427 assert_eq!(error.length(), 16);
2428 assert_eq!(error.canonical_form(), BUILD_LOCK_DIGEST_CANONICAL_FORM);
2429 }
2430
2431 #[test]
2432 fn build_provenance_refuses_uppercase_hex() {
2433 let uppercase_sha = "A".repeat(40);
2434 let error = build_provenance_from_source(
2435 BuildGitShaSource::Git {
2436 revision: &uppercase_sha,
2437 tree_state: GitTreeState::Clean,
2438 },
2439 None,
2440 None,
2441 )
2442 .expect_err("uppercase hexadecimal is not canonical");
2443
2444 assert_eq!(error.field(), "build_git_sha");
2445 assert_eq!(error.length(), 40);
2446 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2447 }
2448
2449 #[test]
2450 fn build_provenance_refuses_dirty_revision_stamp_claimed_clean() {
2451 let error = build_provenance_from_source(
2452 BuildGitShaSource::Git {
2453 revision: "0123456789abcdef0123456789abcdef01234567-dirty",
2454 tree_state: GitTreeState::Clean,
2455 },
2456 None,
2457 None,
2458 )
2459 .expect_err("a dirty stamp is not a canonical build revision");
2460
2461 assert_eq!(error.field(), "build_git_sha");
2462 assert_eq!(error.length(), 46);
2463 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2464 }
2465
2466 #[test]
2467 fn build_provenance_keeps_a_lock_digest_when_identity_is_unavailable() {
2468 let provenance = build_provenance_from_source(
2469 BuildGitShaSource::Git {
2470 revision: "unavailable",
2471 tree_state: GitTreeState::Clean,
2472 },
2473 Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"),
2474 None,
2475 )
2476 .expect("sentinel SHA is omitted before the valid lock digest is checked");
2477
2478 assert_eq!(provenance.build_git_sha, None);
2479 assert_eq!(
2480 provenance.build_lock_digest,
2481 Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string())
2482 );
2483 assert_eq!(
2484 provenance.wire_crate_version,
2485 Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
2486 );
2487 }
2488
2489 #[test]
2490 fn build_provenance_omits_fully_unavailable_inputs() {
2491 let provenance = build_provenance_from_source(
2492 BuildGitShaSource::NeverDerived,
2493 Some(" unavailable "),
2494 Some(" "),
2495 )
2496 .expect("omitted and sentinel inputs are not form errors");
2497
2498 assert_eq!(provenance.build_git_sha, None);
2499 assert_eq!(provenance.build_lock_digest, None);
2500 assert_eq!(provenance.store_schema_version, None);
2501 assert_eq!(
2502 provenance.wire_crate_version,
2503 Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
2504 );
2505 }
2506
2507 #[test]
2508 fn legacy_build_provenance_keeps_master_wire_bytes_without_an_absence_reason() {
2509 let revision = "0123456789abcdef0123456789abcdef01234567";
2510 for (input, expected) in [
2511 (
2512 Some(revision),
2513 format!(
2514 r#"{{"build_git_sha":"{revision}","wire_crate_version":"{}"}}"#,
2515 crate::SUBC_PROTOCOL_CRATE_VERSION
2516 ),
2517 ),
2518 (
2519 None,
2520 format!(
2521 r#"{{"wire_crate_version":"{}"}}"#,
2522 crate::SUBC_PROTOCOL_CRATE_VERSION
2523 ),
2524 ),
2525 (
2526 Some("unknown"),
2527 format!(
2528 r#"{{"wire_crate_version":"{}"}}"#,
2529 crate::SUBC_PROTOCOL_CRATE_VERSION
2530 ),
2531 ),
2532 ] {
2533 let provenance = build_provenance(input, None, None)
2534 .expect("the legacy build facts remain constructible");
2535 assert_eq!(provenance.build_git_sha_absence_reason, None);
2536 assert_eq!(
2537 serde_json::to_string(&provenance).expect("legacy provenance serializes"),
2538 expected
2539 );
2540 }
2541 }
2542
2543 #[test]
2544 fn build_provenance_derives_git_sha_absence_from_the_stamping_inputs() {
2545 let revision = "0123456789abcdef0123456789abcdef01234567";
2546 let cases = [
2547 (
2548 BuildGitShaSource::Git {
2549 revision,
2550 tree_state: GitTreeState::Clean,
2551 },
2552 Some(revision),
2553 None,
2554 ),
2555 (
2556 BuildGitShaSource::Git {
2557 revision,
2558 tree_state: GitTreeState::Dirty,
2559 },
2560 None,
2561 Some(BuildGitShaAbsenceReason::DeclinedDirty),
2562 ),
2563 (
2564 BuildGitShaSource::NeverDerived,
2565 None,
2566 Some(BuildGitShaAbsenceReason::NeverDerived),
2567 ),
2568 (
2569 BuildGitShaSource::NoGitDir,
2570 None,
2571 Some(BuildGitShaAbsenceReason::NoGitDir),
2572 ),
2573 ];
2574
2575 for (source, expected_sha, expected_reason) in cases {
2576 let provenance = build_provenance_from_source(source, None, None)
2577 .expect("every stamping state constructs honest provenance");
2578 assert_eq!(provenance.build_git_sha.as_deref(), expected_sha);
2579 assert_eq!(provenance.build_git_sha_absence_reason, expected_reason);
2580 }
2581 }
2582
2583 #[test]
2584 fn unknown_git_sha_absence_reason_round_trips_byte_faithfully() {
2585 let wire = format!(
2586 r#"{{"build_git_sha_absence_reason":"future_stamper_state","wire_crate_version":"{}"}}"#,
2587 crate::SUBC_PROTOCOL_CRATE_VERSION
2588 );
2589 let provenance: ManifestProvenance =
2590 serde_json::from_str(&wire).expect("future absence reasons remain readable");
2591
2592 assert_eq!(
2593 provenance.build_git_sha_absence_reason,
2594 Some(BuildGitShaAbsenceReason::ForwardCompatibleUnknown(
2595 "future_stamper_state".to_string()
2596 ))
2597 );
2598 assert_eq!(
2599 serde_json::to_string(&provenance).expect("future absence reason reserializes"),
2600 wire
2601 );
2602 }
2603
2604 #[test]
2605 fn provenance_rejects_an_absence_reason_beside_a_declared_commit() {
2606 let error = serde_json::from_value::<ManifestProvenance>(json!({
2607 "build_git_sha": "0123456789abcdef0123456789abcdef01234567",
2608 "build_git_sha_absence_reason": "declined_dirty"
2609 }))
2610 .expect_err("a declared commit cannot also claim an absence reason");
2611
2612 assert!(error.to_string().contains(
2613 "build_git_sha_absence_reason has must be omitted when build_git_sha is present"
2614 ));
2615 }
2616}