Skip to main content

canic_host/release_set/infrastructure/persistence/
mod.rs

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