1#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub enum ReleaseBuildPlanState {
35 Planned,
36 Finalized {
37 release_set_manifest_digest: [u8; 32],
38 },
39}
40
41#[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#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct PlannedReleaseBuild {
66 pub record: ReleaseBuildPlanRecord,
67 pub path: PathBuf,
68}
69
70#[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#[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
141pub fn plan_release_build(root: &Path) -> Result<PlannedReleaseBuild, ReleaseBuildPlanError> {
143 plan_release_build_for_profile(root, CanisterBuildProfile::Release)
144}
145
146pub 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
164pub 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
176pub 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
186pub 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
207pub 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#[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 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}