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