Skip to main content

canic_host/release_set/application/persistence/
mod.rs

1//! Module: release_set::application::persistence
2//!
3//! Responsibility: compile and immutably persist one qualified application artifact union.
4//! Does not own: Cargo execution, root projection, Store publication, or installation.
5//! Boundary: exact current-build files populate one topology-bound union before finalization.
6
7#[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///
40/// ApplicationArtifactFileBuildOutput
41///
42/// Exact current-build files for one topology role under one release-build identity.
43///
44
45#[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///
55/// PersistedApplicationArtifactUnion
56///
57/// Canonical application union admitted under one durable release-build identity.
58///
59
60#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct PersistedApplicationArtifactUnion {
62    pub union: ApplicationArtifactUnion,
63    pub digest: [u8; 32],
64    pub path: PathBuf,
65}
66
67///
68/// ApplicationArtifactUnionPersistenceError
69///
70/// Typed rejection while materializing or admitting durable application evidence.
71///
72
73#[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
150/// Compile one union from qualified files and durably publish its canonical bytes.
151pub 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
216/// Load and validate the exact canonical application union for one release build.
217pub 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}