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