use super::{
CANIC_WASM_CHUNK_BYTES, ComponentTopology, FixtureArtifactEntry, FixtureArtifactError,
FixtureArtifactManifest, FixtureContentApi, FixtureSourceInput, ReleaseBuildId, compile_entry,
topology_roles,
};
use crate::{
release_build::{ReleaseBuildPlanState, load_release_build_plan},
release_set::validate_release_artifact_relative_path,
};
use canic_contracts::dto::{
fixture_provisioning::FixtureStoreError, root_store::ROOT_STORE_RELEASE_SET_MANIFEST_MAX_BYTES,
};
use canic_core::cdk::utils::hash::hex_bytes;
use ic_host_artifacts::artifact::ArtifactError;
use ic_host_fs::{durable::create_new_bytes_with_parents, read::read_optional_file_no_follow};
use sha2_host::{Digest, Sha256};
use std::{
collections::BTreeMap,
fs, io,
path::{Path, PathBuf},
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PersistedFixtureArtifactManifest {
pub manifest: FixtureArtifactManifest,
pub digest: [u8; 32],
pub path: PathBuf,
}
pub fn compile_and_persist_fixture_artifact_manifest(
root: &Path,
topology: &ComponentTopology,
release_build_id: ReleaseBuildId,
inputs: &[FixtureSourceInput],
) -> Result<PersistedFixtureArtifactManifest, FixtureArtifactError> {
let plan = load_release_build_plan(root, release_build_id)?;
let finalized = matches!(plan.state, ReleaseBuildPlanState::Finalized { .. });
let roles = topology_roles(topology);
let mut sources = BTreeMap::new();
for input in inputs {
if !roles.contains(&input.role) || sources.insert(input.role.clone(), input).is_some() {
return Err(FixtureArtifactError::Role(input.role.clone()));
}
}
let entries = sources
.values()
.map(|input| compile_entry(root, input))
.collect::<Result<Vec<_>, _>>()?;
let manifest = FixtureArtifactManifest {
schema_version: 1,
release_build_id,
component_topology_digest: topology
.digest()
.map_err(|_| FixtureArtifactError::Authority)?,
entries,
};
manifest.validate(topology, release_build_id)?;
let bytes = serde_json::to_vec(&manifest)?;
if bytes.len() as u64 > ROOT_STORE_RELEASE_SET_MANIFEST_MAX_BYTES {
return Err(FixtureStoreError::Bounds.into());
}
let relative_path = manifest_path(release_build_id);
let path = root.join(&relative_path);
match read_workspace_file(root, &relative_path, manifest_limit()) {
Ok(existing) if existing == bytes => {}
Ok(_) => return Err(FixtureArtifactError::Conflict(path)),
Err(error) if is_missing(&error) && !finalized => {}
Err(error) if is_missing(&error) => return Err(FixtureArtifactError::Finalized),
Err(error) => return Err(error),
}
for entry in &manifest.entries {
let input = sources[&entry.role];
for (index, source) in input.chunk_paths.iter().enumerate() {
let index = u32::try_from(index).map_err(|_| FixtureStoreError::Bounds)?;
let payload = read_workspace_file(root, source, CANIC_WASM_CHUNK_BYTES)?;
FixtureContentApi::verify_chunk(&entry.descriptor, index, &payload)?;
retain_exact(
root,
&chunk_path(release_build_id, entry, index),
&payload,
finalized,
)?;
}
}
retain_exact(root, &relative_path, &bytes, finalized)?;
Ok(PersistedFixtureArtifactManifest {
digest: Sha256::digest(&bytes).into(),
manifest,
path,
})
}
pub fn load_fixture_artifact_manifest(
root: &Path,
topology: &ComponentTopology,
release_build_id: ReleaseBuildId,
expected_digest: [u8; 32],
) -> Result<PersistedFixtureArtifactManifest, FixtureArtifactError> {
load_release_build_plan(root, release_build_id)?;
let relative = manifest_path(release_build_id);
let bytes = read_workspace_file(root, &relative, manifest_limit())?;
let digest: [u8; 32] = Sha256::digest(&bytes).into();
if digest != expected_digest {
return Err(FixtureArtifactError::Authority);
}
let manifest: FixtureArtifactManifest = serde_json::from_slice(&bytes)?;
manifest.validate(topology, release_build_id)?;
if serde_json::to_vec(&manifest)? != bytes {
return Err(FixtureArtifactError::Content);
}
Ok(PersistedFixtureArtifactManifest {
manifest,
digest,
path: root.join(relative),
})
}
pub fn verify_fixture_artifacts(
root: &Path,
topology: &ComponentTopology,
release_build_id: ReleaseBuildId,
expected_digest: [u8; 32],
) -> Result<(), FixtureArtifactError> {
let retained =
load_fixture_artifact_manifest(root, topology, release_build_id, expected_digest)?;
for entry in &retained.manifest.entries {
for index in 0..entry.descriptor.chunks.len() {
let index = u32::try_from(index).map_err(|_| FixtureStoreError::Bounds)?;
read_chunk(root, release_build_id, entry, index)?;
}
}
Ok(())
}
pub fn read_chunk(
root: &Path,
release: ReleaseBuildId,
entry: &FixtureArtifactEntry,
index: u32,
) -> Result<Vec<u8>, FixtureArtifactError> {
let bytes = read_workspace_file(
root,
&chunk_path(release, entry, index),
CANIC_WASM_CHUNK_BYTES,
)?;
FixtureContentApi::verify_chunk(&entry.descriptor, index, &bytes)?;
Ok(bytes)
}
pub(super) fn read_workspace_file(
root: &Path,
relative: &str,
limit: usize,
) -> Result<Vec<u8>, FixtureArtifactError> {
let path = root.join(relative);
validate_release_artifact_relative_path(relative)
.map_err(|_| FixtureArtifactError::Path(path.clone()))?;
let mut parent = root.to_path_buf();
for component in Path::new(relative).components() {
parent.push(component);
if fs::symlink_metadata(&parent).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
return Err(FixtureArtifactError::Symlink(parent));
}
}
match read_optional_file_no_follow(&path, limit) {
Ok(Some(bytes)) => Ok(bytes),
Ok(None) => Err(FixtureArtifactError::Io {
path,
source: io::ErrorKind::NotFound.into(),
}),
Err(ArtifactError::LimitExceeded { .. }) => Err(FixtureStoreError::Bounds.into()),
Err(ArtifactError::Io(source)) => Err(FixtureArtifactError::Io { path, source }),
Err(_) => Err(FixtureArtifactError::Path(path)),
}
}
fn retain_exact(
root: &Path,
relative: &str,
bytes: &[u8],
finalized: bool,
) -> Result<(), FixtureArtifactError> {
match read_workspace_file(root, relative, bytes.len()) {
Ok(existing) if existing == bytes => return Ok(()),
Ok(_) => return Err(FixtureArtifactError::Conflict(root.join(relative))),
Err(error) if is_missing(&error) && !finalized => {}
Err(error) if is_missing(&error) => return Err(FixtureArtifactError::Finalized),
Err(error) => return Err(error),
}
let path = root.join(relative);
if let Err(source) =
create_new_bytes_with_parents(&path, bytes).map_err(crate::publication::ops::io_error)
{
if read_workspace_file(root, relative, bytes.len()).is_ok_and(|retained| retained == bytes)
{
return Ok(());
}
return Err(FixtureArtifactError::Io { path, source });
}
Ok(())
}
fn is_missing(error: &FixtureArtifactError) -> bool {
matches!(error, FixtureArtifactError::Io { source, .. } if source.kind() == io::ErrorKind::NotFound)
}
fn manifest_limit() -> usize {
usize::try_from(ROOT_STORE_RELEASE_SET_MANIFEST_MAX_BYTES)
.expect("manifest limit fits host usize")
}
fn manifest_path(release: ReleaseBuildId) -> String {
format!(".canic/release-builds/{release}/fixture-artifact-manifest.json")
}
fn chunk_path(release: ReleaseBuildId, entry: &FixtureArtifactEntry, index: u32) -> String {
let name = hex_bytes(entry.content_id);
format!(".canic/release-builds/{release}/fixture-content/{name}/{index}.bin")
}