1use std::{collections::HashSet, fmt};
10
11use serde::{de::Error as _, Deserialize, Deserializer, Serialize};
12use serde_json::Value;
13
14use crate::PROTOCOL_VERSION;
15
16#[derive(Serialize, Debug, Clone, PartialEq)]
23#[non_exhaustive]
24pub struct ModuleManifest {
25 pub module_id: String,
26 pub module_version: String,
27 pub protocol_ver: u8,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub trust_tier: Option<TrustTier>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub ready: Option<bool>,
38 pub provides: Vec<ProviderRole>,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub consumes: Vec<ConsumerRole>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub bindings: Option<Bindings>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub capabilities: Option<CapabilityDeclarations>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub self_signals: Option<Vec<SelfSignalDeclaration>>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub provenance: Option<ManifestProvenance>,
90}
91
92#[derive(Debug, Clone)]
94pub struct ModuleManifestBuilder {
95 module_id: String,
96 module_version: String,
97 protocol_ver: u8,
98 trust_tier: Option<TrustTier>,
99 ready: Option<bool>,
100 provides: Vec<ProviderRole>,
101 consumes: Vec<ConsumerRole>,
102 bindings: Option<Bindings>,
103 capabilities: Option<CapabilityDeclarations>,
104 self_signals: Option<Vec<SelfSignalDeclaration>>,
105 provenance: Option<ManifestProvenance>,
106}
107
108impl ModuleManifest {
109 pub fn builder(
117 module_id: impl Into<String>,
118 module_version: impl Into<String>,
119 ) -> ModuleManifestBuilder {
120 ModuleManifestBuilder {
121 module_id: module_id.into(),
122 module_version: module_version.into(),
123 protocol_ver: PROTOCOL_VERSION,
124 trust_tier: None,
125 ready: None,
126 provides: Vec::new(),
127 consumes: Vec::new(),
128 bindings: None,
129 capabilities: None,
130 self_signals: None,
131 provenance: None,
132 }
133 }
134}
135
136impl ModuleManifestBuilder {
137 pub fn protocol_ver(mut self, protocol_ver: u8) -> Self {
139 self.protocol_ver = protocol_ver;
140 self
141 }
142
143 pub fn trust_tier(mut self, trust_tier: Option<TrustTier>) -> Self {
147 self.trust_tier = trust_tier;
148 self
149 }
150
151 pub fn ready(mut self, ready: bool) -> Self {
153 self.ready = Some(ready);
154 self
155 }
156
157 pub fn provides(mut self, provides: Vec<ProviderRole>) -> Self {
159 self.provides = provides;
160 self
161 }
162
163 pub fn consumes(mut self, consumes: Vec<ConsumerRole>) -> Self {
165 self.consumes = consumes;
166 self
167 }
168
169 pub fn bindings(mut self, bindings: Option<Bindings>) -> Self {
173 self.bindings = bindings;
174 self
175 }
176
177 pub fn capabilities(mut self, capabilities: Option<CapabilityDeclarations>) -> Self {
179 self.capabilities = capabilities;
180 self
181 }
182
183 pub fn self_signals(mut self, self_signals: Option<Vec<SelfSignalDeclaration>>) -> Self {
185 self.self_signals = self_signals;
186 self
187 }
188
189 pub fn provenance(mut self, provenance: Option<ManifestProvenance>) -> Self {
191 self.provenance = provenance;
192 self
193 }
194
195 pub fn build(self) -> ModuleManifest {
197 ModuleManifest {
198 module_id: self.module_id,
199 module_version: self.module_version,
200 protocol_ver: self.protocol_ver,
201 trust_tier: self.trust_tier,
202 ready: self.ready,
203 provides: self.provides,
204 consumes: self.consumes,
205 bindings: self.bindings,
206 capabilities: self.capabilities,
207 self_signals: self.self_signals,
208 provenance: self.provenance,
209 }
210 }
211}
212
213#[derive(Deserialize)]
231struct ModuleManifestWire {
232 module_id: String,
233 module_version: String,
234 protocol_ver: u8,
235 #[serde(default)]
236 trust_tier: Option<TrustTier>,
237 #[serde(default)]
238 ready: Option<bool>,
239 provides: Vec<ProviderRole>,
240 #[serde(default)]
241 consumes: Vec<ConsumerRole>,
242 #[serde(default)]
243 bindings: Option<Bindings>,
244 #[serde(default)]
245 capabilities: Option<CapabilityDeclarations>,
246 #[serde(default)]
247 self_signals: Option<Vec<SelfSignalDeclaration>>,
248 #[serde(default)]
249 provenance: Option<ManifestProvenance>,
250 #[serde(default)]
254 runtime_computed: Option<Value>,
255}
256
257impl<'de> Deserialize<'de> for ModuleManifest {
258 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
259 where
260 D: Deserializer<'de>,
261 {
262 let wire = ModuleManifestWire::deserialize(deserializer)?;
263 validate_runtime_computed(wire.runtime_computed.as_ref(), "runtime_computed")
264 .map_err(D::Error::custom)?;
265 let mut builder = Self::builder(wire.module_id, wire.module_version)
266 .protocol_ver(wire.protocol_ver)
267 .trust_tier(wire.trust_tier);
268 if let Some(ready) = wire.ready {
269 builder = builder.ready(ready);
270 }
271 let manifest = builder
272 .provides(wire.provides)
273 .consumes(wire.consumes)
274 .bindings(wire.bindings)
275 .capabilities(wire.capabilities)
276 .self_signals(wire.self_signals)
277 .provenance(wire.provenance)
278 .build();
279 manifest
280 .validate_capability_grammar()
281 .map_err(D::Error::custom)?;
282 Ok(manifest)
283 }
284}
285
286#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct SelfSignalDeclarationError {
289 module_id: String,
290 entry_index: usize,
291 field: &'static str,
292}
293
294impl fmt::Display for SelfSignalDeclarationError {
295 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296 write!(
297 f,
298 "module_id '{}' self_signals[{}] is missing required field '{}'",
299 self.module_id.escape_debug(),
300 self.entry_index,
301 self.field
302 )
303 }
304}
305
306pub fn validate_hello_self_signal_declarations(
313 hello: &Value,
314) -> Result<(), SelfSignalDeclarationError> {
315 let Some(manifest) = hello.get("manifest").and_then(Value::as_object) else {
316 return Ok(());
317 };
318 let module_id = manifest
319 .get("module_id")
320 .and_then(Value::as_str)
321 .unwrap_or("<unknown>");
322 let Some(entries) = manifest.get("self_signals").and_then(Value::as_array) else {
323 return Ok(());
324 };
325
326 for (entry_index, entry) in entries.iter().enumerate() {
327 let Some(entry) = entry.as_object() else {
328 continue;
329 };
330 for field in ["effect", "anchored_to"] {
331 if !entry.contains_key(field) {
332 return Err(SelfSignalDeclarationError {
333 module_id: module_id.to_string(),
334 entry_index,
335 field,
336 });
337 }
338 }
339 }
340 Ok(())
341}
342
343#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
345#[serde(deny_unknown_fields)]
346pub struct CapabilityDeclarations {
347 #[serde(default)]
348 pub provides: Vec<String>,
349 #[serde(default)]
350 pub requires: Vec<CapabilityRequirement>,
351 #[serde(default)]
352 pub must_never_reach: Vec<String>,
353}
354
355#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
357pub struct SelfSignalDeclaration {
358 pub name: String,
360 pub kind: SelfSignalKind,
388 pub effect: SelfSignalEffect,
390 pub anchored_to: SignalAnchor,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub cadence: Option<SignalCadence>,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub domain: Option<String>,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub note: Option<String>,
405}
406
407#[derive(Debug, Clone, PartialEq, Eq)]
409pub enum SelfSignalKind {
410 Keepalive,
411 Busy,
413 Poller,
414 Cron,
415 Sweep,
416 Watchdog,
417 Heartbeat,
418 Other(String),
419}
420
421impl SelfSignalKind {
422 fn wire_name(&self) -> &str {
423 match self {
424 Self::Keepalive => "keepalive",
425 Self::Busy => "busy",
426 Self::Poller => "poller",
427 Self::Cron => "cron",
428 Self::Sweep => "sweep",
429 Self::Watchdog => "watchdog",
430 Self::Heartbeat => "heartbeat",
431 Self::Other(value) => value,
432 }
433 }
434}
435
436impl Serialize for SelfSignalKind {
437 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
438 where
439 S: serde::Serializer,
440 {
441 serializer.serialize_str(self.wire_name())
442 }
443}
444
445impl<'de> Deserialize<'de> for SelfSignalKind {
446 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
447 where
448 D: Deserializer<'de>,
449 {
450 let value = String::deserialize(deserializer)?;
451 Ok(match value.as_str() {
452 "keepalive" => Self::Keepalive,
453 "busy" => Self::Busy,
454 "poller" => Self::Poller,
455 "cron" => Self::Cron,
456 "sweep" => Self::Sweep,
457 "watchdog" => Self::Watchdog,
458 "heartbeat" => Self::Heartbeat,
459 _ => Self::Other(value),
460 })
461 }
462}
463
464#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
466#[serde(rename_all = "lowercase")]
467pub enum SelfSignalEffect {
468 Observe,
469 Mutate,
470}
471
472#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
474#[serde(rename_all = "snake_case")]
475pub enum SignalAnchor {
476 FixedInterval,
479 Event { event: String },
482 HealthGauges { gauges: Vec<String> },
484}
485
486#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
488#[serde(rename_all = "snake_case")]
489pub enum SignalCadence {
490 Literal { interval_ms: u64 },
491 Derived { source: String },
492}
493
494#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
566pub struct ManifestProvenance {
567 #[serde(default, skip_serializing_if = "Option::is_none")]
568 pub build_git_sha: Option<String>,
569 #[serde(default, skip_serializing_if = "Option::is_none")]
573 pub build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
574 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub build_lock_digest: Option<String>,
576 #[serde(default, skip_serializing_if = "Option::is_none")]
585 pub wire_crate_version: Option<String>,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub store_schema_version: Option<String>,
588}
589
590#[derive(Debug, Clone, PartialEq, Eq)]
595pub enum BuildGitShaAbsenceReason {
596 DeclinedDirty,
597 NeverDerived,
598 NoGitDir,
599 ForwardCompatibleUnknown(String),
600}
601
602impl BuildGitShaAbsenceReason {
603 fn wire_name(&self) -> &str {
604 match self {
605 Self::DeclinedDirty => "declined_dirty",
606 Self::NeverDerived => "never_derived",
607 Self::NoGitDir => "no_git_dir",
608 Self::ForwardCompatibleUnknown(value) => value,
609 }
610 }
611}
612
613impl Serialize for BuildGitShaAbsenceReason {
614 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
615 where
616 S: serde::Serializer,
617 {
618 serializer.serialize_str(self.wire_name())
619 }
620}
621
622impl<'de> Deserialize<'de> for BuildGitShaAbsenceReason {
623 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
624 where
625 D: serde::Deserializer<'de>,
626 {
627 let value = String::deserialize(deserializer)?;
628 Ok(match value.as_str() {
629 "declined_dirty" => Self::DeclinedDirty,
630 "never_derived" => Self::NeverDerived,
631 "no_git_dir" => Self::NoGitDir,
632 _ => Self::ForwardCompatibleUnknown(value),
633 })
634 }
635}
636
637#[derive(Debug, Clone, Copy, PartialEq, Eq)]
639pub enum GitTreeState {
640 Clean,
641 Dirty,
642}
643
644#[derive(Debug, Clone, Copy, PartialEq, Eq)]
649pub enum BuildGitShaSource<'a> {
650 Git {
651 revision: &'a str,
652 tree_state: GitTreeState,
653 },
654 NeverDerived,
655 NoGitDir,
656}
657
658pub fn attestable_commit(revision: &str, tree_state: GitTreeState) -> Option<&str> {
662 match tree_state {
663 GitTreeState::Clean => Some(revision),
664 GitTreeState::Dirty => None,
665 }
666}
667
668const MAX_PROVENANCE_VALUE_BYTES: usize = 128;
669const BUILD_GIT_SHA_CANONICAL_FORM: &str = "exactly 40 lowercase hexadecimal characters";
670const BUILD_LOCK_DIGEST_CANONICAL_FORM: &str = "exactly 64 lowercase hexadecimal characters";
671
672#[derive(Deserialize)]
673struct ManifestProvenanceWire {
674 #[serde(default)]
675 build_git_sha: Option<String>,
676 #[serde(default)]
677 build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
678 #[serde(default)]
679 build_lock_digest: Option<String>,
680 #[serde(default)]
681 wire_crate_version: Option<String>,
682 #[serde(default)]
683 store_schema_version: Option<String>,
684}
685
686impl<'de> Deserialize<'de> for ManifestProvenance {
687 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
688 where
689 D: Deserializer<'de>,
690 {
691 let wire = ManifestProvenanceWire::deserialize(deserializer)?;
692 let provenance = Self {
693 build_git_sha: wire.build_git_sha,
694 build_git_sha_absence_reason: wire.build_git_sha_absence_reason,
695 build_lock_digest: wire.build_lock_digest,
696 wire_crate_version: wire.wire_crate_version,
697 store_schema_version: wire.store_schema_version,
698 };
699 provenance.validate().map_err(D::Error::custom)?;
700 Ok(provenance)
701 }
702}
703
704#[derive(Debug, Clone, PartialEq, Eq)]
706pub struct ProvenanceFormError {
707 field: &'static str,
708 length: usize,
709 canonical_form: &'static str,
710}
711
712impl ProvenanceFormError {
713 fn new(field: &'static str, length: usize, canonical_form: &'static str) -> Self {
714 Self {
715 field,
716 length,
717 canonical_form,
718 }
719 }
720
721 pub fn field(&self) -> &str {
723 self.field
724 }
725
726 pub fn length(&self) -> usize {
728 self.length
729 }
730
731 pub fn canonical_form(&self) -> &str {
733 self.canonical_form
734 }
735}
736
737impl fmt::Display for ProvenanceFormError {
738 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
739 write!(
740 f,
741 "invalid manifest provenance form: field {} has length {}; canonical form is {}",
742 self.field, self.length, self.canonical_form
743 )
744 }
745}
746
747impl std::error::Error for ProvenanceFormError {}
748
749#[derive(Debug, Clone, PartialEq, Eq)]
750pub struct ManifestProvenanceError {
751 field: String,
752 value: String,
753 reason: &'static str,
754}
755
756impl ManifestProvenanceError {
757 fn new(field: &str, value: &str, reason: &'static str) -> Self {
758 Self {
759 field: field.to_string(),
760 value: safe_error_value(value),
761 reason,
762 }
763 }
764
765 pub fn field(&self) -> &str {
766 &self.field
767 }
768}
769
770impl fmt::Display for ManifestProvenanceError {
771 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
772 write!(
773 f,
774 "invalid manifest provenance: field {} has {} (value {:?})",
775 self.field, self.reason, self.value
776 )
777 }
778}
779
780impl std::error::Error for ManifestProvenanceError {}
781
782impl ManifestProvenance {
783 pub fn validate(&self) -> Result<(), ManifestProvenanceError> {
784 if let (Some(_), Some(reason)) = (
785 self.build_git_sha.as_ref(),
786 self.build_git_sha_absence_reason.as_ref(),
787 ) {
788 return Err(ManifestProvenanceError::new(
789 "build_git_sha_absence_reason",
790 reason.wire_name(),
791 "must be omitted when build_git_sha is present",
792 ));
793 }
794 for (field, value) in [
795 ("build_git_sha", self.build_git_sha.as_deref()),
796 (
797 "build_git_sha_absence_reason",
798 self.build_git_sha_absence_reason
799 .as_ref()
800 .map(|reason| reason.wire_name()),
801 ),
802 ("build_lock_digest", self.build_lock_digest.as_deref()),
803 ("wire_crate_version", self.wire_crate_version.as_deref()),
804 ("store_schema_version", self.store_schema_version.as_deref()),
805 ] {
806 let Some(value) = value else { continue };
807 if value.is_empty() {
808 return Err(ManifestProvenanceError::new(
809 field,
810 value,
811 "must not be empty",
812 ));
813 }
814 if value.len() > MAX_PROVENANCE_VALUE_BYTES {
819 return Err(ManifestProvenanceError::new(
820 field,
821 value,
822 "exceeds the 128-byte maximum",
823 ));
824 }
825 if value.bytes().any(|byte| !(0x20..=0x7e).contains(&byte)) {
826 return Err(ManifestProvenanceError::new(
827 field,
828 value,
829 "contains non-printable ASCII",
830 ));
831 }
832 }
833 Ok(())
834 }
835}
836
837pub fn build_provenance(
855 build_git_sha: Option<&str>,
856 build_lock_digest: Option<&str>,
857 store_schema_version: Option<&str>,
858) -> Result<ManifestProvenance, ProvenanceFormError> {
859 let build_git_sha = normalize_and_validate_build_git_sha(build_git_sha)?;
860 build_provenance_with_build_git_sha(
861 build_git_sha,
862 None,
863 build_lock_digest,
864 store_schema_version,
865 )
866}
867
868pub fn build_provenance_from_source(
884 build_git_sha_source: BuildGitShaSource<'_>,
885 build_lock_digest: Option<&str>,
886 store_schema_version: Option<&str>,
887) -> Result<ManifestProvenance, ProvenanceFormError> {
888 let (raw_build_git_sha, mut build_git_sha_absence_reason) = match build_git_sha_source {
889 BuildGitShaSource::Git {
890 revision,
891 tree_state,
892 } => match attestable_commit(revision, tree_state) {
893 Some(revision) => (Some(revision), None),
894 None => (None, Some(BuildGitShaAbsenceReason::DeclinedDirty)),
895 },
896 BuildGitShaSource::NeverDerived => (None, Some(BuildGitShaAbsenceReason::NeverDerived)),
897 BuildGitShaSource::NoGitDir => (None, Some(BuildGitShaAbsenceReason::NoGitDir)),
898 };
899 let build_git_sha = normalize_and_validate_build_git_sha(raw_build_git_sha)?;
900 if build_git_sha.is_none() {
901 build_git_sha_absence_reason.get_or_insert(BuildGitShaAbsenceReason::NeverDerived);
902 }
903 build_provenance_with_build_git_sha(
904 build_git_sha,
905 build_git_sha_absence_reason,
906 build_lock_digest,
907 store_schema_version,
908 )
909}
910
911fn normalize_and_validate_build_git_sha(
912 build_git_sha: Option<&str>,
913) -> Result<Option<String>, ProvenanceFormError> {
914 let build_git_sha = normalize_provenance_fact(build_git_sha);
915 validate_provenance_form(
916 "build_git_sha",
917 build_git_sha.as_deref(),
918 BUILD_GIT_SHA_CANONICAL_FORM,
919 40,
920 )?;
921 Ok(build_git_sha)
922}
923
924fn build_provenance_with_build_git_sha(
925 build_git_sha: Option<String>,
926 build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
927 build_lock_digest: Option<&str>,
928 store_schema_version: Option<&str>,
929) -> Result<ManifestProvenance, ProvenanceFormError> {
930 let build_lock_digest = normalize_provenance_fact(build_lock_digest);
931 validate_provenance_form(
932 "build_lock_digest",
933 build_lock_digest.as_deref(),
934 BUILD_LOCK_DIGEST_CANONICAL_FORM,
935 64,
936 )?;
937
938 Ok(ManifestProvenance {
939 build_git_sha,
940 build_git_sha_absence_reason,
941 build_lock_digest,
942 wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
943 store_schema_version: normalize_provenance_fact(store_schema_version),
944 })
945}
946
947fn validate_provenance_form(
948 field: &'static str,
949 value: Option<&str>,
950 canonical_form: &'static str,
951 expected_length: usize,
952) -> Result<(), ProvenanceFormError> {
953 let Some(value) = value else { return Ok(()) };
954 if value.len() != expected_length
955 || !value
956 .bytes()
957 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
958 {
959 return Err(ProvenanceFormError::new(field, value.len(), canonical_form));
960 }
961 Ok(())
962}
963
964pub const PROVENANCE_SENTINELS: [&str; 3] = ["unknown", "unavailable", "none"];
972
973fn normalize_provenance_fact(value: Option<&str>) -> Option<String> {
974 let value = value?.trim();
975 if value.is_empty() {
976 return None;
977 }
978 let lowered = value.to_ascii_lowercase();
979 if PROVENANCE_SENTINELS.contains(&lowered.as_str()) {
980 return None;
981 }
982 Some(value.to_string())
983}
984
985#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
987#[serde(deny_unknown_fields)]
988pub struct CapabilityRequirement {
989 pub capability: String,
990 pub need: CapabilityNeed,
991}
992
993#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
995#[serde(rename_all = "snake_case")]
996pub enum CapabilityNeed {
997 Required,
998 Optional,
999}
1000
1001#[derive(Debug, Clone, PartialEq, Eq)]
1003pub struct CapabilityGrammarError {
1004 field: String,
1005 value: String,
1006}
1007
1008impl CapabilityGrammarError {
1009 fn new(field: impl Into<String>, value: impl AsRef<str>) -> Self {
1010 Self {
1011 field: field.into(),
1012 value: safe_error_value(value.as_ref()),
1013 }
1014 }
1015
1016 pub fn field(&self) -> &str {
1018 &self.field
1019 }
1020
1021 pub fn value(&self) -> &str {
1023 &self.value
1024 }
1025}
1026
1027impl fmt::Display for CapabilityGrammarError {
1028 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1029 write!(
1030 f,
1031 "invalid capability grammar: field {} has offending value {:?}",
1032 self.field, self.value
1033 )
1034 }
1035}
1036
1037impl std::error::Error for CapabilityGrammarError {}
1038
1039impl ModuleManifest {
1040 pub fn validate_capability_grammar(&self) -> Result<(), CapabilityGrammarError> {
1042 let Some(capabilities) = &self.capabilities else {
1043 return Ok(());
1044 };
1045
1046 validate_capability_list("capabilities.provides", &capabilities.provides)?;
1047 validate_requires(&capabilities.requires)?;
1048 validate_capability_list(
1049 "capabilities.must_never_reach",
1050 &capabilities.must_never_reach,
1051 )
1052 }
1053}
1054
1055pub fn validate_manifest_capability_grammar(
1060 manifest: &Value,
1061) -> Result<(), CapabilityGrammarError> {
1062 let Some(object) = manifest.as_object() else {
1063 return Ok(());
1064 };
1065
1066 validate_capabilities_value(object.get("capabilities"))?;
1067 validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
1068}
1069
1070pub fn validate_hello_capability_grammar(hello: &Value) -> Result<(), CapabilityGrammarError> {
1076 let Some(object) = hello.as_object() else {
1077 return Ok(());
1078 };
1079 if let Some(manifest) = object.get("manifest") {
1080 validate_manifest_capability_grammar(manifest)?;
1081 }
1082 validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
1083}
1084
1085pub fn is_valid_capability_identifier(identifier: &str) -> bool {
1087 if identifier.chars().any(char::is_whitespace) {
1088 return false;
1089 }
1090 let Some((name, version)) = identifier.split_once("/v") else {
1091 return false;
1092 };
1093 if name.is_empty() || name.len() > 64 || version.is_empty() {
1094 return false;
1095 }
1096
1097 let name_bytes = name.as_bytes();
1098 if !name_bytes[0].is_ascii_lowercase()
1099 || (name.len() > 1
1100 && !name_bytes[name.len() - 1].is_ascii_lowercase()
1101 && !name_bytes[name.len() - 1].is_ascii_digit())
1102 || name_bytes.windows(2).any(|pair| pair == b"--")
1103 {
1104 return false;
1105 }
1106 if !name_bytes
1107 .iter()
1108 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
1109 {
1110 return false;
1111 }
1112
1113 if version.len() > 1 && version.starts_with('0')
1114 || !version.bytes().all(|byte| byte.is_ascii_digit())
1115 {
1116 return false;
1117 }
1118 matches!(
1119 version.parse::<u64>(),
1120 Ok(value) if (1..=u64::from(u32::MAX)).contains(&value)
1121 )
1122}
1123
1124fn validate_capabilities_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
1125 let Some(value) = value else {
1126 return Ok(());
1127 };
1128 let Some(object) = value.as_object() else {
1129 return Err(CapabilityGrammarError::new(
1130 "capabilities",
1131 value_description(value),
1132 ));
1133 };
1134
1135 for (key, value) in object {
1136 if !matches!(key.as_str(), "provides" | "requires" | "must_never_reach") {
1137 return Err(CapabilityGrammarError::new(
1138 field_child("capabilities", key),
1139 value_description(value),
1140 ));
1141 }
1142 }
1143
1144 validate_capability_list_value("capabilities.provides", object.get("provides"))?;
1145 validate_requires_value(object.get("requires"))?;
1146 validate_capability_list_value(
1147 "capabilities.must_never_reach",
1148 object.get("must_never_reach"),
1149 )
1150}
1151
1152fn validate_capability_list_value(
1153 field: &str,
1154 value: Option<&Value>,
1155) -> Result<(), CapabilityGrammarError> {
1156 let Some(value) = value else {
1157 return Ok(());
1158 };
1159 let Some(values) = value.as_array() else {
1160 return Err(CapabilityGrammarError::new(field, value_description(value)));
1161 };
1162
1163 let mut seen = HashSet::new();
1164 for (index, value) in values.iter().enumerate() {
1165 let field = format!("{field}[{index}]");
1166 let Some(identifier) = value.as_str() else {
1167 return Err(CapabilityGrammarError::new(field, value_description(value)));
1168 };
1169 validate_capability_identifier(&field, identifier)?;
1170 if !seen.insert(identifier) {
1171 return Err(CapabilityGrammarError::new(field, identifier));
1172 }
1173 }
1174 Ok(())
1175}
1176
1177fn validate_requires_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
1178 let Some(value) = value else {
1179 return Ok(());
1180 };
1181 let Some(values) = value.as_array() else {
1182 return Err(CapabilityGrammarError::new(
1183 "capabilities.requires",
1184 value_description(value),
1185 ));
1186 };
1187
1188 let mut seen = HashSet::new();
1189 for (index, value) in values.iter().enumerate() {
1190 let entry_field = format!("capabilities.requires[{index}]");
1191 let Some(object) = value.as_object() else {
1192 return Err(CapabilityGrammarError::new(
1193 entry_field,
1194 value_description(value),
1195 ));
1196 };
1197 for (key, value) in object {
1198 if !matches!(key.as_str(), "capability" | "need") {
1199 return Err(CapabilityGrammarError::new(
1200 field_child(&entry_field, key),
1201 value_description(value),
1202 ));
1203 }
1204 }
1205 let capability_field = format!("{entry_field}.capability");
1206 let Some(capability) = object.get("capability").and_then(Value::as_str) else {
1207 return Err(CapabilityGrammarError::new(
1208 capability_field,
1209 object
1210 .get("capability")
1211 .map_or("<missing>".to_string(), value_description),
1212 ));
1213 };
1214 validate_capability_identifier(&capability_field, capability)?;
1215
1216 let need_field = format!("{entry_field}.need");
1217 let Some(need) = object.get("need").and_then(Value::as_str) else {
1218 return Err(CapabilityGrammarError::new(
1219 need_field,
1220 object
1221 .get("need")
1222 .map_or("<missing>".to_string(), value_description),
1223 ));
1224 };
1225 if !matches!(need, "required" | "optional") {
1226 return Err(CapabilityGrammarError::new(need_field, need));
1227 }
1228 if !seen.insert(capability) {
1229 return Err(CapabilityGrammarError::new(entry_field, capability));
1230 }
1231 }
1232 Ok(())
1233}
1234
1235fn validate_capability_list(field: &str, values: &[String]) -> Result<(), CapabilityGrammarError> {
1236 let mut seen = HashSet::new();
1237 for (index, identifier) in values.iter().enumerate() {
1238 let field = format!("{field}[{index}]");
1239 validate_capability_identifier(&field, identifier)?;
1240 if !seen.insert(identifier) {
1241 return Err(CapabilityGrammarError::new(field, identifier));
1242 }
1243 }
1244 Ok(())
1245}
1246
1247fn validate_requires(values: &[CapabilityRequirement]) -> Result<(), CapabilityGrammarError> {
1248 let mut seen = HashSet::new();
1249 for (index, requirement) in values.iter().enumerate() {
1250 let field = format!("capabilities.requires[{index}].capability");
1251 validate_capability_identifier(&field, &requirement.capability)?;
1252 if !seen.insert(&requirement.capability) {
1253 return Err(CapabilityGrammarError::new(
1254 format!("capabilities.requires[{index}]"),
1255 &requirement.capability,
1256 ));
1257 }
1258 }
1259 Ok(())
1260}
1261
1262fn validate_capability_identifier(
1263 field: &str,
1264 identifier: &str,
1265) -> Result<(), CapabilityGrammarError> {
1266 if is_valid_capability_identifier(identifier) {
1267 Ok(())
1268 } else {
1269 Err(CapabilityGrammarError::new(field, identifier))
1270 }
1271}
1272
1273fn validate_runtime_computed(
1274 value: Option<&Value>,
1275 field: &str,
1276) -> Result<(), CapabilityGrammarError> {
1277 let Some(value) = value else {
1278 return Ok(());
1279 };
1280 let Some(pointers) = value.as_array() else {
1281 return Err(CapabilityGrammarError::new(field, value_description(value)));
1282 };
1283
1284 for (index, pointer) in pointers.iter().enumerate() {
1285 let field = format!("{field}[{index}]");
1286 let Some(pointer) = pointer.as_str() else {
1287 return Err(CapabilityGrammarError::new(
1288 field,
1289 value_description(pointer),
1290 ));
1291 };
1292 let Some(tokens) = parse_json_pointer(pointer) else {
1293 return Err(CapabilityGrammarError::new(field, pointer));
1294 };
1295 if tokens.first().is_some_and(|token| token == "capabilities") {
1296 return Err(CapabilityGrammarError::new(field, pointer));
1297 }
1298 }
1299 Ok(())
1300}
1301
1302fn parse_json_pointer(pointer: &str) -> Option<Vec<String>> {
1303 if pointer.is_empty() {
1304 return Some(Vec::new());
1305 }
1306 let raw_tokens = pointer.strip_prefix('/')?;
1307 raw_tokens
1308 .split('/')
1309 .map(unescape_json_pointer_token)
1310 .collect()
1311}
1312
1313fn unescape_json_pointer_token(token: &str) -> Option<String> {
1314 let mut output = String::with_capacity(token.len());
1315 let mut characters = token.chars();
1316 while let Some(character) = characters.next() {
1317 if character != '~' {
1318 output.push(character);
1319 continue;
1320 }
1321 match characters.next()? {
1322 '0' => output.push('~'),
1323 '1' => output.push('/'),
1324 _ => return None,
1325 }
1326 }
1327 Some(output)
1328}
1329
1330fn field_child(parent: &str, child: &str) -> String {
1331 let child = safe_error_value(child);
1332 format!("{parent}.{child}")
1333}
1334
1335fn value_description(value: &Value) -> String {
1336 match value {
1337 Value::String(value) => safe_error_value(value),
1338 Value::Null => "null".to_string(),
1339 Value::Bool(value) => value.to_string(),
1340 Value::Number(value) => value.to_string(),
1341 Value::Array(_) => "<array>".to_string(),
1342 Value::Object(_) => "<object>".to_string(),
1343 }
1344}
1345
1346fn safe_error_value(value: &str) -> String {
1347 let lower = value.to_ascii_lowercase();
1348 if ["secret", "password", "api_key"]
1349 .iter()
1350 .any(|marker| lower.contains(marker))
1351 || lower.starts_with("sk-")
1352 || lower.starts_with("akia")
1353 || lower.starts_with("bearer ")
1354 || lower.starts_with("token=")
1355 || lower.starts_with("credential=")
1356 {
1357 "<redacted>".to_string()
1358 } else {
1359 value.to_string()
1360 }
1361}
1362
1363#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1369#[serde(rename_all = "snake_case")]
1370pub enum TrustTier {
1371 FirstParty,
1372 Reviewed,
1373 Untrusted,
1374}
1375
1376#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1380#[serde(tag = "role", rename_all = "snake_case")]
1381pub enum ProviderRole {
1382 ToolProvider {
1383 tools: Vec<Tool>,
1384 identity_scope: Vec<IdentityScope>,
1392 concurrency: Concurrency,
1393 emits_push: bool,
1394 sub_supervises: bool,
1395 },
1396 PipelineStage {
1397 stage: PipelineStageKind,
1398 applies_to: PipelineAppliesTo,
1399 interface: String,
1400 declares_frozen_floor: bool,
1401 needs_signals: Vec<String>,
1402 conformance_class: String,
1403 },
1404 ManagementSurface {
1405 operations: Vec<ManagementOperation>,
1406 config_schema: Value,
1407 observability: Vec<ObservabilitySurface>,
1408 identity_scope: Vec<IdentityScope>,
1412 #[serde(default)]
1413 concurrency: Concurrency,
1414 },
1415 InternalService {
1416 service_id: String,
1417 transport: InternalTransport,
1418 agent_facing: bool,
1419 operations: Vec<String>,
1420 },
1421}
1422
1423#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
1436#[serde(rename_all = "snake_case")]
1437pub enum ExecutionMode {
1438 Pure,
1439 Mutating,
1440 Unfenceable,
1441}
1442
1443#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1445pub struct Tool {
1446 pub name: String,
1447 #[serde(default, skip_serializing_if = "Option::is_none")]
1448 pub description: Option<String>,
1449 pub execution_mode: ExecutionMode,
1454 pub schema: Value,
1455}
1456
1457#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1463#[serde(rename_all = "snake_case")]
1464pub enum Concurrency {
1465 Serial,
1467 ModuleManaged,
1470 StatelessParallel,
1473}
1474
1475#[allow(clippy::derivable_impls)]
1476impl Default for Concurrency {
1487 fn default() -> Self {
1488 Self::ModuleManaged
1489 }
1490}
1491
1492#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1499#[serde(rename_all = "snake_case")]
1500pub enum IdentityScope {
1501 Session,
1502 Project,
1503}
1504
1505#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1507#[serde(rename_all = "snake_case")]
1508pub enum PipelineStageKind {
1509 Transform,
1510 Codec,
1511 Auth,
1512}
1513
1514#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1516pub struct PipelineAppliesTo {
1517 pub provider: String,
1518 pub model: String,
1519}
1520
1521#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1523pub struct ManagementOperation {
1524 pub name: String,
1525 pub kind: ManagementOperationKind,
1526 #[serde(default, skip_serializing_if = "Option::is_none")]
1527 pub description: Option<String>,
1528}
1529
1530#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1531#[serde(rename_all = "snake_case")]
1532pub enum ManagementOperationKind {
1533 Query,
1534 Mutate,
1535}
1536
1537#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1539pub struct ObservabilitySurface {
1540 pub name: String,
1541 pub kind: ObservabilityKind,
1542}
1543
1544#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1545#[serde(rename_all = "snake_case")]
1546pub enum ObservabilityKind {
1547 Snapshot,
1548 Stream,
1549}
1550
1551#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1552#[serde(rename_all = "snake_case")]
1553pub enum InternalTransport {
1554 Bulk,
1555}
1556
1557#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1559#[serde(tag = "role", rename_all = "snake_case")]
1560pub enum ConsumerRole {
1561 ToolClient { of: Vec<String> },
1562 LlmClient { via: String, auth: String },
1563 ServiceClient { of: Vec<String> },
1564}
1565
1566#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1568pub struct Bindings {
1569 pub storage: StorageBinding,
1570 pub vault_grants: Vec<VaultGrant>,
1571 pub identity: IdentityBinding,
1572}
1573
1574#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1576pub struct StorageBinding {
1577 pub kind: StorageKind,
1578 pub scope: StorageScope,
1579 pub owns_schema: bool,
1580}
1581
1582#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1583#[serde(rename_all = "snake_case")]
1584pub enum StorageKind {
1585 Sqlite,
1586}
1587
1588#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1589#[serde(rename_all = "snake_case")]
1590pub enum StorageScope {
1591 Project,
1592}
1593
1594#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1595pub struct VaultGrant {
1596 pub secret: String,
1597 pub reason: String,
1598}
1599
1600#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1601pub struct IdentityBinding {
1602 pub requires: Vec<IdentityScope>,
1603 pub optional: Vec<IdentityScope>,
1604}
1605
1606#[cfg(test)]
1607mod tests {
1608 use super::*;
1609 use serde_json::json;
1610
1611 fn aft_manifest_fixture() -> ModuleManifest {
1612 ModuleManifest::builder("aft", "0.39.2")
1613 .trust_tier(Some(TrustTier::FirstParty))
1614 .bindings(Some(Bindings {
1615 storage: StorageBinding {
1616 kind: StorageKind::Sqlite,
1617 scope: StorageScope::Project,
1618 owns_schema: true,
1619 },
1620 vault_grants: vec![VaultGrant {
1621 secret: "provider_api_key".to_string(),
1622 reason: "cortexkit_native auth".to_string(),
1623 }],
1624 identity: IdentityBinding {
1625 requires: vec![IdentityScope::Project],
1626 optional: vec![IdentityScope::Session],
1627 },
1628 }))
1629 .protocol_ver(1)
1630 .provides(vec![ProviderRole::ToolProvider {
1631 tools: vec![
1632 Tool {
1633 name: "read".to_string(),
1634 description: None,
1635 execution_mode: ExecutionMode::Pure,
1636 schema: json!({"type": "object"}),
1637 },
1638 Tool {
1639 name: "grep".to_string(),
1640 description: None,
1641 execution_mode: ExecutionMode::Pure,
1642 schema: json!({"type": "object"}),
1643 },
1644 Tool {
1645 name: "outline".to_string(),
1646 description: None,
1647 execution_mode: ExecutionMode::Pure,
1648 schema: json!({"type": "object"}),
1649 },
1650 Tool {
1651 name: "semantic_search".to_string(),
1652 description: None,
1653 execution_mode: ExecutionMode::Pure,
1654 schema: json!({"type": "object"}),
1655 },
1656 Tool {
1657 name: "edit".to_string(),
1658 description: None,
1659 execution_mode: ExecutionMode::Mutating,
1660 schema: json!({"type": "object"}),
1661 },
1662 Tool {
1663 name: "write".to_string(),
1664 description: None,
1665 execution_mode: ExecutionMode::Mutating,
1666 schema: json!({"type": "object"}),
1667 },
1668 Tool {
1669 name: "bash".to_string(),
1670 description: None,
1671 execution_mode: ExecutionMode::Unfenceable,
1672 schema: json!({"type": "object"}),
1673 },
1674 ],
1675 identity_scope: vec![IdentityScope::Session, IdentityScope::Project],
1676 concurrency: Concurrency::ModuleManaged,
1677 emits_push: true,
1678 sub_supervises: true,
1679 }])
1680 .consumes(vec![ConsumerRole::ServiceClient {
1681 of: vec!["embedding.v2".to_string()],
1682 }])
1683 .build()
1684 }
1685
1686 #[test]
1687 fn serde_round_trips_representative_manifest() {
1688 let manifest = aft_manifest_fixture();
1689 let serialized = serde_json::to_string_pretty(&manifest).unwrap();
1690 let decoded: ModuleManifest = serde_json::from_str(&serialized).unwrap();
1691
1692 assert_eq!(manifest, decoded);
1693 }
1694
1695 #[test]
1696 fn builder_defaults_additions_to_honest_absence_and_round_trips() {
1697 let manifest = ModuleManifest::builder("builder-defaults", "2.0.0").build();
1698
1699 assert_eq!(manifest.module_id, "builder-defaults");
1700 assert_eq!(manifest.module_version, "2.0.0");
1701 assert_eq!(manifest.protocol_ver, PROTOCOL_VERSION);
1702 assert_eq!(manifest.trust_tier, None);
1703 assert!(manifest.provides.is_empty());
1704 assert!(manifest.consumes.is_empty());
1705 assert_eq!(manifest.bindings, None);
1706 assert_eq!(manifest.capabilities, None);
1707 assert_eq!(manifest.self_signals, None);
1708 assert_eq!(manifest.provenance, None);
1709
1710 let encoded = serde_json::to_value(&manifest).expect("builder manifest serializes");
1711 for optional in [
1712 "trust_tier",
1713 "consumes",
1714 "bindings",
1715 "capabilities",
1716 "self_signals",
1717 "provenance",
1718 ] {
1719 assert!(
1720 encoded.get(optional).is_none(),
1721 "an absent {optional} declaration must stay absent on the wire"
1722 );
1723 }
1724 let decoded: ModuleManifest =
1725 serde_json::from_value(encoded).expect("builder manifest round-trips");
1726 assert_eq!(decoded, manifest);
1727 }
1728
1729 #[test]
1730 fn fully_populated_builder_manifest_matches_the_literal_wire_golden() {
1731 let manifest = ModuleManifest::builder("full-builder", "2.0.0")
1732 .trust_tier(Some(TrustTier::Reviewed))
1733 .bindings(Some(Bindings {
1734 storage: StorageBinding {
1735 kind: StorageKind::Sqlite,
1736 scope: StorageScope::Project,
1737 owns_schema: false,
1738 },
1739 vault_grants: Vec::new(),
1740 identity: IdentityBinding {
1741 requires: vec![IdentityScope::Project],
1742 optional: Vec::new(),
1743 },
1744 }))
1745 .provides(vec![ProviderRole::ToolProvider {
1746 tools: vec![Tool {
1747 name: "read".to_string(),
1748 description: None,
1749 execution_mode: ExecutionMode::Pure,
1750 schema: json!({"type": "object"}),
1751 }],
1752 identity_scope: vec![IdentityScope::Project],
1753 concurrency: Concurrency::Serial,
1754 emits_push: false,
1755 sub_supervises: false,
1756 }])
1757 .consumes(vec![ConsumerRole::ServiceClient {
1758 of: vec!["embedding.v2".to_string()],
1759 }])
1760 .capabilities(Some(CapabilityDeclarations {
1761 provides: vec!["embedding/v2".to_string()],
1762 requires: Vec::new(),
1763 must_never_reach: Vec::new(),
1764 }))
1765 .self_signals(Some(vec![SelfSignalDeclaration {
1766 name: "usage_poller".to_string(),
1767 kind: SelfSignalKind::Poller,
1768 effect: SelfSignalEffect::Observe,
1769 anchored_to: SignalAnchor::FixedInterval,
1770 cadence: Some(SignalCadence::Literal {
1771 interval_ms: 60_000,
1772 }),
1773 domain: Some("provider-usage".to_string()),
1774 note: None,
1775 }]))
1776 .provenance(Some(ManifestProvenance {
1777 build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
1778 build_git_sha_absence_reason: None,
1779 build_lock_digest: Some(
1780 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
1781 ),
1782 wire_crate_version: Some("0.16.0".to_string()),
1783 store_schema_version: Some("42".to_string()),
1784 }))
1785 .build();
1786
1787 assert_eq!(
1788 serde_json::to_vec(&manifest).expect("builder manifest serializes"),
1789 include_bytes!("../tests/golden/module_manifest_builder_full.json"),
1790 "the builder must preserve the prior fully populated literal wire bytes"
1791 );
1792 }
1793
1794 #[test]
1795 fn old_manifest_with_unread_fields_decodes_and_round_trips_verbatim() {
1796 let raw = include_bytes!("../tests/golden/module_manifest_builder_full.json");
1797 let decoded: ModuleManifest =
1798 serde_json::from_slice(raw).expect("old manifest with all unread fields decodes");
1799
1800 assert_eq!(decoded.trust_tier, Some(TrustTier::Reviewed));
1801 assert!(!decoded.consumes.is_empty());
1802 assert!(decoded.bindings.is_some());
1803
1804 let reencoded = serde_json::to_vec(&decoded).expect("re-encode succeeds");
1805 assert_eq!(
1806 reencoded, raw,
1807 "old manifest relay stays byte-for-byte verbatim"
1808 );
1809 }
1810
1811 #[test]
1812 fn new_manifest_omits_unread_fields_on_wire_and_decodes_cleanly() {
1813 let raw = include_bytes!("../tests/golden/module_manifest_diet.json");
1814 let decoded: ModuleManifest =
1815 serde_json::from_slice(raw).expect("new manifest omitting unread fields decodes");
1816
1817 assert_eq!(decoded.trust_tier, None);
1818 assert!(decoded.consumes.is_empty());
1819 assert_eq!(decoded.bindings, None);
1820
1821 let pretty = format!("{}\n", serde_json::to_string_pretty(&decoded).unwrap());
1822 assert_eq!(
1823 pretty.as_bytes(),
1824 raw,
1825 "new manifest matches golden byte-for-byte without unread keys"
1826 );
1827
1828 let as_val: serde_json::Value = serde_json::to_value(&decoded).unwrap();
1829 assert!(
1830 as_val.get("trust_tier").is_none(),
1831 "no trust_tier on wire for new manifest"
1832 );
1833 assert!(
1834 as_val.get("consumes").is_none(),
1835 "no consumes on wire for empty consumes"
1836 );
1837 assert!(
1838 as_val.get("bindings").is_none(),
1839 "no bindings on wire for new manifest"
1840 );
1841 }
1842
1843 #[test]
1844 fn aft_manifest_fixture_matches_v1_contract() {
1845 let manifest = aft_manifest_fixture();
1846
1847 assert_eq!(manifest.module_id, "aft");
1848 let ProviderRole::ToolProvider {
1849 tools,
1850 identity_scope,
1851 concurrency,
1852 emits_push,
1853 sub_supervises,
1854 } = &manifest.provides[0]
1855 else {
1856 panic!("AFT fixture must expose one tool_provider role");
1857 };
1858
1859 assert_eq!(*concurrency, Concurrency::ModuleManaged);
1860 assert!(*emits_push);
1861 assert!(*sub_supervises);
1862 assert_eq!(
1863 identity_scope,
1864 &vec![IdentityScope::Session, IdentityScope::Project]
1865 );
1866 assert_eq!(
1867 tools
1868 .iter()
1869 .map(|tool| (tool.name.as_str(), tool.execution_mode))
1870 .collect::<Vec<_>>(),
1871 vec![
1872 ("read", ExecutionMode::Pure),
1873 ("grep", ExecutionMode::Pure),
1874 ("outline", ExecutionMode::Pure),
1875 ("semantic_search", ExecutionMode::Pure),
1876 ("edit", ExecutionMode::Mutating),
1877 ("write", ExecutionMode::Mutating),
1878 ("bash", ExecutionMode::Unfenceable),
1879 ]
1880 );
1881 }
1882
1883 #[test]
1884 fn tool_provider_role_tag_serializes_as_snake_case() {
1885 let manifest = aft_manifest_fixture();
1886 let value = serde_json::to_value(&manifest).unwrap();
1887
1888 assert_eq!(value["provides"][0]["role"], "tool_provider");
1889 }
1890
1891 #[test]
1892 fn manifest_without_capabilities_preserves_the_existing_wire_shape() {
1893 let manifest = aft_manifest_fixture();
1894 let encoded = serde_json::to_value(&manifest).expect("manifest serializes");
1895 assert!(encoded.get("capabilities").is_none());
1896
1897 let decoded: ModuleManifest =
1898 serde_json::from_value(encoded).expect("legacy manifest parses");
1899 assert_eq!(decoded.capabilities, None);
1900 }
1901
1902 #[test]
1903 fn capability_identifier_lexical_grammar_accepts_only_pinned_forms() {
1904 for identifier in [
1905 "a/v1",
1906 "credentials-provider/v1",
1907 "a1-b2/v4294967295",
1908 "a123456789012345678901234567890123456789012345678901234567890123/v1",
1909 ] {
1910 assert!(
1911 is_valid_capability_identifier(identifier),
1912 "identifier must be accepted: {identifier}"
1913 );
1914 }
1915
1916 for identifier in [
1917 "credentials-Provider/v1",
1918 "credentials-provider/v01",
1919 "credentials-provider-/v1",
1920 "credentials--provider/v1",
1921 "Credentials-provider/v1",
1922 "credentials-provider/1",
1923 "credentials provider/v1",
1924 "credentials-provider/v0",
1925 "credentials-provider/v4294967296",
1926 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/v1",
1927 ] {
1928 assert!(
1929 !is_valid_capability_identifier(identifier),
1930 "identifier must be rejected: {identifier}"
1931 );
1932 }
1933 }
1934
1935 #[test]
1936 fn capability_grammar_errors_redact_secret_shaped_values() {
1937 let error = validate_manifest_capability_grammar(&json!({
1938 "capabilities": { "provides": ["sk-secret-value/v0"] }
1939 }))
1940 .expect_err("secret-shaped capability identifier is malformed");
1941 assert_eq!(error.field(), "capabilities.provides[0]");
1942 assert_eq!(error.value(), "<redacted>");
1943 assert!(!error.to_string().contains("sk-secret-value"));
1944 }
1945
1946 #[test]
1953 fn provenance_builder_sentinels_become_field_omission() {
1954 for sentinel in [
1955 "unknown",
1956 "UNKNOWN",
1957 "Unknown",
1958 "unavailable",
1959 "none",
1960 "None",
1961 " unknown ",
1962 "",
1963 ] {
1964 let p = build_provenance_from_source(
1965 BuildGitShaSource::Git {
1966 revision: sentinel,
1967 tree_state: GitTreeState::Clean,
1968 },
1969 Some(sentinel),
1970 Some(sentinel),
1971 )
1972 .expect("sentinels are omitted before form validation");
1973 assert_eq!(
1974 (
1975 p.build_git_sha,
1976 p.build_git_sha_absence_reason,
1977 p.build_lock_digest,
1978 p.store_schema_version,
1979 ),
1980 (
1981 None,
1982 Some(BuildGitShaAbsenceReason::NeverDerived),
1983 None,
1984 None,
1985 ),
1986 "sentinel {sentinel:?} must be omitted, not published"
1987 );
1988 }
1989 let real = build_provenance_from_source(
1990 BuildGitShaSource::Git {
1991 revision: "0123456789abcdef0123456789abcdef01234567",
1992 tree_state: GitTreeState::Clean,
1993 },
1994 None,
1995 Some("9"),
1996 )
1997 .expect("canonical build revision is accepted");
1998 assert_eq!(
1999 real.build_git_sha.as_deref(),
2000 Some("0123456789abcdef0123456789abcdef01234567")
2001 );
2002 assert_eq!(real.store_schema_version.as_deref(), Some("9"));
2003 assert_eq!(
2007 real.wire_crate_version.as_deref(),
2008 Some(crate::SUBC_PROTOCOL_CRATE_VERSION)
2009 );
2010 }
2011
2012 #[test]
2013 fn build_provenance_accepts_canonical_sha_and_lock_digest() {
2014 let provenance = build_provenance_from_source(
2015 BuildGitShaSource::Git {
2016 revision: " 0123456789abcdef0123456789abcdef01234567 ",
2017 tree_state: GitTreeState::Clean,
2018 },
2019 Some(" abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 "),
2020 Some(" schema-v3 "),
2021 )
2022 .expect("canonical build facts are accepted");
2023
2024 assert_eq!(
2025 provenance,
2026 ManifestProvenance {
2027 build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
2028 build_git_sha_absence_reason: None,
2029 build_lock_digest: Some(
2030 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
2031 ),
2032 wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
2033 store_schema_version: Some("schema-v3".to_string()),
2034 }
2035 );
2036 }
2037
2038 #[test]
2039 fn build_provenance_refuses_an_abbreviated_git_sha() {
2040 let error = build_provenance_from_source(
2041 BuildGitShaSource::Git {
2042 revision: "0123456789ab",
2043 tree_state: GitTreeState::Clean,
2044 },
2045 None,
2046 None,
2047 )
2048 .expect_err("a 12-character abbreviation is not canonical");
2049
2050 assert_eq!(error.field(), "build_git_sha");
2051 assert_eq!(error.length(), 12);
2052 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2053 assert_eq!(
2054 error.to_string(),
2055 "invalid manifest provenance form: field build_git_sha has length 12; canonical form is exactly 40 lowercase hexadecimal characters"
2056 );
2057 }
2058
2059 #[test]
2060 fn build_provenance_refuses_an_abbreviated_lock_digest() {
2061 let error = build_provenance_from_source(
2062 BuildGitShaSource::NeverDerived,
2063 Some("0123456789abcdef"),
2064 None,
2065 )
2066 .expect_err("a 16-character digest is not canonical");
2067
2068 assert_eq!(error.field(), "build_lock_digest");
2069 assert_eq!(error.length(), 16);
2070 assert_eq!(error.canonical_form(), BUILD_LOCK_DIGEST_CANONICAL_FORM);
2071 }
2072
2073 #[test]
2074 fn build_provenance_refuses_uppercase_hex() {
2075 let uppercase_sha = "A".repeat(40);
2076 let error = build_provenance_from_source(
2077 BuildGitShaSource::Git {
2078 revision: &uppercase_sha,
2079 tree_state: GitTreeState::Clean,
2080 },
2081 None,
2082 None,
2083 )
2084 .expect_err("uppercase hexadecimal is not canonical");
2085
2086 assert_eq!(error.field(), "build_git_sha");
2087 assert_eq!(error.length(), 40);
2088 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2089 }
2090
2091 #[test]
2092 fn build_provenance_refuses_dirty_revision_stamp_claimed_clean() {
2093 let error = build_provenance_from_source(
2094 BuildGitShaSource::Git {
2095 revision: "0123456789abcdef0123456789abcdef01234567-dirty",
2096 tree_state: GitTreeState::Clean,
2097 },
2098 None,
2099 None,
2100 )
2101 .expect_err("a dirty stamp is not a canonical build revision");
2102
2103 assert_eq!(error.field(), "build_git_sha");
2104 assert_eq!(error.length(), 46);
2105 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2106 }
2107
2108 #[test]
2109 fn build_provenance_keeps_a_lock_digest_when_identity_is_unavailable() {
2110 let provenance = build_provenance_from_source(
2111 BuildGitShaSource::Git {
2112 revision: "unavailable",
2113 tree_state: GitTreeState::Clean,
2114 },
2115 Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"),
2116 None,
2117 )
2118 .expect("sentinel SHA is omitted before the valid lock digest is checked");
2119
2120 assert_eq!(provenance.build_git_sha, None);
2121 assert_eq!(
2122 provenance.build_lock_digest,
2123 Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string())
2124 );
2125 assert_eq!(
2126 provenance.wire_crate_version,
2127 Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
2128 );
2129 }
2130
2131 #[test]
2132 fn build_provenance_omits_fully_unavailable_inputs() {
2133 let provenance = build_provenance_from_source(
2134 BuildGitShaSource::NeverDerived,
2135 Some(" unavailable "),
2136 Some(" "),
2137 )
2138 .expect("omitted and sentinel inputs are not form errors");
2139
2140 assert_eq!(provenance.build_git_sha, None);
2141 assert_eq!(provenance.build_lock_digest, None);
2142 assert_eq!(provenance.store_schema_version, None);
2143 assert_eq!(
2144 provenance.wire_crate_version,
2145 Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
2146 );
2147 }
2148
2149 #[test]
2150 fn legacy_build_provenance_keeps_master_wire_bytes_without_an_absence_reason() {
2151 let revision = "0123456789abcdef0123456789abcdef01234567";
2152 for (input, expected) in [
2153 (
2154 Some(revision),
2155 format!(
2156 r#"{{"build_git_sha":"{revision}","wire_crate_version":"{}"}}"#,
2157 crate::SUBC_PROTOCOL_CRATE_VERSION
2158 ),
2159 ),
2160 (
2161 None,
2162 format!(
2163 r#"{{"wire_crate_version":"{}"}}"#,
2164 crate::SUBC_PROTOCOL_CRATE_VERSION
2165 ),
2166 ),
2167 (
2168 Some("unknown"),
2169 format!(
2170 r#"{{"wire_crate_version":"{}"}}"#,
2171 crate::SUBC_PROTOCOL_CRATE_VERSION
2172 ),
2173 ),
2174 ] {
2175 let provenance = build_provenance(input, None, None)
2176 .expect("the legacy build facts remain constructible");
2177 assert_eq!(provenance.build_git_sha_absence_reason, None);
2178 assert_eq!(
2179 serde_json::to_string(&provenance).expect("legacy provenance serializes"),
2180 expected
2181 );
2182 }
2183 }
2184
2185 #[test]
2186 fn build_provenance_derives_git_sha_absence_from_the_stamping_inputs() {
2187 let revision = "0123456789abcdef0123456789abcdef01234567";
2188 let cases = [
2189 (
2190 BuildGitShaSource::Git {
2191 revision,
2192 tree_state: GitTreeState::Clean,
2193 },
2194 Some(revision),
2195 None,
2196 ),
2197 (
2198 BuildGitShaSource::Git {
2199 revision,
2200 tree_state: GitTreeState::Dirty,
2201 },
2202 None,
2203 Some(BuildGitShaAbsenceReason::DeclinedDirty),
2204 ),
2205 (
2206 BuildGitShaSource::NeverDerived,
2207 None,
2208 Some(BuildGitShaAbsenceReason::NeverDerived),
2209 ),
2210 (
2211 BuildGitShaSource::NoGitDir,
2212 None,
2213 Some(BuildGitShaAbsenceReason::NoGitDir),
2214 ),
2215 ];
2216
2217 for (source, expected_sha, expected_reason) in cases {
2218 let provenance = build_provenance_from_source(source, None, None)
2219 .expect("every stamping state constructs honest provenance");
2220 assert_eq!(provenance.build_git_sha.as_deref(), expected_sha);
2221 assert_eq!(provenance.build_git_sha_absence_reason, expected_reason);
2222 }
2223 }
2224
2225 #[test]
2226 fn unknown_git_sha_absence_reason_round_trips_byte_faithfully() {
2227 let wire = format!(
2228 r#"{{"build_git_sha_absence_reason":"future_stamper_state","wire_crate_version":"{}"}}"#,
2229 crate::SUBC_PROTOCOL_CRATE_VERSION
2230 );
2231 let provenance: ManifestProvenance =
2232 serde_json::from_str(&wire).expect("future absence reasons remain readable");
2233
2234 assert_eq!(
2235 provenance.build_git_sha_absence_reason,
2236 Some(BuildGitShaAbsenceReason::ForwardCompatibleUnknown(
2237 "future_stamper_state".to_string()
2238 ))
2239 );
2240 assert_eq!(
2241 serde_json::to_string(&provenance).expect("future absence reason reserializes"),
2242 wire
2243 );
2244 }
2245
2246 #[test]
2247 fn provenance_rejects_an_absence_reason_beside_a_declared_commit() {
2248 let error = serde_json::from_value::<ManifestProvenance>(json!({
2249 "build_git_sha": "0123456789abcdef0123456789abcdef01234567",
2250 "build_git_sha_absence_reason": "declined_dirty"
2251 }))
2252 .expect_err("a declared commit cannot also claim an absence reason");
2253
2254 assert!(error.to_string().contains(
2255 "build_git_sha_absence_reason has must be omitted when build_git_sha is present"
2256 ));
2257 }
2258}