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_host::{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
139/// Create and durably publish one new random release-build plan.
140pub fn plan_release_build(root: &Path) -> Result<PlannedReleaseBuild, ReleaseBuildPlanError> {
141    plan_release_build_for_profile(root, CanisterBuildProfile::Release)
142}
143
144/// Create and durably publish a release-build plan for one exact Cargo profile.
145pub fn plan_release_build_for_profile(
146    root: &Path,
147    build_profile: CanisterBuildProfile,
148) -> Result<PlannedReleaseBuild, ReleaseBuildPlanError> {
149    plan_release_build_for_profile_and_network(root, build_profile, BuildNetwork::Local)
150}
151
152/// Create one durable release-build authority for an exact profile and runtime network.
153pub fn plan_release_build_for_profile_and_network(
154    root: &Path,
155    build_profile: CanisterBuildProfile,
156    build_network: BuildNetwork,
157) -> Result<PlannedReleaseBuild, ReleaseBuildPlanError> {
158    for _ in 0..RANDOM_ATTEMPTS {
159        let nonce = random_nonce()?;
160        match plan_release_build_with_nonce_and_network(root, nonce, build_profile, build_network) {
161            Ok(plan) => return Ok(plan),
162            Err(ReleaseBuildPlanError::Io { source, .. })
163                if source.kind() == io::ErrorKind::AlreadyExists => {}
164            Err(error) => return Err(error),
165        }
166    }
167
168    Err(ReleaseBuildPlanError::IdentityAllocationExhausted)
169}
170
171/// Load and validate one exact planned or finalized release-build record.
172pub fn load_release_build_plan(
173    root: &Path,
174    release_build_id: ReleaseBuildId,
175) -> Result<ReleaseBuildPlanRecord, ReleaseBuildPlanError> {
176    let path = release_build_plan_path(root, release_build_id);
177    let bytes = read_plan_bytes(&path)?;
178    let record = decode_record(&path, &bytes)?;
179    validate_record_identity(release_build_id, &record)?;
180    Ok(record)
181}
182
183/// Load one immutable finalized record for current-Fleet admission.
184pub fn load_finalized_release_build(
185    root: &Path,
186    release_build_id: ReleaseBuildId,
187) -> Result<FinalizedReleaseBuild, ReleaseBuildPlanError> {
188    let path = release_build_plan_path(root, release_build_id);
189    let record = load_release_build_plan(root, release_build_id)?;
190    finalized_record(path, record)
191}
192
193/// Validate one finalized build against its immutable regular no-follow manifest bytes.
194pub fn validate_finalized_release_build_manifest(
195    root: &Path,
196    release_build_id: ReleaseBuildId,
197    manifest_path: &Path,
198) -> Result<FinalizedReleaseBuild, ReleaseBuildPlanError> {
199    let finalized = load_finalized_release_build(root, release_build_id)?;
200    let ReleaseBuildPlanState::Finalized {
201        release_set_manifest_digest,
202    } = finalized.record.state
203    else {
204        unreachable!("finalized loader admits only finalized records");
205    };
206    let manifest_bytes = read_plan_bytes(manifest_path)?;
207    let actual: [u8; 32] = Sha256::digest(&manifest_bytes).into();
208    if actual != release_set_manifest_digest {
209        return Err(ReleaseBuildPlanError::ManifestDigestMismatch { release_build_id });
210    }
211    Ok(finalized)
212}
213
214/// Finalize one planned build from the exact durable release-set manifest bytes.
215pub fn finalize_release_build_from_manifest(
216    root: &Path,
217    release_build_id: ReleaseBuildId,
218    manifest_path: &Path,
219) -> Result<FinalizedReleaseBuild, ReleaseBuildPlanError> {
220    let manifest_bytes = read_plan_bytes(manifest_path)?;
221    let digest = Sha256::digest(&manifest_bytes).into();
222    finalize_release_build(root, release_build_id, digest)
223}
224
225/// Return the canonical durable path for one release-build plan.
226#[must_use]
227pub fn release_build_plan_path(root: &Path, release_build_id: ReleaseBuildId) -> PathBuf {
228    root.join(".canic")
229        .join("release-builds")
230        .join(release_build_id.to_string())
231        .join("plan.cbor")
232}
233
234#[cfg(test)]
235fn plan_release_build_with_nonce(
236    root: &Path,
237    nonce: ReleaseBuildNonce,
238    build_profile: CanisterBuildProfile,
239) -> Result<PlannedReleaseBuild, ReleaseBuildPlanError> {
240    plan_release_build_with_nonce_and_network(root, nonce, build_profile, BuildNetwork::Local)
241}
242
243fn plan_release_build_with_nonce_and_network(
244    root: &Path,
245    nonce: ReleaseBuildNonce,
246    build_profile: CanisterBuildProfile,
247    build_network: BuildNetwork,
248) -> Result<PlannedReleaseBuild, ReleaseBuildPlanError> {
249    plan_release_build_with_nonce_and_builder(
250        root,
251        nonce,
252        build_profile,
253        build_network,
254        env!("CARGO_PKG_VERSION"),
255    )
256}
257
258fn plan_release_build_with_nonce_and_builder(
259    root: &Path,
260    nonce: ReleaseBuildNonce,
261    build_profile: CanisterBuildProfile,
262    build_network: BuildNetwork,
263    builder_version: &str,
264) -> Result<PlannedReleaseBuild, ReleaseBuildPlanError> {
265    let release_build_id = ReleaseBuildId::from_nonce(nonce);
266    let record = ReleaseBuildPlanRecord {
267        nonce,
268        release_build_id,
269        builder_version: builder_version.to_string(),
270        build_profile,
271        build_network,
272        state: ReleaseBuildPlanState::Planned,
273    };
274    let path = release_build_plan_path(root, release_build_id);
275    let bytes = encode_record(&record);
276    create_new_bytes_with_parents(&path, &bytes).map_err(|source| ReleaseBuildPlanError::Io {
277        path: path.clone(),
278        source,
279    })?;
280    Ok(PlannedReleaseBuild { record, path })
281}
282
283fn finalize_release_build(
284    root: &Path,
285    release_build_id: ReleaseBuildId,
286    release_set_manifest_digest: [u8; 32],
287) -> Result<FinalizedReleaseBuild, ReleaseBuildPlanError> {
288    let path = release_build_plan_path(root, release_build_id);
289    let _plan_lock = lock_plan_file(&path)?;
290    let mut record = load_release_build_plan(root, release_build_id)?;
291    match record.state {
292        ReleaseBuildPlanState::Planned => {}
293        ReleaseBuildPlanState::Finalized {
294            release_set_manifest_digest: observed,
295        } if observed == release_set_manifest_digest => {
296            return finalized_record(path, record);
297        }
298        ReleaseBuildPlanState::Finalized { .. } => {
299            return Err(ReleaseBuildPlanError::ConflictingFinalization { release_build_id });
300        }
301    }
302
303    record.state = ReleaseBuildPlanState::Finalized {
304        release_set_manifest_digest,
305    };
306    let bytes = encode_record(&record);
307    if let Err(source) = write_bytes(&path, &bytes) {
308        // A final parent-sync error may still have published the complete new
309        // record. Re-read the authority before projecting failure.
310        match load_release_build_plan(root, release_build_id) {
311            Ok(observed) if observed == record => return finalized_record(path, observed),
312            _ => {
313                return Err(ReleaseBuildPlanError::Io {
314                    path: path.clone(),
315                    source,
316                });
317            }
318        }
319    }
320
321    let observed = load_release_build_plan(root, release_build_id)?;
322    if observed != record {
323        return Err(ReleaseBuildPlanError::ConflictingFinalization { release_build_id });
324    }
325    finalized_record(path, observed)
326}
327
328fn finalized_record(
329    path: PathBuf,
330    record: ReleaseBuildPlanRecord,
331) -> Result<FinalizedReleaseBuild, ReleaseBuildPlanError> {
332    if !matches!(record.state, ReleaseBuildPlanState::Finalized { .. }) {
333        return Err(ReleaseBuildPlanError::InvalidDocument {
334            path,
335            reason: "finalized release-build evidence remained planned".to_string(),
336        });
337    }
338    let bytes = encode_record(&record);
339    Ok(FinalizedReleaseBuild {
340        record,
341        plan_hash: domain_hash(PLAN_HASH_DOMAIN, &bytes),
342        path,
343    })
344}
345
346fn validate_record_identity(
347    expected: ReleaseBuildId,
348    record: &ReleaseBuildPlanRecord,
349) -> Result<(), ReleaseBuildPlanError> {
350    let derived = ReleaseBuildId::from_nonce(record.nonce);
351    if derived != record.release_build_id {
352        return Err(ReleaseBuildPlanError::NonceIdentityMismatch {
353            derived,
354            recorded: record.release_build_id,
355        });
356    }
357    if expected != record.release_build_id {
358        return Err(ReleaseBuildPlanError::PathIdentityMismatch {
359            expected,
360            recorded: record.release_build_id,
361        });
362    }
363    Ok(())
364}
365
366fn encode_record(record: &ReleaseBuildPlanRecord) -> Vec<u8> {
367    let state = match record.state {
368        ReleaseBuildPlanState::Planned => Value::Array(vec![Value::Integer(0.into())]),
369        ReleaseBuildPlanState::Finalized {
370            release_set_manifest_digest,
371        } => Value::Array(vec![
372            Value::Integer(1.into()),
373            Value::Bytes(release_set_manifest_digest.to_vec()),
374        ]),
375    };
376    let value = Value::Array(vec![
377        Value::Bytes(record.nonce.as_bytes().to_vec()),
378        Value::Bytes(record.release_build_id.as_bytes().to_vec()),
379        Value::Text(record.builder_version.clone()),
380        Value::Text(record.build_profile.target_dir_name().to_string()),
381        Value::Text(record.build_network.as_str().to_string()),
382        state,
383    ]);
384    let mut bytes = Vec::new();
385    ciborium::ser::into_writer(&value, &mut bytes)
386        .expect("serializing a fixed release-build CBOR value cannot fail");
387    bytes
388}
389
390fn decode_record(
391    path: &Path,
392    bytes: &[u8],
393) -> Result<ReleaseBuildPlanRecord, ReleaseBuildPlanError> {
394    let value: Value =
395        ciborium::de::from_reader(bytes).map_err(|error| invalid(path, error.to_string()))?;
396    let fields = exact_array(path, value, 6, "record")?;
397    let nonce = ReleaseBuildNonce::from_random_bytes(exact_digest(path, &fields[0], "nonce")?);
398    let release_build_id = exact_digest(path, &fields[1], "release_build_id")?
399        .iter()
400        .fold(String::with_capacity(64), |mut text, byte| {
401            use std::fmt::Write as _;
402            write!(text, "{byte:02x}").expect("writing to String cannot fail");
403            text
404        })
405        .parse()
406        .expect("lowercase digest text is a canonical release-build ID");
407    let builder_version = fields[2]
408        .as_text()
409        .filter(|version| !version.trim().is_empty())
410        .ok_or_else(|| invalid(path, "builder_version must be nonempty text"))?
411        .to_string();
412    let build_profile = fields[3]
413        .as_text()
414        .ok_or_else(|| invalid(path, "build_profile must be text"))?
415        .parse()
416        .map_err(|error| invalid(path, error))?;
417    let build_network = match fields[4]
418        .as_text()
419        .ok_or_else(|| invalid(path, "build_network must be text"))?
420    {
421        "ic" => BuildNetwork::Ic,
422        "local" => BuildNetwork::Local,
423        value => {
424            return Err(invalid(
425                path,
426                format!("build_network must be `ic` or `local`, found {value}"),
427            ));
428        }
429    };
430    let state_fields = exact_array_ref(path, &fields[5], "state")?;
431    let discriminant = state_fields
432        .first()
433        .and_then(Value::as_integer)
434        .and_then(|value| u8::try_from(value).ok())
435        .ok_or_else(|| invalid(path, "state discriminant must be an unsigned integer"))?;
436    let state = match (discriminant, state_fields) {
437        (0, [_]) => ReleaseBuildPlanState::Planned,
438        (1, [_, digest]) => ReleaseBuildPlanState::Finalized {
439            release_set_manifest_digest: exact_digest(path, digest, "release_set_manifest_digest")?,
440        },
441        _ => return Err(invalid(path, "state has an unknown or invalid shape")),
442    };
443    let record = ReleaseBuildPlanRecord {
444        nonce,
445        release_build_id,
446        builder_version,
447        build_profile,
448        build_network,
449        state,
450    };
451    if encode_record(&record) != bytes {
452        return Err(invalid(path, "CBOR bytes are not canonical"));
453    }
454    Ok(record)
455}
456
457fn exact_array(
458    path: &Path,
459    value: Value,
460    len: usize,
461    field: &str,
462) -> Result<Vec<Value>, ReleaseBuildPlanError> {
463    let Value::Array(values) = value else {
464        return Err(invalid(path, format!("{field} must be an array")));
465    };
466    if values.len() != len {
467        return Err(invalid(
468            path,
469            format!("{field} must contain exactly {len} values"),
470        ));
471    }
472    Ok(values)
473}
474
475fn exact_array_ref<'a>(
476    path: &Path,
477    value: &'a Value,
478    field: &str,
479) -> Result<&'a [Value], ReleaseBuildPlanError> {
480    let Value::Array(values) = value else {
481        return Err(invalid(path, format!("{field} must be an array")));
482    };
483    Ok(values)
484}
485
486fn exact_digest(
487    path: &Path,
488    value: &Value,
489    field: &str,
490) -> Result<[u8; 32], ReleaseBuildPlanError> {
491    let Value::Bytes(bytes) = value else {
492        return Err(invalid(path, format!("{field} must be a byte string")));
493    };
494    bytes
495        .as_slice()
496        .try_into()
497        .map_err(|_| invalid(path, format!("{field} must contain exactly 32 bytes")))
498}
499
500fn read_plan_bytes(path: &Path) -> Result<Vec<u8>, ReleaseBuildPlanError> {
501    match read_optional_regular_bytes(path) {
502        Ok(Some(bytes)) => Ok(bytes),
503        Ok(None) => Err(ReleaseBuildPlanError::Missing {
504            path: path.to_path_buf(),
505        }),
506        Err(RegularFileReadError::NotRegular) => Err(ReleaseBuildPlanError::UnsafeFile {
507            path: path.to_path_buf(),
508        }),
509        Err(RegularFileReadError::Io(source)) => Err(ReleaseBuildPlanError::Io {
510            path: path.to_path_buf(),
511            source,
512        }),
513        #[cfg(not(unix))]
514        Err(RegularFileReadError::UnsupportedPlatform) => Err(ReleaseBuildPlanError::Io {
515            path: path.to_path_buf(),
516            source: io::Error::new(
517                io::ErrorKind::Unsupported,
518                "no-follow release-build reads are unsupported on this platform",
519            ),
520        }),
521    }
522}
523
524fn lock_plan_file(path: &Path) -> Result<std::fs::File, ReleaseBuildPlanError> {
525    #[cfg(not(windows))]
526    {
527        use rustix::{
528            fd::OwnedFd,
529            fs::{FileType, FlockOperation, Mode, OFlags, flock, fstat, open},
530        };
531
532        let fd: OwnedFd = open(
533            path,
534            OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
535            Mode::empty(),
536        )
537        .map_err(|source| ReleaseBuildPlanError::Io {
538            path: path.to_path_buf(),
539            source: io::Error::from_raw_os_error(source.raw_os_error()),
540        })?;
541        let metadata = fstat(&fd).map_err(|source| ReleaseBuildPlanError::Io {
542            path: path.to_path_buf(),
543            source: io::Error::from_raw_os_error(source.raw_os_error()),
544        })?;
545        if FileType::from_raw_mode(metadata.st_mode) != FileType::RegularFile {
546            return Err(ReleaseBuildPlanError::UnsafeFile {
547                path: path.to_path_buf(),
548            });
549        }
550        let file = std::fs::File::from(fd);
551        flock(&file, FlockOperation::LockExclusive).map_err(|source| {
552            ReleaseBuildPlanError::Io {
553                path: path.to_path_buf(),
554                source: io::Error::from_raw_os_error(source.raw_os_error()),
555            }
556        })?;
557        Ok(file)
558    }
559
560    #[cfg(windows)]
561    {
562        Err(ReleaseBuildPlanError::Io {
563            path: path.to_path_buf(),
564            source: io::Error::new(
565                io::ErrorKind::Unsupported,
566                "release-build plan locking is unsupported on Windows",
567            ),
568        })
569    }
570}
571
572fn random_nonce() -> Result<ReleaseBuildNonce, ReleaseBuildPlanError> {
573    random_bytes_32()
574        .map(ReleaseBuildNonce::from_random_bytes)
575        .map_err(|error| match error {
576            EntropyError::Io(source) => ReleaseBuildPlanError::Io {
577                path: PathBuf::from("<operating-system random source>"),
578                source,
579            },
580            EntropyError::ShortRead { actual } => ReleaseBuildPlanError::ShortRandomRead { actual },
581        })
582}
583
584fn domain_hash(domain: &[u8], bytes: &[u8]) -> [u8; 32] {
585    let mut hasher = Sha256::new();
586    hasher.update(domain);
587    hasher.update(
588        u64::try_from(bytes.len())
589            .expect("host evidence fits u64")
590            .to_be_bytes(),
591    );
592    hasher.update(bytes);
593    hasher.finalize().into()
594}
595
596fn invalid(path: &Path, reason: impl Into<String>) -> ReleaseBuildPlanError {
597    ReleaseBuildPlanError::InvalidDocument {
598        path: path.to_path_buf(),
599        reason: reason.into(),
600    }
601}