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