Skip to main content

canic_host/release_set/
manifest.rs

1//! Module: release_set::manifest
2//!
3//! Responsibility: define, validate, load, and persist root release-set manifests.
4//! Does not own: artifact bytes, ICP calls, or bootstrap sequencing.
5//! Boundary: admits one exact identity and artifact-shape contract for every consumer.
6
7use crate::{
8    durable_io::write_bytes,
9    release_set::{
10        build_release_set_entry, config_path, configured_release_roles, load_root_package_version,
11        resolve_artifact_root, root_release_set_manifest_path, workspace_manifest_path,
12    },
13    role_contract::{declared_role_manifest_path, finding_detail},
14};
15use std::{collections::BTreeSet, fs, path::Path};
16
17use canic_core::{CANIC_WASM_CHUNK_BYTES, cdk::utils::hash::decode_hex};
18use serde::{Deserialize, Serialize};
19
20const SHA_256_BYTES: usize = 32;
21
22///
23/// RootReleaseSetManifest
24///
25
26#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
27pub struct RootReleaseSetManifest {
28    pub release_version: String,
29    pub entries: Vec<ReleaseSetEntry>,
30}
31
32///
33/// ReleaseSetEntry
34///
35
36#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
37pub struct ReleaseSetEntry {
38    pub role: String,
39    pub template_id: String,
40    pub artifact_relative_path: String,
41    pub payload_size_bytes: u64,
42    pub payload_sha256_hex: String,
43    pub chunk_size_bytes: u64,
44    pub chunk_sha256_hex: Vec<String>,
45}
46
47/// Validate the canonical manifest contract shared by writers, loaders, and
48/// staging.
49pub fn validate_root_release_set_manifest(
50    manifest: &RootReleaseSetManifest,
51) -> Result<(), Box<dyn std::error::Error>> {
52    if manifest.release_version.trim().is_empty() {
53        return Err("release-set manifest version must not be empty".into());
54    }
55
56    let mut roles = BTreeSet::new();
57    for entry in &manifest.entries {
58        if entry.role.trim().is_empty() {
59            return Err("release-set manifest role must not be empty".into());
60        }
61        if !roles.insert(entry.role.as_str()) {
62            return Err(format!("duplicate release-set role: {}", entry.role).into());
63        }
64
65        let expected_template_id = format!("embedded:{}", entry.role);
66        if entry.template_id != expected_template_id {
67            return Err(format!(
68                "release-set template identity mismatch for role {}: expected {}",
69                entry.role, expected_template_id
70            )
71            .into());
72        }
73
74        if entry.payload_size_bytes == 0 {
75            return Err(format!(
76                "release-set payload size must be nonzero for role {}",
77                entry.role
78            )
79            .into());
80        }
81
82        let canonical_chunk_size = u64::try_from(CANIC_WASM_CHUNK_BYTES)?;
83        if entry.chunk_size_bytes != canonical_chunk_size {
84            return Err(format!(
85                "release-set chunk size must be {canonical_chunk_size} for role {}",
86                entry.role
87            )
88            .into());
89        }
90
91        validate_sha256_hex(
92            &entry.payload_sha256_hex,
93            &format!("payload hash for role {}", entry.role),
94        )?;
95
96        let expected_chunk_count =
97            usize::try_from(entry.payload_size_bytes.div_ceil(entry.chunk_size_bytes))?;
98        if entry.chunk_sha256_hex.len() != expected_chunk_count {
99            return Err(format!(
100                "release-set chunk count must be {expected_chunk_count} for role {}",
101                entry.role
102            )
103            .into());
104        }
105        for (chunk_index, chunk_hash) in entry.chunk_sha256_hex.iter().enumerate() {
106            validate_sha256_hex(
107                chunk_hash,
108                &format!("chunk hash {chunk_index} for role {}", entry.role),
109            )?;
110        }
111    }
112
113    Ok(())
114}
115
116fn validate_sha256_hex(value: &str, field: &str) -> Result<(), Box<dyn std::error::Error>> {
117    let bytes = decode_hex(value).map_err(|error| format!("invalid {field}: {error}"))?;
118    if bytes.len() != SHA_256_BYTES {
119        return Err(format!(
120            "invalid {field}: expected {SHA_256_BYTES} bytes, got {}",
121            bytes.len()
122        )
123        .into());
124    }
125    Ok(())
126}
127
128// Build and persist the current root release-set manifest from built `.wasm.gz` artifacts.
129pub fn emit_root_release_set_manifest(
130    workspace_root: &Path,
131    icp_root: &Path,
132    network: &str,
133) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
134    let config_path = config_path(workspace_root);
135    emit_root_release_set_manifest_with_config(workspace_root, icp_root, network, &config_path)
136}
137
138// Build and persist the current root release-set manifest with an explicit config path.
139pub fn emit_root_release_set_manifest_with_config(
140    workspace_root: &Path,
141    icp_root: &Path,
142    network: &str,
143    config_path: &Path,
144) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
145    let artifact_root = resolve_artifact_root(icp_root, network)?;
146    let manifest_path = root_release_set_manifest_path(&artifact_root)?;
147    let root_manifest_path =
148        declared_role_manifest_path(config_path, &canic_core::ids::CanisterRole::ROOT)
149            .map_err(|finding| finding_detail(&finding))?;
150    let release_version = load_root_package_version(
151        &root_manifest_path,
152        &workspace_manifest_path(workspace_root),
153    )?;
154    let entries = configured_release_roles(config_path)?
155        .into_iter()
156        .map(|role_name| build_release_set_entry(icp_root, &artifact_root, &role_name))
157        .collect::<Result<Vec<_>, _>>()?;
158    let manifest = RootReleaseSetManifest {
159        release_version,
160        entries,
161    };
162
163    validate_root_release_set_manifest(&manifest)?;
164    write_bytes(&manifest_path, &serde_json::to_vec_pretty(&manifest)?)?;
165    Ok(manifest_path)
166}
167
168// Emit the root release-set manifest only once every required ordinary artifact exists.
169pub fn emit_root_release_set_manifest_if_ready(
170    workspace_root: &Path,
171    icp_root: &Path,
172    network: &str,
173) -> Result<Option<std::path::PathBuf>, Box<dyn std::error::Error>> {
174    let config_path = config_path(workspace_root);
175    emit_root_release_set_manifest_if_ready_with_config(
176        workspace_root,
177        icp_root,
178        network,
179        &config_path,
180    )
181}
182
183// Emit the root release-set manifest using an explicit config path once every
184// required ordinary artifact exists.
185pub fn emit_root_release_set_manifest_if_ready_with_config(
186    workspace_root: &Path,
187    icp_root: &Path,
188    network: &str,
189    config_path: &Path,
190) -> Result<Option<std::path::PathBuf>, Box<dyn std::error::Error>> {
191    let artifact_root = resolve_artifact_root(icp_root, network)?;
192    let roles = configured_release_roles(config_path)?;
193
194    for role_name in roles {
195        let artifact_path = artifact_root
196            .join(&role_name)
197            .join(format!("{role_name}.wasm.gz"));
198        if !artifact_path.is_file() {
199            return Ok(None);
200        }
201    }
202
203    emit_root_release_set_manifest_with_config(workspace_root, icp_root, network, config_path)
204        .map(Some)
205}
206
207// Load one previously emitted root release-set manifest from disk.
208pub fn load_root_release_set_manifest(
209    manifest_path: &Path,
210) -> Result<RootReleaseSetManifest, Box<dyn std::error::Error>> {
211    let source = fs::read(manifest_path)?;
212    let manifest = serde_json::from_slice(&source)?;
213    validate_root_release_set_manifest(&manifest)?;
214    Ok(manifest)
215}
216
217// -----------------------------------------------------------------------------
218// Tests
219// -----------------------------------------------------------------------------
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn manifest() -> RootReleaseSetManifest {
226        RootReleaseSetManifest {
227            release_version: "test-version".to_string(),
228            entries: vec![ReleaseSetEntry {
229                role: "app".to_string(),
230                template_id: "embedded:app".to_string(),
231                artifact_relative_path: ".icp/local/canisters/app/app.wasm.gz".to_string(),
232                payload_size_bytes: 128,
233                payload_sha256_hex: "00".repeat(32),
234                chunk_size_bytes: 1_048_576,
235                chunk_sha256_hex: vec!["00".repeat(32)],
236            }],
237        }
238    }
239
240    #[test]
241    fn release_set_manifest_identity_accepts_canonical_role() {
242        assert!(validate_root_release_set_manifest(&manifest()).is_ok());
243    }
244
245    #[test]
246    fn release_set_manifest_identity_rejects_empty_version_and_role() {
247        let mut missing_version = manifest();
248        missing_version.release_version.clear();
249        let mut missing_role = manifest();
250        missing_role.entries[0].role.clear();
251
252        assert!(validate_root_release_set_manifest(&missing_version).is_err());
253        assert!(validate_root_release_set_manifest(&missing_role).is_err());
254    }
255
256    #[test]
257    fn release_set_manifest_identity_rejects_duplicate_role() {
258        let mut manifest = manifest();
259        manifest.entries.push(manifest.entries[0].clone());
260
261        assert!(validate_root_release_set_manifest(&manifest).is_err());
262    }
263
264    #[test]
265    fn release_set_manifest_identity_rejects_template_role_mismatch() {
266        let mut manifest = manifest();
267        manifest.entries[0].template_id = "embedded:other".to_string();
268
269        assert!(validate_root_release_set_manifest(&manifest).is_err());
270    }
271
272    #[test]
273    fn release_set_manifest_artifact_shape_rejects_zero_payload_and_wrong_chunk_size() {
274        let mut zero_payload = manifest();
275        zero_payload.entries[0].payload_size_bytes = 0;
276        let mut wrong_chunk_size = manifest();
277        wrong_chunk_size.entries[0].chunk_size_bytes -= 1;
278
279        assert!(validate_root_release_set_manifest(&zero_payload).is_err());
280        assert!(validate_root_release_set_manifest(&wrong_chunk_size).is_err());
281    }
282
283    #[test]
284    fn release_set_manifest_artifact_shape_rejects_impossible_chunk_count() {
285        let mut manifest = manifest();
286        manifest.entries[0].chunk_sha256_hex.clear();
287
288        assert!(validate_root_release_set_manifest(&manifest).is_err());
289    }
290
291    #[test]
292    fn release_set_manifest_artifact_shape_rejects_malformed_hashes() {
293        let mut payload_hash = manifest();
294        payload_hash.entries[0].payload_sha256_hex = "00".to_string();
295        let mut chunk_hash = manifest();
296        chunk_hash.entries[0].chunk_sha256_hex[0] = "not-hex".to_string();
297
298        assert!(validate_root_release_set_manifest(&payload_hash).is_err());
299        assert!(validate_root_release_set_manifest(&chunk_hash).is_err());
300    }
301}