1#[cfg(test)]
8mod tests;
9
10use crate::{
11 durable_io::{
12 RegularFileReadError, create_new_bytes_with_parents, read_optional_regular_bytes,
13 },
14 release_build::{ReleaseBuildPlanError, ReleaseBuildPlanState, load_release_build_plan},
15 release_set::artifact::{
16 ReleaseArtifactMaterializationError, contains_release_build_identity,
17 materialize_qualified_release_artifact,
18 },
19};
20use std::{
21 io,
22 path::{Path, PathBuf},
23};
24
25use canic_core::{
26 bootstrap::compiled::ComponentTopology,
27 ids::{CanisterRole, ReleaseBuildId},
28};
29use sha2::{Digest, Sha256};
30use thiserror::Error as ThisError;
31
32use super::{
33 ApplicationArtifactBuildOutput, ApplicationArtifactBuildTarget, ApplicationArtifactUnion,
34 ApplicationReleaseSetError,
35};
36
37const APPLICATION_ARTIFACT_UNION_FILE: &str = "application-artifact-union.json";
38
39#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct ApplicationArtifactFileBuildOutput {
47 pub role: CanisterRole,
48 pub package: String,
49 pub release_build_id: ReleaseBuildId,
50 pub wasm_path: PathBuf,
51 pub wasm_gz_path: PathBuf,
52}
53
54#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct PersistedApplicationArtifactUnion {
62 pub union: ApplicationArtifactUnion,
63 pub digest: [u8; 32],
64 pub path: PathBuf,
65}
66
67#[derive(Debug, ThisError)]
74pub enum ApplicationArtifactUnionPersistenceError {
75 #[error("application artifact {role} {kind} path is outside the ICP root: {path}")]
76 ArtifactOutsideRoot {
77 role: CanisterRole,
78 kind: &'static str,
79 path: PathBuf,
80 },
81
82 #[error("failed to read application artifact {role} {kind} at {path}: {source}")]
83 ArtifactRead {
84 role: CanisterRole,
85 kind: &'static str,
86 path: PathBuf,
87 #[source]
88 source: io::Error,
89 },
90
91 #[error("application artifact union already exists with different canonical bytes: {path}")]
92 ConflictingUnion { path: PathBuf },
93
94 #[error("finalized release build {release_build_id} has no exact application artifact union")]
95 FinalizedWithoutExactUnion { release_build_id: ReleaseBuildId },
96
97 #[error("application artifact {role} has an invalid {kind} path: {path}")]
98 InvalidArtifactPath {
99 role: CanisterRole,
100 kind: &'static str,
101 path: PathBuf,
102 },
103
104 #[error("invalid application artifact union {path}: {reason}")]
105 InvalidUnionDocument { path: PathBuf, reason: String },
106
107 #[error("application artifact union is missing: {path}")]
108 MissingUnion { path: PathBuf },
109
110 #[error(
111 "application artifact {role} raw Wasm at {path} does not embed release build {release_build_id}"
112 )]
113 MissingReleaseBuildIdentity {
114 role: CanisterRole,
115 release_build_id: ReleaseBuildId,
116 path: PathBuf,
117 },
118
119 #[error("application artifact {role} has a non-UTF-8 {kind} path: {path}")]
120 NonUtf8ArtifactPath {
121 role: CanisterRole,
122 kind: &'static str,
123 path: PathBuf,
124 },
125
126 #[error(transparent)]
127 ReleaseBuild(#[from] ReleaseBuildPlanError),
128
129 #[error(transparent)]
130 ReleaseSet(#[from] ApplicationReleaseSetError),
131
132 #[error("application artifact {role} {kind} is not a regular no-follow file: {path}")]
133 UnsafeArtifact {
134 role: CanisterRole,
135 kind: &'static str,
136 path: PathBuf,
137 },
138
139 #[error("application artifact union is not a regular no-follow file: {path}")]
140 UnsafeUnion { path: PathBuf },
141
142 #[error("failed to access application artifact union {path}: {source}")]
143 UnionIo {
144 path: PathBuf,
145 #[source]
146 source: io::Error,
147 },
148}
149
150pub fn compile_and_persist_application_artifact_union(
152 root: &Path,
153 topology: &ComponentTopology,
154 release_build_id: ReleaseBuildId,
155 targets: &[ApplicationArtifactBuildTarget],
156 outputs: &[ApplicationArtifactFileBuildOutput],
157) -> Result<PersistedApplicationArtifactUnion, ApplicationArtifactUnionPersistenceError> {
158 let release_build = load_release_build_plan(root, release_build_id)?;
159 ApplicationArtifactUnion::validate_build_targets(topology, targets)?;
160 let mut output_roles = outputs
161 .iter()
162 .map(|output| output.role.clone())
163 .collect::<Vec<_>>();
164 ApplicationArtifactUnion::validate_build_output_roles(topology, &mut output_roles)?;
165 let materialized = outputs
166 .iter()
167 .map(|output| materialize_build_output(root, release_build_id, output))
168 .collect::<Result<Vec<_>, _>>()?;
169 let union =
170 ApplicationArtifactUnion::compile(topology, release_build_id, targets, &materialized)?;
171 let expected = persisted_union(root, topology, union)?;
172 let existing = load_optional_persisted_union(&expected.path, topology, release_build_id)?;
173
174 if matches!(release_build.state, ReleaseBuildPlanState::Finalized { .. }) {
175 return match existing {
176 Some(observed) if observed.union == expected.union => Ok(observed),
177 _ => Err(
178 ApplicationArtifactUnionPersistenceError::FinalizedWithoutExactUnion {
179 release_build_id,
180 },
181 ),
182 };
183 }
184
185 if let Some(observed) = existing {
186 return if observed.union == expected.union {
187 Ok(observed)
188 } else {
189 Err(ApplicationArtifactUnionPersistenceError::ConflictingUnion {
190 path: expected.path,
191 })
192 };
193 }
194
195 let canonical_bytes = expected.union.canonical_bytes(topology)?;
196 if let Err(source) = create_new_bytes_with_parents(&expected.path, &canonical_bytes) {
197 match load_optional_persisted_union(&expected.path, topology, release_build_id) {
198 Ok(Some(observed)) if observed.union == expected.union => return Ok(observed),
199 Ok(Some(_)) if source.kind() == io::ErrorKind::AlreadyExists => {
200 return Err(ApplicationArtifactUnionPersistenceError::ConflictingUnion {
201 path: expected.path,
202 });
203 }
204 _ => {
205 return Err(ApplicationArtifactUnionPersistenceError::UnionIo {
206 path: expected.path,
207 source,
208 });
209 }
210 }
211 }
212
213 load_persisted_application_artifact_union(root, topology, release_build_id)
214}
215
216pub fn load_persisted_application_artifact_union(
218 root: &Path,
219 topology: &ComponentTopology,
220 release_build_id: ReleaseBuildId,
221) -> Result<PersistedApplicationArtifactUnion, ApplicationArtifactUnionPersistenceError> {
222 let path = application_artifact_union_path(root, release_build_id);
223 load_optional_persisted_union(&path, topology, release_build_id)?
224 .ok_or(ApplicationArtifactUnionPersistenceError::MissingUnion { path })
225}
226
227fn materialize_build_output(
228 root: &Path,
229 release_build_id: ReleaseBuildId,
230 output: &ApplicationArtifactFileBuildOutput,
231) -> Result<ApplicationArtifactBuildOutput, ApplicationArtifactUnionPersistenceError> {
232 if output.release_build_id != release_build_id {
233 return Err(ApplicationReleaseSetError::ReleaseBuildMismatch {
234 role: output.role.clone(),
235 expected: release_build_id,
236 actual: output.release_build_id,
237 }
238 .into());
239 }
240
241 let wasm = materialize_artifact(root, &output.role, "raw Wasm", &output.wasm_path)?;
242 if !contains_release_build_identity(&wasm.bytes, release_build_id) {
243 return Err(
244 ApplicationArtifactUnionPersistenceError::MissingReleaseBuildIdentity {
245 role: output.role.clone(),
246 release_build_id,
247 path: output.wasm_path.clone(),
248 },
249 );
250 }
251 let wasm_gz = materialize_artifact(root, &output.role, "gzip Wasm", &output.wasm_gz_path)?;
252 Ok(ApplicationArtifactBuildOutput {
253 role: output.role.clone(),
254 package: output.package.clone(),
255 release_build_id: output.release_build_id,
256 wasm_relative_path: wasm.relative_path,
257 wasm: wasm.bytes,
258 wasm_gz_relative_path: wasm_gz.relative_path,
259 wasm_gz: wasm_gz.bytes,
260 })
261}
262
263fn materialize_artifact(
264 root: &Path,
265 role: &CanisterRole,
266 kind: &'static str,
267 path: &Path,
268) -> Result<
269 crate::release_set::artifact::MaterializedReleaseArtifact,
270 ApplicationArtifactUnionPersistenceError,
271> {
272 materialize_qualified_release_artifact(root, path).map_err(|error| match error {
273 ReleaseArtifactMaterializationError::InvalidPath => {
274 ApplicationArtifactUnionPersistenceError::InvalidArtifactPath {
275 role: role.clone(),
276 kind,
277 path: path.to_path_buf(),
278 }
279 }
280 ReleaseArtifactMaterializationError::NonUtf8Path => {
281 ApplicationArtifactUnionPersistenceError::NonUtf8ArtifactPath {
282 role: role.clone(),
283 kind,
284 path: path.to_path_buf(),
285 }
286 }
287 ReleaseArtifactMaterializationError::OutsideRoot => {
288 ApplicationArtifactUnionPersistenceError::ArtifactOutsideRoot {
289 role: role.clone(),
290 kind,
291 path: path.to_path_buf(),
292 }
293 }
294 ReleaseArtifactMaterializationError::Read(source) => {
295 ApplicationArtifactUnionPersistenceError::ArtifactRead {
296 role: role.clone(),
297 kind,
298 path: path.to_path_buf(),
299 source,
300 }
301 }
302 ReleaseArtifactMaterializationError::UnsafeFile => {
303 ApplicationArtifactUnionPersistenceError::UnsafeArtifact {
304 role: role.clone(),
305 kind,
306 path: path.to_path_buf(),
307 }
308 }
309 })
310}
311
312fn persisted_union(
313 root: &Path,
314 topology: &ComponentTopology,
315 union: ApplicationArtifactUnion,
316) -> Result<PersistedApplicationArtifactUnion, ApplicationArtifactUnionPersistenceError> {
317 let digest = union.digest(topology)?;
318 let path = application_artifact_union_path(root, union.release_build_id);
319 Ok(PersistedApplicationArtifactUnion {
320 union,
321 digest,
322 path,
323 })
324}
325
326fn load_optional_persisted_union(
327 path: &Path,
328 topology: &ComponentTopology,
329 release_build_id: ReleaseBuildId,
330) -> Result<Option<PersistedApplicationArtifactUnion>, ApplicationArtifactUnionPersistenceError> {
331 let bytes = match read_optional_regular_bytes(path) {
332 Ok(bytes) => bytes,
333 Err(RegularFileReadError::NotRegular) => {
334 return Err(ApplicationArtifactUnionPersistenceError::UnsafeUnion {
335 path: path.to_path_buf(),
336 });
337 }
338 Err(RegularFileReadError::Io(source)) => {
339 return Err(ApplicationArtifactUnionPersistenceError::UnionIo {
340 path: path.to_path_buf(),
341 source,
342 });
343 }
344 #[cfg(not(unix))]
345 Err(RegularFileReadError::UnsupportedPlatform) => {
346 return Err(ApplicationArtifactUnionPersistenceError::UnionIo {
347 path: path.to_path_buf(),
348 source: io::Error::new(
349 io::ErrorKind::Unsupported,
350 "regular no-follow union reads are unsupported",
351 ),
352 });
353 }
354 };
355 let Some(bytes) = bytes else {
356 return Ok(None);
357 };
358
359 let union: ApplicationArtifactUnion = serde_json::from_slice(&bytes).map_err(|error| {
360 ApplicationArtifactUnionPersistenceError::InvalidUnionDocument {
361 path: path.to_path_buf(),
362 reason: error.to_string(),
363 }
364 })?;
365 if union.release_build_id != release_build_id {
366 return Err(
367 ApplicationArtifactUnionPersistenceError::InvalidUnionDocument {
368 path: path.to_path_buf(),
369 reason: format!(
370 "union release build {} does not match path release build {release_build_id}",
371 union.release_build_id
372 ),
373 },
374 );
375 }
376 let canonical = union.canonical_bytes(topology)?;
377 if canonical != bytes {
378 return Err(
379 ApplicationArtifactUnionPersistenceError::InvalidUnionDocument {
380 path: path.to_path_buf(),
381 reason: "union bytes are not canonical".to_string(),
382 },
383 );
384 }
385
386 Ok(Some(PersistedApplicationArtifactUnion {
387 digest: Sha256::digest(&bytes).into(),
388 union,
389 path: path.to_path_buf(),
390 }))
391}
392
393fn application_artifact_union_path(root: &Path, release_build_id: ReleaseBuildId) -> PathBuf {
394 root.join(".canic")
395 .join("release-builds")
396 .join(release_build_id.to_string())
397 .join(APPLICATION_ARTIFACT_UNION_FILE)
398}