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, Default, PartialEq, Eq)]
571#[non_exhaustive]
572pub struct ManifestProvenance {
573 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub build_git_sha: Option<String>,
575 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
580 #[serde(default, skip_serializing_if = "Option::is_none")]
581 pub build_lock_digest: Option<String>,
582 #[serde(default, skip_serializing_if = "Option::is_none")]
591 pub wire_crate_version: Option<String>,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub store_schema_version: Option<String>,
594 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub launch_nonce_source: Option<LaunchNonceSource>,
600}
601
602#[derive(Debug, Clone, PartialEq, Eq)]
609#[non_exhaustive]
610pub enum LaunchNonceSource {
611 Fd,
613 Env,
615 ForwardCompatibleUnknown(String),
616}
617
618impl LaunchNonceSource {
619 pub fn wire_name(&self) -> &str {
621 match self {
622 Self::Fd => "fd",
623 Self::Env => "env",
624 Self::ForwardCompatibleUnknown(value) => value,
625 }
626 }
627
628 pub fn from_wire_name(value: &str) -> Self {
630 match value {
631 "fd" => Self::Fd,
632 "env" => Self::Env,
633 _ => Self::ForwardCompatibleUnknown(value.to_string()),
634 }
635 }
636}
637
638impl Serialize for LaunchNonceSource {
639 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
640 where
641 S: serde::Serializer,
642 {
643 serializer.serialize_str(self.wire_name())
644 }
645}
646
647impl<'de> Deserialize<'de> for LaunchNonceSource {
648 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
649 where
650 D: serde::Deserializer<'de>,
651 {
652 let value = String::deserialize(deserializer)?;
653 Ok(Self::from_wire_name(&value))
654 }
655}
656
657#[derive(Debug, Clone, PartialEq, Eq)]
662pub enum BuildGitShaAbsenceReason {
663 DeclinedDirty,
664 NeverDerived,
665 NoGitDir,
666 ForwardCompatibleUnknown(String),
667}
668
669impl BuildGitShaAbsenceReason {
670 fn wire_name(&self) -> &str {
671 match self {
672 Self::DeclinedDirty => "declined_dirty",
673 Self::NeverDerived => "never_derived",
674 Self::NoGitDir => "no_git_dir",
675 Self::ForwardCompatibleUnknown(value) => value,
676 }
677 }
678}
679
680impl Serialize for BuildGitShaAbsenceReason {
681 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
682 where
683 S: serde::Serializer,
684 {
685 serializer.serialize_str(self.wire_name())
686 }
687}
688
689impl<'de> Deserialize<'de> for BuildGitShaAbsenceReason {
690 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
691 where
692 D: serde::Deserializer<'de>,
693 {
694 let value = String::deserialize(deserializer)?;
695 Ok(match value.as_str() {
696 "declined_dirty" => Self::DeclinedDirty,
697 "never_derived" => Self::NeverDerived,
698 "no_git_dir" => Self::NoGitDir,
699 _ => Self::ForwardCompatibleUnknown(value),
700 })
701 }
702}
703
704#[derive(Debug, Clone, Copy, PartialEq, Eq)]
706pub enum GitTreeState {
707 Clean,
708 Dirty,
709}
710
711#[derive(Debug, Clone, Copy, PartialEq, Eq)]
716pub enum BuildGitShaSource<'a> {
717 Git {
718 revision: &'a str,
719 tree_state: GitTreeState,
720 },
721 NeverDerived,
722 NoGitDir,
723}
724
725pub fn attestable_commit(revision: &str, tree_state: GitTreeState) -> Option<&str> {
729 match tree_state {
730 GitTreeState::Clean => Some(revision),
731 GitTreeState::Dirty => None,
732 }
733}
734
735const MAX_PROVENANCE_VALUE_BYTES: usize = 128;
736const BUILD_GIT_SHA_CANONICAL_FORM: &str = "exactly 40 lowercase hexadecimal characters";
737const BUILD_LOCK_DIGEST_CANONICAL_FORM: &str = "exactly 64 lowercase hexadecimal characters";
738
739#[derive(Deserialize)]
740struct ManifestProvenanceWire {
741 #[serde(default)]
742 build_git_sha: Option<String>,
743 #[serde(default)]
744 build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
745 #[serde(default)]
746 build_lock_digest: Option<String>,
747 #[serde(default)]
748 wire_crate_version: Option<String>,
749 #[serde(default)]
750 store_schema_version: Option<String>,
751 #[serde(default)]
752 launch_nonce_source: Option<LaunchNonceSource>,
753}
754
755impl<'de> Deserialize<'de> for ManifestProvenance {
756 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
757 where
758 D: Deserializer<'de>,
759 {
760 let wire = ManifestProvenanceWire::deserialize(deserializer)?;
761 let provenance = Self {
762 build_git_sha: wire.build_git_sha,
763 build_git_sha_absence_reason: wire.build_git_sha_absence_reason,
764 build_lock_digest: wire.build_lock_digest,
765 wire_crate_version: wire.wire_crate_version,
766 store_schema_version: wire.store_schema_version,
767 launch_nonce_source: wire.launch_nonce_source,
768 };
769 provenance.validate().map_err(D::Error::custom)?;
770 Ok(provenance)
771 }
772}
773
774#[derive(Debug, Clone, PartialEq, Eq)]
776pub struct ProvenanceFormError {
777 field: &'static str,
778 length: usize,
779 canonical_form: &'static str,
780}
781
782impl ProvenanceFormError {
783 fn new(field: &'static str, length: usize, canonical_form: &'static str) -> Self {
784 Self {
785 field,
786 length,
787 canonical_form,
788 }
789 }
790
791 pub fn field(&self) -> &str {
793 self.field
794 }
795
796 pub fn length(&self) -> usize {
798 self.length
799 }
800
801 pub fn canonical_form(&self) -> &str {
803 self.canonical_form
804 }
805}
806
807impl fmt::Display for ProvenanceFormError {
808 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
809 write!(
810 f,
811 "invalid manifest provenance form: field {} has length {}; canonical form is {}",
812 self.field, self.length, self.canonical_form
813 )
814 }
815}
816
817impl std::error::Error for ProvenanceFormError {}
818
819#[derive(Debug, Clone, PartialEq, Eq)]
820pub struct ManifestProvenanceError {
821 field: String,
822 value: String,
823 reason: &'static str,
824}
825
826impl ManifestProvenanceError {
827 fn new(field: &str, value: &str, reason: &'static str) -> Self {
828 Self {
829 field: field.to_string(),
830 value: safe_error_value(value),
831 reason,
832 }
833 }
834
835 pub fn field(&self) -> &str {
836 &self.field
837 }
838}
839
840impl fmt::Display for ManifestProvenanceError {
841 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
842 write!(
843 f,
844 "invalid manifest provenance: field {} has {} (value {:?})",
845 self.field, self.reason, self.value
846 )
847 }
848}
849
850impl std::error::Error for ManifestProvenanceError {}
851
852impl ManifestProvenance {
853 pub fn new() -> Self {
855 Self::default()
856 }
857
858 pub fn with_build_git_sha(mut self, value: Option<String>) -> Self {
859 self.build_git_sha = value;
860 self
861 }
862
863 pub fn with_build_git_sha_absence_reason(
864 mut self,
865 value: Option<BuildGitShaAbsenceReason>,
866 ) -> Self {
867 self.build_git_sha_absence_reason = value;
868 self
869 }
870
871 pub fn with_build_lock_digest(mut self, value: Option<String>) -> Self {
872 self.build_lock_digest = value;
873 self
874 }
875
876 pub fn with_wire_crate_version(mut self, value: Option<String>) -> Self {
877 self.wire_crate_version = value;
878 self
879 }
880
881 pub fn with_store_schema_version(mut self, value: Option<String>) -> Self {
882 self.store_schema_version = value;
883 self
884 }
885
886 pub fn with_launch_nonce_source(mut self, value: Option<LaunchNonceSource>) -> Self {
891 self.launch_nonce_source = value;
892 self
893 }
894
895 pub fn validate(&self) -> Result<(), ManifestProvenanceError> {
896 if let (Some(_), Some(reason)) = (
897 self.build_git_sha.as_ref(),
898 self.build_git_sha_absence_reason.as_ref(),
899 ) {
900 return Err(ManifestProvenanceError::new(
901 "build_git_sha_absence_reason",
902 reason.wire_name(),
903 "must be omitted when build_git_sha is present",
904 ));
905 }
906 for (field, value) in [
907 ("build_git_sha", self.build_git_sha.as_deref()),
908 (
909 "build_git_sha_absence_reason",
910 self.build_git_sha_absence_reason
911 .as_ref()
912 .map(|reason| reason.wire_name()),
913 ),
914 ("build_lock_digest", self.build_lock_digest.as_deref()),
915 ("wire_crate_version", self.wire_crate_version.as_deref()),
916 ("store_schema_version", self.store_schema_version.as_deref()),
917 (
918 "launch_nonce_source",
919 self.launch_nonce_source
920 .as_ref()
921 .map(|source| source.wire_name()),
922 ),
923 ] {
924 let Some(value) = value else { continue };
925 if value.is_empty() {
926 return Err(ManifestProvenanceError::new(
927 field,
928 value,
929 "must not be empty",
930 ));
931 }
932 if value.len() > MAX_PROVENANCE_VALUE_BYTES {
937 return Err(ManifestProvenanceError::new(
938 field,
939 value,
940 "exceeds the 128-byte maximum",
941 ));
942 }
943 if value.bytes().any(|byte| !(0x20..=0x7e).contains(&byte)) {
944 return Err(ManifestProvenanceError::new(
945 field,
946 value,
947 "contains non-printable ASCII",
948 ));
949 }
950 }
951 Ok(())
952 }
953}
954
955pub fn build_provenance(
973 build_git_sha: Option<&str>,
974 build_lock_digest: Option<&str>,
975 store_schema_version: Option<&str>,
976) -> Result<ManifestProvenance, ProvenanceFormError> {
977 let build_git_sha = normalize_and_validate_build_git_sha(build_git_sha)?;
978 build_provenance_with_build_git_sha(
979 build_git_sha,
980 None,
981 build_lock_digest,
982 store_schema_version,
983 )
984}
985
986pub fn build_provenance_from_source(
1002 build_git_sha_source: BuildGitShaSource<'_>,
1003 build_lock_digest: Option<&str>,
1004 store_schema_version: Option<&str>,
1005) -> Result<ManifestProvenance, ProvenanceFormError> {
1006 let (raw_build_git_sha, mut build_git_sha_absence_reason) = match build_git_sha_source {
1007 BuildGitShaSource::Git {
1008 revision,
1009 tree_state,
1010 } => match attestable_commit(revision, tree_state) {
1011 Some(revision) => (Some(revision), None),
1012 None => (None, Some(BuildGitShaAbsenceReason::DeclinedDirty)),
1013 },
1014 BuildGitShaSource::NeverDerived => (None, Some(BuildGitShaAbsenceReason::NeverDerived)),
1015 BuildGitShaSource::NoGitDir => (None, Some(BuildGitShaAbsenceReason::NoGitDir)),
1016 };
1017 let build_git_sha = normalize_and_validate_build_git_sha(raw_build_git_sha)?;
1018 if build_git_sha.is_none() {
1019 build_git_sha_absence_reason.get_or_insert(BuildGitShaAbsenceReason::NeverDerived);
1020 }
1021 build_provenance_with_build_git_sha(
1022 build_git_sha,
1023 build_git_sha_absence_reason,
1024 build_lock_digest,
1025 store_schema_version,
1026 )
1027}
1028
1029fn normalize_and_validate_build_git_sha(
1030 build_git_sha: Option<&str>,
1031) -> Result<Option<String>, ProvenanceFormError> {
1032 let build_git_sha = normalize_provenance_fact(build_git_sha);
1033 validate_provenance_form(
1034 "build_git_sha",
1035 build_git_sha.as_deref(),
1036 BUILD_GIT_SHA_CANONICAL_FORM,
1037 40,
1038 )?;
1039 Ok(build_git_sha)
1040}
1041
1042fn build_provenance_with_build_git_sha(
1043 build_git_sha: Option<String>,
1044 build_git_sha_absence_reason: Option<BuildGitShaAbsenceReason>,
1045 build_lock_digest: Option<&str>,
1046 store_schema_version: Option<&str>,
1047) -> Result<ManifestProvenance, ProvenanceFormError> {
1048 let build_lock_digest = normalize_provenance_fact(build_lock_digest);
1049 validate_provenance_form(
1050 "build_lock_digest",
1051 build_lock_digest.as_deref(),
1052 BUILD_LOCK_DIGEST_CANONICAL_FORM,
1053 64,
1054 )?;
1055
1056 Ok(ManifestProvenance {
1057 build_git_sha,
1058 build_git_sha_absence_reason,
1059 build_lock_digest,
1060 wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
1061 store_schema_version: normalize_provenance_fact(store_schema_version),
1062 launch_nonce_source: None,
1063 })
1064}
1065
1066fn validate_provenance_form(
1067 field: &'static str,
1068 value: Option<&str>,
1069 canonical_form: &'static str,
1070 expected_length: usize,
1071) -> Result<(), ProvenanceFormError> {
1072 let Some(value) = value else { return Ok(()) };
1073 if value.len() != expected_length
1074 || !value
1075 .bytes()
1076 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
1077 {
1078 return Err(ProvenanceFormError::new(field, value.len(), canonical_form));
1079 }
1080 Ok(())
1081}
1082
1083pub const PROVENANCE_SENTINELS: [&str; 3] = ["unknown", "unavailable", "none"];
1091
1092fn normalize_provenance_fact(value: Option<&str>) -> Option<String> {
1093 let value = value?.trim();
1094 if value.is_empty() {
1095 return None;
1096 }
1097 let lowered = value.to_ascii_lowercase();
1098 if PROVENANCE_SENTINELS.contains(&lowered.as_str()) {
1099 return None;
1100 }
1101 Some(value.to_string())
1102}
1103
1104#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1106#[serde(deny_unknown_fields)]
1107pub struct CapabilityRequirement {
1108 pub capability: String,
1109 pub need: CapabilityNeed,
1110}
1111
1112#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
1114#[serde(rename_all = "snake_case")]
1115pub enum CapabilityNeed {
1116 Required,
1117 Optional,
1118}
1119
1120#[derive(Debug, Clone, PartialEq, Eq)]
1122pub struct CapabilityGrammarError {
1123 field: String,
1124 value: String,
1125}
1126
1127impl CapabilityGrammarError {
1128 fn new(field: impl Into<String>, value: impl AsRef<str>) -> Self {
1129 Self {
1130 field: field.into(),
1131 value: safe_error_value(value.as_ref()),
1132 }
1133 }
1134
1135 pub fn field(&self) -> &str {
1137 &self.field
1138 }
1139
1140 pub fn value(&self) -> &str {
1142 &self.value
1143 }
1144}
1145
1146impl fmt::Display for CapabilityGrammarError {
1147 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1148 write!(
1149 f,
1150 "invalid capability grammar: field {} has offending value {:?}",
1151 self.field, self.value
1152 )
1153 }
1154}
1155
1156impl std::error::Error for CapabilityGrammarError {}
1157
1158impl ModuleManifest {
1159 pub fn validate_capability_grammar(&self) -> Result<(), CapabilityGrammarError> {
1161 let Some(capabilities) = &self.capabilities else {
1162 return Ok(());
1163 };
1164
1165 validate_capability_list("capabilities.provides", &capabilities.provides)?;
1166 validate_requires(&capabilities.requires)?;
1167 validate_capability_list(
1168 "capabilities.must_never_reach",
1169 &capabilities.must_never_reach,
1170 )
1171 }
1172}
1173
1174pub fn validate_manifest_capability_grammar(
1179 manifest: &Value,
1180) -> Result<(), CapabilityGrammarError> {
1181 let Some(object) = manifest.as_object() else {
1182 return Ok(());
1183 };
1184
1185 validate_capabilities_value(object.get("capabilities"))?;
1186 validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
1187}
1188
1189pub fn validate_hello_capability_grammar(hello: &Value) -> Result<(), CapabilityGrammarError> {
1195 let Some(object) = hello.as_object() else {
1196 return Ok(());
1197 };
1198 if let Some(manifest) = object.get("manifest") {
1199 validate_manifest_capability_grammar(manifest)?;
1200 }
1201 validate_runtime_computed(object.get("runtime_computed"), "runtime_computed")
1202}
1203
1204pub fn is_valid_capability_identifier(identifier: &str) -> bool {
1206 if identifier.chars().any(char::is_whitespace) {
1207 return false;
1208 }
1209 let Some((name, version)) = identifier.split_once("/v") else {
1210 return false;
1211 };
1212 if name.is_empty() || name.len() > 64 || version.is_empty() {
1213 return false;
1214 }
1215
1216 let name_bytes = name.as_bytes();
1217 if !name_bytes[0].is_ascii_lowercase()
1218 || (name.len() > 1
1219 && !name_bytes[name.len() - 1].is_ascii_lowercase()
1220 && !name_bytes[name.len() - 1].is_ascii_digit())
1221 || name_bytes.windows(2).any(|pair| pair == b"--")
1222 {
1223 return false;
1224 }
1225 if !name_bytes
1226 .iter()
1227 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
1228 {
1229 return false;
1230 }
1231
1232 if version.len() > 1 && version.starts_with('0')
1233 || !version.bytes().all(|byte| byte.is_ascii_digit())
1234 {
1235 return false;
1236 }
1237 matches!(
1238 version.parse::<u64>(),
1239 Ok(value) if (1..=u64::from(u32::MAX)).contains(&value)
1240 )
1241}
1242
1243fn validate_capabilities_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
1244 let Some(value) = value else {
1245 return Ok(());
1246 };
1247 let Some(object) = value.as_object() else {
1248 return Err(CapabilityGrammarError::new(
1249 "capabilities",
1250 value_description(value),
1251 ));
1252 };
1253
1254 for (key, value) in object {
1255 if !matches!(key.as_str(), "provides" | "requires" | "must_never_reach") {
1256 return Err(CapabilityGrammarError::new(
1257 field_child("capabilities", key),
1258 value_description(value),
1259 ));
1260 }
1261 }
1262
1263 validate_capability_list_value("capabilities.provides", object.get("provides"))?;
1264 validate_requires_value(object.get("requires"))?;
1265 validate_capability_list_value(
1266 "capabilities.must_never_reach",
1267 object.get("must_never_reach"),
1268 )
1269}
1270
1271fn validate_capability_list_value(
1272 field: &str,
1273 value: Option<&Value>,
1274) -> Result<(), CapabilityGrammarError> {
1275 let Some(value) = value else {
1276 return Ok(());
1277 };
1278 let Some(values) = value.as_array() else {
1279 return Err(CapabilityGrammarError::new(field, value_description(value)));
1280 };
1281
1282 let mut seen = HashSet::new();
1283 for (index, value) in values.iter().enumerate() {
1284 let field = format!("{field}[{index}]");
1285 let Some(identifier) = value.as_str() else {
1286 return Err(CapabilityGrammarError::new(field, value_description(value)));
1287 };
1288 validate_capability_identifier(&field, identifier)?;
1289 if !seen.insert(identifier) {
1290 return Err(CapabilityGrammarError::new(field, identifier));
1291 }
1292 }
1293 Ok(())
1294}
1295
1296fn validate_requires_value(value: Option<&Value>) -> Result<(), CapabilityGrammarError> {
1297 let Some(value) = value else {
1298 return Ok(());
1299 };
1300 let Some(values) = value.as_array() else {
1301 return Err(CapabilityGrammarError::new(
1302 "capabilities.requires",
1303 value_description(value),
1304 ));
1305 };
1306
1307 let mut seen = HashSet::new();
1308 for (index, value) in values.iter().enumerate() {
1309 let entry_field = format!("capabilities.requires[{index}]");
1310 let Some(object) = value.as_object() else {
1311 return Err(CapabilityGrammarError::new(
1312 entry_field,
1313 value_description(value),
1314 ));
1315 };
1316 for (key, value) in object {
1317 if !matches!(key.as_str(), "capability" | "need") {
1318 return Err(CapabilityGrammarError::new(
1319 field_child(&entry_field, key),
1320 value_description(value),
1321 ));
1322 }
1323 }
1324 let capability_field = format!("{entry_field}.capability");
1325 let Some(capability) = object.get("capability").and_then(Value::as_str) else {
1326 return Err(CapabilityGrammarError::new(
1327 capability_field,
1328 object
1329 .get("capability")
1330 .map_or("<missing>".to_string(), value_description),
1331 ));
1332 };
1333 validate_capability_identifier(&capability_field, capability)?;
1334
1335 let need_field = format!("{entry_field}.need");
1336 let Some(need) = object.get("need").and_then(Value::as_str) else {
1337 return Err(CapabilityGrammarError::new(
1338 need_field,
1339 object
1340 .get("need")
1341 .map_or("<missing>".to_string(), value_description),
1342 ));
1343 };
1344 if !matches!(need, "required" | "optional") {
1345 return Err(CapabilityGrammarError::new(need_field, need));
1346 }
1347 if !seen.insert(capability) {
1348 return Err(CapabilityGrammarError::new(entry_field, capability));
1349 }
1350 }
1351 Ok(())
1352}
1353
1354fn validate_capability_list(field: &str, values: &[String]) -> Result<(), CapabilityGrammarError> {
1355 let mut seen = HashSet::new();
1356 for (index, identifier) in values.iter().enumerate() {
1357 let field = format!("{field}[{index}]");
1358 validate_capability_identifier(&field, identifier)?;
1359 if !seen.insert(identifier) {
1360 return Err(CapabilityGrammarError::new(field, identifier));
1361 }
1362 }
1363 Ok(())
1364}
1365
1366fn validate_requires(values: &[CapabilityRequirement]) -> Result<(), CapabilityGrammarError> {
1367 let mut seen = HashSet::new();
1368 for (index, requirement) in values.iter().enumerate() {
1369 let field = format!("capabilities.requires[{index}].capability");
1370 validate_capability_identifier(&field, &requirement.capability)?;
1371 if !seen.insert(&requirement.capability) {
1372 return Err(CapabilityGrammarError::new(
1373 format!("capabilities.requires[{index}]"),
1374 &requirement.capability,
1375 ));
1376 }
1377 }
1378 Ok(())
1379}
1380
1381fn validate_capability_identifier(
1382 field: &str,
1383 identifier: &str,
1384) -> Result<(), CapabilityGrammarError> {
1385 if is_valid_capability_identifier(identifier) {
1386 Ok(())
1387 } else {
1388 Err(CapabilityGrammarError::new(field, identifier))
1389 }
1390}
1391
1392fn validate_runtime_computed(
1393 value: Option<&Value>,
1394 field: &str,
1395) -> Result<(), CapabilityGrammarError> {
1396 let Some(value) = value else {
1397 return Ok(());
1398 };
1399 let Some(pointers) = value.as_array() else {
1400 return Err(CapabilityGrammarError::new(field, value_description(value)));
1401 };
1402
1403 for (index, pointer) in pointers.iter().enumerate() {
1404 let field = format!("{field}[{index}]");
1405 let Some(pointer) = pointer.as_str() else {
1406 return Err(CapabilityGrammarError::new(
1407 field,
1408 value_description(pointer),
1409 ));
1410 };
1411 let Some(tokens) = parse_json_pointer(pointer) else {
1412 return Err(CapabilityGrammarError::new(field, pointer));
1413 };
1414 if tokens.first().is_some_and(|token| token == "capabilities") {
1415 return Err(CapabilityGrammarError::new(field, pointer));
1416 }
1417 }
1418 Ok(())
1419}
1420
1421fn parse_json_pointer(pointer: &str) -> Option<Vec<String>> {
1422 if pointer.is_empty() {
1423 return Some(Vec::new());
1424 }
1425 let raw_tokens = pointer.strip_prefix('/')?;
1426 raw_tokens
1427 .split('/')
1428 .map(unescape_json_pointer_token)
1429 .collect()
1430}
1431
1432fn unescape_json_pointer_token(token: &str) -> Option<String> {
1433 let mut output = String::with_capacity(token.len());
1434 let mut characters = token.chars();
1435 while let Some(character) = characters.next() {
1436 if character != '~' {
1437 output.push(character);
1438 continue;
1439 }
1440 match characters.next()? {
1441 '0' => output.push('~'),
1442 '1' => output.push('/'),
1443 _ => return None,
1444 }
1445 }
1446 Some(output)
1447}
1448
1449fn field_child(parent: &str, child: &str) -> String {
1450 let child = safe_error_value(child);
1451 format!("{parent}.{child}")
1452}
1453
1454fn value_description(value: &Value) -> String {
1455 match value {
1456 Value::String(value) => safe_error_value(value),
1457 Value::Null => "null".to_string(),
1458 Value::Bool(value) => value.to_string(),
1459 Value::Number(value) => value.to_string(),
1460 Value::Array(_) => "<array>".to_string(),
1461 Value::Object(_) => "<object>".to_string(),
1462 }
1463}
1464
1465fn safe_error_value(value: &str) -> String {
1466 let lower = value.to_ascii_lowercase();
1467 if ["secret", "password", "api_key"]
1468 .iter()
1469 .any(|marker| lower.contains(marker))
1470 || lower.starts_with("sk-")
1471 || lower.starts_with("akia")
1472 || lower.starts_with("bearer ")
1473 || lower.starts_with("token=")
1474 || lower.starts_with("credential=")
1475 {
1476 "<redacted>".to_string()
1477 } else {
1478 value.to_string()
1479 }
1480}
1481
1482#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1488#[serde(rename_all = "snake_case")]
1489pub enum TrustTier {
1490 FirstParty,
1491 Reviewed,
1492 Untrusted,
1493}
1494
1495#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1499#[serde(tag = "role", rename_all = "snake_case")]
1500pub enum ProviderRole {
1501 ToolProvider {
1502 tools: Vec<Tool>,
1503 identity_scope: Vec<IdentityScope>,
1511 concurrency: Concurrency,
1512 emits_push: bool,
1513 sub_supervises: bool,
1514 },
1515 PipelineStage {
1516 stage: PipelineStageKind,
1517 applies_to: PipelineAppliesTo,
1518 interface: String,
1519 declares_frozen_floor: bool,
1520 needs_signals: Vec<String>,
1521 conformance_class: String,
1522 },
1523 ManagementSurface {
1524 operations: Vec<ManagementOperation>,
1525 config_schema: Value,
1526 observability: Vec<ObservabilitySurface>,
1527 identity_scope: Vec<IdentityScope>,
1531 #[serde(default)]
1532 concurrency: Concurrency,
1533 },
1534 InternalService {
1535 service_id: String,
1536 transport: InternalTransport,
1537 agent_facing: bool,
1538 operations: Vec<String>,
1539 },
1540}
1541
1542#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
1555#[serde(rename_all = "snake_case")]
1556pub enum ExecutionMode {
1557 Pure,
1558 Mutating,
1559 Unfenceable,
1560}
1561
1562#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1564pub struct Tool {
1565 pub name: String,
1566 #[serde(default, skip_serializing_if = "Option::is_none")]
1567 pub description: Option<String>,
1568 pub execution_mode: ExecutionMode,
1573 pub schema: Value,
1574}
1575
1576#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1582#[serde(rename_all = "snake_case")]
1583pub enum Concurrency {
1584 Serial,
1586 ModuleManaged,
1589 StatelessParallel,
1592}
1593
1594#[allow(clippy::derivable_impls)]
1595impl Default for Concurrency {
1606 fn default() -> Self {
1607 Self::ModuleManaged
1608 }
1609}
1610
1611#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1618#[serde(rename_all = "snake_case")]
1619pub enum IdentityScope {
1620 Session,
1621 Project,
1622}
1623
1624#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1626#[serde(rename_all = "snake_case")]
1627pub enum PipelineStageKind {
1628 Transform,
1629 Codec,
1630 Auth,
1631}
1632
1633#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1635pub struct PipelineAppliesTo {
1636 pub provider: String,
1637 pub model: String,
1638}
1639
1640#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1642pub struct ManagementOperation {
1643 pub name: String,
1644 pub kind: ManagementOperationKind,
1645 #[serde(default, skip_serializing_if = "Option::is_none")]
1646 pub description: Option<String>,
1647}
1648
1649#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1650#[serde(rename_all = "snake_case")]
1651pub enum ManagementOperationKind {
1652 Query,
1653 Mutate,
1654}
1655
1656#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1658pub struct ObservabilitySurface {
1659 pub name: String,
1660 pub kind: ObservabilityKind,
1661}
1662
1663#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1664#[serde(rename_all = "snake_case")]
1665pub enum ObservabilityKind {
1666 Snapshot,
1667 Stream,
1668}
1669
1670#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1671#[serde(rename_all = "snake_case")]
1672pub enum InternalTransport {
1673 Bulk,
1674}
1675
1676#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1678#[serde(tag = "role", rename_all = "snake_case")]
1679pub enum ConsumerRole {
1680 ToolClient { of: Vec<String> },
1681 LlmClient { via: String, auth: String },
1682 ServiceClient { of: Vec<String> },
1683}
1684
1685#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1687pub struct Bindings {
1688 pub storage: StorageBinding,
1689 pub vault_grants: Vec<VaultGrant>,
1690 pub identity: IdentityBinding,
1691}
1692
1693#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1695pub struct StorageBinding {
1696 pub kind: StorageKind,
1697 pub scope: StorageScope,
1698 pub owns_schema: bool,
1699}
1700
1701#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1702#[serde(rename_all = "snake_case")]
1703pub enum StorageKind {
1704 Sqlite,
1705}
1706
1707#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1708#[serde(rename_all = "snake_case")]
1709pub enum StorageScope {
1710 Project,
1711}
1712
1713#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1714pub struct VaultGrant {
1715 pub secret: String,
1716 pub reason: String,
1717}
1718
1719#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
1720pub struct IdentityBinding {
1721 pub requires: Vec<IdentityScope>,
1722 pub optional: Vec<IdentityScope>,
1723}
1724
1725#[cfg(test)]
1726mod tests {
1727 use super::*;
1728 use serde_json::json;
1729
1730 fn aft_manifest_fixture() -> ModuleManifest {
1731 ModuleManifest::builder("aft", "0.39.2")
1732 .trust_tier(Some(TrustTier::FirstParty))
1733 .bindings(Some(Bindings {
1734 storage: StorageBinding {
1735 kind: StorageKind::Sqlite,
1736 scope: StorageScope::Project,
1737 owns_schema: true,
1738 },
1739 vault_grants: vec![VaultGrant {
1740 secret: "provider_api_key".to_string(),
1741 reason: "cortexkit_native auth".to_string(),
1742 }],
1743 identity: IdentityBinding {
1744 requires: vec![IdentityScope::Project],
1745 optional: vec![IdentityScope::Session],
1746 },
1747 }))
1748 .protocol_ver(1)
1749 .provides(vec![ProviderRole::ToolProvider {
1750 tools: vec![
1751 Tool {
1752 name: "read".to_string(),
1753 description: None,
1754 execution_mode: ExecutionMode::Pure,
1755 schema: json!({"type": "object"}),
1756 },
1757 Tool {
1758 name: "grep".to_string(),
1759 description: None,
1760 execution_mode: ExecutionMode::Pure,
1761 schema: json!({"type": "object"}),
1762 },
1763 Tool {
1764 name: "outline".to_string(),
1765 description: None,
1766 execution_mode: ExecutionMode::Pure,
1767 schema: json!({"type": "object"}),
1768 },
1769 Tool {
1770 name: "semantic_search".to_string(),
1771 description: None,
1772 execution_mode: ExecutionMode::Pure,
1773 schema: json!({"type": "object"}),
1774 },
1775 Tool {
1776 name: "edit".to_string(),
1777 description: None,
1778 execution_mode: ExecutionMode::Mutating,
1779 schema: json!({"type": "object"}),
1780 },
1781 Tool {
1782 name: "write".to_string(),
1783 description: None,
1784 execution_mode: ExecutionMode::Mutating,
1785 schema: json!({"type": "object"}),
1786 },
1787 Tool {
1788 name: "bash".to_string(),
1789 description: None,
1790 execution_mode: ExecutionMode::Unfenceable,
1791 schema: json!({"type": "object"}),
1792 },
1793 ],
1794 identity_scope: vec![IdentityScope::Session, IdentityScope::Project],
1795 concurrency: Concurrency::ModuleManaged,
1796 emits_push: true,
1797 sub_supervises: true,
1798 }])
1799 .consumes(vec![ConsumerRole::ServiceClient {
1800 of: vec!["embedding.v2".to_string()],
1801 }])
1802 .build()
1803 }
1804
1805 #[test]
1806 fn serde_round_trips_representative_manifest() {
1807 let manifest = aft_manifest_fixture();
1808 let serialized = serde_json::to_string_pretty(&manifest).unwrap();
1809 let decoded: ModuleManifest = serde_json::from_str(&serialized).unwrap();
1810
1811 assert_eq!(manifest, decoded);
1812 }
1813
1814 #[test]
1815 fn builder_defaults_additions_to_honest_absence_and_round_trips() {
1816 let manifest = ModuleManifest::builder("builder-defaults", "2.0.0").build();
1817
1818 assert_eq!(manifest.module_id, "builder-defaults");
1819 assert_eq!(manifest.module_version, "2.0.0");
1820 assert_eq!(manifest.protocol_ver, PROTOCOL_VERSION);
1821 assert_eq!(manifest.trust_tier, None);
1822 assert!(manifest.provides.is_empty());
1823 assert!(manifest.consumes.is_empty());
1824 assert_eq!(manifest.bindings, None);
1825 assert_eq!(manifest.capabilities, None);
1826 assert_eq!(manifest.self_signals, None);
1827 assert_eq!(manifest.provenance, None);
1828
1829 let encoded = serde_json::to_value(&manifest).expect("builder manifest serializes");
1830 for optional in [
1831 "trust_tier",
1832 "consumes",
1833 "bindings",
1834 "capabilities",
1835 "self_signals",
1836 "provenance",
1837 ] {
1838 assert!(
1839 encoded.get(optional).is_none(),
1840 "an absent {optional} declaration must stay absent on the wire"
1841 );
1842 }
1843 let decoded: ModuleManifest =
1844 serde_json::from_value(encoded).expect("builder manifest round-trips");
1845 assert_eq!(decoded, manifest);
1846 }
1847
1848 #[test]
1849 fn fully_populated_builder_manifest_matches_the_literal_wire_golden() {
1850 let manifest = ModuleManifest::builder("full-builder", "2.0.0")
1851 .trust_tier(Some(TrustTier::Reviewed))
1852 .bindings(Some(Bindings {
1853 storage: StorageBinding {
1854 kind: StorageKind::Sqlite,
1855 scope: StorageScope::Project,
1856 owns_schema: false,
1857 },
1858 vault_grants: Vec::new(),
1859 identity: IdentityBinding {
1860 requires: vec![IdentityScope::Project],
1861 optional: Vec::new(),
1862 },
1863 }))
1864 .provides(vec![ProviderRole::ToolProvider {
1865 tools: vec![Tool {
1866 name: "read".to_string(),
1867 description: None,
1868 execution_mode: ExecutionMode::Pure,
1869 schema: json!({"type": "object"}),
1870 }],
1871 identity_scope: vec![IdentityScope::Project],
1872 concurrency: Concurrency::Serial,
1873 emits_push: false,
1874 sub_supervises: false,
1875 }])
1876 .consumes(vec![ConsumerRole::ServiceClient {
1877 of: vec!["embedding.v2".to_string()],
1878 }])
1879 .capabilities(Some(CapabilityDeclarations {
1880 provides: vec!["embedding/v2".to_string()],
1881 requires: Vec::new(),
1882 must_never_reach: Vec::new(),
1883 }))
1884 .self_signals(Some(vec![SelfSignalDeclaration {
1885 name: "usage_poller".to_string(),
1886 kind: SelfSignalKind::Poller,
1887 effect: SelfSignalEffect::Observe,
1888 anchored_to: SignalAnchor::FixedInterval,
1889 cadence: Some(SignalCadence::Literal {
1890 interval_ms: 60_000,
1891 }),
1892 domain: Some("provider-usage".to_string()),
1893 note: None,
1894 }]))
1895 .provenance(Some(ManifestProvenance {
1896 build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
1897 build_git_sha_absence_reason: None,
1898 build_lock_digest: Some(
1899 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
1900 ),
1901 wire_crate_version: Some("0.16.0".to_string()),
1902 store_schema_version: Some("42".to_string()),
1903 launch_nonce_source: None,
1904 }))
1905 .build();
1906
1907 assert_eq!(
1908 serde_json::to_vec(&manifest).expect("builder manifest serializes"),
1909 include_bytes!("../tests/golden/module_manifest_builder_full.json"),
1910 "the builder must preserve the prior fully populated literal wire bytes"
1911 );
1912 }
1913
1914 #[test]
1915 fn old_manifest_with_unread_fields_decodes_and_round_trips_verbatim() {
1916 let raw = include_bytes!("../tests/golden/module_manifest_builder_full.json");
1917 let decoded: ModuleManifest =
1918 serde_json::from_slice(raw).expect("old manifest with all unread fields decodes");
1919
1920 assert_eq!(decoded.trust_tier, Some(TrustTier::Reviewed));
1921 assert!(!decoded.consumes.is_empty());
1922 assert!(decoded.bindings.is_some());
1923
1924 let reencoded = serde_json::to_vec(&decoded).expect("re-encode succeeds");
1925 assert_eq!(
1926 reencoded, raw,
1927 "old manifest relay stays byte-for-byte verbatim"
1928 );
1929 }
1930
1931 #[test]
1932 fn new_manifest_omits_unread_fields_on_wire_and_decodes_cleanly() {
1933 let raw = include_bytes!("../tests/golden/module_manifest_diet.json");
1934 let decoded: ModuleManifest =
1935 serde_json::from_slice(raw).expect("new manifest omitting unread fields decodes");
1936
1937 assert_eq!(decoded.trust_tier, None);
1938 assert!(decoded.consumes.is_empty());
1939 assert_eq!(decoded.bindings, None);
1940
1941 let pretty = format!("{}\n", serde_json::to_string_pretty(&decoded).unwrap());
1942 assert_eq!(
1943 pretty.as_bytes(),
1944 raw,
1945 "new manifest matches golden byte-for-byte without unread keys"
1946 );
1947
1948 let as_val: serde_json::Value = serde_json::to_value(&decoded).unwrap();
1949 assert!(
1950 as_val.get("trust_tier").is_none(),
1951 "no trust_tier on wire for new manifest"
1952 );
1953 assert!(
1954 as_val.get("consumes").is_none(),
1955 "no consumes on wire for empty consumes"
1956 );
1957 assert!(
1958 as_val.get("bindings").is_none(),
1959 "no bindings on wire for new manifest"
1960 );
1961 }
1962
1963 #[test]
1964 fn aft_manifest_fixture_matches_v1_contract() {
1965 let manifest = aft_manifest_fixture();
1966
1967 assert_eq!(manifest.module_id, "aft");
1968 let ProviderRole::ToolProvider {
1969 tools,
1970 identity_scope,
1971 concurrency,
1972 emits_push,
1973 sub_supervises,
1974 } = &manifest.provides[0]
1975 else {
1976 panic!("AFT fixture must expose one tool_provider role");
1977 };
1978
1979 assert_eq!(*concurrency, Concurrency::ModuleManaged);
1980 assert!(*emits_push);
1981 assert!(*sub_supervises);
1982 assert_eq!(
1983 identity_scope,
1984 &vec![IdentityScope::Session, IdentityScope::Project]
1985 );
1986 assert_eq!(
1987 tools
1988 .iter()
1989 .map(|tool| (tool.name.as_str(), tool.execution_mode))
1990 .collect::<Vec<_>>(),
1991 vec![
1992 ("read", ExecutionMode::Pure),
1993 ("grep", ExecutionMode::Pure),
1994 ("outline", ExecutionMode::Pure),
1995 ("semantic_search", ExecutionMode::Pure),
1996 ("edit", ExecutionMode::Mutating),
1997 ("write", ExecutionMode::Mutating),
1998 ("bash", ExecutionMode::Unfenceable),
1999 ]
2000 );
2001 }
2002
2003 #[test]
2004 fn tool_provider_role_tag_serializes_as_snake_case() {
2005 let manifest = aft_manifest_fixture();
2006 let value = serde_json::to_value(&manifest).unwrap();
2007
2008 assert_eq!(value["provides"][0]["role"], "tool_provider");
2009 }
2010
2011 #[test]
2012 fn manifest_without_capabilities_preserves_the_existing_wire_shape() {
2013 let manifest = aft_manifest_fixture();
2014 let encoded = serde_json::to_value(&manifest).expect("manifest serializes");
2015 assert!(encoded.get("capabilities").is_none());
2016
2017 let decoded: ModuleManifest =
2018 serde_json::from_value(encoded).expect("legacy manifest parses");
2019 assert_eq!(decoded.capabilities, None);
2020 }
2021
2022 #[test]
2023 fn capability_identifier_lexical_grammar_accepts_only_pinned_forms() {
2024 for identifier in [
2025 "a/v1",
2026 "credentials-provider/v1",
2027 "a1-b2/v4294967295",
2028 "a123456789012345678901234567890123456789012345678901234567890123/v1",
2029 ] {
2030 assert!(
2031 is_valid_capability_identifier(identifier),
2032 "identifier must be accepted: {identifier}"
2033 );
2034 }
2035
2036 for identifier in [
2037 "credentials-Provider/v1",
2038 "credentials-provider/v01",
2039 "credentials-provider-/v1",
2040 "credentials--provider/v1",
2041 "Credentials-provider/v1",
2042 "credentials-provider/1",
2043 "credentials provider/v1",
2044 "credentials-provider/v0",
2045 "credentials-provider/v4294967296",
2046 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/v1",
2047 ] {
2048 assert!(
2049 !is_valid_capability_identifier(identifier),
2050 "identifier must be rejected: {identifier}"
2051 );
2052 }
2053 }
2054
2055 #[test]
2056 fn capability_grammar_errors_redact_secret_shaped_values() {
2057 let error = validate_manifest_capability_grammar(&json!({
2058 "capabilities": { "provides": ["sk-secret-value/v0"] }
2059 }))
2060 .expect_err("secret-shaped capability identifier is malformed");
2061 assert_eq!(error.field(), "capabilities.provides[0]");
2062 assert_eq!(error.value(), "<redacted>");
2063 assert!(!error.to_string().contains("sk-secret-value"));
2064 }
2065
2066 #[test]
2073 fn provenance_builder_sentinels_become_field_omission() {
2074 for sentinel in [
2075 "unknown",
2076 "UNKNOWN",
2077 "Unknown",
2078 "unavailable",
2079 "none",
2080 "None",
2081 " unknown ",
2082 "",
2083 ] {
2084 let p = build_provenance_from_source(
2085 BuildGitShaSource::Git {
2086 revision: sentinel,
2087 tree_state: GitTreeState::Clean,
2088 },
2089 Some(sentinel),
2090 Some(sentinel),
2091 )
2092 .expect("sentinels are omitted before form validation");
2093 assert_eq!(
2094 (
2095 p.build_git_sha,
2096 p.build_git_sha_absence_reason,
2097 p.build_lock_digest,
2098 p.store_schema_version,
2099 ),
2100 (
2101 None,
2102 Some(BuildGitShaAbsenceReason::NeverDerived),
2103 None,
2104 None,
2105 ),
2106 "sentinel {sentinel:?} must be omitted, not published"
2107 );
2108 }
2109 let real = build_provenance_from_source(
2110 BuildGitShaSource::Git {
2111 revision: "0123456789abcdef0123456789abcdef01234567",
2112 tree_state: GitTreeState::Clean,
2113 },
2114 None,
2115 Some("9"),
2116 )
2117 .expect("canonical build revision is accepted");
2118 assert_eq!(
2119 real.build_git_sha.as_deref(),
2120 Some("0123456789abcdef0123456789abcdef01234567")
2121 );
2122 assert_eq!(real.store_schema_version.as_deref(), Some("9"));
2123 assert_eq!(
2127 real.wire_crate_version.as_deref(),
2128 Some(crate::SUBC_PROTOCOL_CRATE_VERSION)
2129 );
2130 }
2131
2132 #[test]
2133 fn build_provenance_accepts_canonical_sha_and_lock_digest() {
2134 let provenance = build_provenance_from_source(
2135 BuildGitShaSource::Git {
2136 revision: " 0123456789abcdef0123456789abcdef01234567 ",
2137 tree_state: GitTreeState::Clean,
2138 },
2139 Some(" abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 "),
2140 Some(" schema-v3 "),
2141 )
2142 .expect("canonical build facts are accepted");
2143
2144 assert_eq!(
2145 provenance,
2146 ManifestProvenance {
2147 build_git_sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
2148 build_git_sha_absence_reason: None,
2149 build_lock_digest: Some(
2150 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(),
2151 ),
2152 wire_crate_version: Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string()),
2153 store_schema_version: Some("schema-v3".to_string()),
2154 launch_nonce_source: None,
2155 }
2156 );
2157 }
2158
2159 #[test]
2160 fn build_provenance_refuses_an_abbreviated_git_sha() {
2161 let error = build_provenance_from_source(
2162 BuildGitShaSource::Git {
2163 revision: "0123456789ab",
2164 tree_state: GitTreeState::Clean,
2165 },
2166 None,
2167 None,
2168 )
2169 .expect_err("a 12-character abbreviation is not canonical");
2170
2171 assert_eq!(error.field(), "build_git_sha");
2172 assert_eq!(error.length(), 12);
2173 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2174 assert_eq!(
2175 error.to_string(),
2176 "invalid manifest provenance form: field build_git_sha has length 12; canonical form is exactly 40 lowercase hexadecimal characters"
2177 );
2178 }
2179
2180 #[test]
2181 fn build_provenance_refuses_an_abbreviated_lock_digest() {
2182 let error = build_provenance_from_source(
2183 BuildGitShaSource::NeverDerived,
2184 Some("0123456789abcdef"),
2185 None,
2186 )
2187 .expect_err("a 16-character digest is not canonical");
2188
2189 assert_eq!(error.field(), "build_lock_digest");
2190 assert_eq!(error.length(), 16);
2191 assert_eq!(error.canonical_form(), BUILD_LOCK_DIGEST_CANONICAL_FORM);
2192 }
2193
2194 #[test]
2195 fn build_provenance_refuses_uppercase_hex() {
2196 let uppercase_sha = "A".repeat(40);
2197 let error = build_provenance_from_source(
2198 BuildGitShaSource::Git {
2199 revision: &uppercase_sha,
2200 tree_state: GitTreeState::Clean,
2201 },
2202 None,
2203 None,
2204 )
2205 .expect_err("uppercase hexadecimal is not canonical");
2206
2207 assert_eq!(error.field(), "build_git_sha");
2208 assert_eq!(error.length(), 40);
2209 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2210 }
2211
2212 #[test]
2213 fn build_provenance_refuses_dirty_revision_stamp_claimed_clean() {
2214 let error = build_provenance_from_source(
2215 BuildGitShaSource::Git {
2216 revision: "0123456789abcdef0123456789abcdef01234567-dirty",
2217 tree_state: GitTreeState::Clean,
2218 },
2219 None,
2220 None,
2221 )
2222 .expect_err("a dirty stamp is not a canonical build revision");
2223
2224 assert_eq!(error.field(), "build_git_sha");
2225 assert_eq!(error.length(), 46);
2226 assert_eq!(error.canonical_form(), BUILD_GIT_SHA_CANONICAL_FORM);
2227 }
2228
2229 #[test]
2230 fn build_provenance_keeps_a_lock_digest_when_identity_is_unavailable() {
2231 let provenance = build_provenance_from_source(
2232 BuildGitShaSource::Git {
2233 revision: "unavailable",
2234 tree_state: GitTreeState::Clean,
2235 },
2236 Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"),
2237 None,
2238 )
2239 .expect("sentinel SHA is omitted before the valid lock digest is checked");
2240
2241 assert_eq!(provenance.build_git_sha, None);
2242 assert_eq!(
2243 provenance.build_lock_digest,
2244 Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string())
2245 );
2246 assert_eq!(
2247 provenance.wire_crate_version,
2248 Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
2249 );
2250 }
2251
2252 #[test]
2253 fn build_provenance_omits_fully_unavailable_inputs() {
2254 let provenance = build_provenance_from_source(
2255 BuildGitShaSource::NeverDerived,
2256 Some(" unavailable "),
2257 Some(" "),
2258 )
2259 .expect("omitted and sentinel inputs are not form errors");
2260
2261 assert_eq!(provenance.build_git_sha, None);
2262 assert_eq!(provenance.build_lock_digest, None);
2263 assert_eq!(provenance.store_schema_version, None);
2264 assert_eq!(
2265 provenance.wire_crate_version,
2266 Some(crate::SUBC_PROTOCOL_CRATE_VERSION.to_string())
2267 );
2268 }
2269
2270 #[test]
2271 fn legacy_build_provenance_keeps_master_wire_bytes_without_an_absence_reason() {
2272 let revision = "0123456789abcdef0123456789abcdef01234567";
2273 for (input, expected) in [
2274 (
2275 Some(revision),
2276 format!(
2277 r#"{{"build_git_sha":"{revision}","wire_crate_version":"{}"}}"#,
2278 crate::SUBC_PROTOCOL_CRATE_VERSION
2279 ),
2280 ),
2281 (
2282 None,
2283 format!(
2284 r#"{{"wire_crate_version":"{}"}}"#,
2285 crate::SUBC_PROTOCOL_CRATE_VERSION
2286 ),
2287 ),
2288 (
2289 Some("unknown"),
2290 format!(
2291 r#"{{"wire_crate_version":"{}"}}"#,
2292 crate::SUBC_PROTOCOL_CRATE_VERSION
2293 ),
2294 ),
2295 ] {
2296 let provenance = build_provenance(input, None, None)
2297 .expect("the legacy build facts remain constructible");
2298 assert_eq!(provenance.build_git_sha_absence_reason, None);
2299 assert_eq!(
2300 serde_json::to_string(&provenance).expect("legacy provenance serializes"),
2301 expected
2302 );
2303 }
2304 }
2305
2306 #[test]
2307 fn build_provenance_derives_git_sha_absence_from_the_stamping_inputs() {
2308 let revision = "0123456789abcdef0123456789abcdef01234567";
2309 let cases = [
2310 (
2311 BuildGitShaSource::Git {
2312 revision,
2313 tree_state: GitTreeState::Clean,
2314 },
2315 Some(revision),
2316 None,
2317 ),
2318 (
2319 BuildGitShaSource::Git {
2320 revision,
2321 tree_state: GitTreeState::Dirty,
2322 },
2323 None,
2324 Some(BuildGitShaAbsenceReason::DeclinedDirty),
2325 ),
2326 (
2327 BuildGitShaSource::NeverDerived,
2328 None,
2329 Some(BuildGitShaAbsenceReason::NeverDerived),
2330 ),
2331 (
2332 BuildGitShaSource::NoGitDir,
2333 None,
2334 Some(BuildGitShaAbsenceReason::NoGitDir),
2335 ),
2336 ];
2337
2338 for (source, expected_sha, expected_reason) in cases {
2339 let provenance = build_provenance_from_source(source, None, None)
2340 .expect("every stamping state constructs honest provenance");
2341 assert_eq!(provenance.build_git_sha.as_deref(), expected_sha);
2342 assert_eq!(provenance.build_git_sha_absence_reason, expected_reason);
2343 }
2344 }
2345
2346 #[test]
2347 fn unknown_git_sha_absence_reason_round_trips_byte_faithfully() {
2348 let wire = format!(
2349 r#"{{"build_git_sha_absence_reason":"future_stamper_state","wire_crate_version":"{}"}}"#,
2350 crate::SUBC_PROTOCOL_CRATE_VERSION
2351 );
2352 let provenance: ManifestProvenance =
2353 serde_json::from_str(&wire).expect("future absence reasons remain readable");
2354
2355 assert_eq!(
2356 provenance.build_git_sha_absence_reason,
2357 Some(BuildGitShaAbsenceReason::ForwardCompatibleUnknown(
2358 "future_stamper_state".to_string()
2359 ))
2360 );
2361 assert_eq!(
2362 serde_json::to_string(&provenance).expect("future absence reason reserializes"),
2363 wire
2364 );
2365 }
2366
2367 #[test]
2368 fn provenance_rejects_an_absence_reason_beside_a_declared_commit() {
2369 let error = serde_json::from_value::<ManifestProvenance>(json!({
2370 "build_git_sha": "0123456789abcdef0123456789abcdef01234567",
2371 "build_git_sha_absence_reason": "declined_dirty"
2372 }))
2373 .expect_err("a declared commit cannot also claim an absence reason");
2374
2375 assert!(error.to_string().contains(
2376 "build_git_sha_absence_reason has must be omitted when build_git_sha is present"
2377 ));
2378 }
2379}