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