mod persistence;
#[cfg(test)]
mod tests;
use std::{collections::BTreeSet, io::Read};
use canic_core::{
cdk::utils::hash::{decode_hex, sha256_hex},
ids::ReleaseBuildId,
};
use flate2::read::GzDecoder;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error as ThisError;
use super::{GZIP_MAGIC, WASM_MAGIC, validate_release_artifact_relative_path};
pub use persistence::{
CanicInfrastructureArtifactBuildOutput, CanicInfrastructureArtifactPersistenceError,
PersistedCanicInfrastructureArtifactManifest,
compile_and_persist_canic_infrastructure_artifact_manifest,
load_persisted_canic_infrastructure_artifact_manifest,
};
const SHA_256_HEX_BYTES: usize = 64;
const MAX_ARTIFACT_PATH_BYTES: usize = 4_096;
const MAX_PACKAGE_BYTES: usize = 128;
const REQUIRED_INFRASTRUCTURE_ROLES: [CanicInfrastructureRole; 3] = [
CanicInfrastructureRole::FleetCoordinator,
CanicInfrastructureRole::FleetSubnetRoot,
CanicInfrastructureRole::WasmStore,
];
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CanicInfrastructureRole {
FleetCoordinator,
FleetSubnetRoot,
WasmStore,
}
impl CanicInfrastructureRole {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::FleetCoordinator => "fleet_coordinator",
Self::FleetSubnetRoot => "fleet_subnet_root",
Self::WasmStore => "wasm_store",
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct CanicInfrastructureArtifactInput<'a> {
pub role: CanicInfrastructureRole,
pub package: &'a str,
pub release_build_id: ReleaseBuildId,
pub wasm_relative_path: &'a str,
pub wasm: &'a [u8],
pub wasm_gz_relative_path: &'a str,
pub wasm_gz: &'a [u8],
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct CanicInfrastructureArtifactManifest {
pub release_build_id: ReleaseBuildId,
pub entries: Vec<CanicInfrastructureArtifactEntry>,
}
impl CanicInfrastructureArtifactManifest {
pub fn compile(
release_build_id: ReleaseBuildId,
inputs: &[CanicInfrastructureArtifactInput<'_>],
) -> Result<Self, CanicInfrastructureArtifactManifestError> {
let mut entries = inputs
.iter()
.map(|input| compile_entry(release_build_id, input))
.collect::<Result<Vec<_>, _>>()?;
entries.sort_unstable_by_key(|entry| entry.role);
let manifest = Self {
release_build_id,
entries,
};
manifest.validate()?;
Ok(manifest)
}
pub fn validate(&self) -> Result<(), CanicInfrastructureArtifactManifestError> {
let actual_roles = self
.entries
.iter()
.map(|entry| entry.role)
.collect::<Vec<_>>();
if actual_roles != REQUIRED_INFRASTRUCTURE_ROLES {
return Err(
CanicInfrastructureArtifactManifestError::InfrastructureRoleSet {
actual: actual_roles,
},
);
}
let mut paths = BTreeSet::new();
for entry in &self.entries {
validate_entry(self.release_build_id, entry)?;
for path in [&entry.wasm_relative_path, &entry.wasm_gz_relative_path] {
if !paths.insert(path.as_str()) {
return Err(
CanicInfrastructureArtifactManifestError::DuplicateArtifactPath {
path: path.clone(),
},
);
}
}
}
Ok(())
}
pub fn canonical_bytes(&self) -> Result<Vec<u8>, CanicInfrastructureArtifactManifestError> {
self.validate()?;
serde_json::to_vec(self).map_err(CanicInfrastructureArtifactManifestError::Serialization)
}
pub fn digest(&self) -> Result<[u8; 32], CanicInfrastructureArtifactManifestError> {
Ok(Sha256::digest(self.canonical_bytes()?).into())
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct CanicInfrastructureArtifactEntry {
pub role: CanicInfrastructureRole,
pub package: String,
pub release_build_id: ReleaseBuildId,
pub wasm_relative_path: String,
pub wasm_size_bytes: u64,
pub wasm_sha256_hex: String,
pub wasm_gz_relative_path: String,
pub wasm_gz_size_bytes: u64,
pub wasm_gz_sha256_hex: String,
}
#[derive(Debug, ThisError)]
pub enum CanicInfrastructureArtifactManifestError {
#[error("infrastructure artifact {role:?} {kind} size cannot be represented")]
ArtifactSizeOverflow {
role: CanicInfrastructureRole,
kind: &'static str,
},
#[error("infrastructure artifact path is duplicated: {path}")]
DuplicateArtifactPath { path: String },
#[error("infrastructure artifact {role:?} has an empty {kind} payload")]
EmptyArtifact {
role: CanicInfrastructureRole,
kind: &'static str,
},
#[error("infrastructure artifact {role:?} has invalid gzip Wasm: {source}")]
InvalidGzip {
role: CanicInfrastructureRole,
#[source]
source: std::io::Error,
},
#[error(
"infrastructure manifest must contain each exact role once in canonical order: {actual:?}"
)]
InfrastructureRoleSet {
actual: Vec<CanicInfrastructureRole>,
},
#[error("infrastructure artifact {role:?} has invalid package identity: {package}")]
InvalidPackage {
role: CanicInfrastructureRole,
package: String,
},
#[error("infrastructure artifact {role:?} has invalid {kind} path: {path}")]
InvalidPath {
role: CanicInfrastructureRole,
kind: &'static str,
path: String,
},
#[error("infrastructure artifact {role:?} has invalid {kind} SHA-256: {value}")]
InvalidSha256 {
role: CanicInfrastructureRole,
kind: &'static str,
value: String,
},
#[error("infrastructure artifact {role:?} has invalid raw Wasm bytes")]
InvalidWasm { role: CanicInfrastructureRole },
#[error(
"infrastructure artifact {role:?} release build {actual} does not match manifest release build {expected}"
)]
ReleaseBuildMismatch {
role: CanicInfrastructureRole,
expected: ReleaseBuildId,
actual: ReleaseBuildId,
},
#[error("infrastructure artifact {role:?} raw and gzip Wasm representations differ")]
RepresentationMismatch { role: CanicInfrastructureRole },
#[error("failed to serialize infrastructure artifact manifest: {0}")]
Serialization(serde_json::Error),
#[error("infrastructure artifact {role:?} {kind} size must be nonzero")]
ZeroSize {
role: CanicInfrastructureRole,
kind: &'static str,
},
}
fn compile_entry(
release_build_id: ReleaseBuildId,
input: &CanicInfrastructureArtifactInput<'_>,
) -> Result<CanicInfrastructureArtifactEntry, CanicInfrastructureArtifactManifestError> {
if input.release_build_id != release_build_id {
return Err(
CanicInfrastructureArtifactManifestError::ReleaseBuildMismatch {
role: input.role,
expected: release_build_id,
actual: input.release_build_id,
},
);
}
if input.wasm.is_empty() {
return Err(CanicInfrastructureArtifactManifestError::EmptyArtifact {
role: input.role,
kind: "raw Wasm",
});
}
if !input.wasm.starts_with(&WASM_MAGIC) {
return Err(CanicInfrastructureArtifactManifestError::InvalidWasm { role: input.role });
}
if input.wasm_gz.is_empty() {
return Err(CanicInfrastructureArtifactManifestError::EmptyArtifact {
role: input.role,
kind: "gzip Wasm",
});
}
if !input.wasm_gz.starts_with(&GZIP_MAGIC) {
return Err(CanicInfrastructureArtifactManifestError::InvalidGzip {
role: input.role,
source: std::io::Error::new(std::io::ErrorKind::InvalidData, "missing gzip header"),
});
}
let wasm_size_bytes = u64::try_from(input.wasm.len()).map_err(|_| {
CanicInfrastructureArtifactManifestError::ArtifactSizeOverflow {
role: input.role,
kind: "raw Wasm",
}
})?;
let wasm_gz_size_bytes = u64::try_from(input.wasm_gz.len()).map_err(|_| {
CanicInfrastructureArtifactManifestError::ArtifactSizeOverflow {
role: input.role,
kind: "gzip Wasm",
}
})?;
let mut decoded = Vec::new();
GzDecoder::new(input.wasm_gz)
.take(wasm_size_bytes.saturating_add(1))
.read_to_end(&mut decoded)
.map_err(
|source| CanicInfrastructureArtifactManifestError::InvalidGzip {
role: input.role,
source,
},
)?;
if decoded != input.wasm {
return Err(
CanicInfrastructureArtifactManifestError::RepresentationMismatch { role: input.role },
);
}
let entry = CanicInfrastructureArtifactEntry {
role: input.role,
package: input.package.to_string(),
release_build_id: input.release_build_id,
wasm_relative_path: input.wasm_relative_path.to_string(),
wasm_size_bytes,
wasm_sha256_hex: sha256_hex(input.wasm),
wasm_gz_relative_path: input.wasm_gz_relative_path.to_string(),
wasm_gz_size_bytes,
wasm_gz_sha256_hex: sha256_hex(input.wasm_gz),
};
validate_entry(release_build_id, &entry)?;
Ok(entry)
}
fn validate_entry(
release_build_id: ReleaseBuildId,
entry: &CanicInfrastructureArtifactEntry,
) -> Result<(), CanicInfrastructureArtifactManifestError> {
if entry.release_build_id != release_build_id {
return Err(
CanicInfrastructureArtifactManifestError::ReleaseBuildMismatch {
role: entry.role,
expected: release_build_id,
actual: entry.release_build_id,
},
);
}
if !valid_package_name(&entry.package) {
return Err(CanicInfrastructureArtifactManifestError::InvalidPackage {
role: entry.role,
package: entry.package.clone(),
});
}
validate_path(entry.role, "raw Wasm", &entry.wasm_relative_path)?;
validate_path(entry.role, "gzip Wasm", &entry.wasm_gz_relative_path)?;
if entry.wasm_size_bytes == 0 {
return Err(CanicInfrastructureArtifactManifestError::ZeroSize {
role: entry.role,
kind: "raw Wasm",
});
}
if entry.wasm_gz_size_bytes == 0 {
return Err(CanicInfrastructureArtifactManifestError::ZeroSize {
role: entry.role,
kind: "gzip Wasm",
});
}
validate_sha256(entry.role, "raw Wasm", &entry.wasm_sha256_hex)?;
validate_sha256(entry.role, "gzip Wasm", &entry.wasm_gz_sha256_hex)
}
fn validate_path(
role: CanicInfrastructureRole,
kind: &'static str,
path: &str,
) -> Result<(), CanicInfrastructureArtifactManifestError> {
if path.len() > MAX_ARTIFACT_PATH_BYTES
|| validate_release_artifact_relative_path(path).is_err()
{
return Err(CanicInfrastructureArtifactManifestError::InvalidPath {
role,
kind,
path: path.to_string(),
});
}
Ok(())
}
fn validate_sha256(
role: CanicInfrastructureRole,
kind: &'static str,
value: &str,
) -> Result<(), CanicInfrastructureArtifactManifestError> {
let canonical = value.len() == SHA_256_HEX_BYTES
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
&& decode_hex(value).is_ok();
if canonical {
Ok(())
} else {
Err(CanicInfrastructureArtifactManifestError::InvalidSha256 {
role,
kind,
value: value.to_string(),
})
}
}
fn valid_package_name(package: &str) -> bool {
let bytes = package.as_bytes();
!bytes.is_empty()
&& bytes.len() <= MAX_PACKAGE_BYTES
&& bytes.first().is_some_and(u8::is_ascii_alphanumeric)
&& bytes.last().is_some_and(u8::is_ascii_alphanumeric)
&& bytes
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
}