Skip to main content

canic_host/release_set/candid/
mod.rs

1//! Resolve an offline declaration through exact finalized release authority.
2//!
3//! This owner verifies retained manifest and declaration bytes; it does not select
4//! the latest build, require current application source, or contact a canister.
5
6#[cfg(test)]
7mod tests;
8
9use crate::{
10    MAX_DOCUMENT_READ_BYTES,
11    release_build::{ReleaseBuildPlanError, validate_finalized_release_build_manifest},
12    release_set::{
13        ApplicationArtifactUnionPersistenceError, CanicInfrastructureArtifactPersistenceError,
14        CurrentReleaseSetManifestError, application::load_retained_application_artifact_union,
15        load_persisted_canic_infrastructure_artifact_manifest,
16        load_persisted_current_release_set_manifest,
17    },
18};
19use canic_contracts::ids::ReleaseBuildId;
20use ic_host_artifacts::artifact::ArtifactError;
21use ic_host_fs::read::read_optional_file_no_follow;
22use sha2_host::{Digest, Sha256};
23use std::path::{Path, PathBuf};
24use thiserror::Error;
25
26///
27/// BuiltCandid
28///
29/// Exact declaration bytes verified by the managed artifact owner for offline inspection.
30///
31
32#[derive(Debug)]
33pub struct BuiltCandid {
34    pub release_build_id: ReleaseBuildId,
35    pub path: PathBuf,
36    pub candid: String,
37}
38
39///
40/// BuiltCandidError
41///
42/// Failure to bind an offline declaration to a finalized managed build.
43///
44
45#[derive(Debug, Error)]
46pub enum BuiltCandidError {
47    #[error("selected build contains ambiguous role {0}")]
48    AmbiguousRole(String),
49
50    #[error(transparent)]
51    Application(#[from] ApplicationArtifactUnionPersistenceError),
52
53    #[error("selected build declaration hash differs: {}", .0.display())]
54    CandidDigestMismatch(PathBuf),
55
56    #[error(transparent)]
57    Infrastructure(#[from] CanicInfrastructureArtifactPersistenceError),
58
59    #[error("selected build declaration is not UTF-8: {}", .0.display())]
60    InvalidUtf8(PathBuf),
61
62    #[error("selected build child manifest digest differs from finalized authority")]
63    ManifestDigestMismatch,
64
65    #[error("selected build has no role {0}")]
66    MissingRole(String),
67
68    #[error("failed to read selected build declaration {}: {source}", path.display())]
69    Read {
70        path: PathBuf,
71        #[source]
72        source: std::io::Error,
73    },
74
75    #[error(transparent)]
76    ReleaseBuild(#[from] ReleaseBuildPlanError),
77
78    #[error(transparent)]
79    ReleaseSet(#[from] CurrentReleaseSetManifestError),
80
81    #[error("selected build declaration is missing or not a regular contained file: {}", .0.display())]
82    UnsafeCandid(PathBuf),
83}
84
85/// Load one role's exact declaration without live lookup or current-source substitution.
86pub fn load_built_candid(
87    root: &Path,
88    release_build_id: ReleaseBuildId,
89    role: &str,
90) -> Result<BuiltCandid, BuiltCandidError> {
91    let release = load_persisted_current_release_set_manifest(root, release_build_id)?;
92    validate_finalized_release_build_manifest(root, release_build_id, &release.path)?;
93    let application = load_retained_application_artifact_union(root, release_build_id)?;
94    let infrastructure =
95        load_persisted_canic_infrastructure_artifact_manifest(root, release_build_id)?;
96    if application.digest != release.manifest.application_artifact_union_sha256
97        || infrastructure.digest != release.manifest.infrastructure_artifact_manifest_sha256
98    {
99        return Err(BuiltCandidError::ManifestDigestMismatch);
100    }
101    let mut matches = application
102        .union
103        .entries
104        .iter()
105        .filter(|entry| entry.role.as_str() == role)
106        .map(|entry| (&entry.wasm_relative_path, entry.candid_sha256))
107        .chain(
108            infrastructure
109                .manifest
110                .entries
111                .iter()
112                .filter(|entry| entry.protocol_role.as_str() == role)
113                .map(|entry| (&entry.wasm_relative_path, entry.candid_sha256)),
114        );
115    let (wasm, digest) = matches
116        .next()
117        .ok_or_else(|| BuiltCandidError::MissingRole(role.into()))?;
118    if matches.next().is_some() {
119        return Err(BuiltCandidError::AmbiguousRole(role.into()));
120    }
121    let path = root.join(wasm).with_extension("did");
122    let bytes = read_contained_candid(root, &path)?;
123    let observed: [u8; 32] = Sha256::digest(&bytes).into();
124    if observed != digest {
125        return Err(BuiltCandidError::CandidDigestMismatch(path));
126    }
127    let candid =
128        String::from_utf8(bytes).map_err(|_| BuiltCandidError::InvalidUtf8(path.clone()))?;
129    Ok(BuiltCandid {
130        release_build_id,
131        path,
132        candid,
133    })
134}
135
136fn read_contained_candid(root: &Path, path: &Path) -> Result<Vec<u8>, BuiltCandidError> {
137    let unsafe_path = || BuiltCandidError::UnsafeCandid(path.to_path_buf());
138    let canonical_root = root.canonicalize().map_err(|_| unsafe_path())?;
139    let parent = path.parent().ok_or_else(unsafe_path)?;
140    if !parent
141        .canonicalize()
142        .map_err(|_| unsafe_path())?
143        .starts_with(canonical_root)
144    {
145        return Err(unsafe_path());
146    }
147    match read_optional_file_no_follow(path, MAX_DOCUMENT_READ_BYTES) {
148        Ok(Some(bytes)) => Ok(bytes),
149        Ok(None) | Err(ArtifactError::NotRegularFile) => Err(unsafe_path()),
150        Err(source) => Err(BuiltCandidError::Read {
151            path: path.to_path_buf(),
152            source: source.into(),
153        }),
154    }
155}