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