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::ReleaseBuildId,
16};
17use flate2::read::GzDecoder;
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use thiserror::Error as ThisError;
21
22use super::{GZIP_MAGIC, WASM_MAGIC, validate_release_artifact_relative_path};
23
24pub use persistence::{
25 CanicInfrastructureArtifactBuildOutput, CanicInfrastructureArtifactPersistenceError,
26 PersistedCanicInfrastructureArtifactManifest,
27 compile_and_persist_canic_infrastructure_artifact_manifest,
28 load_persisted_canic_infrastructure_artifact_manifest,
29};
30
31const SHA_256_HEX_BYTES: usize = 64;
32const MAX_ARTIFACT_PATH_BYTES: usize = 4_096;
33const MAX_PACKAGE_BYTES: usize = 128;
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
66#[derive(Clone, Copy, Debug)]
73pub struct CanicInfrastructureArtifactInput<'a> {
74 pub role: CanicInfrastructureRole,
75 pub package: &'a str,
76 pub release_build_id: ReleaseBuildId,
77 pub wasm_relative_path: &'a str,
78 pub wasm: &'a [u8],
79 pub wasm_gz_relative_path: &'a str,
80 pub wasm_gz: &'a [u8],
81}
82
83#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
90#[serde(deny_unknown_fields)]
91pub struct CanicInfrastructureArtifactManifest {
92 pub release_build_id: ReleaseBuildId,
93 pub entries: Vec<CanicInfrastructureArtifactEntry>,
94}
95
96impl CanicInfrastructureArtifactManifest {
97 pub fn compile(
99 release_build_id: ReleaseBuildId,
100 inputs: &[CanicInfrastructureArtifactInput<'_>],
101 ) -> Result<Self, CanicInfrastructureArtifactManifestError> {
102 let mut entries = inputs
103 .iter()
104 .map(|input| compile_entry(release_build_id, input))
105 .collect::<Result<Vec<_>, _>>()?;
106 entries.sort_unstable_by_key(|entry| entry.role);
107
108 let manifest = Self {
109 release_build_id,
110 entries,
111 };
112 manifest.validate()?;
113 Ok(manifest)
114 }
115
116 pub fn validate(&self) -> Result<(), CanicInfrastructureArtifactManifestError> {
118 let actual_roles = self
119 .entries
120 .iter()
121 .map(|entry| entry.role)
122 .collect::<Vec<_>>();
123 if actual_roles != REQUIRED_INFRASTRUCTURE_ROLES {
124 return Err(
125 CanicInfrastructureArtifactManifestError::InfrastructureRoleSet {
126 actual: actual_roles,
127 },
128 );
129 }
130
131 let mut paths = BTreeSet::new();
132 for entry in &self.entries {
133 validate_entry(self.release_build_id, entry)?;
134 for path in [&entry.wasm_relative_path, &entry.wasm_gz_relative_path] {
135 if !paths.insert(path.as_str()) {
136 return Err(
137 CanicInfrastructureArtifactManifestError::DuplicateArtifactPath {
138 path: path.clone(),
139 },
140 );
141 }
142 }
143 }
144
145 Ok(())
146 }
147
148 pub fn canonical_bytes(&self) -> Result<Vec<u8>, CanicInfrastructureArtifactManifestError> {
150 self.validate()?;
151 serde_json::to_vec(self).map_err(CanicInfrastructureArtifactManifestError::Serialization)
152 }
153
154 pub fn digest(&self) -> Result<[u8; 32], CanicInfrastructureArtifactManifestError> {
156 Ok(Sha256::digest(self.canonical_bytes()?).into())
157 }
158}
159
160#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
167#[serde(deny_unknown_fields)]
168pub struct CanicInfrastructureArtifactEntry {
169 pub role: CanicInfrastructureRole,
170 pub package: String,
171 pub release_build_id: ReleaseBuildId,
172 pub wasm_relative_path: String,
173 pub wasm_size_bytes: u64,
174 pub wasm_sha256_hex: String,
175 pub wasm_gz_relative_path: String,
176 pub wasm_gz_size_bytes: u64,
177 pub wasm_gz_sha256_hex: String,
178}
179
180#[derive(Debug, ThisError)]
187pub enum CanicInfrastructureArtifactManifestError {
188 #[error("infrastructure artifact {role:?} {kind} size cannot be represented")]
189 ArtifactSizeOverflow {
190 role: CanicInfrastructureRole,
191 kind: &'static str,
192 },
193
194 #[error("infrastructure artifact path is duplicated: {path}")]
195 DuplicateArtifactPath { path: String },
196
197 #[error("infrastructure artifact {role:?} has an empty {kind} payload")]
198 EmptyArtifact {
199 role: CanicInfrastructureRole,
200 kind: &'static str,
201 },
202
203 #[error("infrastructure artifact {role:?} has invalid gzip Wasm: {source}")]
204 InvalidGzip {
205 role: CanicInfrastructureRole,
206 #[source]
207 source: std::io::Error,
208 },
209
210 #[error(
211 "infrastructure manifest must contain each exact role once in canonical order: {actual:?}"
212 )]
213 InfrastructureRoleSet {
214 actual: Vec<CanicInfrastructureRole>,
215 },
216
217 #[error("infrastructure artifact {role:?} has invalid package identity: {package}")]
218 InvalidPackage {
219 role: CanicInfrastructureRole,
220 package: String,
221 },
222
223 #[error("infrastructure artifact {role:?} has invalid {kind} path: {path}")]
224 InvalidPath {
225 role: CanicInfrastructureRole,
226 kind: &'static str,
227 path: String,
228 },
229
230 #[error("infrastructure artifact {role:?} has invalid {kind} SHA-256: {value}")]
231 InvalidSha256 {
232 role: CanicInfrastructureRole,
233 kind: &'static str,
234 value: String,
235 },
236
237 #[error("infrastructure artifact {role:?} has invalid raw Wasm bytes")]
238 InvalidWasm { role: CanicInfrastructureRole },
239
240 #[error(
241 "infrastructure artifact {role:?} release build {actual} does not match manifest release build {expected}"
242 )]
243 ReleaseBuildMismatch {
244 role: CanicInfrastructureRole,
245 expected: ReleaseBuildId,
246 actual: ReleaseBuildId,
247 },
248
249 #[error("infrastructure artifact {role:?} raw and gzip Wasm representations differ")]
250 RepresentationMismatch { role: CanicInfrastructureRole },
251
252 #[error("failed to serialize infrastructure artifact manifest: {0}")]
253 Serialization(serde_json::Error),
254
255 #[error("infrastructure artifact {role:?} {kind} size must be nonzero")]
256 ZeroSize {
257 role: CanicInfrastructureRole,
258 kind: &'static str,
259 },
260}
261
262fn compile_entry(
263 release_build_id: ReleaseBuildId,
264 input: &CanicInfrastructureArtifactInput<'_>,
265) -> Result<CanicInfrastructureArtifactEntry, CanicInfrastructureArtifactManifestError> {
266 if input.release_build_id != release_build_id {
267 return Err(
268 CanicInfrastructureArtifactManifestError::ReleaseBuildMismatch {
269 role: input.role,
270 expected: release_build_id,
271 actual: input.release_build_id,
272 },
273 );
274 }
275 if input.wasm.is_empty() {
276 return Err(CanicInfrastructureArtifactManifestError::EmptyArtifact {
277 role: input.role,
278 kind: "raw Wasm",
279 });
280 }
281 if !input.wasm.starts_with(&WASM_MAGIC) {
282 return Err(CanicInfrastructureArtifactManifestError::InvalidWasm { role: input.role });
283 }
284 if input.wasm_gz.is_empty() {
285 return Err(CanicInfrastructureArtifactManifestError::EmptyArtifact {
286 role: input.role,
287 kind: "gzip Wasm",
288 });
289 }
290 if !input.wasm_gz.starts_with(&GZIP_MAGIC) {
291 return Err(CanicInfrastructureArtifactManifestError::InvalidGzip {
292 role: input.role,
293 source: std::io::Error::new(std::io::ErrorKind::InvalidData, "missing gzip header"),
294 });
295 }
296
297 let wasm_size_bytes = u64::try_from(input.wasm.len()).map_err(|_| {
298 CanicInfrastructureArtifactManifestError::ArtifactSizeOverflow {
299 role: input.role,
300 kind: "raw Wasm",
301 }
302 })?;
303 let wasm_gz_size_bytes = u64::try_from(input.wasm_gz.len()).map_err(|_| {
304 CanicInfrastructureArtifactManifestError::ArtifactSizeOverflow {
305 role: input.role,
306 kind: "gzip Wasm",
307 }
308 })?;
309 let mut decoded = Vec::new();
310 GzDecoder::new(input.wasm_gz)
311 .take(wasm_size_bytes.saturating_add(1))
312 .read_to_end(&mut decoded)
313 .map_err(
314 |source| CanicInfrastructureArtifactManifestError::InvalidGzip {
315 role: input.role,
316 source,
317 },
318 )?;
319 if decoded != input.wasm {
320 return Err(
321 CanicInfrastructureArtifactManifestError::RepresentationMismatch { role: input.role },
322 );
323 }
324
325 let entry = CanicInfrastructureArtifactEntry {
326 role: input.role,
327 package: input.package.to_string(),
328 release_build_id: input.release_build_id,
329 wasm_relative_path: input.wasm_relative_path.to_string(),
330 wasm_size_bytes,
331 wasm_sha256_hex: sha256_hex(input.wasm),
332 wasm_gz_relative_path: input.wasm_gz_relative_path.to_string(),
333 wasm_gz_size_bytes,
334 wasm_gz_sha256_hex: sha256_hex(input.wasm_gz),
335 };
336 validate_entry(release_build_id, &entry)?;
337 Ok(entry)
338}
339
340fn validate_entry(
341 release_build_id: ReleaseBuildId,
342 entry: &CanicInfrastructureArtifactEntry,
343) -> Result<(), CanicInfrastructureArtifactManifestError> {
344 if entry.release_build_id != release_build_id {
345 return Err(
346 CanicInfrastructureArtifactManifestError::ReleaseBuildMismatch {
347 role: entry.role,
348 expected: release_build_id,
349 actual: entry.release_build_id,
350 },
351 );
352 }
353 if !valid_package_name(&entry.package) {
354 return Err(CanicInfrastructureArtifactManifestError::InvalidPackage {
355 role: entry.role,
356 package: entry.package.clone(),
357 });
358 }
359 validate_path(entry.role, "raw Wasm", &entry.wasm_relative_path)?;
360 validate_path(entry.role, "gzip Wasm", &entry.wasm_gz_relative_path)?;
361 if entry.wasm_size_bytes == 0 {
362 return Err(CanicInfrastructureArtifactManifestError::ZeroSize {
363 role: entry.role,
364 kind: "raw Wasm",
365 });
366 }
367 if entry.wasm_gz_size_bytes == 0 {
368 return Err(CanicInfrastructureArtifactManifestError::ZeroSize {
369 role: entry.role,
370 kind: "gzip Wasm",
371 });
372 }
373 validate_sha256(entry.role, "raw Wasm", &entry.wasm_sha256_hex)?;
374 validate_sha256(entry.role, "gzip Wasm", &entry.wasm_gz_sha256_hex)
375}
376
377fn validate_path(
378 role: CanicInfrastructureRole,
379 kind: &'static str,
380 path: &str,
381) -> Result<(), CanicInfrastructureArtifactManifestError> {
382 if path.len() > MAX_ARTIFACT_PATH_BYTES
383 || validate_release_artifact_relative_path(path).is_err()
384 {
385 return Err(CanicInfrastructureArtifactManifestError::InvalidPath {
386 role,
387 kind,
388 path: path.to_string(),
389 });
390 }
391 Ok(())
392}
393
394fn validate_sha256(
395 role: CanicInfrastructureRole,
396 kind: &'static str,
397 value: &str,
398) -> Result<(), CanicInfrastructureArtifactManifestError> {
399 let canonical = value.len() == SHA_256_HEX_BYTES
400 && value
401 .bytes()
402 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
403 && decode_hex(value).is_ok();
404 if canonical {
405 Ok(())
406 } else {
407 Err(CanicInfrastructureArtifactManifestError::InvalidSha256 {
408 role,
409 kind,
410 value: value.to_string(),
411 })
412 }
413}
414
415fn valid_package_name(package: &str) -> bool {
416 let bytes = package.as_bytes();
417 !bytes.is_empty()
418 && bytes.len() <= MAX_PACKAGE_BYTES
419 && bytes.first().is_some_and(u8::is_ascii_alphanumeric)
420 && bytes.last().is_some_and(u8::is_ascii_alphanumeric)
421 && bytes
422 .iter()
423 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
424}