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