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