canic_host/release_set/fixture/configuration/
mod.rs1use 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#[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 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 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
60pub 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 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}