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