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