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