1mod persistence;
8#[cfg(test)]
9mod tests;
10
11use crate::release_set::{
12 artifact::{RepresentationError, qualify_representation},
13 valid_package_name, validate_release_artifact_relative_path,
14};
15use canic_contracts::ids::{CanisterRole, ReleaseBuildId};
16use canic_core::{
17 cdk::utils::hash::decode_hex,
18 role_contract::{ProtocolProfileDigest, RoleCapabilityKey},
19};
20use serde::{Deserialize, Serialize};
21use sha2_host::{Digest, Sha256};
22use std::collections::BTreeSet;
23use thiserror::Error as ThisError;
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 let qualified =
307 qualify_representation(input.wasm, input.wasm_gz).map_err(|error| match error {
308 RepresentationError::EmptyArtifact { kind } => {
309 CanicInfrastructureArtifactManifestError::EmptyArtifact {
310 role: input.role,
311 kind,
312 }
313 }
314 RepresentationError::InvalidWasm => {
315 CanicInfrastructureArtifactManifestError::InvalidWasm { role: input.role }
316 }
317 RepresentationError::InvalidGzip { source } => {
318 CanicInfrastructureArtifactManifestError::InvalidGzip {
319 role: input.role,
320 source,
321 }
322 }
323 RepresentationError::ArtifactSizeOverflow { kind } => {
324 CanicInfrastructureArtifactManifestError::ArtifactSizeOverflow {
325 role: input.role,
326 kind,
327 }
328 }
329 RepresentationError::RepresentationMismatch => {
330 CanicInfrastructureArtifactManifestError::RepresentationMismatch {
331 role: input.role,
332 }
333 }
334 })?;
335
336 let entry = CanicInfrastructureArtifactEntry {
337 role: input.role,
338 package: input.package.to_string(),
339 protocol_release_identity: input.protocol_release_identity.to_string(),
340 protocol_role: input.protocol_role.clone(),
341 protocol_capabilities: input.protocol_capabilities.clone(),
342 release_build_id: input.release_build_id,
343 wasm_relative_path: input.wasm_relative_path.to_string(),
344 wasm_size_bytes: qualified.wasm_size_bytes,
345 wasm_sha256_hex: qualified.wasm_sha256_hex,
346 wasm_gz_relative_path: input.wasm_gz_relative_path.to_string(),
347 wasm_gz_size_bytes: qualified.wasm_gz_size_bytes,
348 wasm_gz_sha256_hex: qualified.wasm_gz_sha256_hex,
349 candid_sha256: input.candid_sha256,
350 protocol_profile_digest: input.protocol_profile_digest,
351 };
352 validate_entry(release_build_id, &entry)?;
353 Ok(entry)
354}
355
356fn validate_entry(
357 release_build_id: ReleaseBuildId,
358 entry: &CanicInfrastructureArtifactEntry,
359) -> Result<(), CanicInfrastructureArtifactManifestError> {
360 if entry.release_build_id != release_build_id {
361 return Err(
362 CanicInfrastructureArtifactManifestError::ReleaseBuildMismatch {
363 role: entry.role,
364 expected: release_build_id,
365 actual: entry.release_build_id,
366 },
367 );
368 }
369 if !valid_package_name(&entry.package) {
370 return Err(CanicInfrastructureArtifactManifestError::InvalidPackage {
371 role: entry.role,
372 package: entry.package.clone(),
373 });
374 }
375 if entry.protocol_release_identity.trim().is_empty() {
376 return Err(
377 CanicInfrastructureArtifactManifestError::MissingProtocolReleaseIdentity {
378 role: entry.role,
379 },
380 );
381 }
382 if entry.protocol_role.as_str() != entry.role.protocol_role_name() {
383 return Err(
384 CanicInfrastructureArtifactManifestError::ProtocolRoleMismatch {
385 role: entry.role,
386 protocol_role: entry.protocol_role.clone(),
387 },
388 );
389 }
390 validate_path(entry.role, "raw Wasm", &entry.wasm_relative_path)?;
391 validate_path(entry.role, "gzip Wasm", &entry.wasm_gz_relative_path)?;
392 if entry.wasm_size_bytes == 0 {
393 return Err(CanicInfrastructureArtifactManifestError::ZeroSize {
394 role: entry.role,
395 kind: "raw Wasm",
396 });
397 }
398 if entry.wasm_gz_size_bytes == 0 {
399 return Err(CanicInfrastructureArtifactManifestError::ZeroSize {
400 role: entry.role,
401 kind: "gzip Wasm",
402 });
403 }
404 validate_sha256(entry.role, "raw Wasm", &entry.wasm_sha256_hex)?;
405 validate_sha256(entry.role, "gzip Wasm", &entry.wasm_gz_sha256_hex)
406}
407
408fn validate_path(
409 role: CanicInfrastructureRole,
410 kind: &'static str,
411 path: &str,
412) -> Result<(), CanicInfrastructureArtifactManifestError> {
413 if path.len() > MAX_ARTIFACT_PATH_BYTES
414 || validate_release_artifact_relative_path(path).is_err()
415 {
416 return Err(CanicInfrastructureArtifactManifestError::InvalidPath {
417 role,
418 kind,
419 path: path.to_string(),
420 });
421 }
422 Ok(())
423}
424
425fn validate_sha256(
426 role: CanicInfrastructureRole,
427 kind: &'static str,
428 value: &str,
429) -> Result<(), CanicInfrastructureArtifactManifestError> {
430 let canonical = value.len() == SHA_256_HEX_BYTES
431 && value
432 .bytes()
433 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
434 && decode_hex(value).is_ok();
435 if canonical {
436 Ok(())
437 } else {
438 Err(CanicInfrastructureArtifactManifestError::InvalidSha256 {
439 role,
440 kind,
441 value: value.to_string(),
442 })
443 }
444}