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 pub provides: Vec<ProviderRole>,
33 #[serde(default, skip_serializing_if = "Vec::is_empty")]
34 pub consumes: Vec<ConsumerRole>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub bindings: Option<Bindings>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub capabilities: Option<CapabilityDeclarations>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub self_signals: Option<Vec<SelfSignalDeclaration>>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub provenance: Option<ManifestProvenance>,
82}
83
84#[derive(Debug, Clone)]
86pub struct ModuleManifestBuilder {
87 module_id: String,
88 module_version: String,
89 protocol_ver: u8,
90 trust_tier: Option<TrustTier>,
91 provides: Vec<ProviderRole>,
92 consumes: Vec<ConsumerRole>,
93 bindings: Option<Bindings>,
94 capabilities: Option<CapabilityDeclarations>,
95 self_signals: Option<Vec<SelfSignalDeclaration>>,
96 provenance: Option<ManifestProvenance>,
97}
98
99impl ModuleManifest {
100 pub fn builder(
108 module_id: impl Into<String>,
109 module_version: impl Into<String>,
110 ) -> ModuleManifestBuilder {
111 ModuleManifestBuilder {
112 module_id: module_id.into(),
113 module_version: module_version.into(),
114 protocol_ver: PROTOCOL_VERSION,
115 trust_tier: None,
116 provides: Vec::new(),
117 consumes: Vec::new(),
118 bindings: None,
119 capabilities: None,
120 self_signals: None,
121 provenance: None,
122 }
123 }
124}
125
126impl ModuleManifestBuilder {
127 pub fn protocol_ver(mut self, protocol_ver: u8) -> Self {
129 self.protocol_ver = protocol_ver;
130 self
131 }
132
133 pub fn trust_tier(mut self, trust_tier: Option<TrustTier>) -> Self {
137 self.trust_tier = trust_tier;
138 self
139 }
140
141 pub fn provides(mut self, provides: Vec<ProviderRole>) -> Self {
143 self.provides = provides;
144 self
145 }
146
147 pub fn consumes(mut self, consumes: Vec<ConsumerRole>) -> Self {
149 self.consumes = consumes;
150 self
151 }
152
153 pub fn bindings(mut self, bindings: Option<Bindings>) -> Self {
157 self.bindings = bindings;
158 self
159 }
160
161 pub fn capabilities(mut self, capabilities: Option<CapabilityDeclarations>) -> Self {
163 self.capabilities = capabilities;
164 self
165 }
166
167 pub fn self_signals(mut self, self_signals: Option<Vec<SelfSignalDeclaration>>) -> Self {
169 self.self_signals = self_signals;
170 self
171 }
172
173 pub fn provenance(mut self, provenance: Option<ManifestProvenance>) -> Self {
175 self.provenance = provenance;
176 self
177 }
178
179 pub fn build(self) -> ModuleManifest {
181 ModuleManifest {
182 module_id: self.module_id,
183 module_version: self.module_version,
184 protocol_ver: self.protocol_ver,
185 trust_tier: self.trust_tier,
186 provides: self.provides,
187 consumes: self.consumes,
188 bindings: self.bindings,
189 capabilities: self.capabilities,
190 self_signals: self.self_signals,
191 provenance: self.provenance,
192 }
193 }
194}
195
196#[derive(Deserialize)]
214struct ModuleManifestWire {
215 module_id: String,
216 module_version: String,
217 protocol_ver: u8,
218 #[serde(default)]
219 trust_tier: Option<TrustTier>,
220 provides: Vec<ProviderRole>,
221 #[serde(default)]
222 consumes: Vec<ConsumerRole>,
223 #[serde(default)]
224 bindings: Option<Bindings>,
225 #[serde(default)]
226 capabilities: Option<CapabilityDeclarations>,
227 #[serde(default)]
228 self_signals: Option<Vec<SelfSignalDeclaration>>,
229 #[serde(default)]
230 provenance: Option<ManifestProvenance>,
231 #[serde(default)]
235 runtime_computed: Option<Value>,
236}
237
238impl<'de> Deserialize<'de> for ModuleManifest {
239 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
240 where
241 D: Deserializer<'de>,
242 {
243 let wire = ModuleManifestWire::deserialize(deserializer)?;
244 validate_runtime_computed(wire.runtime_computed.as_ref(), "runtime_computed")
245 .map_err(D::Error::custom)?;
246 let manifest = Self::builder(wire.module_id, wire.module_version)
247 .protocol_ver(wire.protocol_ver)
248 .trust_tier(wire.trust_tier)
249 .provides(wire.provides)
250 .consumes(wire.consumes)
251 .bindings(wire.bindings)
252 .capabilities(wire.capabilities)
253 .self_signals(wire.self_signals)
254 .provenance(wire.provenance)
255 .build();
256 manifest
257 .validate_capability_grammar()
258 .map_err(D::Error::custom)?;
259 Ok(manifest)
260 }
261}
262
263#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct SelfSignalDeclarationError {
266 module_id: String,
267 entry_index: usize,
268 field: &'static str,
269}
270
271impl fmt::Display for SelfSignalDeclarationError {
272 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273 write!(
274 f,
275 "module_id '{}' self_signals[{}] is missing required field '{}'",
276 self.module_id.escape_debug(),
277 self.entry_index,
278 self.field
279 )
280 }
281}
282
283pub fn validate_hello_self_signal_declarations(
290 hello: &Value,
291) -> Result<(), SelfSignalDeclarationError> {
292 let Some(manifest) = hello.get("manifest").and_then(Value::as_object) else {
293 return Ok(());
294 };
295 let module_id = manifest
296 .get("module_id")
297 .and_then(Value::as_str)
298 .unwrap_or("<unknown>");
299 let Some(entries) = manifest.get("self_signals").and_then(Value::as_array) else {
300 return Ok(());
301 };
302
303 for (entry_index, entry) in entries.iter().enumerate() {
304 let Some(entry) = entry.as_object() else {
305 continue;
306 };
307 for field in ["effect", "anchored_to"] {
308 if !entry.contains_key(field) {
309 return Err(SelfSignalDeclarationError {
310 module_id: module_id.to_string(),
311 entry_index,
312 field,
313 });
314 }
315 }
316 }
317 Ok(())
318}
319
320#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
322#[serde(deny_unknown_fields)]
323pub struct CapabilityDeclarations {
324 #[serde(default)]
325 pub provides: Vec<String>,
326 #[serde(default)]
327 pub requires: Vec<CapabilityRequirement>,
328 #[serde(default)]
329 pub must_never_reach: Vec<String>,
330}
331
332#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
334pub struct SelfSignalDeclaration {
335 pub name: String,
337 pub kind: SelfSignalKind,
340 pub effect: SelfSignalEffect,
342 pub anchored_to: SignalAnchor,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub cadence: Option<SignalCadence>,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub domain: Option<String>,
355 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub note: Option<String>,
357}
358
359#[derive(Debug, Clone, PartialEq, Eq)]
361pub enum SelfSignalKind {
362 Keepalive,
363 Poller,
364 Cron,
365 Sweep,
366 Watchdog,
367 Heartbeat,
368 Other(String),
369}
370
371impl SelfSignalKind {
372 fn wire_name(&self) -> &str {
373 match self {
374 Self::Keepalive => "keepalive",
375 Self::Poller => "poller",
376 Self::Cron => "cron",
377 Self::Sweep => "sweep",
378 Self::Watchdog => "watchdog",
379 Self::Heartbeat => "heartbeat",
380 Self::Other(value) => value,
381 }
382 }
383}
384
385impl Serialize for SelfSignalKind {
386 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
387 where
388 S: serde::Serializer,
389 {
390 serializer.serialize_str(self.wire_name())
391 }
392}
393
394impl<'de> Deserialize<'de> for SelfSignalKind {
395 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
396 where
397 D: Deserializer<'de>,
398 {
399 let value = String::deserialize(deserializer)?;
400 Ok(match value.as_str() {
401 "keepalive" => Self::Keepalive,
402 "poller" => Self::Poller,
403 "cron" => Self::Cron,
404 "sweep" => Self::Sweep,
405 "watchdog" => Self::Watchdog,
406 "heartbeat" => Self::Heartbeat,
407 _ => Self::Other(value),
408 })
409 }
410}
411
412#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
414#[serde(rename_all = "lowercase")]
415pub enum SelfSignalEffect {
416 Observe,
417 Mutate,
418}
419
420#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
422#[serde(rename_all = "snake_case")]
423pub enum SignalAnchor {
424 FixedInterval,
427 Event { event: String },
430}
431
432#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
434#[serde(rename_all = "snake_case")]
435pub enum SignalCadence {
436 Literal { interval_ms: u64 },
437 Derived { source: String },
438}
439
440#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
512pub struct ManifestProvenance {
513 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub build_git_sha: Option<String>,
515 #[serde(default, skip_serializing_if = "Option::is_none")]
516 pub build_lock_digest: Option<String>,
517 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub wire_crate_version: Option<String>,
527 #[serde(default, skip_serializing_if = "Option::is_none")]
528 pub store_schema_version: Option<String>,
529}
530
531const MAX_PROVENANCE_VALUE_BYTES: usize = 128;
532const BUILD_GIT_SHA_CANONICAL_FORM: &str = "exactly 40 lowercase hexadecimal characters";
533const BUILD_LOCK_DIGEST_CANONICAL_FORM: &str = "exactly 64 lowercase hexadecimal characters";
534
535#[derive(Deserialize)]
536struct ManifestProvenanceWire {
537 #[serde(default)]
538 build_git_sha: Option<String>,
539 #[serde(default)]
540 build_lock_digest: Option<String>,
541 #[serde(default)]
542 wire_crate_version: Option<String>,
543 #[serde(default)]
544 store_schema_version: Option<String>,
545}
546
547impl<'de> Deserialize<'de> for ManifestProvenance {
548 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
549 where
550 D: Deserializer<'de>,
551 {
552 let wire = ManifestProvenanceWire::deserialize(deserializer)?;
553 let provenance = Self {
554 build_git_sha: wire.build_git_sha,
555 build_lock_digest: wire.build_lock_digest,
556 wire_crate_version: wire.wire_crate_version,
557 store_schema_version: wire.store_schema_version,
558 };
559 provenance.validate().map_err(D::Error::custom)?;
560 Ok(provenance)
561 }
562}
563
564#[derive(Debug, Clone, PartialEq, Eq)]
566pub struct ProvenanceFormError {
567 field: &'static str,
568 length: usize,
569 canonical_form: &'static str,
570}
571
572impl ProvenanceFormError {
573 fn new(field: &'static str, length: usize, canonical_form: &'static str) -> Self {
574 Self {
575 field,
576 length,
577 canonical_form,
578 }
579 }
580
581 pub fn field(&self) -> &str {
583 self.field
584 }
585
586 pub fn length(&self) -> usize {
588 self.length
589 }
590
591 pub fn canonical_form(&self) -> &str {
593 self.canonical_form
594 }
595}
596
597impl fmt::Display for ProvenanceFormError {
598 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
599 write!(
600 f,
601 "invalid manifest provenance form: field {} has length {}; canonical form is {}",
602 self.field, self.length, self.canonical_form
603 )
604 }
605}
606
607impl std::error::Error for ProvenanceFormError {}
608
609#[derive(Debug, Clone, PartialEq, Eq)]
610pub struct ManifestProvenanceError {
611 field: String,
612 value: String,
613 reason: &'static str,
614}
615
616impl ManifestProvenanceError {
617 fn new(field: &str, value: &str, reason: &'static str) -> Self {
618 Self {
619 field: field.to_string(),
620 value: safe_error_value(value),
621 reason,
622 }
623 }
624
625 pub fn field(&self) -> &str {
626 &self.field
627 }
628}
629
630impl fmt::Display for ManifestProvenanceError {
631 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
632 write!(
633 f,
634 "invalid manifest provenance: field {} has {} (value {:?})",
635 self.field, self.reason, self.value
636 )
637 }
638}
639
640impl std::error::Error for ManifestProvenanceError {}
641
642impl ManifestProvenance {
643 pub fn validate(&self) -> Result<(), ManifestProvenanceError> {
644 for (field, value) in [
645 ("build_git_sha", self.build_git_sha.as_deref()),
646 ("build_lock_digest", self.build_lock_digest.as_deref()),
647 ("wire_crate_version", self.wire_crate_version.as_deref()),
648 ("store_schema_version", self.store_schema_version.as_deref()),
649 ] {
650 let Some(value) = value else { continue };
651 if value.is_empty() {
652 return Err(ManifestProvenanceError::new(
653 field,
654 value,
655 "must not be empty",
656 ));
657 }
658 if value.len() > MAX_PROVENANCE_VALUE_BYTES {
663 return Err(ManifestProvenanceError::new(
664 field,
665 value,
666 "exceeds the 128-byte maximum",
667 ));
668 }
669 if value.bytes().any(|byte| !(0x20..=0x7e).contains(&byte)) {
670 return Err(ManifestProvenanceError::new(
671 field,
672 value,
673 "contains non-printable ASCII",
674 ));
675 }
676 }
677 Ok(())
678 }
679}
680
681pub fn build_provenance(
695 build_git_sha: Option<&str>,
696 build_lock_digest: Option<&str>,
697 store_schema_version: Option<&str>,
698) -> Result<ManifestProvenance, ProvenanceFormError> {
699 let build_git_sha = normalize_provenance_fact(build_git_sha);
700 validate_provenance_form(
701 "build_git_sha",
702 build_git_sha.as_deref(),
703 BUILD_GIT_SHA_CANONICAL_FORM,
704 40,
705 )?;
706
707 let build_lock_digest = normalize_provenance_fact(build_lock_digest);
708 validate_provenance_form(
709 "build_lock_digest",
710 build_lock_digest.as_deref(),
711 BUILD_LOCK_DIGEST_CANONICAL_FORM,
712 64,
713 )?;
714
715 Ok(ManifestProvenance {
716 build_git_sha,
717 build_lock_digest,
718 wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
719 store_schema_version: normalize_provenance_fact(store_schema_version),
720 })
721}
722
723fn validate_provenance_form(
724 field: &'static str,
725 value: Option<&str>,
726 canonical_form: &'static str,
727 expected_length: usize,
728) -> Result<(), ProvenanceFormError> {
729 let Some(value) = value else { return Ok(()) };
730 if value.len() != expected_length
731 || !value
732 .bytes()
733 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
734 {
735 return Err(ProvenanceFormError::new(field, value.len(), canonical_form));
736 }
737 Ok(())
738}
739
740pub const PROVENANCE_SENTINELS: [&str; 3] = ["unknown", "unavailable", "none"];
748
749fn normalize_provenance_fact(value: Option<&str>) -> Option<String> {
750 let value = value?.trim();
751 if value.is_empty() {
752 return None;
753 }
754 let lowered = value.to_ascii_lowercase();
755 if PROVENANCE_SENTINELS.contains(&lowered.as_str()) {
756 return None;
757 }
758 Some(value.to_string())
759}
760
761#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
763#[serde(deny_unknown_fields)]
764pub struct CapabilityRequirement {
765 pub capability: String,
766 pub need: CapabilityNeed,
767}
768
769#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
771#[serde(rename_all = "snake_case")]
772pub enum CapabilityNeed {
773 Required,
774 Optional,
775}
776
777#[derive(Debug, Clone, PartialEq, Eq)]
779pub struct CapabilityGrammarError {
780 field: String,
781 value: String,
782}
783
784impl CapabilityGrammarError {
785 fn new(field: impl Into<String>, value: impl AsRef<str>) -> Self {
786 Self {
787 field: field.into(),
788 value: safe_error_value(value.as_ref()),
789 }
790 }
791
792 pub fn field(&self) -> &str {
794 &self.field
795 }
796
797 pub fn value(&self) -> &str {
799 &self.value
800 }
801}
802
803impl fmt::Display for CapabilityGrammarError {
804 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
805 write!(
806 f,
807 "invalid capability grammar: field {} has offending value {:?}",
808 self.field, self.value
809 )
810 }
811}
812
813impl std::error::Error for CapabilityGrammarError {}
814
815impl ModuleManifest {
816 pub fn validate_capability_grammar(&self) -> Result<(), CapabilityGrammarError> {
818 let Some(capabilities) = &self.capabilities else {
819 return Ok(());
820 };
821
822 validate_capability_list("capabilities.provides", &capabilities.provides)?;
823 validate_requires(&capabilities.requires)?;
824 validate_capability_list(
825 "capabilities.must_never_reach",
826 &capabilities.must_never_reach,
827 )
828 }
829}
830
831pub fn validate_manifest_capability_grammar(
836 manifest: &Value,
837) -> Result<(), CapabilityGrammarError> {
838 let Some(object) = manifest.as_object() else {
839 return Ok(());
840 };
841
842 validate_capabilities_value(object.get("capabilities"))?;
843 validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
844}
845
846pub fn validate_hello_capability_grammar(hello: &Value) -> Result<(), CapabilityGrammarError> {
852 let Some(object) = hello.as_object() else {
853 return Ok(());
854 };
855 if let Some(manifest) = object.get("manifest") {
856 validate_manifest_capability_grammar(manifest)?;
857 }
858 validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
859}
860
861pub fn is_valid_capability_identifier(identifier: &str) -> bool {
863 if identifier.chars().any(char::is_whitespace) {
864 return false;
865 }
866 let Some((name, version)) = identifier.split_once("/v") else {
867 return false;
868 };
869 if name.is_empty() || name.len() > 64 || version.is_empty() {
870 return false;
871 }
872
873 let name_bytes = name.as_bytes();
874 if !name_bytes[0].is_ascii_lowercase()
875 || (name.len() > 1
876 && !name_bytes[name.len() - 1].is_ascii_lowercase()
877 && !name_bytes[name.len() - 1].is_ascii_digit())
878 || name_bytes.windows(2).any(|pair| pair == b"--")
879 {
880 return false;
881 }
882 if !name_bytes
883 .iter()
884 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
885 {
886 return false;
887 }
888
889 if version.len() > 1 && version.starts_with('0')
890 || !version.bytes().all(|byte| byte.is_ascii_digit())
891 {
892 return false;
893 }
894 matches!(
895 version.parse::<u64>(),
896 Ok(value) if (1..=u64::from(u32::MAX)).contains(&value)
897 )
898}
899
900fn validate_capabilities_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
901 let Some(value) = value else {
902 return Ok(());
903 };
904 let Some(object) = value.as_object() else {
905 return Err(CapabilityGrammarError::new(
906 "capabilities",
907 value_description(value),
908 ));
909 };
910
911 for (key, value) in object {
912 if !matches!(key.as_str(), "provides" | "requires" | "must_never_reach") {
913 return Err(CapabilityGrammarError::new(
914 field_child("capabilities", key),
915 value_description(value),
916 ));
917 }
918 }
919
920 validate_capability_list_value("capabilities.provides", object.get("provides"))?;
921 validate_requires_value(object.get("requires"))?;
922 validate_capability_list_value(
923 "capabilities.must_never_reach",
924 object.get("must_never_reach"),
925 )
926}
927
928fn validate_capability_list_value(
929 field: &str,
930 value: Option<&Value>,
931) -> Result<(), CapabilityGrammarError> {
932 let Some(value) = value else {
933 return Ok(());
934 };
935 let Some(values) = value.as_array() else {
936 return Err(CapabilityGrammarError::new(field, value_description(value)));
937 };
938
939 let mut seen = HashSet::new();
940 for (index, value) in values.iter().enumerate() {
941 let field = format!("{field}[{index}]");
942 let Some(identifier) = value.as_str() else {
943 return Err(CapabilityGrammarError::new(field, value_description(value)));
944 };
945 validate_capability_identifier(&field, identifier)?;
946 if !seen.insert(identifier) {
947 return Err(CapabilityGrammarError::new(field, identifier));
948 }
949 }
950 Ok(())
951}
952
953fn validate_requires_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
954 let Some(value) = value else {
955 return Ok(());
956 };
957 let Some(values) = value.as_array() else {
958 return Err(CapabilityGrammarError::new(
959 "capabilities.requires",
960 value_description(value),
961 ));
962 };
963
964 let mut seen = HashSet::new();
965 for (index, value) in values.iter().enumerate() {
966 let entry_field = format!("capabilities.requires[{index}]");
967 let Some(object) = value.as_object() else {
968 return Err(CapabilityGrammarError::new(
969 entry_field,
970 value_description(value),
971 ));
972 };
973 for (key, value) in object {
974 if !matches!(key.as_str(), "capability" | "need") {
975 return Err(CapabilityGrammarError::new(
976 field_child(&entry_field, key),
977 value_description(value),
978 ));
979 }
980 }
981 let capability_field = format!("{entry_field}.capability");
982 let Some(capability) = object.get("capability").and_then(Value::as_str) else {
983 return Err(CapabilityGrammarError::new(
984 capability_field,
985 object
986 .get("capability")
987 .map_or("<missing>".to_string(), value_description),
988 ));
989 };
990 validate_capability_identifier(&capability_field, capability)?;
991
992 let need_field = format!("{entry_field}.need");
993 let Some(need) = object.get("need").and_then(Value::as_str) else {
994 return Err(CapabilityGrammarError::new(
995 need_field,
996 object
997 .get("need")
998 .map_or("<missing>".to_string(), value_description),
999 ));
1000 };
1001 if !matches!(need, "required" | "optional") {
1002 return Err(CapabilityGrammarError::new(need_field, need));
1003 }
1004 if !seen.insert(capability) {
1005 return Err(CapabilityGrammarError::new(entry_field, capability));
1006 }
1007 }
1008 Ok(())
1009}
1010
1011fn validate_capability_list(field: &str, values: &[String]) -> Result<(), CapabilityGrammarError> {
1012 let mut seen = HashSet::new();
1013 for (index, identifier) in values.iter().enumerate() {
1014 let field = format!("{field}[{index}]");
1015 validate_capability_identifier(&field, identifier)?;
1016 if !seen.insert(identifier) {
1017 return Err(CapabilityGrammarError::new(field, identifier));
1018 }
1019 }
1020 Ok(())
1021}
1022
1023fn validate_requires(values: &[CapabilityRequirement]) -> Result<(), CapabilityGrammarError> {
1024 let mut seen = HashSet::new();
1025 for (index, requirement) in values.iter().enumerate() {
1026 let field = format!("capabilities.requires[{index}].capability");
1027 validate_capability_identifier(&field, &requirement.capability)?;
1028 if !seen.insert(&requirement.capability) {
1029 return Err(CapabilityGrammarError::new(
1030 format!("capabilities.requires[{index}]"),
1031 &requirement.capability,
1032 ));
1033 }
1034 }
1035 Ok(())
1036}
1037
1038fn validate_capability_identifier(
1039 field: &str,
1040 identifier: &str,
1041) -> Result<(), CapabilityGrammarError> {
1042 if is_valid_capability_identifier(identifier) {
1043 Ok(())
1044 } else {
1045 Err(CapabilityGrammarError::new(field, identifier))
1046 }
1047}
1048
1049fn validate_runtime_computed(
1050 value: Option<&Value>,
1051 field: &str,
1052) -> Result<(), CapabilityGrammarError> {
1053 let Some(value) = value else {
1054 return Ok(());
1055 };
1056 let Some(pointers) = value.as_array() else {
1057 return Err(CapabilityGrammarError::new(field, value_description(value)));
1058 };
1059
1060 for (index, pointer) in pointers.iter().enumerate() {
1061 let field = format!("{field}[{index}]");
1062 let Some(pointer) = pointer.as_str() else {
1063 return Err(CapabilityGrammarError::new(
1064 field,
1065 value_description(pointer),
1066 ));
1067 };
1068 let Some(tokens) = parse_json_pointer(pointer) else {
1069 return Err(CapabilityGrammarError::new(field, pointer));
1070 };
1071 if tokens.first().is_some_and(|token| token == "capabilities") {
1072 return Err(CapabilityGrammarError::new(field, pointer));
1073 }
1074 }
1075 Ok(())
1076}
1077
1078fn parse_json_pointer(pointer: &str) -> Option<Vec<String>> {
1079 if pointer.is_empty() {
1080 return Some(Vec::new());
1081 }
1082 let raw_tokens = pointer.strip_prefix('/')?;
1083 raw_tokens
1084 .split('/')
1085 .map(unescape_json_pointer_token)
1086 .collect()
1087}
1088
1089fn unescape_json_pointer_token(token: &str) -> Option<String> {
1090 let mut output = String::with_capacity(token.len());
1091 let mut characters = token.chars();
1092 while let Some(character) = characters.next() {
1093 if character != '~' {
1094 output.push(character);
1095 continue;
1096 }
1097 match characters.next()? {
1098 '0' => output.push('~'),
1099 '1' => output.push('/'),
1100 _ => return None,
1101 }
1102 }
1103 Some(output)
1104}
1105
1106fn field_child(parent: &str, child: &str) -> String {
1107 let child = safe_error_value(child);
1108 format!("{parent}.{child}")
1109}
1110
1111fn value_description(value: &Value) -> String {
1112 match value {
1113 Value::String(value) => safe_error_value(value),
1114 Value::Null => "null".to_string(),
1115 Value::Bool(value) => value.to_string(),
1116 Value::Number(value) => value.to_string(),
1117 Value::Array(_) => "<array>".to_string(),
1118 Value::Object(_) => "<object>".to_string(),
1119 }
1120}
1121
1122fn safe_error_value(value: &str) -> String {
1123 let lower = value.to_ascii_lowercase();
1124 if ["secret", "password", "api_key"]
1125 .iter()
1126 .any(|marker| lower.contains(marker))
1127 || lower.starts_with("sk-")
1128 || lower.starts_with("akia")
1129 || lower.starts_with("bearer ")
1130 || lower.starts_with("token=")
1131 || lower.starts_with("credential=")
1132 {
1133 "<redacted>".to_string()
1134 } else {
1135 value.to_string()
1136 }
1137}
1138
1139#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1145#[serde(rename_all = "snake_case")]
1146pub enum TrustTier {
1147 FirstParty,
1148 Reviewed,
1149 Untrusted,
1150}
1151
1152#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1156#[serde(tag = "role", rename_all = "snake_case")]
1157pub enum ProviderRole {
1158 ToolProvider {
1159 tools: Vec<Tool>,
1160 identity_scope: Vec<IdentityScope>,
1168 concurrency: Concurrency,
1169 emits_push: bool,
1170 sub_supervises: bool,
1171 },
1172 PipelineStage {
1173 stage: PipelineStageKind,
1174 applies_to: PipelineAppliesTo,
1175 interface: String,
1176 declares_frozen_floor: bool,
1177 needs_signals: Vec<String>,
1178 conformance_class: String,
1179 },
1180 ManagementSurface {
1181 operations: Vec<ManagementOperation>,
1182 config_schema: Value,
1183 observability: Vec<ObservabilitySurface>,
1184 identity_scope: Vec<IdentityScope>,
1188 #[serde(default)]
1189 concurrency: Concurrency,
1190 },
1191 InternalService {
1192 service_id: String,
1193 transport: InternalTransport,
1194 agent_facing: bool,
1195 operations: Vec<String>,
1196 },
1197}
1198
1199#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
1212#[serde(rename_all = "snake_case")]
1213pub enum ExecutionMode {
1214 Pure,
1215 Mutating,
1216 Unfenceable,
1217}
1218
1219#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1221pub struct Tool {
1222 pub name: String,
1223 #[serde(default, skip_serializing_if = "Option::is_none")]
1224 pub description: Option<String>,
1225 pub execution_mode: ExecutionMode,
1230 pub schema: Value,
1231}
1232
1233#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1239#[serde(rename_all = "snake_case")]
1240pub enum Concurrency {
1241 Serial,
1243 ModuleManaged,
1246 StatelessParallel,
1249}
1250
1251#[allow(clippy::derivable_impls)]
1252impl Default for Concurrency {
1263 fn default() -> Self {
1264 Self::ModuleManaged
1265 }
1266}
1267
1268#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1275#[serde(rename_all = "snake_case")]
1276pub enum IdentityScope {
1277 Session,
1278 Project,
1279}
1280
1281#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1283#[serde(rename_all = "snake_case")]
1284pub enum PipelineStageKind {
1285 Transform,
1286 Codec,
1287 Auth,
1288}
1289
1290#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1292pub struct PipelineAppliesTo {
1293 pub provider: String,
1294 pub model: String,
1295}
1296
1297#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1299pub struct ManagementOperation {
1300 pub name: String,
1301 pub kind: ManagementOperationKind,
1302 #[serde(default, skip_serializing_if = "Option::is_none")]
1303 pub description: Option<String>,
1304}
1305
1306#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1307#[serde(rename_all = "snake_case")]
1308pub enum ManagementOperationKind {
1309 Query,
1310 Mutate,
1311}
1312
1313#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1315pub struct ObservabilitySurface {
1316 pub name: String,
1317 pub kind: ObservabilityKind,
1318}
1319
1320#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1321#[serde(rename_all = "snake_case")]
1322pub enum ObservabilityKind {
1323 Snapshot,
1324 Stream,
1325}
1326
1327#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1328#[serde(rename_all = "snake_case")]
1329pub enum InternalTransport {
1330 Bulk,
1331}
1332
1333#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1335#[serde(tag = "role", rename_all = "snake_case")]
1336pub enum ConsumerRole {
1337 ToolClient { of: Vec<String> },
1338 LlmClient { via: String, auth: String },
1339 ServiceClient { of: Vec<String> },
1340}
1341
1342#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1344pub struct Bindings {
1345 pub storage: StorageBinding,
1346 pub vault_grants: Vec<VaultGrant>,
1347 pub identity: IdentityBinding,
1348}
1349
1350#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1352pub struct StorageBinding {
1353 pub kind: StorageKind,
1354 pub scope: StorageScope,
1355 pub owns_schema: bool,
1356}
1357
1358#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1359#[serde(rename_all = "snake_case")]
1360pub enum StorageKind {
1361 Sqlite,
1362}
1363
1364#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1365#[serde(rename_all = "snake_case")]
1366pub enum StorageScope {
1367 Project,
1368}
1369
1370#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1371pub struct VaultGrant {
1372 pub secret: String,
1373 pub reason: String,
1374}
1375
1376#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1377pub struct IdentityBinding {
1378 pub requires: Vec<IdentityScope>,
1379 pub optional: Vec<IdentityScope>,
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384 use super::*;
1385 use serde_json::json;
1386
1387 fn aft_manifest_fixture() -> ModuleManifest {
1388 ModuleManifest::builder("aft", "0.39.2")
1389 .trust_tier(Some(TrustTier::FirstParty))
1390 .bindings(Some(Bindings {
1391 storage: StorageBinding {
1392 kind: StorageKind::Sqlite,
1393 scope: StorageScope::Project,
1394 owns_schema: true,
1395 },
1396 vault_grants: vec![VaultGrant {
1397 secret: "provider_api_key".to_string(),
1398 reason: "cortexkit_native auth".to_string(),
1399 }],
1400 identity: IdentityBinding {
1401 requires: vec![IdentityScope::Project],
1402 optional: vec![IdentityScope::Session],
1403 },
1404 }))
1405 .protocol_ver(1)
1406 .provides(vec![ProviderRole::ToolProvider {
1407 tools: vec![
1408 Tool {
1409 name: "read".to_string(),
1410 description: None,
1411 execution_mode: ExecutionMode::Pure,
1412 schema: json!({"type": "object"}),
1413 },
1414 Tool {
1415 name: "grep".to_string(),
1416 description: None,
1417 execution_mode: ExecutionMode::Pure,
1418 schema: json!({"type": "object"}),
1419 },
1420 Tool {
1421 name: "outline".to_string(),
1422 description: None,
1423 execution_mode: ExecutionMode::Pure,
1424 schema: json!({"type": "object"}),
1425 },
1426 Tool {
1427 name: "semantic_search".to_string(),
1428 description: None,
1429 execution_mode: ExecutionMode::Pure,
1430 schema: json!({"type": "object"}),
1431 },
1432 Tool {
1433 name: "edit".to_string(),
1434 description: None,
1435 execution_mode: ExecutionMode::Mutating,
1436 schema: json!({"type": "object"}),
1437 },
1438 Tool {
1439 name: "write".to_string(),
1440 description: None,
1441 execution_mode: ExecutionMode::Mutating,
1442 schema: json!({"type": "object"}),
1443 },
1444 Tool {
1445 name: "bash".to_string(),
1446 description: None,
1447 execution_mode: ExecutionMode::Unfenceable,
1448 schema: json!({"type": "object"}),
1449 },
1450 ],
1451 identity_scope: vec![IdentityScope::Session, IdentityScope::Project],
1452 concurrency: Concurrency::ModuleManaged,
1453 emits_push: true,
1454 sub_supervises: true,
1455 }])
1456 .consumes(vec![ConsumerRole::ServiceClient {
1457 of: vec!["embedding.v2".to_string()],
1458 }])
1459 .build()
1460 }
1461
1462 #[test]
1463 fn serde_round_trips_representative_manifest() {
1464 let manifest = aft_manifest_fixture();
1465 let serialized = serde_json::to_string_pretty(&manifest).unwrap();
1466 let decoded: ModuleManifest = serde_json::from_str(&serialized).unwrap();
1467
1468 assert_eq!(manifest, decoded);
1469 }
1470
1471 #[test]
1472 fn builder_defaults_additions_to_honest_absence_and_round_trips() {
1473 let manifest = ModuleManifest::builder("builder-defaults", "2.0.0").build();
1474
1475 assert_eq!(manifest.module_id, "builder-defaults");
1476 assert_eq!(manifest.module_version, "2.0.0");
1477 assert_eq!(manifest.protocol_ver, PROTOCOL_VERSION);
1478 assert_eq!(manifest.trust_tier, None);
1479 assert!(manifest.provides.is_empty());
1480 assert!(manifest.consumes.is_empty());
1481 assert_eq!(manifest.bindings, None);
1482 assert_eq!(manifest.capabilities, None);
1483 assert_eq!(manifest.self_signals, None);
1484 assert_eq!(manifest.provenance, None);
1485
1486 let encoded = serde_json::to_value(&manifest).expect("builder manifest serializes");
1487 for optional in [
1488 "trust_tier",
1489 "consumes",
1490 "bindings",
1491 "capabilities",
1492 "self_signals",
1493 "provenance",
1494 ] {
1495 assert!(
1496 encoded.get(optional).is_none(),
1497 "an absent {optional} declaration must stay absent on the wire"
1498 );
1499 }
1500 let decoded: ModuleManifest =
1501 serde_json::from_value(encoded).expect("builder manifest round-trips");
1502 assert_eq!(decoded, manifest);
1503 }
1504
1505 #[test]
1506 fn fully_populated_builder_manifest_matches_the_literal_wire_golden() {
1507 let manifest = ModuleManifest::builder("full-builder", "2.0.0")
1508 .trust_tier(Some(TrustTier::Reviewed))
1509 .bindings(Some(Bindings {
1510 storage: StorageBinding {
1511 kind: StorageKind::Sqlite,
1512 scope: StorageScope::Project,
1513 owns_schema: false,
1514 },
1515 vault_grants: Vec::new(),
1516 identity: IdentityBinding {
1517 requires: vec![IdentityScope::Project],
1518 optional: Vec::new(),
1519 },
1520 }))
1521 .provides(vec![ProviderRole::ToolProvider {
1522 tools: vec![Tool {
1523 name: "read".to_string(),
1524 description: None,
1525 execution_mode: ExecutionMode::Pure,
1526 schema: json!({"type": "object"}),
1527 }],
1528 identity_scope: vec![IdentityScope::Project],
1529 concurrency: Concurrency::Serial,
1530 emits_push: false,
1531 sub_supervises: false,
1532 }])
1533 .consumes(vec![ConsumerRole::ServiceClient {
1534 of: vec!["embedding.v2".to_string()],
1535 }])
1536 .capabilities(Some(CapabilityDeclarations {
1537 provides: vec!["embedding/v2".to_string()],
1538 requires: Vec::new(),
1539 must_never_reach: Vec::new(),
1540 }))
1541 .self_signals(Some(vec![SelfSignalDeclaration {
1542 name: "usage_poller".to_string(),
1543 kind: SelfSignalKind::Poller,
1544 effect: SelfSignalEffect::Observe,
1545 anchored_to: SignalAnchor::FixedInterval,
1546 cadence: Some(SignalCadence::Literal {
1547 interval_ms: 60_000,
1548 }),
1549 domain: Some("provider-usage".to_string()),
1550 note: None,
1551 }]))
1552 .provenance(Some(ManifestProvenance {
1553 build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
1554 build_lock_digest: Some(
1555 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
1556 ),
1557 wire_crate_version: Some("0.16.0".to_string()),
1558 store_schema_version: Some("42".to_string()),
1559 }))
1560 .build();
1561
1562 assert_eq!(
1563 serde_json::to_vec(&manifest).expect("builder manifest serializes"),
1564 include_bytes!("../tests/golden/module_manifest_builder_full.json"),
1565 "the builder must preserve the prior fully populated literal wire bytes"
1566 );
1567 }
1568
1569 #[test]
1570 fn old_manifest_with_unread_fields_decodes_and_round_trips_verbatim() {
1571 let raw = include_bytes!("../tests/golden/module_manifest_builder_full.json");
1572 let decoded: ModuleManifest =
1573 serde_json::from_slice(raw).expect("old manifest with all unread fields decodes");
1574
1575 assert_eq!(decoded.trust_tier, Some(TrustTier::Reviewed));
1576 assert!(!decoded.consumes.is_empty());
1577 assert!(decoded.bindings.is_some());
1578
1579 let reencoded = serde_json::to_vec(&decoded).expect("re-encode succeeds");
1580 assert_eq!(
1581 reencoded, raw,
1582 "old manifest relay stays byte-for-byte verbatim"
1583 );
1584 }
1585
1586 #[test]
1587 fn new_manifest_omits_unread_fields_on_wire_and_decodes_cleanly() {
1588 let raw = include_bytes!("../tests/golden/module_manifest_diet.json");
1589 let decoded: ModuleManifest =
1590 serde_json::from_slice(raw).expect("new manifest omitting unread fields decodes");
1591
1592 assert_eq!(decoded.trust_tier, None);
1593 assert!(decoded.consumes.is_empty());
1594 assert_eq!(decoded.bindings, None);
1595
1596 let pretty = format!("{}\n", serde_json::to_string_pretty(&decoded).unwrap());
1597 assert_eq!(
1598 pretty.as_bytes(),
1599 raw,
1600 "new manifest matches golden byte-for-byte without unread keys"
1601 );
1602
1603 let as_val: serde_json::Value = serde_json::to_value(&decoded).unwrap();
1604 assert!(
1605 as_val.get("trust_tier").is_none(),
1606 "no trust_tier on wire for new manifest"
1607 );
1608 assert!(
1609 as_val.get("consumes").is_none(),
1610 "no consumes on wire for empty consumes"
1611 );
1612 assert!(
1613 as_val.get("bindings").is_none(),
1614 "no bindings on wire for new manifest"
1615 );
1616 }
1617
1618 #[test]
1619 fn aft_manifest_fixture_matches_v1_contract() {
1620 let manifest = aft_manifest_fixture();
1621
1622 assert_eq!(manifest.module_id, "aft");
1623 let ProviderRole::ToolProvider {
1624 tools,
1625 identity_scope,
1626 concurrency,
1627 emits_push,
1628 sub_supervises,
1629 } = &manifest.provides[0]
1630 else {
1631 panic!("AFT fixture must expose one tool_provider role");
1632 };
1633
1634 assert_eq!(*concurrency, Concurrency::ModuleManaged);
1635 assert!(*emits_push);
1636 assert!(*sub_supervises);
1637 assert_eq!(
1638 identity_scope,
1639 &vec![IdentityScope::Session, IdentityScope::Project]
1640 );
1641 assert_eq!(
1642 tools
1643 .iter()
1644 .map(|tool| (tool.name.as_str(), tool.execution_mode))
1645 .collect::<Vec<_>>(),
1646 vec![
1647 ("read", ExecutionMode::Pure),
1648 ("grep", ExecutionMode::Pure),
1649 ("outline", ExecutionMode::Pure),
1650 ("semantic_search", ExecutionMode::Pure),
1651 ("edit", ExecutionMode::Mutating),
1652 ("write", ExecutionMode::Mutating),
1653 ("bash", ExecutionMode::Unfenceable),
1654 ]
1655 );
1656 }
1657
1658 #[test]
1659 fn tool_provider_role_tag_serializes_as_snake_case() {
1660 let manifest = aft_manifest_fixture();
1661 let value = serde_json::to_value(&manifest).unwrap();
1662
1663 assert_eq!(value["provides"][0]["role"], "tool_provider");
1664 }
1665
1666 #[test]
1667 fn manifest_without_capabilities_preserves_the_existing_wire_shape() {
1668 let manifest = aft_manifest_fixture();
1669 let encoded = serde_json::to_value(&manifest).expect("manifest serializes");
1670 assert!(encoded.get("capabilities").is_none());
1671
1672 let decoded: ModuleManifest =
1673 serde_json::from_value(encoded).expect("legacy manifest parses");
1674 assert_eq!(decoded.capabilities, None);
1675 }
1676
1677 #[test]
1678 fn capability_identifier_lexical_grammar_accepts_only_pinned_forms() {
1679 for identifier in [
1680 "a/v1",
1681 "credentials-provider/v1",
1682 "a1-b2/v4294967295",
1683 "a123456789012345678901234567890123456789012345678901234567890123/v1",
1684 ] {
1685 assert!(
1686 is_valid_capability_identifier(identifier),
1687 "identifier must be accepted: {identifier}"
1688 );
1689 }
1690
1691 for identifier in [
1692 "credentials-Provider/v1",
1693 "credentials-provider/v01",
1694 "credentials-provider-/v1",
1695 "credentials--provider/v1",
1696 "Credentials-provider/v1",
1697 "credentials-provider/1",
1698 "credentials provider/v1",
1699 "credentials-provider/v0",
1700 "credentials-provider/v4294967296",
1701 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/v1",
1702 ] {
1703 assert!(
1704 !is_valid_capability_identifier(identifier),
1705 "identifier must be rejected: {identifier}"
1706 );
1707 }
1708 }
1709
1710 #[test]
1711 fn capability_grammar_errors_redact_secret_shaped_values() {
1712 let error = validate_manifest_capability_grammar(&json!({
1713 "capabilities": { "provides": ["sk-secret-value/v0"] }
1714 }))
1715 .expect_err("secret-shaped capability identifier is malformed");
1716 assert_eq!(error.field(), "capabilities.provides[0]");
1717 assert_eq!(error.value(), "<redacted>");
1718 assert!(!error.to_string().contains("sk-secret-value"));
1719 }
1720
1721 #[test]
1728 fn provenance_builder_sentinels_become_field_omission() {
1729 for sentinel in [
1730 "unknown",
1731 "UNKNOWN",
1732 "Unknown",
1733 "unavailable",
1734 "none",
1735 "None",
1736 " unknown ",
1737 "",
1738 ] {
1739 let p = build_provenance(Some(sentinel), Some(sentinel), Some(sentinel))
1740 .expect("sentinels are omitted before form validation");
1741 assert_eq!(
1742 (p.build_git_sha, p.build_lock_digest, p.store_schema_version),
1743 (None, None, None),
1744 "sentinel {sentinel:?} must be omitted, not published"
1745 );
1746 }
1747 let real = build_provenance(
1748 Some("0123456789abcdef0123456789abcdef01234567"),
1749 None,
1750 Some("9"),
1751 )
1752 .expect("canonical build revision is accepted");
1753 assert_eq!(
1754 real.build_git_sha.as_deref(),
1755 Some("0123456789abcdef0123456789abcdef01234567")
1756 );
1757 assert_eq!(real.store_schema_version.as_deref(), Some("9"));
1758 assert_eq!(
1762 real.wire_crate_version.as_deref(),
1763 Some(crate::SUBC_PROTOCOL_CRATE_VERSION)
1764 );
1765 }
1766
1767 #[test]
1768 fn build_provenance_accepts_canonical_sha_and_lock_digest() {
1769 let provenance = build_provenance(
1770 Some(" 0123456789abcdef0123456789abcdef01234567 "),
1771 Some(" abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 "),
1772 Some(" schema-v3 "),
1773 )
1774 .expect("canonical build facts are accepted");
1775
1776 assert_eq!(
1777 provenance,
1778 ManifestProvenance {
1779 build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
1780 build_lock_digest: Some(
1781 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
1782 ),
1783 wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
1784 store_schema_version: Some("schema-v3".to_string()),
1785 }
1786 );
1787 }
1788
1789 #[test]
1790 fn build_provenance_refuses_an_abbreviated_git_sha() {
1791 let error = build_provenance(Some("0123456789ab"), None, None)
1792 .expect_err("a 12-character abbreviation is not canonical");
1793
1794 assert_eq!(error.field(), "build_git_sha");
1795 assert_eq!(error.length(), 12);
1796 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
1797 assert_eq!(
1798 error.to_string(),
1799 "invalid manifest provenance form: field build_git_sha has length 12; canonical form is exactly 40 lowercase hexadecimal characters"
1800 );
1801 }
1802
1803 #[test]
1804 fn build_provenance_refuses_an_abbreviated_lock_digest() {
1805 let error = build_provenance(None, Some("0123456789abcdef"), None)
1806 .expect_err("a 16-character digest is not canonical");
1807
1808 assert_eq!(error.field(), "build_lock_digest");
1809 assert_eq!(error.length(), 16);
1810 assert_eq!(error.canonical_form(), BUILD_LOCK_DIGEST_CANONICAL_FORM);
1811 }
1812
1813 #[test]
1814 fn build_provenance_refuses_uppercase_hex() {
1815 let uppercase_sha = "A".repeat(40);
1816 let error = build_provenance(Some(&uppercase_sha), None, None)
1817 .expect_err("uppercase hexadecimal is not canonical");
1818
1819 assert_eq!(error.field(), "build_git_sha");
1820 assert_eq!(error.length(), 40);
1821 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
1822 }
1823
1824 #[test]
1825 fn build_provenance_refuses_dirty_revision_stamp() {
1826 let error = build_provenance(
1827 Some("0123456789abcdef0123456789abcdef01234567-dirty"),
1828 None,
1829 None,
1830 )
1831 .expect_err("a dirty stamp is not a canonical build revision");
1832
1833 assert_eq!(error.field(), "build_git_sha");
1834 assert_eq!(error.length(), 46);
1835 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
1836 }
1837
1838 #[test]
1839 fn build_provenance_keeps_a_lock_digest_when_identity_is_unavailable() {
1840 let provenance = build_provenance(
1841 Some("unavailable"),
1842 Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"),
1843 None,
1844 )
1845 .expect("sentinel SHA is omitted before the valid lock digest is checked");
1846
1847 assert_eq!(provenance.build_git_sha, None);
1848 assert_eq!(
1849 provenance.build_lock_digest,
1850 Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string())
1851 );
1852 assert_eq!(
1853 provenance.wire_crate_version,
1854 Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
1855 );
1856 }
1857
1858 #[test]
1859 fn build_provenance_omits_fully_unavailable_inputs() {
1860 let provenance = build_provenance(None, Some(" unavailable "), Some(" "))
1861 .expect("omitted and sentinel inputs are not form errors");
1862
1863 assert_eq!(provenance.build_git_sha, None);
1864 assert_eq!(provenance.build_lock_digest, None);
1865 assert_eq!(provenance.store_schema_version, None);
1866 assert_eq!(
1867 provenance.wire_crate_version,
1868 Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
1869 );
1870 }
1871}