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