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 role/template identity 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 serde::{Deserialize, Serialize};
18
19///
20/// RootReleaseSetManifest
21///
22
23#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
24pub struct RootReleaseSetManifest {
25    pub release_version: String,
26    pub entries: Vec<ReleaseSetEntry>,
27}
28
29///
30/// ReleaseSetEntry
31///
32
33#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
34pub struct ReleaseSetEntry {
35    pub role: String,
36    pub template_id: String,
37    pub artifact_relative_path: String,
38    pub payload_size_bytes: u64,
39    pub payload_sha256_hex: String,
40    pub chunk_size_bytes: u64,
41    pub chunk_sha256_hex: Vec<String>,
42}
43
44/// Validate the canonical identity contract shared by manifest writers,
45/// loaders, and staging.
46pub fn validate_root_release_set_manifest(
47    manifest: &RootReleaseSetManifest,
48) -> Result<(), Box<dyn std::error::Error>> {
49    if manifest.release_version.trim().is_empty() {
50        return Err("release-set manifest version must not be empty".into());
51    }
52
53    let mut roles = BTreeSet::new();
54    for entry in &manifest.entries {
55        if entry.role.trim().is_empty() {
56            return Err("release-set manifest role must not be empty".into());
57        }
58        if !roles.insert(entry.role.as_str()) {
59            return Err(format!("duplicate release-set role: {}", entry.role).into());
60        }
61
62        let expected_template_id = format!("embedded:{}", entry.role);
63        if entry.template_id != expected_template_id {
64            return Err(format!(
65                "release-set template identity mismatch for role {}: expected {}",
66                entry.role, expected_template_id
67            )
68            .into());
69        }
70    }
71
72    Ok(())
73}
74
75// Build and persist the current root release-set manifest from built `.wasm.gz` artifacts.
76pub fn emit_root_release_set_manifest(
77    workspace_root: &Path,
78    icp_root: &Path,
79    network: &str,
80) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
81    let config_path = config_path(workspace_root);
82    emit_root_release_set_manifest_with_config(workspace_root, icp_root, network, &config_path)
83}
84
85// Build and persist the current root release-set manifest with an explicit config path.
86pub fn emit_root_release_set_manifest_with_config(
87    workspace_root: &Path,
88    icp_root: &Path,
89    network: &str,
90    config_path: &Path,
91) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
92    let artifact_root = resolve_artifact_root(icp_root, network)?;
93    let manifest_path = root_release_set_manifest_path(&artifact_root)?;
94    let root_manifest_path =
95        declared_role_manifest_path(config_path, &canic_core::ids::CanisterRole::ROOT)
96            .map_err(|finding| finding_detail(&finding))?;
97    let release_version = load_root_package_version(
98        &root_manifest_path,
99        &workspace_manifest_path(workspace_root),
100    )?;
101    let entries = configured_release_roles(config_path)?
102        .into_iter()
103        .map(|role_name| build_release_set_entry(icp_root, &artifact_root, &role_name))
104        .collect::<Result<Vec<_>, _>>()?;
105    let manifest = RootReleaseSetManifest {
106        release_version,
107        entries,
108    };
109
110    validate_root_release_set_manifest(&manifest)?;
111    write_bytes(&manifest_path, &serde_json::to_vec_pretty(&manifest)?)?;
112    Ok(manifest_path)
113}
114
115// Emit the root release-set manifest only once every required ordinary artifact exists.
116pub fn emit_root_release_set_manifest_if_ready(
117    workspace_root: &Path,
118    icp_root: &Path,
119    network: &str,
120) -> Result<Option<std::path::PathBuf>, Box<dyn std::error::Error>> {
121    let config_path = config_path(workspace_root);
122    emit_root_release_set_manifest_if_ready_with_config(
123        workspace_root,
124        icp_root,
125        network,
126        &config_path,
127    )
128}
129
130// Emit the root release-set manifest using an explicit config path once every
131// required ordinary artifact exists.
132pub fn emit_root_release_set_manifest_if_ready_with_config(
133    workspace_root: &Path,
134    icp_root: &Path,
135    network: &str,
136    config_path: &Path,
137) -> Result<Option<std::path::PathBuf>, Box<dyn std::error::Error>> {
138    let artifact_root = resolve_artifact_root(icp_root, network)?;
139    let roles = configured_release_roles(config_path)?;
140
141    for role_name in roles {
142        let artifact_path = artifact_root
143            .join(&role_name)
144            .join(format!("{role_name}.wasm.gz"));
145        if !artifact_path.is_file() {
146            return Ok(None);
147        }
148    }
149
150    emit_root_release_set_manifest_with_config(workspace_root, icp_root, network, config_path)
151        .map(Some)
152}
153
154// Load one previously emitted root release-set manifest from disk.
155pub fn load_root_release_set_manifest(
156    manifest_path: &Path,
157) -> Result<RootReleaseSetManifest, Box<dyn std::error::Error>> {
158    let source = fs::read(manifest_path)?;
159    let manifest = serde_json::from_slice(&source)?;
160    validate_root_release_set_manifest(&manifest)?;
161    Ok(manifest)
162}
163
164// -----------------------------------------------------------------------------
165// Tests
166// -----------------------------------------------------------------------------
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn manifest() -> RootReleaseSetManifest {
173        RootReleaseSetManifest {
174            release_version: "test-version".to_string(),
175            entries: vec![ReleaseSetEntry {
176                role: "app".to_string(),
177                template_id: "embedded:app".to_string(),
178                artifact_relative_path: ".icp/local/canisters/app/app.wasm.gz".to_string(),
179                payload_size_bytes: 128,
180                payload_sha256_hex: "00".repeat(32),
181                chunk_size_bytes: 1_048_576,
182                chunk_sha256_hex: vec!["00".repeat(32)],
183            }],
184        }
185    }
186
187    #[test]
188    fn release_set_manifest_identity_accepts_canonical_role() {
189        assert!(validate_root_release_set_manifest(&manifest()).is_ok());
190    }
191
192    #[test]
193    fn release_set_manifest_identity_rejects_empty_version_and_role() {
194        let mut missing_version = manifest();
195        missing_version.release_version.clear();
196        let mut missing_role = manifest();
197        missing_role.entries[0].role.clear();
198
199        assert!(validate_root_release_set_manifest(&missing_version).is_err());
200        assert!(validate_root_release_set_manifest(&missing_role).is_err());
201    }
202
203    #[test]
204    fn release_set_manifest_identity_rejects_duplicate_role() {
205        let mut manifest = manifest();
206        manifest.entries.push(manifest.entries[0].clone());
207
208        assert!(validate_root_release_set_manifest(&manifest).is_err());
209    }
210
211    #[test]
212    fn release_set_manifest_identity_rejects_template_role_mismatch() {
213        let mut manifest = manifest();
214        manifest.entries[0].template_id = "embedded:other".to_string();
215
216        assert!(validate_root_release_set_manifest(&manifest).is_err());
217    }
218}