1mod persistence;
8#[cfg(test)]
9mod tests;
10
11use std::{collections::BTreeSet, io::Read};
12
13use canic_core::{
14 cdk::utils::hash::{decode_hex, sha256_hex},
15 ids::{CanisterRole, ReleaseBuildId},
16 role_contract::{ProtocolProfileDigest, RoleCapabilityKey},
17};
18use flate2::read::GzDecoder;
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21use thiserror::Error as ThisError;
22
23use super::{GZIP_MAGIC, WASM_MAGIC, valid_package_name, validate_release_artifact_relative_path};
24
25pub use persistence::{
26 CanicInfrastructureArtifactBuildOutput, CanicInfrastructureArtifactPersistenceError,
27 PersistedCanicInfrastructureArtifactManifest,
28 compile_and_persist_canic_infrastructure_artifact_manifest,
29 load_persisted_canic_infrastructure_artifact_manifest,
30};
31
32const SHA_256_HEX_BYTES: usize = 64;
33const MAX_ARTIFACT_PATH_BYTES: usize = 4_096;
34const REQUIRED_INFRASTRUCTURE_ROLES: [CanicInfrastructureRole; 4] = [
35 CanicInfrastructureRole::FleetCoordinator,
36 CanicInfrastructureRole::FleetSubnetRoot,
37 CanicInfrastructureRole::PoolLedgerRecovery,
38 CanicInfrastructureRole::WasmStore,
39];
40
41#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
48#[serde(rename_all = "snake_case")]
49pub enum CanicInfrastructureRole {
50 FleetCoordinator,
51 FleetSubnetRoot,
52 PoolLedgerRecovery,
53 WasmStore,
54}
55
56impl CanicInfrastructureRole {
57 #[must_use]
59 pub const fn as_str(self) -> &'static str {
60 match self {
61 Self::FleetCoordinator => "fleet_coordinator",
62 Self::FleetSubnetRoot => "fleet_subnet_root",
63 Self::PoolLedgerRecovery => "pool_ledger_recovery",
64 Self::WasmStore => "wasm_store",
65 }
66 }
67
68 #[must_use]
70 pub const fn protocol_role_name(self) -> &'static str {
71 match self {
72 Self::FleetCoordinator => "fleet_coordinator",
73 Self::FleetSubnetRoot => "root",
74 Self::PoolLedgerRecovery => "pool_ledger_recovery",
75 Self::WasmStore => "wasm_store",
76 }
77 }
78}
79
80#[derive(Clone, Copy, Debug)]
87pub struct CanicInfrastructureArtifactInput<'a> {
88 pub role: CanicInfrastructureRole,
89 pub package: &'a str,
90 pub protocol_release_identity: &'a str,
91 pub protocol_role: &'a CanisterRole,
92 pub protocol_capabilities: &'a BTreeSet<RoleCapabilityKey>,
93 pub release_build_id: ReleaseBuildId,
94 pub wasm_relative_path: &'a str,
95 pub wasm: &'a [u8],
96 pub wasm_gz_relative_path: &'a str,
97 pub wasm_gz: &'a [u8],
98 pub candid_sha256: [u8; 32],
99 pub protocol_profile_digest: ProtocolProfileDigest,
100}
101
102#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
109#[serde(deny_unknown_fields)]
110pub struct CanicInfrastructureArtifactManifest {
111 pub release_build_id: ReleaseBuildId,
112 pub entries: Vec<CanicInfrastructureArtifactEntry>,
113}
114
115impl CanicInfrastructureArtifactManifest {
116 pub fn compile(
118 release_build_id: ReleaseBuildId,
119 inputs: &[CanicInfrastructureArtifactInput<'_>],
120 ) -> Result<Self, CanicInfrastructureArtifactManifestError> {
121 let mut entries = inputs
122 .iter()
123 .map(|input| compile_entry(release_build_id, input))
124 .collect::<Result<Vec<_>, _>>()?;
125 entries.sort_unstable_by_key(|entry| entry.role);
126
127 let manifest = Self {
128 release_build_id,
129 entries,
130 };
131 manifest.validate()?;
132 Ok(manifest)
133 }
134
135 pub fn validate(&self) -> Result<(), CanicInfrastructureArtifactManifestError> {
137 let actual_roles = self
138 .entries
139 .iter()
140 .map(|entry| entry.role)
141 .collect::<Vec<_>>();
142 if actual_roles != REQUIRED_INFRASTRUCTURE_ROLES {
143 return Err(
144 CanicInfrastructureArtifactManifestError::InfrastructureRoleSet {
145 actual: actual_roles,
146 },
147 );
148 }
149
150 let mut paths = BTreeSet::new();
151 for entry in &self.entries {
152 validate_entry(self.release_build_id, entry)?;
153 for path in [&entry.wasm_relative_path, &entry.wasm_gz_relative_path] {
154 if !paths.insert(path.as_str()) {
155 return Err(
156 CanicInfrastructureArtifactManifestError::DuplicateArtifactPath {
157 path: path.clone(),
158 },
159 );
160 }
161 }
162 }
163
164 Ok(())
165 }
166
167 pub fn canonical_bytes(&self) -> Result<Vec<u8>, CanicInfrastructureArtifactManifestError> {
169 self.validate()?;
170 serde_json::to_vec(self).map_err(CanicInfrastructureArtifactManifestError::Serialization)
171 }
172
173 pub fn digest(&self) -> Result<[u8; 32], CanicInfrastructureArtifactManifestError> {
175 Ok(Sha256::digest(self.canonical_bytes()?).into())
176 }
177}
178
179#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
186#[serde(deny_unknown_fields)]
187pub struct CanicInfrastructureArtifactEntry {
188 pub role: CanicInfrastructureRole,
189 pub package: String,
190 pub protocol_release_identity: String,
191 pub protocol_role: CanisterRole,
192 pub protocol_capabilities: BTreeSet<RoleCapabilityKey>,
193 pub release_build_id: ReleaseBuildId,
194 pub wasm_relative_path: String,
195 pub wasm_size_bytes: u64,
196 pub wasm_sha256_hex: String,
197 pub wasm_gz_relative_path: String,
198 pub wasm_gz_size_bytes: u64,
199 pub wasm_gz_sha256_hex: String,
200 pub candid_sha256: [u8; 32],
201 pub protocol_profile_digest: ProtocolProfileDigest,
202}
203
204#[derive(Debug, ThisError)]
211pub enum CanicInfrastructureArtifactManifestError {
212 #[error("infrastructure artifact {role:?} {kind} size cannot be represented")]
213 ArtifactSizeOverflow {
214 role: CanicInfrastructureRole,
215 kind: &'static str,
216 },
217
218 #[error("infrastructure artifact path is duplicated: {path}")]
219 DuplicateArtifactPath { path: String },
220
221 #[error("infrastructure artifact {role:?} has an empty {kind} payload")]
222 EmptyArtifact {
223 role: CanicInfrastructureRole,
224 kind: &'static str,
225 },
226
227 #[error("infrastructure artifact {role:?} has invalid gzip Wasm: {source}")]
228 InvalidGzip {
229 role: CanicInfrastructureRole,
230 #[source]
231 source: std::io::Error,
232 },
233
234 #[error(
235 "infrastructure manifest must contain each exact role once in canonical order: {actual:?}"
236 )]
237 InfrastructureRoleSet {
238 actual: Vec<CanicInfrastructureRole>,
239 },
240
241 #[error("infrastructure artifact {role:?} has invalid package identity: {package}")]
242 InvalidPackage {
243 role: CanicInfrastructureRole,
244 package: String,
245 },
246
247 #[error("infrastructure artifact {role:?} has an empty protocol release identity")]
248 MissingProtocolReleaseIdentity { role: CanicInfrastructureRole },
249
250 #[error(
251 "infrastructure artifact {role:?} protocol role {protocol_role} does not match its artifact role"
252 )]
253 ProtocolRoleMismatch {
254 role: CanicInfrastructureRole,
255 protocol_role: CanisterRole,
256 },
257
258 #[error("infrastructure artifact {role:?} has invalid {kind} path: {path}")]
259 InvalidPath {
260 role: CanicInfrastructureRole,
261 kind: &'static str,
262 path: String,
263 },
264
265 #[error("infrastructure artifact {role:?} has invalid {kind} SHA-256: {value}")]
266 InvalidSha256 {
267 role: CanicInfrastructureRole,
268 kind: &'static str,
269 value: String,
270 },
271
272 #[error("infrastructure artifact {role:?} has invalid raw Wasm bytes")]
273 InvalidWasm { role: CanicInfrastructureRole },
274
275 #[error(
276 "infrastructure artifact {role:?} release build {actual} does not match manifest release build {expected}"
277 )]
278 ReleaseBuildMismatch {
279 role: CanicInfrastructureRole,
280 expected: ReleaseBuildId,
281 actual: ReleaseBuildId,
282 },
283
284 #[error("infrastructure artifact {role:?} raw and gzip Wasm representations differ")]
285 RepresentationMismatch { role: CanicInfrastructureRole },
286
287 #[error("failed to serialize infrastructure artifact manifest: {0}")]
288 Serialization(serde_json::Error),
289
290 #[error("infrastructure artifact {role:?} {kind} size must be nonzero")]
291 ZeroSize {
292 role: CanicInfrastructureRole,
293 kind: &'static str,
294 },
295}
296
297fn compile_entry(
298 release_build_id: ReleaseBuildId,
299 input: &CanicInfrastructureArtifactInput<'_>,
300) -> Result<CanicInfrastructureArtifactEntry, CanicInfrastructureArtifactManifestError> {
301 if input.release_build_id != release_build_id {
302 return Err(
303 CanicInfrastructureArtifactManifestError::ReleaseBuildMismatch {
304 role: input.role,
305 expected: release_build_id,
306 actual: input.release_build_id,
307 },
308 );
309 }
310 if input.wasm.is_empty() {
311 return Err(CanicInfrastructureArtifactManifestError::EmptyArtifact {
312 role: input.role,
313 kind: "raw Wasm",
314 });
315 }
316 if !input.wasm.starts_with(&WASM_MAGIC) {
317 return Err(CanicInfrastructureArtifactManifestError::InvalidWasm { role: input.role });
318 }
319 if input.wasm_gz.is_empty() {
320 return Err(CanicInfrastructureArtifactManifestError::EmptyArtifact {
321 role: input.role,
322 kind: "gzip Wasm",
323 });
324 }
325 if !input.wasm_gz.starts_with(&GZIP_MAGIC) {
326 return Err(CanicInfrastructureArtifactManifestError::InvalidGzip {
327 role: input.role,
328 source: std::io::Error::new(std::io::ErrorKind::InvalidData, "missing gzip header"),
329 });
330 }
331
332 let wasm_size_bytes = u64::try_from(input.wasm.len()).map_err(|_| {
333 CanicInfrastructureArtifactManifestError::ArtifactSizeOverflow {
334 role: input.role,
335 kind: "raw Wasm",
336 }
337 })?;
338 let wasm_gz_size_bytes = u64::try_from(input.wasm_gz.len()).map_err(|_| {
339 CanicInfrastructureArtifactManifestError::ArtifactSizeOverflow {
340 role: input.role,
341 kind: "gzip Wasm",
342 }
343 })?;
344 let mut decoded = Vec::new();
345 GzDecoder::new(input.wasm_gz)
346 .take(wasm_size_bytes.saturating_add(1))
347 .read_to_end(&mut decoded)
348 .map_err(
349 |source| CanicInfrastructureArtifactManifestError::InvalidGzip {
350 role: input.role,
351 source,
352 },
353 )?;
354 if decoded != input.wasm {
355 return Err(
356 CanicInfrastructureArtifactManifestError::RepresentationMismatch { role: input.role },
357 );
358 }
359
360 let entry = CanicInfrastructureArtifactEntry {
361 role: input.role,
362 package: input.package.to_string(),
363 protocol_release_identity: input.protocol_release_identity.to_string(),
364 protocol_role: input.protocol_role.clone(),
365 protocol_capabilities: input.protocol_capabilities.clone(),
366 release_build_id: input.release_build_id,
367 wasm_relative_path: input.wasm_relative_path.to_string(),
368 wasm_size_bytes,
369 wasm_sha256_hex: sha256_hex(input.wasm),
370 wasm_gz_relative_path: input.wasm_gz_relative_path.to_string(),
371 wasm_gz_size_bytes,
372 wasm_gz_sha256_hex: sha256_hex(input.wasm_gz),
373 candid_sha256: input.candid_sha256,
374 protocol_profile_digest: input.protocol_profile_digest,
375 };
376 validate_entry(release_build_id, &entry)?;
377 Ok(entry)
378}
379
380fn validate_entry(
381 release_build_id: ReleaseBuildId,
382 entry: &CanicInfrastructureArtifactEntry,
383) -> Result<(), CanicInfrastructureArtifactManifestError> {
384 if entry.release_build_id != release_build_id {
385 return Err(
386 CanicInfrastructureArtifactManifestError::ReleaseBuildMismatch {
387 role: entry.role,
388 expected: release_build_id,
389 actual: entry.release_build_id,
390 },
391 );
392 }
393 if !valid_package_name(&entry.package) {
394 return Err(CanicInfrastructureArtifactManifestError::InvalidPackage {
395 role: entry.role,
396 package: entry.package.clone(),
397 });
398 }
399 if entry.protocol_release_identity.trim().is_empty() {
400 return Err(
401 CanicInfrastructureArtifactManifestError::MissingProtocolReleaseIdentity {
402 role: entry.role,
403 },
404 );
405 }
406 if entry.protocol_role.as_str() != entry.role.protocol_role_name() {
407 return Err(
408 CanicInfrastructureArtifactManifestError::ProtocolRoleMismatch {
409 role: entry.role,
410 protocol_role: entry.protocol_role.clone(),
411 },
412 );
413 }
414 validate_path(entry.role, "raw Wasm", &entry.wasm_relative_path)?;
415 validate_path(entry.role, "gzip Wasm", &entry.wasm_gz_relative_path)?;
416 if entry.wasm_size_bytes == 0 {
417 return Err(CanicInfrastructureArtifactManifestError::ZeroSize {
418 role: entry.role,
419 kind: "raw Wasm",
420 });
421 }
422 if entry.wasm_gz_size_bytes == 0 {
423 return Err(CanicInfrastructureArtifactManifestError::ZeroSize {
424 role: entry.role,
425 kind: "gzip Wasm",
426 });
427 }
428 validate_sha256(entry.role, "raw Wasm", &entry.wasm_sha256_hex)?;
429 validate_sha256(entry.role, "gzip Wasm", &entry.wasm_gz_sha256_hex)
430}
431
432fn validate_path(
433 role: CanicInfrastructureRole,
434 kind: &'static str,
435 path: &str,
436) -> Result<(), CanicInfrastructureArtifactManifestError> {
437 if path.len() > MAX_ARTIFACT_PATH_BYTES
438 || validate_release_artifact_relative_path(path).is_err()
439 {
440 return Err(CanicInfrastructureArtifactManifestError::InvalidPath {
441 role,
442 kind,
443 path: path.to_string(),
444 });
445 }
446 Ok(())
447}
448
449fn validate_sha256(
450 role: CanicInfrastructureRole,
451 kind: &'static str,
452 value: &str,
453) -> Result<(), CanicInfrastructureArtifactManifestError> {
454 let canonical = value.len() == SHA_256_HEX_BYTES
455 && value
456 .bytes()
457 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
458 && decode_hex(value).is_ok();
459 if canonical {
460 Ok(())
461 } else {
462 Err(CanicInfrastructureArtifactManifestError::InvalidSha256 {
463 role,
464 kind,
465 value: value.to_string(),
466 })
467 }
468}