1#[cfg(test)]
8mod tests;
9
10use crate::durable_io::{
11 RegularFileReadError, create_new_bytes_with_parents, read_optional_regular_bytes, write_bytes,
12};
13use crate::entropy::{EntropyError, random_bytes_32};
14use canic_core::ids::{ReleaseBuildId, ReleaseBuildNonce};
15use ciborium::Value;
16use sha2::{Digest, Sha256};
17use std::{
18 io,
19 path::{Path, PathBuf},
20};
21use thiserror::Error as ThisError;
22
23const PLAN_HASH_DOMAIN: &[u8] = b"canic:release-build:plan\0";
24const RANDOM_ATTEMPTS: usize = 16;
25
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub enum ReleaseBuildPlanState {
34 Planned,
35 Finalized {
36 release_set_manifest_digest: [u8; 32],
37 },
38}
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub struct ReleaseBuildPlanRecord {
48 pub nonce: ReleaseBuildNonce,
49 pub release_build_id: ReleaseBuildId,
50 pub state: ReleaseBuildPlanState,
51}
52
53#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct PlannedReleaseBuild {
63 pub record: ReleaseBuildPlanRecord,
64 pub path: PathBuf,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct FinalizedReleaseBuild {
75 pub record: ReleaseBuildPlanRecord,
76 pub plan_hash: [u8; 32],
77 pub path: PathBuf,
78}
79
80#[derive(Debug, ThisError)]
87pub enum ReleaseBuildPlanError {
88 #[error("failed to access release-build plan {path}: {source}")]
89 Io {
90 path: PathBuf,
91 #[source]
92 source: io::Error,
93 },
94
95 #[error("release-build plan is not a regular no-follow file: {path}")]
96 UnsafeFile { path: PathBuf },
97
98 #[error("release-build plan is missing: {path}")]
99 Missing { path: PathBuf },
100
101 #[error("invalid release-build plan {path}: {reason}")]
102 InvalidDocument { path: PathBuf, reason: String },
103
104 #[error(
105 "release-build plan path identity {expected} does not match record identity {recorded}"
106 )]
107 PathIdentityMismatch {
108 expected: ReleaseBuildId,
109 recorded: ReleaseBuildId,
110 },
111
112 #[error(
113 "release-build plan record identity {recorded} does not derive from nonce as {derived}"
114 )]
115 NonceIdentityMismatch {
116 derived: ReleaseBuildId,
117 recorded: ReleaseBuildId,
118 },
119
120 #[error(
121 "release-build plan {release_build_id} is already finalized with a different manifest digest"
122 )]
123 ConflictingFinalization { release_build_id: ReleaseBuildId },
124
125 #[error("cryptographic random source returned only {actual} of 32 required bytes")]
126 ShortRandomRead { actual: usize },
127
128 #[error("could not allocate a unique release-build identity after {RANDOM_ATTEMPTS} attempts")]
129 IdentityAllocationExhausted,
130
131 #[error("normal install cannot mutate a canister without a finalized release-build plan")]
132 MissingFinalizedAuthority,
133}
134
135pub fn plan_release_build(root: &Path) -> Result<PlannedReleaseBuild, ReleaseBuildPlanError> {
137 for _ in 0..RANDOM_ATTEMPTS {
138 let nonce = random_nonce()?;
139 match plan_release_build_with_nonce(root, nonce) {
140 Ok(plan) => return Ok(plan),
141 Err(ReleaseBuildPlanError::Io { source, .. })
142 if source.kind() == io::ErrorKind::AlreadyExists => {}
143 Err(error) => return Err(error),
144 }
145 }
146
147 Err(ReleaseBuildPlanError::IdentityAllocationExhausted)
148}
149
150pub fn load_release_build_plan(
152 root: &Path,
153 release_build_id: ReleaseBuildId,
154) -> Result<ReleaseBuildPlanRecord, ReleaseBuildPlanError> {
155 let path = release_build_plan_path(root, release_build_id);
156 let bytes = read_plan_bytes(&path)?;
157 let record = decode_record(&path, &bytes)?;
158 validate_record_identity(release_build_id, &record)?;
159 Ok(record)
160}
161
162pub fn load_finalized_release_build(
164 root: &Path,
165 release_build_id: ReleaseBuildId,
166) -> Result<FinalizedReleaseBuild, ReleaseBuildPlanError> {
167 let path = release_build_plan_path(root, release_build_id);
168 let record = load_release_build_plan(root, release_build_id)?;
169 finalized_record(path, record)
170}
171
172pub fn finalize_release_build_from_manifest(
174 root: &Path,
175 release_build_id: ReleaseBuildId,
176 manifest_path: &Path,
177) -> Result<FinalizedReleaseBuild, ReleaseBuildPlanError> {
178 let manifest_bytes = read_plan_bytes(manifest_path)?;
179 let digest = Sha256::digest(&manifest_bytes).into();
180 finalize_release_build(root, release_build_id, digest)
181}
182
183#[must_use]
185pub fn release_build_plan_path(root: &Path, release_build_id: ReleaseBuildId) -> PathBuf {
186 root.join(".canic")
187 .join("release-builds")
188 .join(release_build_id.to_string())
189 .join("plan.cbor")
190}
191
192fn plan_release_build_with_nonce(
193 root: &Path,
194 nonce: ReleaseBuildNonce,
195) -> Result<PlannedReleaseBuild, ReleaseBuildPlanError> {
196 let release_build_id = ReleaseBuildId::from_nonce(nonce);
197 let record = ReleaseBuildPlanRecord {
198 nonce,
199 release_build_id,
200 state: ReleaseBuildPlanState::Planned,
201 };
202 let path = release_build_plan_path(root, release_build_id);
203 let bytes = encode_record(record);
204 create_new_bytes_with_parents(&path, &bytes).map_err(|source| ReleaseBuildPlanError::Io {
205 path: path.clone(),
206 source,
207 })?;
208 Ok(PlannedReleaseBuild { record, path })
209}
210
211fn finalize_release_build(
212 root: &Path,
213 release_build_id: ReleaseBuildId,
214 release_set_manifest_digest: [u8; 32],
215) -> Result<FinalizedReleaseBuild, ReleaseBuildPlanError> {
216 let path = release_build_plan_path(root, release_build_id);
217 let _plan_lock = lock_plan_file(&path)?;
218 let mut record = load_release_build_plan(root, release_build_id)?;
219 match record.state {
220 ReleaseBuildPlanState::Planned => {}
221 ReleaseBuildPlanState::Finalized {
222 release_set_manifest_digest: observed,
223 } if observed == release_set_manifest_digest => {
224 return finalized_record(path, record);
225 }
226 ReleaseBuildPlanState::Finalized { .. } => {
227 return Err(ReleaseBuildPlanError::ConflictingFinalization { release_build_id });
228 }
229 }
230
231 record.state = ReleaseBuildPlanState::Finalized {
232 release_set_manifest_digest,
233 };
234 let bytes = encode_record(record);
235 if let Err(source) = write_bytes(&path, &bytes) {
236 match load_release_build_plan(root, release_build_id) {
239 Ok(observed) if observed == record => return finalized_record(path, observed),
240 _ => {
241 return Err(ReleaseBuildPlanError::Io {
242 path: path.clone(),
243 source,
244 });
245 }
246 }
247 }
248
249 let observed = load_release_build_plan(root, release_build_id)?;
250 if observed != record {
251 return Err(ReleaseBuildPlanError::ConflictingFinalization { release_build_id });
252 }
253 finalized_record(path, observed)
254}
255
256fn finalized_record(
257 path: PathBuf,
258 record: ReleaseBuildPlanRecord,
259) -> Result<FinalizedReleaseBuild, ReleaseBuildPlanError> {
260 if !matches!(record.state, ReleaseBuildPlanState::Finalized { .. }) {
261 return Err(ReleaseBuildPlanError::InvalidDocument {
262 path,
263 reason: "finalized release-build evidence remained planned".to_string(),
264 });
265 }
266 let bytes = encode_record(record);
267 Ok(FinalizedReleaseBuild {
268 record,
269 plan_hash: domain_hash(PLAN_HASH_DOMAIN, &bytes),
270 path,
271 })
272}
273
274fn validate_record_identity(
275 expected: ReleaseBuildId,
276 record: &ReleaseBuildPlanRecord,
277) -> Result<(), ReleaseBuildPlanError> {
278 let derived = ReleaseBuildId::from_nonce(record.nonce);
279 if derived != record.release_build_id {
280 return Err(ReleaseBuildPlanError::NonceIdentityMismatch {
281 derived,
282 recorded: record.release_build_id,
283 });
284 }
285 if expected != record.release_build_id {
286 return Err(ReleaseBuildPlanError::PathIdentityMismatch {
287 expected,
288 recorded: record.release_build_id,
289 });
290 }
291 Ok(())
292}
293
294fn encode_record(record: ReleaseBuildPlanRecord) -> Vec<u8> {
295 let state = match record.state {
296 ReleaseBuildPlanState::Planned => Value::Array(vec![Value::Integer(0.into())]),
297 ReleaseBuildPlanState::Finalized {
298 release_set_manifest_digest,
299 } => Value::Array(vec![
300 Value::Integer(1.into()),
301 Value::Bytes(release_set_manifest_digest.to_vec()),
302 ]),
303 };
304 let value = Value::Array(vec![
305 Value::Bytes(record.nonce.as_bytes().to_vec()),
306 Value::Bytes(record.release_build_id.as_bytes().to_vec()),
307 state,
308 ]);
309 let mut bytes = Vec::new();
310 ciborium::ser::into_writer(&value, &mut bytes)
311 .expect("serializing a fixed release-build CBOR value cannot fail");
312 bytes
313}
314
315fn decode_record(
316 path: &Path,
317 bytes: &[u8],
318) -> Result<ReleaseBuildPlanRecord, ReleaseBuildPlanError> {
319 let value: Value =
320 ciborium::de::from_reader(bytes).map_err(|error| invalid(path, error.to_string()))?;
321 let fields = exact_array(path, value, 3, "record")?;
322 let nonce = ReleaseBuildNonce::from_random_bytes(exact_digest(path, &fields[0], "nonce")?);
323 let release_build_id = exact_digest(path, &fields[1], "release_build_id")?
324 .iter()
325 .fold(String::with_capacity(64), |mut text, byte| {
326 use std::fmt::Write as _;
327 write!(text, "{byte:02x}").expect("writing to String cannot fail");
328 text
329 })
330 .parse()
331 .expect("lowercase digest text is a canonical release-build ID");
332 let state_fields = exact_array_ref(path, &fields[2], "state")?;
333 let discriminant = state_fields
334 .first()
335 .and_then(Value::as_integer)
336 .and_then(|value| u8::try_from(value).ok())
337 .ok_or_else(|| invalid(path, "state discriminant must be an unsigned integer"))?;
338 let state = match (discriminant, state_fields) {
339 (0, [_]) => ReleaseBuildPlanState::Planned,
340 (1, [_, digest]) => ReleaseBuildPlanState::Finalized {
341 release_set_manifest_digest: exact_digest(path, digest, "release_set_manifest_digest")?,
342 },
343 _ => return Err(invalid(path, "state has an unknown or invalid shape")),
344 };
345 let record = ReleaseBuildPlanRecord {
346 nonce,
347 release_build_id,
348 state,
349 };
350 if encode_record(record) != bytes {
351 return Err(invalid(path, "CBOR bytes are not canonical"));
352 }
353 Ok(record)
354}
355
356fn exact_array(
357 path: &Path,
358 value: Value,
359 len: usize,
360 field: &str,
361) -> Result<Vec<Value>, ReleaseBuildPlanError> {
362 let Value::Array(values) = value else {
363 return Err(invalid(path, format!("{field} must be an array")));
364 };
365 if values.len() != len {
366 return Err(invalid(
367 path,
368 format!("{field} must contain exactly {len} values"),
369 ));
370 }
371 Ok(values)
372}
373
374fn exact_array_ref<'a>(
375 path: &Path,
376 value: &'a Value,
377 field: &str,
378) -> Result<&'a [Value], ReleaseBuildPlanError> {
379 let Value::Array(values) = value else {
380 return Err(invalid(path, format!("{field} must be an array")));
381 };
382 Ok(values)
383}
384
385fn exact_digest(
386 path: &Path,
387 value: &Value,
388 field: &str,
389) -> Result<[u8; 32], ReleaseBuildPlanError> {
390 let Value::Bytes(bytes) = value else {
391 return Err(invalid(path, format!("{field} must be a byte string")));
392 };
393 bytes
394 .as_slice()
395 .try_into()
396 .map_err(|_| invalid(path, format!("{field} must contain exactly 32 bytes")))
397}
398
399fn read_plan_bytes(path: &Path) -> Result<Vec<u8>, ReleaseBuildPlanError> {
400 match read_optional_regular_bytes(path) {
401 Ok(Some(bytes)) => Ok(bytes),
402 Ok(None) => Err(ReleaseBuildPlanError::Missing {
403 path: path.to_path_buf(),
404 }),
405 Err(RegularFileReadError::NotRegular) => Err(ReleaseBuildPlanError::UnsafeFile {
406 path: path.to_path_buf(),
407 }),
408 Err(RegularFileReadError::Io(source)) => Err(ReleaseBuildPlanError::Io {
409 path: path.to_path_buf(),
410 source,
411 }),
412 #[cfg(not(unix))]
413 Err(RegularFileReadError::UnsupportedPlatform) => Err(ReleaseBuildPlanError::Io {
414 path: path.to_path_buf(),
415 source: io::Error::new(
416 io::ErrorKind::Unsupported,
417 "no-follow release-build reads are unsupported on this platform",
418 ),
419 }),
420 }
421}
422
423fn lock_plan_file(path: &Path) -> Result<std::fs::File, ReleaseBuildPlanError> {
424 #[cfg(not(windows))]
425 {
426 use rustix::{
427 fd::OwnedFd,
428 fs::{FileType, FlockOperation, Mode, OFlags, flock, fstat, open},
429 };
430
431 let fd: OwnedFd = open(
432 path,
433 OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
434 Mode::empty(),
435 )
436 .map_err(|source| ReleaseBuildPlanError::Io {
437 path: path.to_path_buf(),
438 source: io::Error::from_raw_os_error(source.raw_os_error()),
439 })?;
440 let metadata = fstat(&fd).map_err(|source| ReleaseBuildPlanError::Io {
441 path: path.to_path_buf(),
442 source: io::Error::from_raw_os_error(source.raw_os_error()),
443 })?;
444 if FileType::from_raw_mode(metadata.st_mode) != FileType::RegularFile {
445 return Err(ReleaseBuildPlanError::UnsafeFile {
446 path: path.to_path_buf(),
447 });
448 }
449 let file = std::fs::File::from(fd);
450 flock(&file, FlockOperation::LockExclusive).map_err(|source| {
451 ReleaseBuildPlanError::Io {
452 path: path.to_path_buf(),
453 source: io::Error::from_raw_os_error(source.raw_os_error()),
454 }
455 })?;
456 Ok(file)
457 }
458
459 #[cfg(windows)]
460 {
461 Err(ReleaseBuildPlanError::Io {
462 path: path.to_path_buf(),
463 source: io::Error::new(
464 io::ErrorKind::Unsupported,
465 "release-build plan locking is unsupported on Windows",
466 ),
467 })
468 }
469}
470
471fn random_nonce() -> Result<ReleaseBuildNonce, ReleaseBuildPlanError> {
472 random_bytes_32()
473 .map(ReleaseBuildNonce::from_random_bytes)
474 .map_err(|error| match error {
475 EntropyError::Io(source) => ReleaseBuildPlanError::Io {
476 path: PathBuf::from("<operating-system random source>"),
477 source,
478 },
479 EntropyError::ShortRead { actual } => ReleaseBuildPlanError::ShortRandomRead { actual },
480 })
481}
482
483fn domain_hash(domain: &[u8], bytes: &[u8]) -> [u8; 32] {
484 let mut hasher = Sha256::new();
485 hasher.update(domain);
486 hasher.update(
487 u64::try_from(bytes.len())
488 .expect("host evidence fits u64")
489 .to_be_bytes(),
490 );
491 hasher.update(bytes);
492 hasher.finalize().into()
493}
494
495fn invalid(path: &Path, reason: impl Into<String>) -> ReleaseBuildPlanError {
496 ReleaseBuildPlanError::InvalidDocument {
497 path: path.to_path_buf(),
498 reason: reason.into(),
499 }
500}