Skip to main content

canic_host/release_set/fixture/configuration/
mod.rs

1//! Module: release_set::fixture::configuration
2//!
3//! Responsibility: discover package-declared fixture sources and freeze their build inputs.
4//! Does not own: Cargo execution, release finalization or remote publication.
5//! Boundary: package metadata selects a local source manifest; every observed file is hash-bound.
6
7use super::*;
8use crate::release_set::AppConfigSnapshot;
9use canic_core::{
10    cdk::utils::hash::{decode_hex, sha256_hex},
11    dto::root_store::ROOT_STORE_RELEASE_SET_MANIFEST_MAX_BYTES,
12};
13use std::{collections::BTreeMap, path::Path};
14
15///
16/// ConfiguredFixtureSources
17///
18/// Host-owned pre-build selection and exact source-file fingerprints, including packages without fixtures.
19///
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct ConfiguredFixtureSources {
22    pub inputs: Vec<FixtureSourceInput>,
23    pub source_files: BTreeMap<PathBuf, String>,
24    entries: Vec<FixtureArtifactEntry>,
25}
26
27#[derive(Deserialize)]
28#[serde(deny_unknown_fields)]
29struct FixtureSourceDocument {
30    format_hash: String,
31    completion_summary: String,
32    chunk_paths: Vec<String>,
33}
34
35impl ConfiguredFixtureSources {
36    /// Bind retained payload descriptors to the exact bytes observed before the build.
37    pub fn verify_retained(
38        &self,
39        retained: &PersistedFixtureArtifactManifest,
40    ) -> Result<(), FixtureArtifactError> {
41        if self.entries != retained.manifest.entries {
42            return Err(FixtureArtifactError::ChangedInputs);
43        }
44        Ok(())
45    }
46
47    /// Reject a source addition, removal or changed byte before finalizing the selected build.
48    pub fn verify_unchanged(
49        &self,
50        root: &Path,
51        config_path: &Path,
52    ) -> Result<(), FixtureArtifactError> {
53        if *self != load_configured_fixture_sources(root, config_path)? {
54            return Err(FixtureArtifactError::ChangedInputs);
55        }
56        Ok(())
57    }
58}
59
60/// Read `[package.metadata.canic].fixture` for the roles selected by Component topology.
61/// Source document paths are package-relative; chunk paths are source-document-relative.
62pub fn load_configured_fixture_sources(
63    root: &Path,
64    config_path: &Path,
65) -> Result<ConfiguredFixtureSources, FixtureArtifactError> {
66    let root = root
67        .canonicalize()
68        .map_err(|source| FixtureArtifactError::Io {
69            path: root.into(),
70            source,
71        })?;
72    let config_path = config_path
73        .canonicalize()
74        .map_err(|source| FixtureArtifactError::Io {
75            path: config_path.into(),
76            source,
77        })?;
78    let mut selected = ConfiguredFixtureSources {
79        inputs: Vec::new(),
80        source_files: BTreeMap::new(),
81        entries: Vec::new(),
82    };
83    let before = observe(
84        &root,
85        &config_path,
86        document_limit(),
87        &mut selected.source_files,
88    )?;
89    let config = AppConfigSnapshot::load(&config_path)
90        .map_err(|error| FixtureArtifactError::Configuration(error.to_string()))?;
91    for role in topology_roles(config.component_topology()) {
92        load_role_source(&root, &config_path, &config, role, &mut selected)?;
93    }
94    let after = persistence::read_workspace_file(
95        &root,
96        &relative_to(&root, &config_path)?,
97        document_limit(),
98    )?;
99    if before != after {
100        return Err(FixtureArtifactError::ChangedInputs);
101    }
102    Ok(selected)
103}
104
105fn load_role_source(
106    root: &Path,
107    config_path: &Path,
108    config: &AppConfigSnapshot,
109    role: CanisterRole,
110    selected: &mut ConfiguredFixtureSources,
111) -> Result<(), FixtureArtifactError> {
112    let declaration = config
113        .model()
114        .roles
115        .get(&role)
116        .ok_or_else(|| FixtureArtifactError::Role(role.clone()))?;
117    let package = declaration
118        .package
119        .as_ref()
120        .ok_or_else(|| FixtureArtifactError::Role(role.clone()))?;
121    let mut manifest = config_path
122        .parent()
123        .ok_or(FixtureArtifactError::Authority)?
124        .join(package);
125    if manifest.file_name().is_none_or(|name| name != "Cargo.toml") {
126        manifest.push("Cargo.toml");
127    }
128    // Existing role packages can use parent-relative selectors. Resolve their directory
129    // once, then require all fixture inputs to stay beneath this workspace.
130    let package_root = manifest
131        .parent()
132        .ok_or(FixtureArtifactError::Authority)?
133        .canonicalize()
134        .map_err(|source| FixtureArtifactError::Io {
135            path: manifest.clone(),
136            source,
137        })?;
138    let manifest = package_root.join("Cargo.toml");
139    let bytes =
140        crate::durable_io::read_regular_bytes(&manifest, document_limit()).map_err(|source| {
141            FixtureArtifactError::Io {
142                path: manifest.clone(),
143                source,
144            }
145        })?;
146    let hash = sha256_hex(&bytes);
147    if selected
148        .source_files
149        .insert(manifest, hash.clone())
150        .is_some_and(|before| before != hash)
151    {
152        return Err(FixtureArtifactError::ChangedInputs);
153    }
154    let cargo: toml::Value =
155        toml::from_str(std::str::from_utf8(&bytes).map_err(|_| FixtureArtifactError::Content)?)
156            .map_err(|error| FixtureArtifactError::Configuration(error.to_string()))?;
157    let Some(fixture) = cargo
158        .get("package")
159        .and_then(|value| value.get("metadata"))
160        .and_then(|value| value.get("canic"))
161        .and_then(|value| value.get("fixture"))
162    else {
163        return Ok(());
164    };
165    let relative = fixture.as_str().ok_or_else(|| {
166        FixtureArtifactError::Configuration(
167            "package.metadata.canic.fixture must be a relative source-manifest path".into(),
168        )
169    })?;
170    let source_path = relative_file(&package_root, relative)?;
171    let bytes = observe(
172        root,
173        &source_path,
174        document_limit(),
175        &mut selected.source_files,
176    )?;
177    compile_configured_source(root, &source_path, role, &bytes, selected)
178}
179
180fn compile_configured_source(
181    root: &Path,
182    source_path: &Path,
183    role: CanisterRole,
184    bytes: &[u8],
185    selected: &mut ConfiguredFixtureSources,
186) -> Result<(), FixtureArtifactError> {
187    let document: FixtureSourceDocument = serde_json::from_slice(bytes)?;
188    let parent = source_path
189        .parent()
190        .ok_or(FixtureArtifactError::Authority)?;
191    let mut input = FixtureSourceInput {
192        role,
193        format_hash: digest(&document.format_hash)?,
194        completion_summary: digest(&document.completion_summary)?,
195        chunk_paths: Vec::new(),
196    };
197    if document.chunk_paths.len()
198        > canic_core::ingress::payload::DEFAULT_UPDATE_INGRESS_MAX_BYTES / 32
199    {
200        return Err(FixtureStoreError::Bounds.into());
201    }
202    let mut descriptor = FixtureDescriptor {
203        schema_version: 1,
204        format_hash: input.format_hash,
205        completion_summary: input.completion_summary,
206        encoded_length: 0,
207        chunks: Vec::new(),
208    };
209    for path in document.chunk_paths {
210        let absolute = relative_file(parent, &path)?;
211        let bytes = observe(
212            root,
213            &absolute,
214            CANIC_WASM_CHUNK_BYTES,
215            &mut selected.source_files,
216        )?;
217        descriptor.encoded_length += bytes.len() as u64;
218        descriptor.chunks.push(FixtureChunkDescriptor {
219            digest: Sha256::digest(&bytes).into(),
220            length: u32::try_from(bytes.len()).map_err(|_| FixtureStoreError::Bounds)?,
221        });
222        input.chunk_paths.push(relative_to(root, &absolute)?);
223    }
224    selected.entries.push(FixtureArtifactEntry {
225        role: input.role.clone(),
226        content_id: FixtureContentApi::content_id(&descriptor)?,
227        descriptor,
228    });
229    selected.inputs.push(input);
230    Ok(())
231}
232
233fn observe(
234    root: &Path,
235    path: &Path,
236    limit: usize,
237    files: &mut BTreeMap<PathBuf, String>,
238) -> Result<Vec<u8>, FixtureArtifactError> {
239    let bytes = persistence::read_workspace_file(root, &relative_to(root, path)?, limit)?;
240    let hash = sha256_hex(&bytes);
241    if files
242        .insert(path.into(), hash.clone())
243        .is_some_and(|prior| prior != hash)
244    {
245        return Err(FixtureArtifactError::ChangedInputs);
246    }
247    Ok(bytes)
248}
249
250fn relative_file(parent: &Path, relative: &str) -> Result<PathBuf, FixtureArtifactError> {
251    crate::release_set::validate_release_artifact_relative_path(relative)
252        .map_err(|_| FixtureArtifactError::Path(parent.join(relative)))?;
253    Ok(parent.join(relative))
254}
255
256fn relative_to(root: &Path, path: &Path) -> Result<String, FixtureArtifactError> {
257    path.strip_prefix(root)
258        .ok()
259        .and_then(Path::to_str)
260        .map(str::to_owned)
261        .ok_or_else(|| FixtureArtifactError::Path(path.into()))
262}
263
264fn digest(value: &str) -> Result<[u8; 32], FixtureArtifactError> {
265    if value.len() != 64
266        || !value
267            .bytes()
268            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
269    {
270        return Err(FixtureArtifactError::Content);
271    }
272    decode_hex(value)
273        .map_err(|_| FixtureArtifactError::Content)?
274        .try_into()
275        .map_err(|_| FixtureArtifactError::Content)
276}
277
278fn document_limit() -> usize {
279    usize::try_from(ROOT_STORE_RELEASE_SET_MANIFEST_MAX_BYTES)
280        .expect("host document limit fits usize")
281}