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