Skip to main content

a3s_code_core/release/
manifest.rs

1use super::schema::release_schema;
2use super::types::{
3    AgentReleaseArtifact, AgentReleaseCapability, AgentReleaseCompatibility,
4    AgentReleaseEntrypoint, AgentReleaseHealth, AgentReleaseProvenance,
5    AgentReleaseSecretRequirement, AgentReleaseStorage,
6};
7use super::validation::{
8    parse_artifact, parse_capability, parse_entrypoint, parse_health, parse_provenance,
9    parse_secret, parse_storage, required_block, required_string, unique_capabilities,
10    unique_provenance, unique_secrets, validate_digest, validate_protocol,
11};
12use super::{
13    AgentReleaseError, AgentReleaseField, AGENT_RELEASE_CONTRACT_V1, AGENT_RELEASE_LIMITS,
14};
15use a3s_acl::{
16    canonical_bytes_with_schema, canonical_digest_with_schema, parse_with_limits,
17    validate_document_with_limits, Value,
18};
19use std::collections::BTreeMap;
20use std::io::Read;
21use std::path::Path;
22
23/// Admitted, canonical Agent release manifest.
24#[derive(Debug, Clone)]
25pub struct AgentReleaseManifest {
26    contract: String,
27    protocol: String,
28    artifact: AgentReleaseArtifact,
29    entrypoint: AgentReleaseEntrypoint,
30    health: AgentReleaseHealth,
31    storage: AgentReleaseStorage,
32    required_capabilities: Vec<AgentReleaseCapability>,
33    required_secrets: Vec<AgentReleaseSecretRequirement>,
34    provenance: Vec<AgentReleaseProvenance>,
35    identity: String,
36    canonical_acl: String,
37}
38
39impl AgentReleaseManifest {
40    /// Parse, admit, and canonicalize one untrusted `.a3s/asset.acl` document.
41    pub fn parse(source: &str) -> Result<Self, AgentReleaseError> {
42        let document = parse_with_limits(source, AGENT_RELEASE_LIMITS)?;
43        let schema = release_schema();
44        let report = validate_document_with_limits(&document, &schema, AGENT_RELEASE_LIMITS);
45        if !report.is_empty() {
46            let Some(diagnostic) = report.diagnostics.first() else {
47                return Err(AgentReleaseError::SchemaBudgetExceeded);
48            };
49            return Err(AgentReleaseError::Schema {
50                diagnostic: diagnostic.code,
51                truncated: report.truncated,
52            });
53        }
54
55        let root = required_block(
56            &document.blocks,
57            "agent_release",
58            AgentReleaseField::Contract,
59        )?;
60        let contract = required_string(root, "schema", AgentReleaseField::Contract)?;
61        if contract != AGENT_RELEASE_CONTRACT_V1 {
62            return Err(AgentReleaseError::UnsupportedContract);
63        }
64
65        let protocol = required_string(root, "protocol", AgentReleaseField::Protocol)?;
66        validate_protocol(&protocol)?;
67
68        let artifact = parse_artifact(required_block(
69            &root.blocks,
70            "artifact",
71            AgentReleaseField::ArtifactDigest,
72        )?)?;
73        let entrypoint = parse_entrypoint(required_block(
74            &root.blocks,
75            "entrypoint",
76            AgentReleaseField::EntrypointCommand,
77        )?)?;
78        let health = parse_health(required_block(
79            &root.blocks,
80            "health",
81            AgentReleaseField::HealthTransport,
82        )?)?;
83        let storage = parse_storage(required_block(
84            &root.blocks,
85            "storage",
86            AgentReleaseField::WorkspaceMode,
87        )?)?;
88
89        let required_capabilities = unique_capabilities(
90            root.blocks
91                .iter()
92                .filter(|block| block.name == "capability")
93                .map(parse_capability)
94                .collect::<Result<Vec<_>, _>>()?,
95        )?;
96        let required_secrets = unique_secrets(
97            root.blocks
98                .iter()
99                .filter(|block| block.name == "secret")
100                .map(parse_secret)
101                .collect::<Result<Vec<_>, _>>()?,
102        )?;
103        if !required_secrets.is_empty()
104            && !required_capabilities
105                .iter()
106                .any(|capability| capability.name == "secrets.external")
107        {
108            return Err(AgentReleaseError::InvalidField(
109                AgentReleaseField::CapabilityName,
110            ));
111        }
112        let provenance = unique_provenance(
113            root.blocks
114                .iter()
115                .filter(|block| block.name == "provenance")
116                .map(parse_provenance)
117                .collect::<Result<Vec<_>, _>>()?,
118        )?;
119
120        let canonical_acl = String::from_utf8(canonical_bytes_with_schema(&document, &schema)?)
121            .map_err(|_| AgentReleaseError::CanonicalEncoding)?;
122        let identity = canonical_digest_with_schema(&document, &schema)?;
123
124        Ok(Self {
125            contract,
126            protocol,
127            artifact,
128            entrypoint,
129            health,
130            storage,
131            required_capabilities,
132            required_secrets,
133            provenance,
134            identity,
135            canonical_acl,
136        })
137    }
138
139    /// Read at most the manifest budget plus one byte before parsing.
140    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, AgentReleaseError> {
141        let file = std::fs::File::open(path)?;
142        let mut bytes = Vec::new();
143        file.take(AGENT_RELEASE_LIMITS.max_document_bytes as u64 + 1)
144            .read_to_end(&mut bytes)?;
145        if bytes.len() > AGENT_RELEASE_LIMITS.max_document_bytes {
146            return Err(AgentReleaseError::InputTooLarge);
147        }
148        let source = std::str::from_utf8(&bytes).map_err(|_| AgentReleaseError::InvalidEncoding)?;
149        Self::parse(source)
150    }
151
152    pub fn contract(&self) -> &str {
153        &self.contract
154    }
155
156    pub fn protocol(&self) -> &str {
157        &self.protocol
158    }
159
160    pub fn artifact(&self) -> &AgentReleaseArtifact {
161        &self.artifact
162    }
163
164    pub fn entrypoint(&self) -> &AgentReleaseEntrypoint {
165        &self.entrypoint
166    }
167
168    pub fn health(&self) -> &AgentReleaseHealth {
169        &self.health
170    }
171
172    pub fn storage(&self) -> &AgentReleaseStorage {
173        &self.storage
174    }
175
176    pub fn required_capabilities(&self) -> &[AgentReleaseCapability] {
177        &self.required_capabilities
178    }
179
180    pub fn required_secrets(&self) -> &[AgentReleaseSecretRequirement] {
181        &self.required_secrets
182    }
183
184    pub fn provenance(&self) -> &[AgentReleaseProvenance] {
185        &self.provenance
186    }
187
188    /// Lowercase SHA-256 digest of the schema-aware canonical ACL bytes.
189    pub fn identity(&self) -> &str {
190        &self.identity
191    }
192
193    /// Schema-aware canonical ACL bytes as UTF-8 with one final newline.
194    pub fn canonical_acl(&self) -> &str {
195        &self.canonical_acl
196    }
197
198    /// Bind a built OCI manifest and exact provenance to this admitted template.
199    ///
200    /// Publication happens after the artifact is built, so the final manifest
201    /// cannot be embedded in the artifact whose digest it declares. This method
202    /// changes only the artifact digest and the URI/digest pair for every
203    /// already-declared provenance kind, then re-admits and canonicalizes the
204    /// resulting document. Missing, duplicate, or additional provenance kinds
205    /// fail without reflecting their values in the error.
206    pub fn bind_publication(
207        &self,
208        artifact_digest: impl Into<String>,
209        provenance: impl IntoIterator<Item = AgentReleaseProvenance>,
210    ) -> Result<Self, AgentReleaseError> {
211        let artifact_digest = artifact_digest.into();
212        validate_digest(&artifact_digest, AgentReleaseField::ArtifactDigest)?;
213        let provenance = unique_provenance(provenance)?
214            .into_iter()
215            .map(|reference| (reference.kind.clone(), reference))
216            .collect::<BTreeMap<_, _>>();
217        if provenance.len() != self.provenance.len()
218            || self
219                .provenance
220                .iter()
221                .any(|reference| !provenance.contains_key(reference.kind()))
222        {
223            return Err(AgentReleaseError::InvalidField(
224                AgentReleaseField::ProvenanceKind,
225            ));
226        }
227
228        let mut document = parse_with_limits(&self.canonical_acl, AGENT_RELEASE_LIMITS)?;
229        let root = document
230            .blocks
231            .iter_mut()
232            .find(|block| block.name == "agent_release")
233            .ok_or(AgentReleaseError::InvalidField(AgentReleaseField::Contract))?;
234        let artifact = root
235            .blocks
236            .iter_mut()
237            .find(|block| block.name == "artifact")
238            .ok_or(AgentReleaseError::InvalidField(
239                AgentReleaseField::ArtifactDigest,
240            ))?;
241        artifact
242            .attributes
243            .insert("digest".into(), Value::String(artifact_digest));
244
245        for block in root
246            .blocks
247            .iter_mut()
248            .filter(|block| block.name == "provenance")
249        {
250            let kind = block.labels.first().ok_or(AgentReleaseError::InvalidField(
251                AgentReleaseField::ProvenanceKind,
252            ))?;
253            let reference = provenance.get(kind).ok_or(AgentReleaseError::InvalidField(
254                AgentReleaseField::ProvenanceKind,
255            ))?;
256            block
257                .attributes
258                .insert("uri".into(), Value::String(reference.uri.clone()));
259            block
260                .attributes
261                .insert("digest".into(), Value::String(reference.digest.clone()));
262        }
263
264        let canonical =
265            String::from_utf8(canonical_bytes_with_schema(&document, &release_schema())?)
266                .map_err(|_| AgentReleaseError::CanonicalEncoding)?;
267        Self::parse(&canonical)
268    }
269
270    /// Fail before activation unless protocol and every required capability match.
271    pub fn verify_compatibility(
272        &self,
273        available: &AgentReleaseCompatibility,
274    ) -> Result<(), AgentReleaseError> {
275        if self.protocol != available.protocol {
276            return Err(AgentReleaseError::IncompatibleProtocol);
277        }
278        for (required_index, required) in self.required_capabilities.iter().enumerate() {
279            let supported = available
280                .capabilities
281                .iter()
282                .find(|capability| capability.name == required.name);
283            if supported.is_none_or(|capability| capability.level < required.level) {
284                return Err(AgentReleaseError::UnsupportedCapability { required_index });
285            }
286        }
287        Ok(())
288    }
289}