1#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
39pub enum ReleaseBuildPlanState {
40 Planned,
41 Finalized {
42 release_set_manifest_digest: [u8; 32],
43 },
44}
45
46#[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#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct PlannedReleaseBuild {
72 pub record: ReleaseBuildPlanRecord,
73 pub path: PathBuf,
74}
75
76#[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#[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
144pub fn plan_release_build(root: &Path) -> Result<PlannedReleaseBuild, ReleaseBuildPlanError> {
146 plan_release_build_for_profile(root, CanisterBuildProfile::Release)
147}
148
149pub 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
157pub 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
176pub 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
188pub 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
198pub 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
219pub 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#[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 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}