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