1use 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
30pub struct RootReleaseSetManifest {
31 pub release_version: String,
32 pub entries: Vec<ReleaseSetEntry>,
33}
34
35#[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#[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#[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
67pub 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
147pub 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
202pub 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#[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}