Skip to main content

canic_host/release_build/
mod.rs

1//! Module: release_build
2//!
3//! Responsibility: create, validate, finalize, and hash one durable release-build plan.
4//! Does not own: artifact compilation, release-set manifest construction, or Fleet convergence.
5//! Boundary: a random nonce is durable before building and finalization binds exact manifest bytes.
6
7#[cfg(test)]
8mod tests;
9
10use crate::canister_build::CanisterBuildProfile;
11use crate::durable_io::{
12    RegularFileReadError, create_new_bytes_with_parents, read_optional_regular_bytes, write_bytes,
13};
14use crate::entropy::{EntropyError, random_bytes_32};
15use canic_core::ids::{BuildNetwork, ReleaseBuildId, ReleaseBuildNonce};
16use ciborium::Value;
17use sha2::{Digest, Sha256};
18use std::{
19    io,
20    path::{Path, PathBuf},
21};
22use thiserror::Error as ThisError;
23
24const PLAN_HASH_DOMAIN: &[u8] = b"canic:release-build:plan\0";
25const RANDOM_ATTEMPTS: usize = 16;
26
27///
28/// ReleaseBuildPlanState
29///
30/// Monotonic state of one pre-build identity and its post-build manifest evidence.
31///
32
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub enum ReleaseBuildPlanState {
35    Planned,
36    Finalized {
37        release_set_manifest_digest: [u8; 32],
38    },
39}
40
41///
42/// ReleaseBuildPlanRecord
43///
44/// Canonical durable owner of the random release-build nonce.
45///
46
47#[derive(Clone, Debug, Eq, PartialEq)]
48pub struct ReleaseBuildPlanRecord {
49    pub nonce: ReleaseBuildNonce,
50    pub release_build_id: ReleaseBuildId,
51    pub builder_version: String,
52    pub build_profile: CanisterBuildProfile,
53    pub build_network: BuildNetwork,
54    pub state: ReleaseBuildPlanState,
55}
56
57///
58/// PlannedReleaseBuild
59///
60/// Exact durable record and path supplied to the artifact builder.
61/// The record is newly built for current desired state or already finalized for
62/// an idempotent current-Fleet ensure replay.
63///
64
65#[derive(Clone, Debug, Eq, PartialEq)]
66pub struct PlannedReleaseBuild {
67    pub record: ReleaseBuildPlanRecord,
68    pub path: PathBuf,
69}
70
71///
72/// FinalizedReleaseBuild
73///
74/// Immutable post-build evidence admitted by current-Fleet ensure.
75///
76
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub struct FinalizedReleaseBuild {
79    pub record: ReleaseBuildPlanRecord,
80    pub plan_hash: [u8; 32],
81    pub path: PathBuf,
82}
83
84///
85/// ReleaseBuildPlanError
86///
87/// Typed failure at the durable release-build authority boundary.
88///
89
90#[derive(Debug, ThisError)]
91pub enum ReleaseBuildPlanError {
92    #[error("failed to access release-build plan {path}: {source}")]
93    Io {
94        path: PathBuf,
95        #[source]
96        source: io::Error,
97    },
98
99    #[error("release-build plan is not a regular no-follow file: {path}")]
100    UnsafeFile { path: PathBuf },
101
102    #[error("release-build plan is missing: {path}")]
103    Missing { path: PathBuf },
104
105    #[error("invalid release-build plan {path}: {reason}")]
106    InvalidDocument { path: PathBuf, reason: String },
107
108    #[error(
109        "release-build plan path identity {expected} does not match record identity {recorded}"
110    )]
111    PathIdentityMismatch {
112        expected: ReleaseBuildId,
113        recorded: ReleaseBuildId,
114    },
115
116    #[error(
117        "release-build plan record identity {recorded} does not derive from nonce as {derived}"
118    )]
119    NonceIdentityMismatch {
120        derived: ReleaseBuildId,
121        recorded: ReleaseBuildId,
122    },
123
124    #[error(
125        "release-build plan {release_build_id} is already finalized with a different manifest digest"
126    )]
127    ConflictingFinalization { release_build_id: ReleaseBuildId },
128
129    #[error("finalized release-build manifest digest differs for release build {release_build_id}")]
130    ManifestDigestMismatch { release_build_id: ReleaseBuildId },
131
132    #[error("cryptographic random source returned only {actual} of 32 required bytes")]
133    ShortRandomRead { actual: usize },
134
135    #[error("could not allocate a unique release-build identity after {RANDOM_ATTEMPTS} attempts")]
136    IdentityAllocationExhausted,
137
138    #[error("Fleet ensure cannot mutate a canister without a finalized release-build plan")]
139    MissingFinalizedAuthority,
140}
141
142/// Create and durably publish one new random release-build plan.
143pub fn plan_release_build(root: &Path) -> Result<PlannedReleaseBuild, ReleaseBuildPlanError> {
144    plan_release_build_for_profile(root, CanisterBuildProfile::Release)
145}
146
147/// Create and durably publish a release-build plan for one exact Cargo profile.
148pub fn plan_release_build_for_profile(
149    root: &Path,
150    build_profile: CanisterBuildProfile,
151) -> Result<PlannedReleaseBuild, ReleaseBuildPlanError> {
152    plan_release_build_for_profile_and_network(root, build_profile, BuildNetwork::Local)
153}
154
155/// Create one durable release-build authority for an exact profile and runtime network.
156pub fn plan_release_build_for_profile_and_network(
157    root: &Path,
158    build_profile: CanisterBuildProfile,
159    build_network: BuildNetwork,
160) -> Result<PlannedReleaseBuild, ReleaseBuildPlanError> {
161    for _ in 0..RANDOM_ATTEMPTS {
162        let nonce = random_nonce()?;
163        match plan_release_build_with_nonce_and_network(root, nonce, build_profile, build_network) {
164            Ok(plan) => return Ok(plan),
165            Err(ReleaseBuildPlanError::Io { source, .. })
166                if source.kind() == io::ErrorKind::AlreadyExists => {}
167            Err(error) => return Err(error),
168        }
169    }
170
171    Err(ReleaseBuildPlanError::IdentityAllocationExhausted)
172}
173
174/// Load and validate one exact planned or finalized release-build record.
175pub fn load_release_build_plan(
176    root: &Path,
177    release_build_id: ReleaseBuildId,
178) -> Result<ReleaseBuildPlanRecord, ReleaseBuildPlanError> {
179    let path = release_build_plan_path(root, release_build_id);
180    let bytes = read_plan_bytes(&path)?;
181    let record = decode_record(&path, &bytes)?;
182    validate_record_identity(release_build_id, &record)?;
183    Ok(record)
184}
185
186/// Load one immutable finalized record for current-Fleet admission.
187pub fn load_finalized_release_build(
188    root: &Path,
189    release_build_id: ReleaseBuildId,
190) -> Result<FinalizedReleaseBuild, ReleaseBuildPlanError> {
191    let path = release_build_plan_path(root, release_build_id);
192    let record = load_release_build_plan(root, release_build_id)?;
193    finalized_record(path, record)
194}
195
196/// Validate one finalized build against its immutable regular no-follow manifest bytes.
197pub fn validate_finalized_release_build_manifest(
198    root: &Path,
199    release_build_id: ReleaseBuildId,
200    manifest_path: &Path,
201) -> Result<FinalizedReleaseBuild, ReleaseBuildPlanError> {
202    let finalized = load_finalized_release_build(root, release_build_id)?;
203    let ReleaseBuildPlanState::Finalized {
204        release_set_manifest_digest,
205    } = finalized.record.state
206    else {
207        unreachable!("finalized loader admits only finalized records");
208    };
209    let manifest_bytes = read_plan_bytes(manifest_path)?;
210    let actual: [u8; 32] = Sha256::digest(&manifest_bytes).into();
211    if actual != release_set_manifest_digest {
212        return Err(ReleaseBuildPlanError::ManifestDigestMismatch { release_build_id });
213    }
214    Ok(finalized)
215}
216
217/// Finalize one planned build from the exact durable release-set manifest bytes.
218pub fn finalize_release_build_from_manifest(
219    root: &Path,
220    release_build_id: ReleaseBuildId,
221    manifest_path: &Path,
222) -> Result<FinalizedReleaseBuild, ReleaseBuildPlanError> {
223    let manifest_bytes = read_plan_bytes(manifest_path)?;
224    let digest = Sha256::digest(&manifest_bytes).into();
225    finalize_release_build(root, release_build_id, digest)
226}
227
228/// Return the canonical durable path for one release-build plan.
229#[must_use]
230pub fn release_build_plan_path(root: &Path, release_build_id: ReleaseBuildId) -> PathBuf {
231    root.join(".canic")
232        .join("release-builds")
233        .join(release_build_id.to_string())
234        .join("plan.cbor")
235}
236
237#[cfg(test)]
238fn plan_release_build_with_nonce(
239    root: &Path,
240    nonce: ReleaseBuildNonce,
241    build_profile: CanisterBuildProfile,
242) -> Result<PlannedReleaseBuild, ReleaseBuildPlanError> {
243    plan_release_build_with_nonce_and_network(root, nonce, build_profile, BuildNetwork::Local)
244}
245
246fn plan_release_build_with_nonce_and_network(
247    root: &Path,
248    nonce: ReleaseBuildNonce,
249    build_profile: CanisterBuildProfile,
250    build_network: BuildNetwork,
251) -> Result<PlannedReleaseBuild, ReleaseBuildPlanError> {
252    plan_release_build_with_nonce_and_builder(
253        root,
254        nonce,
255        build_profile,
256        build_network,
257        env!("CARGO_PKG_VERSION"),
258    )
259}
260
261fn plan_release_build_with_nonce_and_builder(
262    root: &Path,
263    nonce: ReleaseBuildNonce,
264    build_profile: CanisterBuildProfile,
265    build_network: BuildNetwork,
266    builder_version: &str,
267) -> Result<PlannedReleaseBuild, ReleaseBuildPlanError> {
268    let release_build_id = ReleaseBuildId::from_nonce(nonce);
269    let record = ReleaseBuildPlanRecord {
270        nonce,
271        release_build_id,
272        builder_version: builder_version.to_string(),
273        build_profile,
274        build_network,
275        state: ReleaseBuildPlanState::Planned,
276    };
277    let path = release_build_plan_path(root, release_build_id);
278    let bytes = encode_record(&record);
279    create_new_bytes_with_parents(&path, &bytes).map_err(|source| ReleaseBuildPlanError::Io {
280        path: path.clone(),
281        source,
282    })?;
283    Ok(PlannedReleaseBuild { record, path })
284}
285
286fn finalize_release_build(
287    root: &Path,
288    release_build_id: ReleaseBuildId,
289    release_set_manifest_digest: [u8; 32],
290) -> Result<FinalizedReleaseBuild, ReleaseBuildPlanError> {
291    let path = release_build_plan_path(root, release_build_id);
292    let _plan_lock = lock_plan_file(&path)?;
293    let mut record = load_release_build_plan(root, release_build_id)?;
294    match record.state {
295        ReleaseBuildPlanState::Planned => {}
296        ReleaseBuildPlanState::Finalized {
297            release_set_manifest_digest: observed,
298        } if observed == release_set_manifest_digest => {
299            return finalized_record(path, record);
300        }
301        ReleaseBuildPlanState::Finalized { .. } => {
302            return Err(ReleaseBuildPlanError::ConflictingFinalization { release_build_id });
303        }
304    }
305
306    record.state = ReleaseBuildPlanState::Finalized {
307        release_set_manifest_digest,
308    };
309    let bytes = encode_record(&record);
310    if let Err(source) = write_bytes(&path, &bytes) {
311        // A final parent-sync error may still have published the complete new
312        // record. Re-read the authority before projecting failure.
313        match load_release_build_plan(root, release_build_id) {
314            Ok(observed) if observed == record => return finalized_record(path, observed),
315            _ => {
316                return Err(ReleaseBuildPlanError::Io {
317                    path: path.clone(),
318                    source,
319                });
320            }
321        }
322    }
323
324    let observed = load_release_build_plan(root, release_build_id)?;
325    if observed != record {
326        return Err(ReleaseBuildPlanError::ConflictingFinalization { release_build_id });
327    }
328    finalized_record(path, observed)
329}
330
331fn finalized_record(
332    path: PathBuf,
333    record: ReleaseBuildPlanRecord,
334) -> Result<FinalizedReleaseBuild, ReleaseBuildPlanError> {
335    if !matches!(record.state, ReleaseBuildPlanState::Finalized { .. }) {
336        return Err(ReleaseBuildPlanError::InvalidDocument {
337            path,
338            reason: "finalized release-build evidence remained planned".to_string(),
339        });
340    }
341    let bytes = encode_record(&record);
342    Ok(FinalizedReleaseBuild {
343        record,
344        plan_hash: domain_hash(PLAN_HASH_DOMAIN, &bytes),
345        path,
346    })
347}
348
349fn validate_record_identity(
350    expected: ReleaseBuildId,
351    record: &ReleaseBuildPlanRecord,
352) -> Result<(), ReleaseBuildPlanError> {
353    let derived = ReleaseBuildId::from_nonce(record.nonce);
354    if derived != record.release_build_id {
355        return Err(ReleaseBuildPlanError::NonceIdentityMismatch {
356            derived,
357            recorded: record.release_build_id,
358        });
359    }
360    if expected != record.release_build_id {
361        return Err(ReleaseBuildPlanError::PathIdentityMismatch {
362            expected,
363            recorded: record.release_build_id,
364        });
365    }
366    Ok(())
367}
368
369fn encode_record(record: &ReleaseBuildPlanRecord) -> Vec<u8> {
370    let state = match record.state {
371        ReleaseBuildPlanState::Planned => Value::Array(vec![Value::Integer(0.into())]),
372        ReleaseBuildPlanState::Finalized {
373            release_set_manifest_digest,
374        } => Value::Array(vec![
375            Value::Integer(1.into()),
376            Value::Bytes(release_set_manifest_digest.to_vec()),
377        ]),
378    };
379    let value = Value::Array(vec![
380        Value::Bytes(record.nonce.as_bytes().to_vec()),
381        Value::Bytes(record.release_build_id.as_bytes().to_vec()),
382        Value::Text(record.builder_version.clone()),
383        Value::Text(record.build_profile.target_dir_name().to_string()),
384        Value::Text(record.build_network.as_str().to_string()),
385        state,
386    ]);
387    let mut bytes = Vec::new();
388    ciborium::ser::into_writer(&value, &mut bytes)
389        .expect("serializing a fixed release-build CBOR value cannot fail");
390    bytes
391}
392
393fn decode_record(
394    path: &Path,
395    bytes: &[u8],
396) -> Result<ReleaseBuildPlanRecord, ReleaseBuildPlanError> {
397    let value: Value =
398        ciborium::de::from_reader(bytes).map_err(|error| invalid(path, error.to_string()))?;
399    let fields = exact_array(path, value, 6, "record")?;
400    let nonce = ReleaseBuildNonce::from_random_bytes(exact_digest(path, &fields[0], "nonce")?);
401    let release_build_id = exact_digest(path, &fields[1], "release_build_id")?
402        .iter()
403        .fold(String::with_capacity(64), |mut text, byte| {
404            use std::fmt::Write as _;
405            write!(text, "{byte:02x}").expect("writing to String cannot fail");
406            text
407        })
408        .parse()
409        .expect("lowercase digest text is a canonical release-build ID");
410    let builder_version = fields[2]
411        .as_text()
412        .filter(|version| !version.trim().is_empty())
413        .ok_or_else(|| invalid(path, "builder_version must be nonempty text"))?
414        .to_string();
415    let build_profile = fields[3]
416        .as_text()
417        .ok_or_else(|| invalid(path, "build_profile must be text"))?
418        .parse()
419        .map_err(|error| invalid(path, error))?;
420    let build_network = match fields[4]
421        .as_text()
422        .ok_or_else(|| invalid(path, "build_network must be text"))?
423    {
424        "ic" => BuildNetwork::Ic,
425        "local" => BuildNetwork::Local,
426        value => {
427            return Err(invalid(
428                path,
429                format!("build_network must be `ic` or `local`, found {value}"),
430            ));
431        }
432    };
433    let state_fields = exact_array_ref(path, &fields[5], "state")?;
434    let discriminant = state_fields
435        .first()
436        .and_then(Value::as_integer)
437        .and_then(|value| u8::try_from(value).ok())
438        .ok_or_else(|| invalid(path, "state discriminant must be an unsigned integer"))?;
439    let state = match (discriminant, state_fields) {
440        (0, [_]) => ReleaseBuildPlanState::Planned,
441        (1, [_, digest]) => ReleaseBuildPlanState::Finalized {
442            release_set_manifest_digest: exact_digest(path, digest, "release_set_manifest_digest")?,
443        },
444        _ => return Err(invalid(path, "state has an unknown or invalid shape")),
445    };
446    let record = ReleaseBuildPlanRecord {
447        nonce,
448        release_build_id,
449        builder_version,
450        build_profile,
451        build_network,
452        state,
453    };
454    if encode_record(&record) != bytes {
455        return Err(invalid(path, "CBOR bytes are not canonical"));
456    }
457    Ok(record)
458}
459
460fn exact_array(
461    path: &Path,
462    value: Value,
463    len: usize,
464    field: &str,
465) -> Result<Vec<Value>, ReleaseBuildPlanError> {
466    let Value::Array(values) = value else {
467        return Err(invalid(path, format!("{field} must be an array")));
468    };
469    if values.len() != len {
470        return Err(invalid(
471            path,
472            format!("{field} must contain exactly {len} values"),
473        ));
474    }
475    Ok(values)
476}
477
478fn exact_array_ref<'a>(
479    path: &Path,
480    value: &'a Value,
481    field: &str,
482) -> Result<&'a [Value], ReleaseBuildPlanError> {
483    let Value::Array(values) = value else {
484        return Err(invalid(path, format!("{field} must be an array")));
485    };
486    Ok(values)
487}
488
489fn exact_digest(
490    path: &Path,
491    value: &Value,
492    field: &str,
493) -> Result<[u8; 32], ReleaseBuildPlanError> {
494    let Value::Bytes(bytes) = value else {
495        return Err(invalid(path, format!("{field} must be a byte string")));
496    };
497    bytes
498        .as_slice()
499        .try_into()
500        .map_err(|_| invalid(path, format!("{field} must contain exactly 32 bytes")))
501}
502
503fn read_plan_bytes(path: &Path) -> Result<Vec<u8>, ReleaseBuildPlanError> {
504    match read_optional_regular_bytes(path) {
505        Ok(Some(bytes)) => Ok(bytes),
506        Ok(None) => Err(ReleaseBuildPlanError::Missing {
507            path: path.to_path_buf(),
508        }),
509        Err(RegularFileReadError::NotRegular) => Err(ReleaseBuildPlanError::UnsafeFile {
510            path: path.to_path_buf(),
511        }),
512        Err(RegularFileReadError::Io(source)) => Err(ReleaseBuildPlanError::Io {
513            path: path.to_path_buf(),
514            source,
515        }),
516        #[cfg(not(unix))]
517        Err(RegularFileReadError::UnsupportedPlatform) => Err(ReleaseBuildPlanError::Io {
518            path: path.to_path_buf(),
519            source: io::Error::new(
520                io::ErrorKind::Unsupported,
521                "no-follow release-build reads are unsupported on this platform",
522            ),
523        }),
524    }
525}
526
527fn lock_plan_file(path: &Path) -> Result<std::fs::File, ReleaseBuildPlanError> {
528    #[cfg(not(windows))]
529    {
530        use rustix::{
531            fd::OwnedFd,
532            fs::{FileType, FlockOperation, Mode, OFlags, flock, fstat, open},
533        };
534
535        let fd: OwnedFd = open(
536            path,
537            OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
538            Mode::empty(),
539        )
540        .map_err(|source| ReleaseBuildPlanError::Io {
541            path: path.to_path_buf(),
542            source: io::Error::from_raw_os_error(source.raw_os_error()),
543        })?;
544        let metadata = fstat(&fd).map_err(|source| ReleaseBuildPlanError::Io {
545            path: path.to_path_buf(),
546            source: io::Error::from_raw_os_error(source.raw_os_error()),
547        })?;
548        if FileType::from_raw_mode(metadata.st_mode) != FileType::RegularFile {
549            return Err(ReleaseBuildPlanError::UnsafeFile {
550                path: path.to_path_buf(),
551            });
552        }
553        let file = std::fs::File::from(fd);
554        flock(&file, FlockOperation::LockExclusive).map_err(|source| {
555            ReleaseBuildPlanError::Io {
556                path: path.to_path_buf(),
557                source: io::Error::from_raw_os_error(source.raw_os_error()),
558            }
559        })?;
560        Ok(file)
561    }
562
563    #[cfg(windows)]
564    {
565        Err(ReleaseBuildPlanError::Io {
566            path: path.to_path_buf(),
567            source: io::Error::new(
568                io::ErrorKind::Unsupported,
569                "release-build plan locking is unsupported on Windows",
570            ),
571        })
572    }
573}
574
575fn random_nonce() -> Result<ReleaseBuildNonce, ReleaseBuildPlanError> {
576    random_bytes_32()
577        .map(ReleaseBuildNonce::from_random_bytes)
578        .map_err(|error| match error {
579            EntropyError::Io(source) => ReleaseBuildPlanError::Io {
580                path: PathBuf::from("<operating-system random source>"),
581                source,
582            },
583            EntropyError::ShortRead { actual } => ReleaseBuildPlanError::ShortRandomRead { actual },
584        })
585}
586
587fn domain_hash(domain: &[u8], bytes: &[u8]) -> [u8; 32] {
588    let mut hasher = Sha256::new();
589    hasher.update(domain);
590    hasher.update(
591        u64::try_from(bytes.len())
592            .expect("host evidence fits u64")
593            .to_be_bytes(),
594    );
595    hasher.update(bytes);
596    hasher.finalize().into()
597}
598
599fn invalid(path: &Path, reason: impl Into<String>) -> ReleaseBuildPlanError {
600    ReleaseBuildPlanError::InvalidDocument {
601        path: path.to_path_buf(),
602        reason: reason.into(),
603    }
604}