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