1use 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
27pub struct RootReleaseSetManifest {
28 pub release_version: String,
29 pub entries: Vec<ReleaseSetEntry>,
30}
31
32#[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
47pub 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
128pub 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
138pub 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
168pub 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
183pub 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
207pub 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#[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}