canic_host/release_set/fixture/
mod.rs1mod 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#[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#[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#[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 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#[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 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}