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    canister_build::CurrentCanisterArtifactBuildOutput, durable_io::write_bytes,
9    release_set::build_release_set_entry,
10};
11use std::{
12    collections::{BTreeMap, BTreeSet},
13    fs,
14    path::{Path, PathBuf},
15};
16
17use canic_core::{
18    CANIC_WASM_CHUNK_BYTES, bootstrap::compiled::validate_canister_role_name,
19    cdk::utils::hash::decode_hex,
20};
21use serde::{Deserialize, Serialize};
22
23const SHA_256_BYTES: usize = 32;
24
25///
26/// RootReleaseSetManifest
27///
28
29#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
30pub struct RootReleaseSetManifest {
31    pub release_version: String,
32    pub entries: Vec<ReleaseSetEntry>,
33}
34
35///
36/// ReleaseSetEntry
37///
38
39#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
40pub struct ReleaseSetEntry {
41    pub role: String,
42    pub template_id: String,
43    pub artifact_relative_path: String,
44    pub payload_size_bytes: u64,
45    pub payload_sha256_hex: String,
46    pub chunk_size_bytes: u64,
47    pub chunk_sha256_hex: Vec<String>,
48}
49
50/// One required complete-build target and its exact admitted gzip path.
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub struct RootReleaseSetBuildTarget {
53    pub(crate) role: String,
54    pub(crate) expected_wasm_gz_path: PathBuf,
55    pub(crate) publish_entry: bool,
56}
57
58/// Immutable manifest inputs derived before the complete build starts.
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct RootReleaseSetBuildSnapshot {
61    pub(crate) icp_root: PathBuf,
62    pub(crate) manifest_path: PathBuf,
63    pub(crate) release_version: String,
64    pub(crate) targets: Vec<RootReleaseSetBuildTarget>,
65}
66
67/// Validate the canonical manifest contract shared by writers, loaders, and
68/// staging.
69pub fn validate_root_release_set_manifest(
70    manifest: &RootReleaseSetManifest,
71) -> Result<(), Box<dyn std::error::Error>> {
72    if manifest.release_version.trim().is_empty() {
73        return Err("release-set manifest version must not be empty".into());
74    }
75
76    let mut roles = BTreeSet::new();
77    for entry in &manifest.entries {
78        validate_canister_role_name(&entry.role)
79            .map_err(|issue| format!("invalid release-set role {:?}: {issue}", entry.role))?;
80        if !roles.insert(entry.role.as_str()) {
81            return Err(format!("duplicate release-set role: {}", entry.role).into());
82        }
83
84        let expected_template_id = format!("embedded:{}", entry.role);
85        if entry.template_id != expected_template_id {
86            return Err(format!(
87                "release-set template identity mismatch for role {}: expected {}",
88                entry.role, expected_template_id
89            )
90            .into());
91        }
92
93        if entry.payload_size_bytes == 0 {
94            return Err(format!(
95                "release-set payload size must be nonzero for role {}",
96                entry.role
97            )
98            .into());
99        }
100
101        let canonical_chunk_size = u64::try_from(CANIC_WASM_CHUNK_BYTES)?;
102        if entry.chunk_size_bytes != canonical_chunk_size {
103            return Err(format!(
104                "release-set chunk size must be {canonical_chunk_size} for role {}",
105                entry.role
106            )
107            .into());
108        }
109
110        validate_sha256_hex(
111            &entry.payload_sha256_hex,
112            &format!("payload hash for role {}", entry.role),
113        )?;
114
115        let expected_chunk_count =
116            usize::try_from(entry.payload_size_bytes.div_ceil(entry.chunk_size_bytes))?;
117        if entry.chunk_sha256_hex.len() != expected_chunk_count {
118            return Err(format!(
119                "release-set chunk count must be {expected_chunk_count} for role {}",
120                entry.role
121            )
122            .into());
123        }
124        for (chunk_index, chunk_hash) in entry.chunk_sha256_hex.iter().enumerate() {
125            validate_sha256_hex(
126                chunk_hash,
127                &format!("chunk hash {chunk_index} for role {}", entry.role),
128            )?;
129        }
130    }
131
132    Ok(())
133}
134
135fn validate_sha256_hex(value: &str, field: &str) -> Result<(), Box<dyn std::error::Error>> {
136    let bytes = decode_hex(value).map_err(|error| format!("invalid {field}: {error}"))?;
137    if bytes.len() != SHA_256_BYTES {
138        return Err(format!(
139            "invalid {field}: expected {SHA_256_BYTES} bytes, got {}",
140            bytes.len()
141        )
142        .into());
143    }
144    Ok(())
145}
146
147// Publish one manifest only from the exact outputs returned by this complete build.
148pub fn emit_root_release_set_manifest_from_build(
149    snapshot: &RootReleaseSetBuildSnapshot,
150    outputs: &[CurrentCanisterArtifactBuildOutput],
151) -> Result<PathBuf, Box<dyn std::error::Error>> {
152    let mut expected = BTreeMap::<&str, &RootReleaseSetBuildTarget>::new();
153    for target in &snapshot.targets {
154        if expected.insert(&target.role, target).is_some() {
155            return Err(format!("duplicate required build target: {}", target.role).into());
156        }
157    }
158
159    let mut current = BTreeMap::<&str, &CurrentCanisterArtifactBuildOutput>::new();
160    for output in outputs {
161        if current.insert(&output.role, output).is_some() {
162            return Err(format!("duplicate current build output: {}", output.role).into());
163        }
164        if !expected.contains_key(output.role.as_str()) {
165            return Err(format!("unexpected current build output: {}", output.role).into());
166        }
167    }
168
169    let mut entries = Vec::new();
170    for target in &snapshot.targets {
171        let output = current
172            .get(target.role.as_str())
173            .ok_or_else(|| format!("missing current build output: {}", target.role))?;
174        if output.output.wasm_gz_path != target.expected_wasm_gz_path {
175            return Err(format!(
176                "current build output path mismatch for {}: expected {}, got {}",
177                target.role,
178                target.expected_wasm_gz_path.display(),
179                output.output.wasm_gz_path.display()
180            )
181            .into());
182        }
183        if target.publish_entry {
184            entries.push(build_release_set_entry(
185                &snapshot.icp_root,
186                &target.role,
187                &output.output.wasm_gz_path,
188            )?);
189        }
190    }
191
192    let manifest = RootReleaseSetManifest {
193        release_version: snapshot.release_version.clone(),
194        entries,
195    };
196    validate_root_release_set_manifest(&manifest)?;
197    let bytes = serde_json::to_vec_pretty(&manifest)?;
198    write_bytes(&snapshot.manifest_path, &bytes)?;
199    Ok(snapshot.manifest_path.clone())
200}
201
202// Load one previously emitted root release-set manifest from disk.
203pub fn load_root_release_set_manifest(
204    manifest_path: &Path,
205) -> Result<RootReleaseSetManifest, Box<dyn std::error::Error>> {
206    let source = fs::read(manifest_path)?;
207    let manifest = serde_json::from_slice(&source)?;
208    validate_root_release_set_manifest(&manifest)?;
209    Ok(manifest)
210}
211
212// -----------------------------------------------------------------------------
213// Tests
214// -----------------------------------------------------------------------------
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::{
220        canister_build::{CanisterArtifactBuildOutput, CurrentCanisterArtifactBuildOutput},
221        test_support::temp_dir,
222    };
223    use flate2::{Compression, write::GzEncoder};
224    use std::io::Write;
225
226    fn manifest() -> RootReleaseSetManifest {
227        RootReleaseSetManifest {
228            release_version: "test-version".to_string(),
229            entries: vec![ReleaseSetEntry {
230                role: "app".to_string(),
231                template_id: "embedded:app".to_string(),
232                artifact_relative_path: ".icp/local/canisters/app/app.wasm.gz".to_string(),
233                payload_size_bytes: 128,
234                payload_sha256_hex: "00".repeat(32),
235                chunk_size_bytes: 1_048_576,
236                chunk_sha256_hex: vec!["00".repeat(32)],
237            }],
238        }
239    }
240
241    #[test]
242    fn release_set_manifest_identity_accepts_canonical_roles() {
243        for role in ["a", "app", "app2", "user_hub", "scale_replica", "role_2"] {
244            let mut manifest = manifest();
245            manifest.entries[0].role = role.to_string();
246            manifest.entries[0].template_id = format!("embedded:{role}");
247
248            assert!(validate_root_release_set_manifest(&manifest).is_ok());
249        }
250    }
251
252    #[test]
253    fn release_set_manifest_identity_rejects_empty_version_and_role() {
254        let mut missing_version = manifest();
255        missing_version.release_version.clear();
256        let mut missing_role = manifest();
257        missing_role.entries[0].role.clear();
258
259        assert!(validate_root_release_set_manifest(&missing_version).is_err());
260        assert!(validate_root_release_set_manifest(&missing_role).is_err());
261    }
262
263    #[test]
264    fn release_set_manifest_identity_rejects_duplicate_role() {
265        let mut manifest = manifest();
266        manifest.entries.push(manifest.entries[0].clone());
267
268        assert!(validate_root_release_set_manifest(&manifest).is_err());
269    }
270
271    #[test]
272    fn release_set_manifest_identity_rejects_unadmitted_roles() {
273        let overlong = "a".repeat(canic_core::bootstrap::compiled::NAME_MAX_BYTES + 1);
274        for role in [
275            "-app",
276            "App",
277            "_app",
278            "1app",
279            "user-hub",
280            "app_",
281            "app__worker",
282            "../sentinel",
283            "app/name",
284            "app.name",
285            "app name",
286            "café",
287            &overlong,
288        ] {
289            let mut manifest = manifest();
290            manifest.entries[0].role = role.to_string();
291            manifest.entries[0].template_id = format!("embedded:{role}");
292
293            assert!(validate_root_release_set_manifest(&manifest).is_err());
294        }
295    }
296
297    #[test]
298    fn release_set_manifest_identity_rejects_template_role_mismatch() {
299        let mut manifest = manifest();
300        manifest.entries[0].template_id = "embedded:other".to_string();
301
302        assert!(validate_root_release_set_manifest(&manifest).is_err());
303    }
304
305    #[test]
306    fn release_set_manifest_artifact_shape_rejects_zero_payload_and_wrong_chunk_size() {
307        let mut zero_payload = manifest();
308        zero_payload.entries[0].payload_size_bytes = 0;
309        let mut wrong_chunk_size = manifest();
310        wrong_chunk_size.entries[0].chunk_size_bytes -= 1;
311
312        assert!(validate_root_release_set_manifest(&zero_payload).is_err());
313        assert!(validate_root_release_set_manifest(&wrong_chunk_size).is_err());
314    }
315
316    #[test]
317    fn release_set_manifest_artifact_shape_rejects_impossible_chunk_count() {
318        let mut manifest = manifest();
319        manifest.entries[0].chunk_sha256_hex.clear();
320
321        assert!(validate_root_release_set_manifest(&manifest).is_err());
322    }
323
324    #[test]
325    fn release_set_manifest_artifact_shape_rejects_malformed_hashes() {
326        let mut payload_hash = manifest();
327        payload_hash.entries[0].payload_sha256_hex = "00".to_string();
328        let mut chunk_hash = manifest();
329        chunk_hash.entries[0].chunk_sha256_hex[0] = "not-hex".to_string();
330
331        assert!(validate_root_release_set_manifest(&payload_hash).is_err());
332        assert!(validate_root_release_set_manifest(&chunk_hash).is_err());
333    }
334
335    #[test]
336    fn complete_build_manifest_preserves_snapshot_order() {
337        let root = temp_dir("canic-complete-build-manifest-order");
338        let snapshot = build_snapshot(&root, &["user_hub", "app"]);
339        write_gzip_wasm(&expected_gzip_path(&root, "user_hub"));
340        write_gzip_wasm(&expected_gzip_path(&root, "app"));
341        let outputs = vec![
342            build_output(&root, "app"),
343            build_output(&root, "root"),
344            build_output(&root, "user_hub"),
345        ];
346
347        let manifest_path =
348            emit_root_release_set_manifest_from_build(&snapshot, &outputs).expect("emit manifest");
349        let manifest = load_root_release_set_manifest(&manifest_path).expect("load manifest");
350
351        fs::remove_dir_all(&root).expect("remove temp root");
352        assert_eq!(
353            manifest
354                .entries
355                .iter()
356                .map(|entry| entry.role.as_str())
357                .collect::<Vec<_>>(),
358            ["user_hub", "app"]
359        );
360    }
361
362    #[test]
363    fn rejected_complete_build_outputs_leave_existing_manifest_unchanged() {
364        let root = temp_dir("canic-complete-build-manifest-rejection");
365        let snapshot = build_snapshot(&root, &["app"]);
366        fs::create_dir_all(snapshot.manifest_path.parent().expect("manifest parent"))
367            .expect("create manifest parent");
368        let previous = b"previous manifest bytes";
369
370        let root_output = build_output(&root, "root");
371        let app_output = build_output(&root, "app");
372        let wrong_path_output = CurrentCanisterArtifactBuildOutput {
373            role: "app".to_string(),
374            output: CanisterArtifactBuildOutput {
375                wasm_gz_path: root.join("wrong/app.wasm.gz"),
376                ..app_output.output.clone()
377            },
378        };
379        let cases = vec![
380            vec![root_output.clone()],
381            vec![root_output.clone(), app_output.clone(), app_output.clone()],
382            vec![
383                root_output.clone(),
384                app_output.clone(),
385                build_output(&root, "unexpected"),
386            ],
387            vec![root_output.clone(), wrong_path_output],
388            vec![root_output, app_output],
389        ];
390
391        for outputs in cases {
392            fs::write(&snapshot.manifest_path, previous).expect("restore previous manifest");
393            assert!(emit_root_release_set_manifest_from_build(&snapshot, &outputs).is_err());
394            assert_eq!(
395                fs::read(&snapshot.manifest_path).expect("read preserved manifest"),
396                previous
397            );
398        }
399
400        fs::remove_dir_all(&root).expect("remove temp root");
401    }
402
403    fn build_snapshot(root: &Path, release_roles: &[&str]) -> RootReleaseSetBuildSnapshot {
404        let mut targets = vec![RootReleaseSetBuildTarget {
405            role: "root".to_string(),
406            expected_wasm_gz_path: expected_gzip_path(root, "root"),
407            publish_entry: false,
408        }];
409        targets.extend(release_roles.iter().map(|role| RootReleaseSetBuildTarget {
410            role: (*role).to_string(),
411            expected_wasm_gz_path: expected_gzip_path(root, role),
412            publish_entry: true,
413        }));
414        RootReleaseSetBuildSnapshot {
415            icp_root: root.to_path_buf(),
416            manifest_path: root.join(".icp/local/canisters/root/root.release-set.json"),
417            release_version: "test-version".to_string(),
418            targets,
419        }
420    }
421
422    fn build_output(root: &Path, role: &str) -> CurrentCanisterArtifactBuildOutput {
423        let artifact_root = root.join(".icp/local/canisters").join(role);
424        CurrentCanisterArtifactBuildOutput {
425            role: role.to_string(),
426            output: CanisterArtifactBuildOutput {
427                wasm_path: artifact_root.join(format!("{role}.wasm")),
428                wasm_gz_path: artifact_root.join(format!("{role}.wasm.gz")),
429                did_path: artifact_root.join(format!("{role}.did")),
430                artifact_root,
431            },
432        }
433    }
434
435    fn expected_gzip_path(root: &Path, role: &str) -> PathBuf {
436        root.join(".icp/local/canisters")
437            .join(role)
438            .join(format!("{role}.wasm.gz"))
439    }
440
441    fn write_gzip_wasm(path: &Path) {
442        fs::create_dir_all(path.parent().expect("artifact parent"))
443            .expect("create artifact parent");
444        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
445        encoder.write_all(b"\0asm\x01\0\0\0").expect("encode wasm");
446        fs::write(path, encoder.finish().expect("finish gzip")).expect("write gzip wasm");
447    }
448}