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