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