Skip to main content

canic_host/release_set/fixture/
mod.rs

1//! Module: release_set::fixture
2//!
3//! Responsibility: compile and retain opaque fixture artifacts under one release identity.
4//! Does not own: application row encoding, complete release finalization, grants or network effects.
5//! Boundary: authored chunk order is preserved; only exact retained bytes may populate publication.
6
7mod configuration;
8mod persistence;
9#[cfg(test)]
10mod tests;
11
12use canic_contracts::{
13    dto::fixture_provisioning::{FixtureChunkDescriptor, FixtureDescriptor, FixtureStoreError},
14    ids::{CanisterRole, ComponentTopologyDigest, ReleaseBuildId},
15};
16use canic_control_plane::api::fixture_content::FixtureContentApi;
17use canic_core::{CANIC_WASM_CHUNK_BYTES, bootstrap::compiled::ComponentTopology};
18use serde::{Deserialize, Serialize};
19use sha2_host::{Digest, Sha256};
20use std::{collections::BTreeSet, io, path::PathBuf};
21use thiserror::Error;
22
23pub use configuration::{ConfiguredFixtureSources, load_configured_fixture_sources};
24pub(crate) use persistence::read_chunk;
25pub use persistence::{
26    PersistedFixtureArtifactManifest, compile_and_persist_fixture_artifact_manifest,
27    load_fixture_artifact_manifest, verify_fixture_artifacts,
28};
29
30///
31/// FixtureSourceInput
32///
33/// Host build input for one role, with application-authored, independently decodable chunks.
34/// Paths are relative to the supplied workspace root; their order carries application semantics.
35///
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct FixtureSourceInput {
38    pub role: CanisterRole,
39    pub format_hash: [u8; 32],
40    pub completion_summary: [u8; 32],
41    pub chunk_paths: Vec<String>,
42}
43
44///
45/// FixtureArtifactEntry
46///
47/// Release manifest binding between a declared application role and immutable opaque content.
48/// Runtime target Principals and local source paths are deliberately absent.
49///
50#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
51#[serde(deny_unknown_fields)]
52pub struct FixtureArtifactEntry {
53    pub role: CanisterRole,
54    pub content_id: [u8; 32],
55    pub descriptor: FixtureDescriptor,
56}
57
58///
59/// FixtureArtifactManifest
60///
61/// Canonical host-owned fixture child manifest awaiting binding by the complete release owner.
62/// Its digest alone does not authorize Store writes or target delivery.
63///
64#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
65#[serde(deny_unknown_fields)]
66pub struct FixtureArtifactManifest {
67    pub schema_version: u16,
68    pub release_build_id: ReleaseBuildId,
69    pub component_topology_digest: ComponentTopologyDigest,
70    pub entries: Vec<FixtureArtifactEntry>,
71}
72
73impl FixtureArtifactManifest {
74    /// Reject unknown/duplicate roles, descriptor drift and a substituted release or topology.
75    pub fn validate(
76        &self,
77        topology: &ComponentTopology,
78        release_build_id: ReleaseBuildId,
79    ) -> Result<(), FixtureArtifactError> {
80        if self.schema_version != 1 || self.release_build_id != release_build_id {
81            return Err(FixtureArtifactError::Authority);
82        }
83        let digest = topology
84            .digest()
85            .map_err(|_| FixtureArtifactError::Authority)?;
86        if self.component_topology_digest != digest {
87            return Err(FixtureArtifactError::Authority);
88        }
89        let roles = topology_roles(topology);
90        let mut previous = None;
91        for entry in &self.entries {
92            if !roles.contains(&entry.role) || previous.is_some_and(|role| role >= &entry.role) {
93                return Err(FixtureArtifactError::Role(entry.role.clone()));
94            }
95            previous = Some(&entry.role);
96            if FixtureContentApi::content_id(&entry.descriptor)? != entry.content_id {
97                return Err(FixtureArtifactError::Content);
98            }
99        }
100        Ok(())
101    }
102}
103
104///
105/// FixtureArtifactError
106///
107/// Typed host build/publication refusal; callers retain the selected operation on failure.
108///
109#[derive(Debug, Error)]
110pub enum FixtureArtifactError {
111    #[error("fixture release or topology authority differs")]
112    Authority,
113    #[error("fixture inputs changed during the build")]
114    ChangedInputs,
115    #[error("fixture source configuration is invalid: {0}")]
116    Configuration(String),
117    #[error("fixture artifact bytes differ from selected content")]
118    Content,
119    #[error("fixture artifact already exists with different bytes: {0}")]
120    Conflict(PathBuf),
121    #[error("finalized release cannot acquire or repair fixture artifacts")]
122    Finalized,
123    #[error("fixture source path is not a regular workspace-relative file: {0}")]
124    Path(PathBuf),
125    #[error(
126        "fixture artifact path contains a symlink at {0}; use real workspace-local files and directories, and keep .canic state independent between checkouts"
127    )]
128    Symlink(PathBuf),
129    #[error("fixture role is duplicate, unordered or outside the application topology: {0}")]
130    Role(CanisterRole),
131    #[error("fixture descriptor or payload is invalid: {0:?}")]
132    Store(FixtureStoreError),
133    #[error("fixture artifact I/O failed at {path}: {source}")]
134    Io { path: PathBuf, source: io::Error },
135    #[error("fixture manifest encoding failed: {0}")]
136    Json(#[from] serde_json::Error),
137    #[error(transparent)]
138    ReleaseBuild(#[from] crate::release_build::ReleaseBuildPlanError),
139}
140
141impl From<FixtureStoreError> for FixtureArtifactError {
142    fn from(error: FixtureStoreError) -> Self {
143        Self::Store(error)
144    }
145}
146
147fn topology_roles(topology: &ComponentTopology) -> BTreeSet<CanisterRole> {
148    topology
149        .component_specs
150        .iter()
151        .flat_map(|spec| {
152            std::iter::once(spec.component_role.clone())
153                .chain(spec.children.iter().map(|child| child.role.clone()))
154        })
155        .collect()
156}
157
158fn compile_entry(
159    root: &std::path::Path,
160    input: &FixtureSourceInput,
161) -> Result<FixtureArtifactEntry, FixtureArtifactError> {
162    if input.chunk_paths.len() > canic_core::ingress::payload::DEFAULT_UPDATE_INGRESS_MAX_BYTES / 32
163    {
164        return Err(FixtureStoreError::Bounds.into());
165    }
166    // Validate the complete command envelope before reading any payload. The temporary
167    // lengths/digests have the same encoded shape as their eventual values.
168    let mut descriptor = FixtureDescriptor {
169        schema_version: 1,
170        format_hash: input.format_hash,
171        encoded_length: input.chunk_paths.len() as u64,
172        chunks: vec![
173            FixtureChunkDescriptor {
174                digest: [0; 32],
175                length: 1
176            };
177            input.chunk_paths.len()
178        ],
179        completion_summary: input.completion_summary,
180    };
181    FixtureContentApi::content_id(&descriptor)?;
182    descriptor.encoded_length = 0;
183    for (chunk, path) in descriptor.chunks.iter_mut().zip(&input.chunk_paths) {
184        let bytes = persistence::read_workspace_file(root, path, CANIC_WASM_CHUNK_BYTES)?;
185        chunk.length = u32::try_from(bytes.len()).map_err(|_| FixtureStoreError::Bounds)?;
186        chunk.digest = Sha256::digest(&bytes).into();
187        descriptor.encoded_length += u64::from(chunk.length);
188    }
189    let content_id = FixtureContentApi::content_id(&descriptor)?;
190    Ok(FixtureArtifactEntry {
191        role: input.role.clone(),
192        content_id,
193        descriptor,
194    })
195}