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