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