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