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,
365 pub effect: SelfSignalEffect,
367 pub anchored_to: SignalAnchor,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub cadence: Option<SignalCadence>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub domain: Option<String>,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub note: Option<String>,
382}
383
384#[derive(Debug, Clone, PartialEq, Eq)]
386pub enum SelfSignalKind {
387 Keepalive,
388 Busy,
390 Poller,
391 Cron,
392 Sweep,
393 Watchdog,
394 Heartbeat,
395 Other(String),
396}
397
398impl SelfSignalKind {
399 fn wire_name(&self) -> &str {
400 match self {
401 Self::Keepalive => "keepalive",
402 Self::Busy => "busy",
403 Self::Poller => "poller",
404 Self::Cron => "cron",
405 Self::Sweep => "sweep",
406 Self::Watchdog => "watchdog",
407 Self::Heartbeat => "heartbeat",
408 Self::Other(value) => value,
409 }
410 }
411}
412
413impl Serialize for SelfSignalKind {
414 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
415 where
416 S: serde::Serializer,
417 {
418 serializer.serialize_str(self.wire_name())
419 }
420}
421
422impl<'de> Deserialize<'de> for SelfSignalKind {
423 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
424 where
425 D: Deserializer<'de>,
426 {
427 let value = String::deserialize(deserializer)?;
428 Ok(match value.as_str() {
429 "keepalive" => Self::Keepalive,
430 "busy" => Self::Busy,
431 "poller" => Self::Poller,
432 "cron" => Self::Cron,
433 "sweep" => Self::Sweep,
434 "watchdog" => Self::Watchdog,
435 "heartbeat" => Self::Heartbeat,
436 _ => Self::Other(value),
437 })
438 }
439}
440
441#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
443#[serde(rename_all = "lowercase")]
444pub enum SelfSignalEffect {
445 Observe,
446 Mutate,
447}
448
449#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
451#[serde(rename_all = "snake_case")]
452pub enum SignalAnchor {
453 FixedInterval,
456 Event { event: String },
459 HealthGauges { gauges: Vec<String> },
461}
462
463#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
465#[serde(rename_all = "snake_case")]
466pub enum SignalCadence {
467 Literal { interval_ms: u64 },
468 Derived { source: String },
469}
470
471#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
543pub struct ManifestProvenance {
544 #[serde(default, skip_serializing_if = "Option::is_none")]
545 pub build_git_sha: Option<String>,
546 #[serde(default, skip_serializing_if = "Option::is_none")]
550 pub build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
551 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub build_lock_digest: Option<String>,
553 #[serde(default, skip_serializing_if = "Option::is_none")]
562 pub wire_crate_version: Option<String>,
563 #[serde(default, skip_serializing_if = "Option::is_none")]
564 pub store_schema_version: Option<String>,
565}
566
567#[derive(Debug, Clone, PartialEq, Eq)]
572pub enum BuildGitShaAbsenceReason {
573 DeclinedDirty,
574 NeverDerived,
575 NoGitDir,
576 ForwardCompatibleUnknown(String),
577}
578
579impl BuildGitShaAbsenceReason {
580 fn wire_name(&self) -> &str {
581 match self {
582 Self::DeclinedDirty => "declined_dirty",
583 Self::NeverDerived => "never_derived",
584 Self::NoGitDir => "no_git_dir",
585 Self::ForwardCompatibleUnknown(value) => value,
586 }
587 }
588}
589
590impl Serialize for BuildGitShaAbsenceReason {
591 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
592 where
593 S: serde::Serializer,
594 {
595 serializer.serialize_str(self.wire_name())
596 }
597}
598
599impl<'de> Deserialize<'de> for BuildGitShaAbsenceReason {
600 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
601 where
602 D: serde::Deserializer<'de>,
603 {
604 let value = String::deserialize(deserializer)?;
605 Ok(match value.as_str() {
606 "declined_dirty" => Self::DeclinedDirty,
607 "never_derived" => Self::NeverDerived,
608 "no_git_dir" => Self::NoGitDir,
609 _ => Self::ForwardCompatibleUnknown(value),
610 })
611 }
612}
613
614#[derive(Debug, Clone, Copy, PartialEq, Eq)]
616pub enum GitTreeState {
617 Clean,
618 Dirty,
619}
620
621#[derive(Debug, Clone, Copy, PartialEq, Eq)]
626pub enum BuildGitShaSource<'a> {
627 Git {
628 revision: &'a str,
629 tree_state: GitTreeState,
630 },
631 NeverDerived,
632 NoGitDir,
633}
634
635pub fn attestable_commit(revision: &str, tree_state: GitTreeState) -> Option<&str> {
639 match tree_state {
640 GitTreeState::Clean => Some(revision),
641 GitTreeState::Dirty => None,
642 }
643}
644
645const MAX_PROVENANCE_VALUE_BYTES: usize = 128;
646const BUILD_GIT_SHA_CANONICAL_FORM: &str = "exactly 40 lowercase hexadecimal characters";
647const BUILD_LOCK_DIGEST_CANONICAL_FORM: &str = "exactly 64 lowercase hexadecimal characters";
648
649#[derive(Deserialize)]
650struct ManifestProvenanceWire {
651 #[serde(default)]
652 build_git_sha: Option<String>,
653 #[serde(default)]
654 build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
655 #[serde(default)]
656 build_lock_digest: Option<String>,
657 #[serde(default)]
658 wire_crate_version: Option<String>,
659 #[serde(default)]
660 store_schema_version: Option<String>,
661}
662
663impl<'de> Deserialize<'de> for ManifestProvenance {
664 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
665 where
666 D: Deserializer<'de>,
667 {
668 let wire = ManifestProvenanceWire::deserialize(deserializer)?;
669 let provenance = Self {
670 build_git_sha: wire.build_git_sha,
671 build_git_sha_absence_reason: wire.build_git_sha_absence_reason,
672 build_lock_digest: wire.build_lock_digest,
673 wire_crate_version: wire.wire_crate_version,
674 store_schema_version: wire.store_schema_version,
675 };
676 provenance.validate().map_err(D::Error::custom)?;
677 Ok(provenance)
678 }
679}
680
681#[derive(Debug, Clone, PartialEq, Eq)]
683pub struct ProvenanceFormError {
684 field: &'static str,
685 length: usize,
686 canonical_form: &'static str,
687}
688
689impl ProvenanceFormError {
690 fn new(field: &'static str, length: usize, canonical_form: &'static str) -> Self {
691 Self {
692 field,
693 length,
694 canonical_form,
695 }
696 }
697
698 pub fn field(&self) -> &str {
700 self.field
701 }
702
703 pub fn length(&self) -> usize {
705 self.length
706 }
707
708 pub fn canonical_form(&self) -> &str {
710 self.canonical_form
711 }
712}
713
714impl fmt::Display for ProvenanceFormError {
715 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
716 write!(
717 f,
718 "invalid manifest provenance form: field {} has length {}; canonical form is {}",
719 self.field, self.length, self.canonical_form
720 )
721 }
722}
723
724impl std::error::Error for ProvenanceFormError {}
725
726#[derive(Debug, Clone, PartialEq, Eq)]
727pub struct ManifestProvenanceError {
728 field: String,
729 value: String,
730 reason: &'static str,
731}
732
733impl ManifestProvenanceError {
734 fn new(field: &str, value: &str, reason: &'static str) -> Self {
735 Self {
736 field: field.to_string(),
737 value: safe_error_value(value),
738 reason,
739 }
740 }
741
742 pub fn field(&self) -> &str {
743 &self.field
744 }
745}
746
747impl fmt::Display for ManifestProvenanceError {
748 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
749 write!(
750 f,
751 "invalid manifest provenance: field {} has {} (value {:?})",
752 self.field, self.reason, self.value
753 )
754 }
755}
756
757impl std::error::Error for ManifestProvenanceError {}
758
759impl ManifestProvenance {
760 pub fn validate(&self) -> Result<(), ManifestProvenanceError> {
761 if let (Some(_), Some(reason)) = (
762 self.build_git_sha.as_ref(),
763 self.build_git_sha_absence_reason.as_ref(),
764 ) {
765 return Err(ManifestProvenanceError::new(
766 "build_git_sha_absence_reason",
767 reason.wire_name(),
768 "must be omitted when build_git_sha is present",
769 ));
770 }
771 for (field, value) in [
772 ("build_git_sha", self.build_git_sha.as_deref()),
773 (
774 "build_git_sha_absence_reason",
775 self.build_git_sha_absence_reason
776 .as_ref()
777 .map(|reason| reason.wire_name()),
778 ),
779 ("build_lock_digest", self.build_lock_digest.as_deref()),
780 ("wire_crate_version", self.wire_crate_version.as_deref()),
781 ("store_schema_version", self.store_schema_version.as_deref()),
782 ] {
783 let Some(value) = value else { continue };
784 if value.is_empty() {
785 return Err(ManifestProvenanceError::new(
786 field,
787 value,
788 "must not be empty",
789 ));
790 }
791 if value.len() > MAX_PROVENANCE_VALUE_BYTES {
796 return Err(ManifestProvenanceError::new(
797 field,
798 value,
799 "exceeds the 128-byte maximum",
800 ));
801 }
802 if value.bytes().any(|byte| !(0x20..=0x7e).contains(&byte)) {
803 return Err(ManifestProvenanceError::new(
804 field,
805 value,
806 "contains non-printable ASCII",
807 ));
808 }
809 }
810 Ok(())
811 }
812}
813
814pub fn build_provenance(
832 build_git_sha: Option<&str>,
833 build_lock_digest: Option<&str>,
834 store_schema_version: Option<&str>,
835) -> Result<ManifestProvenance, ProvenanceFormError> {
836 let build_git_sha = normalize_and_validate_build_git_sha(build_git_sha)?;
837 build_provenance_with_build_git_sha(
838 build_git_sha,
839 None,
840 build_lock_digest,
841 store_schema_version,
842 )
843}
844
845pub fn build_provenance_from_source(
861 build_git_sha_source: BuildGitShaSource<'_>,
862 build_lock_digest: Option<&str>,
863 store_schema_version: Option<&str>,
864) -> Result<ManifestProvenance, ProvenanceFormError> {
865 let (raw_build_git_sha, mut build_git_sha_absence_reason) = match build_git_sha_source {
866 BuildGitShaSource::Git {
867 revision,
868 tree_state,
869 } => match attestable_commit(revision, tree_state) {
870 Some(revision) => (Some(revision), None),
871 None => (None, Some(BuildGitShaAbsenceReason::DeclinedDirty)),
872 },
873 BuildGitShaSource::NeverDerived => (None, Some(BuildGitShaAbsenceReason::NeverDerived)),
874 BuildGitShaSource::NoGitDir => (None, Some(BuildGitShaAbsenceReason::NoGitDir)),
875 };
876 let build_git_sha = normalize_and_validate_build_git_sha(raw_build_git_sha)?;
877 if build_git_sha.is_none() {
878 build_git_sha_absence_reason.get_or_insert(BuildGitShaAbsenceReason::NeverDerived);
879 }
880 build_provenance_with_build_git_sha(
881 build_git_sha,
882 build_git_sha_absence_reason,
883 build_lock_digest,
884 store_schema_version,
885 )
886}
887
888fn normalize_and_validate_build_git_sha(
889 build_git_sha: Option<&str>,
890) -> Result<Option<String>, ProvenanceFormError> {
891 let build_git_sha = normalize_provenance_fact(build_git_sha);
892 validate_provenance_form(
893 "build_git_sha",
894 build_git_sha.as_deref(),
895 BUILD_GIT_SHA_CANONICAL_FORM,
896 40,
897 )?;
898 Ok(build_git_sha)
899}
900
901fn build_provenance_with_build_git_sha(
902 build_git_sha: Option<String>,
903 build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
904 build_lock_digest: Option<&str>,
905 store_schema_version: Option<&str>,
906) -> Result<ManifestProvenance, ProvenanceFormError> {
907 let build_lock_digest = normalize_provenance_fact(build_lock_digest);
908 validate_provenance_form(
909 "build_lock_digest",
910 build_lock_digest.as_deref(),
911 BUILD_LOCK_DIGEST_CANONICAL_FORM,
912 64,
913 )?;
914
915 Ok(ManifestProvenance {
916 build_git_sha,
917 build_git_sha_absence_reason,
918 build_lock_digest,
919 wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
920 store_schema_version: normalize_provenance_fact(store_schema_version),
921 })
922}
923
924fn validate_provenance_form(
925 field: &'static str,
926 value: Option<&str>,
927 canonical_form: &'static str,
928 expected_length: usize,
929) -> Result<(), ProvenanceFormError> {
930 let Some(value) = value else { return Ok(()) };
931 if value.len() != expected_length
932 || !value
933 .bytes()
934 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
935 {
936 return Err(ProvenanceFormError::new(field, value.len(), canonical_form));
937 }
938 Ok(())
939}
940
941pub const PROVENANCE_SENTINELS: [&str; 3] = ["unknown", "unavailable", "none"];
949
950fn normalize_provenance_fact(value: Option<&str>) -> Option<String> {
951 let value = value?.trim();
952 if value.is_empty() {
953 return None;
954 }
955 let lowered = value.to_ascii_lowercase();
956 if PROVENANCE_SENTINELS.contains(&lowered.as_str()) {
957 return None;
958 }
959 Some(value.to_string())
960}
961
962#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
964#[serde(deny_unknown_fields)]
965pub struct CapabilityRequirement {
966 pub capability: String,
967 pub need: CapabilityNeed,
968}
969
970#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
972#[serde(rename_all = "snake_case")]
973pub enum CapabilityNeed {
974 Required,
975 Optional,
976}
977
978#[derive(Debug, Clone, PartialEq, Eq)]
980pub struct CapabilityGrammarError {
981 field: String,
982 value: String,
983}
984
985impl CapabilityGrammarError {
986 fn new(field: impl Into<String>, value: impl AsRef<str>) -> Self {
987 Self {
988 field: field.into(),
989 value: safe_error_value(value.as_ref()),
990 }
991 }
992
993 pub fn field(&self) -> &str {
995 &self.field
996 }
997
998 pub fn value(&self) -> &str {
1000 &self.value
1001 }
1002}
1003
1004impl fmt::Display for CapabilityGrammarError {
1005 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1006 write!(
1007 f,
1008 "invalid capability grammar: field {} has offending value {:?}",
1009 self.field, self.value
1010 )
1011 }
1012}
1013
1014impl std::error::Error for CapabilityGrammarError {}
1015
1016impl ModuleManifest {
1017 pub fn validate_capability_grammar(&self) -> Result<(), CapabilityGrammarError> {
1019 let Some(capabilities) = &self.capabilities else {
1020 return Ok(());
1021 };
1022
1023 validate_capability_list("capabilities.provides", &capabilities.provides)?;
1024 validate_requires(&capabilities.requires)?;
1025 validate_capability_list(
1026 "capabilities.must_never_reach",
1027 &capabilities.must_never_reach,
1028 )
1029 }
1030}
1031
1032pub fn validate_manifest_capability_grammar(
1037 manifest: &Value,
1038) -> Result<(), CapabilityGrammarError> {
1039 let Some(object) = manifest.as_object() else {
1040 return Ok(());
1041 };
1042
1043 validate_capabilities_value(object.get("capabilities"))?;
1044 validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
1045}
1046
1047pub fn validate_hello_capability_grammar(hello: &Value) -> Result<(), CapabilityGrammarError> {
1053 let Some(object) = hello.as_object() else {
1054 return Ok(());
1055 };
1056 if let Some(manifest) = object.get("manifest") {
1057 validate_manifest_capability_grammar(manifest)?;
1058 }
1059 validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
1060}
1061
1062pub fn is_valid_capability_identifier(identifier: &str) -> bool {
1064 if identifier.chars().any(char::is_whitespace) {
1065 return false;
1066 }
1067 let Some((name, version)) = identifier.split_once("/v") else {
1068 return false;
1069 };
1070 if name.is_empty() || name.len() > 64 || version.is_empty() {
1071 return false;
1072 }
1073
1074 let name_bytes = name.as_bytes();
1075 if !name_bytes[0].is_ascii_lowercase()
1076 || (name.len() > 1
1077 && !name_bytes[name.len() - 1].is_ascii_lowercase()
1078 && !name_bytes[name.len() - 1].is_ascii_digit())
1079 || name_bytes.windows(2).any(|pair| pair == b"--")
1080 {
1081 return false;
1082 }
1083 if !name_bytes
1084 .iter()
1085 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
1086 {
1087 return false;
1088 }
1089
1090 if version.len() > 1 && version.starts_with('0')
1091 || !version.bytes().all(|byte| byte.is_ascii_digit())
1092 {
1093 return false;
1094 }
1095 matches!(
1096 version.parse::<u64>(),
1097 Ok(value) if (1..=u64::from(u32::MAX)).contains(&value)
1098 )
1099}
1100
1101fn validate_capabilities_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
1102 let Some(value) = value else {
1103 return Ok(());
1104 };
1105 let Some(object) = value.as_object() else {
1106 return Err(CapabilityGrammarError::new(
1107 "capabilities",
1108 value_description(value),
1109 ));
1110 };
1111
1112 for (key, value) in object {
1113 if !matches!(key.as_str(), "provides" | "requires" | "must_never_reach") {
1114 return Err(CapabilityGrammarError::new(
1115 field_child("capabilities", key),
1116 value_description(value),
1117 ));
1118 }
1119 }
1120
1121 validate_capability_list_value("capabilities.provides", object.get("provides"))?;
1122 validate_requires_value(object.get("requires"))?;
1123 validate_capability_list_value(
1124 "capabilities.must_never_reach",
1125 object.get("must_never_reach"),
1126 )
1127}
1128
1129fn validate_capability_list_value(
1130 field: &str,
1131 value: Option<&Value>,
1132) -> Result<(), CapabilityGrammarError> {
1133 let Some(value) = value else {
1134 return Ok(());
1135 };
1136 let Some(values) = value.as_array() else {
1137 return Err(CapabilityGrammarError::new(field, value_description(value)));
1138 };
1139
1140 let mut seen = HashSet::new();
1141 for (index, value) in values.iter().enumerate() {
1142 let field = format!("{field}[{index}]");
1143 let Some(identifier) = value.as_str() else {
1144 return Err(CapabilityGrammarError::new(field, value_description(value)));
1145 };
1146 validate_capability_identifier(&field, identifier)?;
1147 if !seen.insert(identifier) {
1148 return Err(CapabilityGrammarError::new(field, identifier));
1149 }
1150 }
1151 Ok(())
1152}
1153
1154fn validate_requires_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
1155 let Some(value) = value else {
1156 return Ok(());
1157 };
1158 let Some(values) = value.as_array() else {
1159 return Err(CapabilityGrammarError::new(
1160 "capabilities.requires",
1161 value_description(value),
1162 ));
1163 };
1164
1165 let mut seen = HashSet::new();
1166 for (index, value) in values.iter().enumerate() {
1167 let entry_field = format!("capabilities.requires[{index}]");
1168 let Some(object) = value.as_object() else {
1169 return Err(CapabilityGrammarError::new(
1170 entry_field,
1171 value_description(value),
1172 ));
1173 };
1174 for (key, value) in object {
1175 if !matches!(key.as_str(), "capability" | "need") {
1176 return Err(CapabilityGrammarError::new(
1177 field_child(&entry_field, key),
1178 value_description(value),
1179 ));
1180 }
1181 }
1182 let capability_field = format!("{entry_field}.capability");
1183 let Some(capability) = object.get("capability").and_then(Value::as_str) else {
1184 return Err(CapabilityGrammarError::new(
1185 capability_field,
1186 object
1187 .get("capability")
1188 .map_or("<missing>".to_string(), value_description),
1189 ));
1190 };
1191 validate_capability_identifier(&capability_field, capability)?;
1192
1193 let need_field = format!("{entry_field}.need");
1194 let Some(need) = object.get("need").and_then(Value::as_str) else {
1195 return Err(CapabilityGrammarError::new(
1196 need_field,
1197 object
1198 .get("need")
1199 .map_or("<missing>".to_string(), value_description),
1200 ));
1201 };
1202 if !matches!(need, "required" | "optional") {
1203 return Err(CapabilityGrammarError::new(need_field, need));
1204 }
1205 if !seen.insert(capability) {
1206 return Err(CapabilityGrammarError::new(entry_field, capability));
1207 }
1208 }
1209 Ok(())
1210}
1211
1212fn validate_capability_list(field: &str, values: &[String]) -> Result<(), CapabilityGrammarError> {
1213 let mut seen = HashSet::new();
1214 for (index, identifier) in values.iter().enumerate() {
1215 let field = format!("{field}[{index}]");
1216 validate_capability_identifier(&field, identifier)?;
1217 if !seen.insert(identifier) {
1218 return Err(CapabilityGrammarError::new(field, identifier));
1219 }
1220 }
1221 Ok(())
1222}
1223
1224fn validate_requires(values: &[CapabilityRequirement]) -> Result<(), CapabilityGrammarError> {
1225 let mut seen = HashSet::new();
1226 for (index, requirement) in values.iter().enumerate() {
1227 let field = format!("capabilities.requires[{index}].capability");
1228 validate_capability_identifier(&field, &requirement.capability)?;
1229 if !seen.insert(&requirement.capability) {
1230 return Err(CapabilityGrammarError::new(
1231 format!("capabilities.requires[{index}]"),
1232 &requirement.capability,
1233 ));
1234 }
1235 }
1236 Ok(())
1237}
1238
1239fn validate_capability_identifier(
1240 field: &str,
1241 identifier: &str,
1242) -> Result<(), CapabilityGrammarError> {
1243 if is_valid_capability_identifier(identifier) {
1244 Ok(())
1245 } else {
1246 Err(CapabilityGrammarError::new(field, identifier))
1247 }
1248}
1249
1250fn validate_runtime_computed(
1251 value: Option<&Value>,
1252 field: &str,
1253) -> Result<(), CapabilityGrammarError> {
1254 let Some(value) = value else {
1255 return Ok(());
1256 };
1257 let Some(pointers) = value.as_array() else {
1258 return Err(CapabilityGrammarError::new(field, value_description(value)));
1259 };
1260
1261 for (index, pointer) in pointers.iter().enumerate() {
1262 let field = format!("{field}[{index}]");
1263 let Some(pointer) = pointer.as_str() else {
1264 return Err(CapabilityGrammarError::new(
1265 field,
1266 value_description(pointer),
1267 ));
1268 };
1269 let Some(tokens) = parse_json_pointer(pointer) else {
1270 return Err(CapabilityGrammarError::new(field, pointer));
1271 };
1272 if tokens.first().is_some_and(|token| token == "capabilities") {
1273 return Err(CapabilityGrammarError::new(field, pointer));
1274 }
1275 }
1276 Ok(())
1277}
1278
1279fn parse_json_pointer(pointer: &str) -> Option<Vec<String>> {
1280 if pointer.is_empty() {
1281 return Some(Vec::new());
1282 }
1283 let raw_tokens = pointer.strip_prefix('/')?;
1284 raw_tokens
1285 .split('/')
1286 .map(unescape_json_pointer_token)
1287 .collect()
1288}
1289
1290fn unescape_json_pointer_token(token: &str) -> Option<String> {
1291 let mut output = String::with_capacity(token.len());
1292 let mut characters = token.chars();
1293 while let Some(character) = characters.next() {
1294 if character != '~' {
1295 output.push(character);
1296 continue;
1297 }
1298 match characters.next()? {
1299 '0' => output.push('~'),
1300 '1' => output.push('/'),
1301 _ => return None,
1302 }
1303 }
1304 Some(output)
1305}
1306
1307fn field_child(parent: &str, child: &str) -> String {
1308 let child = safe_error_value(child);
1309 format!("{parent}.{child}")
1310}
1311
1312fn value_description(value: &Value) -> String {
1313 match value {
1314 Value::String(value) => safe_error_value(value),
1315 Value::Null => "null".to_string(),
1316 Value::Bool(value) => value.to_string(),
1317 Value::Number(value) => value.to_string(),
1318 Value::Array(_) => "<array>".to_string(),
1319 Value::Object(_) => "<object>".to_string(),
1320 }
1321}
1322
1323fn safe_error_value(value: &str) -> String {
1324 let lower = value.to_ascii_lowercase();
1325 if ["secret", "password", "api_key"]
1326 .iter()
1327 .any(|marker| lower.contains(marker))
1328 || lower.starts_with("sk-")
1329 || lower.starts_with("akia")
1330 || lower.starts_with("bearer ")
1331 || lower.starts_with("token=")
1332 || lower.starts_with("credential=")
1333 {
1334 "<redacted>".to_string()
1335 } else {
1336 value.to_string()
1337 }
1338}
1339
1340#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1346#[serde(rename_all = "snake_case")]
1347pub enum TrustTier {
1348 FirstParty,
1349 Reviewed,
1350 Untrusted,
1351}
1352
1353#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1357#[serde(tag = "role", rename_all = "snake_case")]
1358pub enum ProviderRole {
1359 ToolProvider {
1360 tools: Vec<Tool>,
1361 identity_scope: Vec<IdentityScope>,
1369 concurrency: Concurrency,
1370 emits_push: bool,
1371 sub_supervises: bool,
1372 },
1373 PipelineStage {
1374 stage: PipelineStageKind,
1375 applies_to: PipelineAppliesTo,
1376 interface: String,
1377 declares_frozen_floor: bool,
1378 needs_signals: Vec<String>,
1379 conformance_class: String,
1380 },
1381 ManagementSurface {
1382 operations: Vec<ManagementOperation>,
1383 config_schema: Value,
1384 observability: Vec<ObservabilitySurface>,
1385 identity_scope: Vec<IdentityScope>,
1389 #[serde(default)]
1390 concurrency: Concurrency,
1391 },
1392 InternalService {
1393 service_id: String,
1394 transport: InternalTransport,
1395 agent_facing: bool,
1396 operations: Vec<String>,
1397 },
1398}
1399
1400#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
1413#[serde(rename_all = "snake_case")]
1414pub enum ExecutionMode {
1415 Pure,
1416 Mutating,
1417 Unfenceable,
1418}
1419
1420#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1422pub struct Tool {
1423 pub name: String,
1424 #[serde(default, skip_serializing_if = "Option::is_none")]
1425 pub description: Option<String>,
1426 pub execution_mode: ExecutionMode,
1431 pub schema: Value,
1432}
1433
1434#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1440#[serde(rename_all = "snake_case")]
1441pub enum Concurrency {
1442 Serial,
1444 ModuleManaged,
1447 StatelessParallel,
1450}
1451
1452#[allow(clippy::derivable_impls)]
1453impl Default for Concurrency {
1464 fn default() -> Self {
1465 Self::ModuleManaged
1466 }
1467}
1468
1469#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1476#[serde(rename_all = "snake_case")]
1477pub enum IdentityScope {
1478 Session,
1479 Project,
1480}
1481
1482#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1484#[serde(rename_all = "snake_case")]
1485pub enum PipelineStageKind {
1486 Transform,
1487 Codec,
1488 Auth,
1489}
1490
1491#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1493pub struct PipelineAppliesTo {
1494 pub provider: String,
1495 pub model: String,
1496}
1497
1498#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1500pub struct ManagementOperation {
1501 pub name: String,
1502 pub kind: ManagementOperationKind,
1503 #[serde(default, skip_serializing_if = "Option::is_none")]
1504 pub description: Option<String>,
1505}
1506
1507#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1508#[serde(rename_all = "snake_case")]
1509pub enum ManagementOperationKind {
1510 Query,
1511 Mutate,
1512}
1513
1514#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1516pub struct ObservabilitySurface {
1517 pub name: String,
1518 pub kind: ObservabilityKind,
1519}
1520
1521#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1522#[serde(rename_all = "snake_case")]
1523pub enum ObservabilityKind {
1524 Snapshot,
1525 Stream,
1526}
1527
1528#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1529#[serde(rename_all = "snake_case")]
1530pub enum InternalTransport {
1531 Bulk,
1532}
1533
1534#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1536#[serde(tag = "role", rename_all = "snake_case")]
1537pub enum ConsumerRole {
1538 ToolClient { of: Vec<String> },
1539 LlmClient { via: String, auth: String },
1540 ServiceClient { of: Vec<String> },
1541}
1542
1543#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1545pub struct Bindings {
1546 pub storage: StorageBinding,
1547 pub vault_grants: Vec<VaultGrant>,
1548 pub identity: IdentityBinding,
1549}
1550
1551#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1553pub struct StorageBinding {
1554 pub kind: StorageKind,
1555 pub scope: StorageScope,
1556 pub owns_schema: bool,
1557}
1558
1559#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1560#[serde(rename_all = "snake_case")]
1561pub enum StorageKind {
1562 Sqlite,
1563}
1564
1565#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1566#[serde(rename_all = "snake_case")]
1567pub enum StorageScope {
1568 Project,
1569}
1570
1571#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1572pub struct VaultGrant {
1573 pub secret: String,
1574 pub reason: String,
1575}
1576
1577#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1578pub struct IdentityBinding {
1579 pub requires: Vec<IdentityScope>,
1580 pub optional: Vec<IdentityScope>,
1581}
1582
1583#[cfg(test)]
1584mod tests {
1585 use super::*;
1586 use serde_json::json;
1587
1588 fn aft_manifest_fixture() -> ModuleManifest {
1589 ModuleManifest::builder("aft", "0.39.2")
1590 .trust_tier(Some(TrustTier::FirstParty))
1591 .bindings(Some(Bindings {
1592 storage: StorageBinding {
1593 kind: StorageKind::Sqlite,
1594 scope: StorageScope::Project,
1595 owns_schema: true,
1596 },
1597 vault_grants: vec![VaultGrant {
1598 secret: "provider_api_key".to_string(),
1599 reason: "cortexkit_native auth".to_string(),
1600 }],
1601 identity: IdentityBinding {
1602 requires: vec![IdentityScope::Project],
1603 optional: vec![IdentityScope::Session],
1604 },
1605 }))
1606 .protocol_ver(1)
1607 .provides(vec![ProviderRole::ToolProvider {
1608 tools: vec![
1609 Tool {
1610 name: "read".to_string(),
1611 description: None,
1612 execution_mode: ExecutionMode::Pure,
1613 schema: json!({"type": "object"}),
1614 },
1615 Tool {
1616 name: "grep".to_string(),
1617 description: None,
1618 execution_mode: ExecutionMode::Pure,
1619 schema: json!({"type": "object"}),
1620 },
1621 Tool {
1622 name: "outline".to_string(),
1623 description: None,
1624 execution_mode: ExecutionMode::Pure,
1625 schema: json!({"type": "object"}),
1626 },
1627 Tool {
1628 name: "semantic_search".to_string(),
1629 description: None,
1630 execution_mode: ExecutionMode::Pure,
1631 schema: json!({"type": "object"}),
1632 },
1633 Tool {
1634 name: "edit".to_string(),
1635 description: None,
1636 execution_mode: ExecutionMode::Mutating,
1637 schema: json!({"type": "object"}),
1638 },
1639 Tool {
1640 name: "write".to_string(),
1641 description: None,
1642 execution_mode: ExecutionMode::Mutating,
1643 schema: json!({"type": "object"}),
1644 },
1645 Tool {
1646 name: "bash".to_string(),
1647 description: None,
1648 execution_mode: ExecutionMode::Unfenceable,
1649 schema: json!({"type": "object"}),
1650 },
1651 ],
1652 identity_scope: vec![IdentityScope::Session, IdentityScope::Project],
1653 concurrency: Concurrency::ModuleManaged,
1654 emits_push: true,
1655 sub_supervises: true,
1656 }])
1657 .consumes(vec![ConsumerRole::ServiceClient {
1658 of: vec!["embedding.v2".to_string()],
1659 }])
1660 .build()
1661 }
1662
1663 #[test]
1664 fn serde_round_trips_representative_manifest() {
1665 let manifest = aft_manifest_fixture();
1666 let serialized = serde_json::to_string_pretty(&manifest).unwrap();
1667 let decoded: ModuleManifest = serde_json::from_str(&serialized).unwrap();
1668
1669 assert_eq!(manifest, decoded);
1670 }
1671
1672 #[test]
1673 fn builder_defaults_additions_to_honest_absence_and_round_trips() {
1674 let manifest = ModuleManifest::builder("builder-defaults", "2.0.0").build();
1675
1676 assert_eq!(manifest.module_id, "builder-defaults");
1677 assert_eq!(manifest.module_version, "2.0.0");
1678 assert_eq!(manifest.protocol_ver, PROTOCOL_VERSION);
1679 assert_eq!(manifest.trust_tier, None);
1680 assert!(manifest.provides.is_empty());
1681 assert!(manifest.consumes.is_empty());
1682 assert_eq!(manifest.bindings, None);
1683 assert_eq!(manifest.capabilities, None);
1684 assert_eq!(manifest.self_signals, None);
1685 assert_eq!(manifest.provenance, None);
1686
1687 let encoded = serde_json::to_value(&manifest).expect("builder manifest serializes");
1688 for optional in [
1689 "trust_tier",
1690 "consumes",
1691 "bindings",
1692 "capabilities",
1693 "self_signals",
1694 "provenance",
1695 ] {
1696 assert!(
1697 encoded.get(optional).is_none(),
1698 "an absent {optional} declaration must stay absent on the wire"
1699 );
1700 }
1701 let decoded: ModuleManifest =
1702 serde_json::from_value(encoded).expect("builder manifest round-trips");
1703 assert_eq!(decoded, manifest);
1704 }
1705
1706 #[test]
1707 fn fully_populated_builder_manifest_matches_the_literal_wire_golden() {
1708 let manifest = ModuleManifest::builder("full-builder", "2.0.0")
1709 .trust_tier(Some(TrustTier::Reviewed))
1710 .bindings(Some(Bindings {
1711 storage: StorageBinding {
1712 kind: StorageKind::Sqlite,
1713 scope: StorageScope::Project,
1714 owns_schema: false,
1715 },
1716 vault_grants: Vec::new(),
1717 identity: IdentityBinding {
1718 requires: vec![IdentityScope::Project],
1719 optional: Vec::new(),
1720 },
1721 }))
1722 .provides(vec![ProviderRole::ToolProvider {
1723 tools: vec![Tool {
1724 name: "read".to_string(),
1725 description: None,
1726 execution_mode: ExecutionMode::Pure,
1727 schema: json!({"type": "object"}),
1728 }],
1729 identity_scope: vec![IdentityScope::Project],
1730 concurrency: Concurrency::Serial,
1731 emits_push: false,
1732 sub_supervises: false,
1733 }])
1734 .consumes(vec![ConsumerRole::ServiceClient {
1735 of: vec!["embedding.v2".to_string()],
1736 }])
1737 .capabilities(Some(CapabilityDeclarations {
1738 provides: vec!["embedding/v2".to_string()],
1739 requires: Vec::new(),
1740 must_never_reach: Vec::new(),
1741 }))
1742 .self_signals(Some(vec![SelfSignalDeclaration {
1743 name: "usage_poller".to_string(),
1744 kind: SelfSignalKind::Poller,
1745 effect: SelfSignalEffect::Observe,
1746 anchored_to: SignalAnchor::FixedInterval,
1747 cadence: Some(SignalCadence::Literal {
1748 interval_ms: 60_000,
1749 }),
1750 domain: Some("provider-usage".to_string()),
1751 note: None,
1752 }]))
1753 .provenance(Some(ManifestProvenance {
1754 build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
1755 build_git_sha_absence_reason: None,
1756 build_lock_digest: Some(
1757 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
1758 ),
1759 wire_crate_version: Some("0.16.0".to_string()),
1760 store_schema_version: Some("42".to_string()),
1761 }))
1762 .build();
1763
1764 assert_eq!(
1765 serde_json::to_vec(&manifest).expect("builder manifest serializes"),
1766 include_bytes!("../tests/golden/module_manifest_builder_full.json"),
1767 "the builder must preserve the prior fully populated literal wire bytes"
1768 );
1769 }
1770
1771 #[test]
1772 fn old_manifest_with_unread_fields_decodes_and_round_trips_verbatim() {
1773 let raw = include_bytes!("../tests/golden/module_manifest_builder_full.json");
1774 let decoded: ModuleManifest =
1775 serde_json::from_slice(raw).expect("old manifest with all unread fields decodes");
1776
1777 assert_eq!(decoded.trust_tier, Some(TrustTier::Reviewed));
1778 assert!(!decoded.consumes.is_empty());
1779 assert!(decoded.bindings.is_some());
1780
1781 let reencoded = serde_json::to_vec(&decoded).expect("re-encode succeeds");
1782 assert_eq!(
1783 reencoded, raw,
1784 "old manifest relay stays byte-for-byte verbatim"
1785 );
1786 }
1787
1788 #[test]
1789 fn new_manifest_omits_unread_fields_on_wire_and_decodes_cleanly() {
1790 let raw = include_bytes!("../tests/golden/module_manifest_diet.json");
1791 let decoded: ModuleManifest =
1792 serde_json::from_slice(raw).expect("new manifest omitting unread fields decodes");
1793
1794 assert_eq!(decoded.trust_tier, None);
1795 assert!(decoded.consumes.is_empty());
1796 assert_eq!(decoded.bindings, None);
1797
1798 let pretty = format!("{}\n", serde_json::to_string_pretty(&decoded).unwrap());
1799 assert_eq!(
1800 pretty.as_bytes(),
1801 raw,
1802 "new manifest matches golden byte-for-byte without unread keys"
1803 );
1804
1805 let as_val: serde_json::Value = serde_json::to_value(&decoded).unwrap();
1806 assert!(
1807 as_val.get("trust_tier").is_none(),
1808 "no trust_tier on wire for new manifest"
1809 );
1810 assert!(
1811 as_val.get("consumes").is_none(),
1812 "no consumes on wire for empty consumes"
1813 );
1814 assert!(
1815 as_val.get("bindings").is_none(),
1816 "no bindings on wire for new manifest"
1817 );
1818 }
1819
1820 #[test]
1821 fn aft_manifest_fixture_matches_v1_contract() {
1822 let manifest = aft_manifest_fixture();
1823
1824 assert_eq!(manifest.module_id, "aft");
1825 let ProviderRole::ToolProvider {
1826 tools,
1827 identity_scope,
1828 concurrency,
1829 emits_push,
1830 sub_supervises,
1831 } = &manifest.provides[0]
1832 else {
1833 panic!("AFT fixture must expose one tool_provider role");
1834 };
1835
1836 assert_eq!(*concurrency, Concurrency::ModuleManaged);
1837 assert!(*emits_push);
1838 assert!(*sub_supervises);
1839 assert_eq!(
1840 identity_scope,
1841 &vec![IdentityScope::Session, IdentityScope::Project]
1842 );
1843 assert_eq!(
1844 tools
1845 .iter()
1846 .map(|tool| (tool.name.as_str(), tool.execution_mode))
1847 .collect::<Vec<_>>(),
1848 vec![
1849 ("read", ExecutionMode::Pure),
1850 ("grep", ExecutionMode::Pure),
1851 ("outline", ExecutionMode::Pure),
1852 ("semantic_search", ExecutionMode::Pure),
1853 ("edit", ExecutionMode::Mutating),
1854 ("write", ExecutionMode::Mutating),
1855 ("bash", ExecutionMode::Unfenceable),
1856 ]
1857 );
1858 }
1859
1860 #[test]
1861 fn tool_provider_role_tag_serializes_as_snake_case() {
1862 let manifest = aft_manifest_fixture();
1863 let value = serde_json::to_value(&manifest).unwrap();
1864
1865 assert_eq!(value["provides"][0]["role"], "tool_provider");
1866 }
1867
1868 #[test]
1869 fn manifest_without_capabilities_preserves_the_existing_wire_shape() {
1870 let manifest = aft_manifest_fixture();
1871 let encoded = serde_json::to_value(&manifest).expect("manifest serializes");
1872 assert!(encoded.get("capabilities").is_none());
1873
1874 let decoded: ModuleManifest =
1875 serde_json::from_value(encoded).expect("legacy manifest parses");
1876 assert_eq!(decoded.capabilities, None);
1877 }
1878
1879 #[test]
1880 fn capability_identifier_lexical_grammar_accepts_only_pinned_forms() {
1881 for identifier in [
1882 "a/v1",
1883 "credentials-provider/v1",
1884 "a1-b2/v4294967295",
1885 "a123456789012345678901234567890123456789012345678901234567890123/v1",
1886 ] {
1887 assert!(
1888 is_valid_capability_identifier(identifier),
1889 "identifier must be accepted: {identifier}"
1890 );
1891 }
1892
1893 for identifier in [
1894 "credentials-Provider/v1",
1895 "credentials-provider/v01",
1896 "credentials-provider-/v1",
1897 "credentials--provider/v1",
1898 "Credentials-provider/v1",
1899 "credentials-provider/1",
1900 "credentials provider/v1",
1901 "credentials-provider/v0",
1902 "credentials-provider/v4294967296",
1903 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/v1",
1904 ] {
1905 assert!(
1906 !is_valid_capability_identifier(identifier),
1907 "identifier must be rejected: {identifier}"
1908 );
1909 }
1910 }
1911
1912 #[test]
1913 fn capability_grammar_errors_redact_secret_shaped_values() {
1914 let error = validate_manifest_capability_grammar(&json!({
1915 "capabilities": { "provides": ["sk-secret-value/v0"] }
1916 }))
1917 .expect_err("secret-shaped capability identifier is malformed");
1918 assert_eq!(error.field(), "capabilities.provides[0]");
1919 assert_eq!(error.value(), "<redacted>");
1920 assert!(!error.to_string().contains("sk-secret-value"));
1921 }
1922
1923 #[test]
1930 fn provenance_builder_sentinels_become_field_omission() {
1931 for sentinel in [
1932 "unknown",
1933 "UNKNOWN",
1934 "Unknown",
1935 "unavailable",
1936 "none",
1937 "None",
1938 " unknown ",
1939 "",
1940 ] {
1941 let p = build_provenance_from_source(
1942 BuildGitShaSource::Git {
1943 revision: sentinel,
1944 tree_state: GitTreeState::Clean,
1945 },
1946 Some(sentinel),
1947 Some(sentinel),
1948 )
1949 .expect("sentinels are omitted before form validation");
1950 assert_eq!(
1951 (
1952 p.build_git_sha,
1953 p.build_git_sha_absence_reason,
1954 p.build_lock_digest,
1955 p.store_schema_version,
1956 ),
1957 (
1958 None,
1959 Some(BuildGitShaAbsenceReason::NeverDerived),
1960 None,
1961 None,
1962 ),
1963 "sentinel {sentinel:?} must be omitted, not published"
1964 );
1965 }
1966 let real = build_provenance_from_source(
1967 BuildGitShaSource::Git {
1968 revision: "0123456789abcdef0123456789abcdef01234567",
1969 tree_state: GitTreeState::Clean,
1970 },
1971 None,
1972 Some("9"),
1973 )
1974 .expect("canonical build revision is accepted");
1975 assert_eq!(
1976 real.build_git_sha.as_deref(),
1977 Some("0123456789abcdef0123456789abcdef01234567")
1978 );
1979 assert_eq!(real.store_schema_version.as_deref(), Some("9"));
1980 assert_eq!(
1984 real.wire_crate_version.as_deref(),
1985 Some(crate::SUBC_PROTOCOL_CRATE_VERSION)
1986 );
1987 }
1988
1989 #[test]
1990 fn build_provenance_accepts_canonical_sha_and_lock_digest() {
1991 let provenance = build_provenance_from_source(
1992 BuildGitShaSource::Git {
1993 revision: " 0123456789abcdef0123456789abcdef01234567 ",
1994 tree_state: GitTreeState::Clean,
1995 },
1996 Some(" abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 "),
1997 Some(" schema-v3 "),
1998 )
1999 .expect("canonical build facts are accepted");
2000
2001 assert_eq!(
2002 provenance,
2003 ManifestProvenance {
2004 build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
2005 build_git_sha_absence_reason: None,
2006 build_lock_digest: Some(
2007 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
2008 ),
2009 wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
2010 store_schema_version: Some("schema-v3".to_string()),
2011 }
2012 );
2013 }
2014
2015 #[test]
2016 fn build_provenance_refuses_an_abbreviated_git_sha() {
2017 let error = build_provenance_from_source(
2018 BuildGitShaSource::Git {
2019 revision: "0123456789ab",
2020 tree_state: GitTreeState::Clean,
2021 },
2022 None,
2023 None,
2024 )
2025 .expect_err("a 12-character abbreviation is not canonical");
2026
2027 assert_eq!(error.field(), "build_git_sha");
2028 assert_eq!(error.length(), 12);
2029 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2030 assert_eq!(
2031 error.to_string(),
2032 "invalid manifest provenance form: field build_git_sha has length 12; canonical form is exactly 40 lowercase hexadecimal characters"
2033 );
2034 }
2035
2036 #[test]
2037 fn build_provenance_refuses_an_abbreviated_lock_digest() {
2038 let error = build_provenance_from_source(
2039 BuildGitShaSource::NeverDerived,
2040 Some("0123456789abcdef"),
2041 None,
2042 )
2043 .expect_err("a 16-character digest is not canonical");
2044
2045 assert_eq!(error.field(), "build_lock_digest");
2046 assert_eq!(error.length(), 16);
2047 assert_eq!(error.canonical_form(), BUILD_LOCK_DIGEST_CANONICAL_FORM);
2048 }
2049
2050 #[test]
2051 fn build_provenance_refuses_uppercase_hex() {
2052 let uppercase_sha = "A".repeat(40);
2053 let error = build_provenance_from_source(
2054 BuildGitShaSource::Git {
2055 revision: &uppercase_sha,
2056 tree_state: GitTreeState::Clean,
2057 },
2058 None,
2059 None,
2060 )
2061 .expect_err("uppercase hexadecimal is not canonical");
2062
2063 assert_eq!(error.field(), "build_git_sha");
2064 assert_eq!(error.length(), 40);
2065 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2066 }
2067
2068 #[test]
2069 fn build_provenance_refuses_dirty_revision_stamp_claimed_clean() {
2070 let error = build_provenance_from_source(
2071 BuildGitShaSource::Git {
2072 revision: "0123456789abcdef0123456789abcdef01234567-dirty",
2073 tree_state: GitTreeState::Clean,
2074 },
2075 None,
2076 None,
2077 )
2078 .expect_err("a dirty stamp is not a canonical build revision");
2079
2080 assert_eq!(error.field(), "build_git_sha");
2081 assert_eq!(error.length(), 46);
2082 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2083 }
2084
2085 #[test]
2086 fn build_provenance_keeps_a_lock_digest_when_identity_is_unavailable() {
2087 let provenance = build_provenance_from_source(
2088 BuildGitShaSource::Git {
2089 revision: "unavailable",
2090 tree_state: GitTreeState::Clean,
2091 },
2092 Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"),
2093 None,
2094 )
2095 .expect("sentinel SHA is omitted before the valid lock digest is checked");
2096
2097 assert_eq!(provenance.build_git_sha, None);
2098 assert_eq!(
2099 provenance.build_lock_digest,
2100 Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string())
2101 );
2102 assert_eq!(
2103 provenance.wire_crate_version,
2104 Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
2105 );
2106 }
2107
2108 #[test]
2109 fn build_provenance_omits_fully_unavailable_inputs() {
2110 let provenance = build_provenance_from_source(
2111 BuildGitShaSource::NeverDerived,
2112 Some(" unavailable "),
2113 Some(" "),
2114 )
2115 .expect("omitted and sentinel inputs are not form errors");
2116
2117 assert_eq!(provenance.build_git_sha, None);
2118 assert_eq!(provenance.build_lock_digest, None);
2119 assert_eq!(provenance.store_schema_version, None);
2120 assert_eq!(
2121 provenance.wire_crate_version,
2122 Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
2123 );
2124 }
2125
2126 #[test]
2127 fn legacy_build_provenance_keeps_master_wire_bytes_without_an_absence_reason() {
2128 let revision = "0123456789abcdef0123456789abcdef01234567";
2129 for (input, expected) in [
2130 (
2131 Some(revision),
2132 format!(
2133 r#"{{"build_git_sha":"{revision}","wire_crate_version":"{}"}}"#,
2134 crate::SUBC_PROTOCOL_CRATE_VERSION
2135 ),
2136 ),
2137 (
2138 None,
2139 format!(
2140 r#"{{"wire_crate_version":"{}"}}"#,
2141 crate::SUBC_PROTOCOL_CRATE_VERSION
2142 ),
2143 ),
2144 (
2145 Some("unknown"),
2146 format!(
2147 r#"{{"wire_crate_version":"{}"}}"#,
2148 crate::SUBC_PROTOCOL_CRATE_VERSION
2149 ),
2150 ),
2151 ] {
2152 let provenance = build_provenance(input, None, None)
2153 .expect("the legacy build facts remain constructible");
2154 assert_eq!(provenance.build_git_sha_absence_reason, None);
2155 assert_eq!(
2156 serde_json::to_string(&provenance).expect("legacy provenance serializes"),
2157 expected
2158 );
2159 }
2160 }
2161
2162 #[test]
2163 fn build_provenance_derives_git_sha_absence_from_the_stamping_inputs() {
2164 let revision = "0123456789abcdef0123456789abcdef01234567";
2165 let cases = [
2166 (
2167 BuildGitShaSource::Git {
2168 revision,
2169 tree_state: GitTreeState::Clean,
2170 },
2171 Some(revision),
2172 None,
2173 ),
2174 (
2175 BuildGitShaSource::Git {
2176 revision,
2177 tree_state: GitTreeState::Dirty,
2178 },
2179 None,
2180 Some(BuildGitShaAbsenceReason::DeclinedDirty),
2181 ),
2182 (
2183 BuildGitShaSource::NeverDerived,
2184 None,
2185 Some(BuildGitShaAbsenceReason::NeverDerived),
2186 ),
2187 (
2188 BuildGitShaSource::NoGitDir,
2189 None,
2190 Some(BuildGitShaAbsenceReason::NoGitDir),
2191 ),
2192 ];
2193
2194 for (source, expected_sha, expected_reason) in cases {
2195 let provenance = build_provenance_from_source(source, None, None)
2196 .expect("every stamping state constructs honest provenance");
2197 assert_eq!(provenance.build_git_sha.as_deref(), expected_sha);
2198 assert_eq!(provenance.build_git_sha_absence_reason, expected_reason);
2199 }
2200 }
2201
2202 #[test]
2203 fn unknown_git_sha_absence_reason_round_trips_byte_faithfully() {
2204 let wire = format!(
2205 r#"{{"build_git_sha_absence_reason":"future_stamper_state","wire_crate_version":"{}"}}"#,
2206 crate::SUBC_PROTOCOL_CRATE_VERSION
2207 );
2208 let provenance: ManifestProvenance =
2209 serde_json::from_str(&wire).expect("future absence reasons remain readable");
2210
2211 assert_eq!(
2212 provenance.build_git_sha_absence_reason,
2213 Some(BuildGitShaAbsenceReason::ForwardCompatibleUnknown(
2214 "future_stamper_state".to_string()
2215 ))
2216 );
2217 assert_eq!(
2218 serde_json::to_string(&provenance).expect("future absence reason reserializes"),
2219 wire
2220 );
2221 }
2222
2223 #[test]
2224 fn provenance_rejects_an_absence_reason_beside_a_declared_commit() {
2225 let error = serde_json::from_value::<ManifestProvenance>(json!({
2226 "build_git_sha": "0123456789abcdef0123456789abcdef01234567",
2227 "build_git_sha_absence_reason": "declined_dirty"
2228 }))
2229 .expect_err("a declared commit cannot also claim an absence reason");
2230
2231 assert!(error.to_string().contains(
2232 "build_git_sha_absence_reason has must be omitted when build_git_sha is present"
2233 ));
2234 }
2235}