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