use super::*;
use crate::release_set::AppConfigSnapshot;
use canic_core::{
cdk::utils::hash::{decode_hex, sha256_hex},
dto::root_store::ROOT_STORE_RELEASE_SET_MANIFEST_MAX_BYTES,
};
use std::{collections::BTreeMap, path::Path};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfiguredFixtureSources {
pub inputs: Vec<FixtureSourceInput>,
pub source_files: BTreeMap<PathBuf, String>,
entries: Vec<FixtureArtifactEntry>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct FixtureSourceDocument {
format_hash: String,
completion_summary: String,
chunk_paths: Vec<String>,
}
impl ConfiguredFixtureSources {
pub fn verify_retained(
&self,
retained: &PersistedFixtureArtifactManifest,
) -> Result<(), FixtureArtifactError> {
if self.entries != retained.manifest.entries {
return Err(FixtureArtifactError::ChangedInputs);
}
Ok(())
}
pub fn verify_unchanged(
&self,
root: &Path,
config_path: &Path,
) -> Result<(), FixtureArtifactError> {
if *self != load_configured_fixture_sources(root, config_path)? {
return Err(FixtureArtifactError::ChangedInputs);
}
Ok(())
}
}
pub fn load_configured_fixture_sources(
root: &Path,
config_path: &Path,
) -> Result<ConfiguredFixtureSources, FixtureArtifactError> {
let root = root
.canonicalize()
.map_err(|source| FixtureArtifactError::Io {
path: root.into(),
source,
})?;
let config_path = config_path
.canonicalize()
.map_err(|source| FixtureArtifactError::Io {
path: config_path.into(),
source,
})?;
let mut selected = ConfiguredFixtureSources {
inputs: Vec::new(),
source_files: BTreeMap::new(),
entries: Vec::new(),
};
let before = observe(
&root,
&config_path,
document_limit(),
&mut selected.source_files,
)?;
let config = AppConfigSnapshot::load(&config_path)
.map_err(|error| FixtureArtifactError::Configuration(error.to_string()))?;
for role in topology_roles(config.component_topology()) {
load_role_source(&root, &config_path, &config, role, &mut selected)?;
}
let after = persistence::read_workspace_file(
&root,
&relative_to(&root, &config_path)?,
document_limit(),
)?;
if before != after {
return Err(FixtureArtifactError::ChangedInputs);
}
Ok(selected)
}
fn load_role_source(
root: &Path,
config_path: &Path,
config: &AppConfigSnapshot,
role: CanisterRole,
selected: &mut ConfiguredFixtureSources,
) -> Result<(), FixtureArtifactError> {
let declaration = config
.model()
.roles
.get(&role)
.ok_or_else(|| FixtureArtifactError::Role(role.clone()))?;
let package = declaration
.package
.as_ref()
.ok_or_else(|| FixtureArtifactError::Role(role.clone()))?;
let mut manifest = config_path
.parent()
.ok_or(FixtureArtifactError::Authority)?
.join(package);
if manifest.file_name().is_none_or(|name| name != "Cargo.toml") {
manifest.push("Cargo.toml");
}
let package_root = manifest
.parent()
.ok_or(FixtureArtifactError::Authority)?
.canonicalize()
.map_err(|source| FixtureArtifactError::Io {
path: manifest.clone(),
source,
})?;
let manifest = package_root.join("Cargo.toml");
let bytes =
crate::durable_io::read_regular_bytes(&manifest, document_limit()).map_err(|source| {
FixtureArtifactError::Io {
path: manifest.clone(),
source,
}
})?;
let hash = sha256_hex(&bytes);
if selected
.source_files
.insert(manifest, hash.clone())
.is_some_and(|before| before != hash)
{
return Err(FixtureArtifactError::ChangedInputs);
}
let cargo: toml::Value =
toml::from_str(std::str::from_utf8(&bytes).map_err(|_| FixtureArtifactError::Content)?)
.map_err(|error| FixtureArtifactError::Configuration(error.to_string()))?;
let Some(fixture) = cargo
.get("package")
.and_then(|value| value.get("metadata"))
.and_then(|value| value.get("canic"))
.and_then(|value| value.get("fixture"))
else {
return Ok(());
};
let relative = fixture.as_str().ok_or_else(|| {
FixtureArtifactError::Configuration(
"package.metadata.canic.fixture must be a relative source-manifest path".into(),
)
})?;
let source_path = relative_file(&package_root, relative)?;
let bytes = observe(
root,
&source_path,
document_limit(),
&mut selected.source_files,
)?;
compile_configured_source(root, &source_path, role, &bytes, selected)
}
fn compile_configured_source(
root: &Path,
source_path: &Path,
role: CanisterRole,
bytes: &[u8],
selected: &mut ConfiguredFixtureSources,
) -> Result<(), FixtureArtifactError> {
let document: FixtureSourceDocument = serde_json::from_slice(bytes)?;
let parent = source_path
.parent()
.ok_or(FixtureArtifactError::Authority)?;
let mut input = FixtureSourceInput {
role,
format_hash: digest(&document.format_hash)?,
completion_summary: digest(&document.completion_summary)?,
chunk_paths: Vec::new(),
};
if document.chunk_paths.len()
> canic_core::ingress::payload::DEFAULT_UPDATE_INGRESS_MAX_BYTES / 32
{
return Err(FixtureStoreError::Bounds.into());
}
let mut descriptor = FixtureDescriptor {
schema_version: 1,
format_hash: input.format_hash,
completion_summary: input.completion_summary,
encoded_length: 0,
chunks: Vec::new(),
};
for path in document.chunk_paths {
let absolute = relative_file(parent, &path)?;
let bytes = observe(
root,
&absolute,
CANIC_WASM_CHUNK_BYTES,
&mut selected.source_files,
)?;
descriptor.encoded_length += bytes.len() as u64;
descriptor.chunks.push(FixtureChunkDescriptor {
digest: Sha256::digest(&bytes).into(),
length: u32::try_from(bytes.len()).map_err(|_| FixtureStoreError::Bounds)?,
});
input.chunk_paths.push(relative_to(root, &absolute)?);
}
selected.entries.push(FixtureArtifactEntry {
role: input.role.clone(),
content_id: FixtureContentApi::content_id(&descriptor)?,
descriptor,
});
selected.inputs.push(input);
Ok(())
}
fn observe(
root: &Path,
path: &Path,
limit: usize,
files: &mut BTreeMap<PathBuf, String>,
) -> Result<Vec<u8>, FixtureArtifactError> {
let bytes = persistence::read_workspace_file(root, &relative_to(root, path)?, limit)?;
let hash = sha256_hex(&bytes);
if files
.insert(path.into(), hash.clone())
.is_some_and(|prior| prior != hash)
{
return Err(FixtureArtifactError::ChangedInputs);
}
Ok(bytes)
}
fn relative_file(parent: &Path, relative: &str) -> Result<PathBuf, FixtureArtifactError> {
crate::release_set::validate_release_artifact_relative_path(relative)
.map_err(|_| FixtureArtifactError::Path(parent.join(relative)))?;
Ok(parent.join(relative))
}
fn relative_to(root: &Path, path: &Path) -> Result<String, FixtureArtifactError> {
path.strip_prefix(root)
.ok()
.and_then(Path::to_str)
.map(str::to_owned)
.ok_or_else(|| FixtureArtifactError::Path(path.into()))
}
fn digest(value: &str) -> Result<[u8; 32], FixtureArtifactError> {
if value.len() != 64
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(FixtureArtifactError::Content);
}
decode_hex(value)
.map_err(|_| FixtureArtifactError::Content)?
.try_into()
.map_err(|_| FixtureArtifactError::Content)
}
fn document_limit() -> usize {
usize::try_from(ROOT_STORE_RELEASE_SET_MANIFEST_MAX_BYTES)
.expect("host document limit fits usize")
}