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