1use crate::canonical::sha256_hex;
4use crate::{
5 DurablePromotionBurn, FailureCode, PromotionDocument, PromotionError, PromotionResult,
6 PromotionTransactionIdentity,
7};
8use rustix::fs::{
9 CWD, FileType, Mode, OFlags, RawDir, RenameFlags, ResolveFlags, fchmod, fsync, mkdirat,
10 openat2, renameat_with,
11};
12use rustix::io::Errno;
13use rustix::process::geteuid;
14use std::collections::BTreeSet;
15use std::fs::{File, Metadata};
16use std::io::Write;
17use std::mem::MaybeUninit;
18use std::os::unix::fs::MetadataExt;
19use std::path::{Path, PathBuf};
20
21const RESULT_FILE: &str = "promotion-result.json";
22const MANIFEST_FILE: &str = "SHA256SUMS";
23const DIRECTORY_BUFFER_BYTES: usize = 16 * 1024;
24const MAXIMUM_RESULT_BYTES: usize = 1024 * 1024;
25
26#[derive(Clone, Debug, Eq, PartialEq)]
27struct Fingerprint {
28 dev: u64,
29 ino: u64,
30 mode: u32,
31 uid: u32,
32 nlink: u64,
33 bytes: u64,
34}
35
36impl Fingerprint {
37 fn from_metadata(metadata: &Metadata) -> Self {
38 Self {
39 dev: metadata.dev(),
40 ino: metadata.ino(),
41 mode: metadata.mode(),
42 uid: metadata.uid(),
43 nlink: metadata.nlink(),
44 bytes: metadata.size(),
45 }
46 }
47
48 fn same_object(&self, other: &Self) -> bool {
49 self.dev == other.dev && self.ino == other.ino
50 }
51}
52
53#[derive(Debug)]
55pub struct AtomicPublication {
56 output_path: PathBuf,
57 parent_path: PathBuf,
58 parent: File,
59 parent_fingerprint: Fingerprint,
60 output_name: String,
61 temporary_name: String,
62 directory: File,
63 directory_fingerprint: Fingerprint,
64 transaction: PromotionTransactionIdentity,
65 manifest_bytes: u64,
66 manifest_sha256: String,
67 result_bytes: u64,
68 result_sha256: String,
69}
70
71#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct PublicationReceipt {
74 output_path: PathBuf,
75 manifest_bytes: u64,
76 manifest_sha256: String,
77 promotion_journal_record_sha256: String,
78}
79
80impl PublicationReceipt {
81 #[must_use]
83 pub fn output_path(&self) -> &Path {
84 &self.output_path
85 }
86
87 #[must_use]
89 pub const fn manifest_bytes(&self) -> u64 {
90 self.manifest_bytes
91 }
92
93 #[must_use]
95 pub fn manifest_sha256(&self) -> &str {
96 &self.manifest_sha256
97 }
98
99 #[must_use]
101 pub fn promotion_journal_record_sha256(&self) -> &str {
102 &self.promotion_journal_record_sha256
103 }
104}
105
106impl AtomicPublication {
107 pub fn prepare(output_path: &Path, document: &PromotionDocument) -> PromotionResult<Self> {
119 validate_absolute_output(output_path)?;
120 let (parent_path, output_name) = split_output_path(output_path)?;
121 validate_component(&output_name)?;
122 let transaction = document.transaction_identity().clone();
123 let run_id = transaction.run_id();
124 if run_id.len() != 32
125 || !run_id
126 .bytes()
127 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
128 || !run_id.bytes().any(|byte| byte != b'0')
129 {
130 return Err(PromotionError::new(
131 FailureCode::Publication,
132 "publication run_id is not canonical 128-bit lowercase hex",
133 ));
134 }
135 let temporary_name = format!(".hashsigs-u280-promotion-{run_id}.tmp");
136 validate_component(&temporary_name)?;
137 let parent = open_absolute_directory(&parent_path)?;
138 let parent_fingerprint = private_parent_fingerprint(&parent)?;
139 require_missing(&parent, &output_name, "output directory")?;
140 require_missing(&parent, &temporary_name, "hidden publication candidate")?;
141 mkdirat(&parent, temporary_name.as_str(), Mode::RWXU).map_err(|error| {
142 publication_error("cannot create hidden publication candidate", error)
143 })?;
144 fsync(&parent)
145 .map_err(|error| publication_error("cannot fsync publication parent", error))?;
146 let directory = open_directory_beneath(&parent, &temporary_name)?;
147 fchmod(&directory, Mode::RWXU)
148 .map_err(|error| publication_error("cannot set candidate mode 0700", error))?;
149 let directory_fingerprint = Fingerprint::from_metadata(
150 &directory
151 .metadata()
152 .map_err(|error| publication_error("cannot stat publication candidate", error))?,
153 );
154
155 let result_bytes = document.canonical_bytes()?;
156 if result_bytes.is_empty() || result_bytes.len() > MAXIMUM_RESULT_BYTES {
157 return Err(PromotionError::new(
158 FailureCode::Publication,
159 "canonical promotion document is empty or exceeds 1 MiB",
160 ));
161 }
162 let result_digest = write_exclusive(&directory, RESULT_FILE, &result_bytes)?;
163 let manifest = format!("{} {RESULT_FILE}\n", result_digest.sha256);
164 let manifest_digest = write_exclusive(&directory, MANIFEST_FILE, manifest.as_bytes())?;
165 require_exact_tree(&directory, &result_digest, &manifest_digest)?;
166 fchmod(&directory, Mode::RUSR | Mode::XUSR)
167 .map_err(|error| publication_error("cannot set candidate mode 0500", error))?;
168 fsync(&directory)
169 .map_err(|error| publication_error("cannot fsync sealed candidate", error))?;
170 fsync(&parent)
171 .map_err(|error| publication_error("cannot fsync publication parent", error))?;
172 let sealed = Fingerprint::from_metadata(
173 &directory
174 .metadata()
175 .map_err(|error| publication_error("cannot restat sealed candidate", error))?,
176 );
177 if !sealed.same_object(&directory_fingerprint) || sealed.mode & 0o777 != 0o500 {
178 return Err(PromotionError::new(
179 FailureCode::Publication,
180 "sealed candidate identity or mode changed",
181 ));
182 }
183
184 Ok(Self {
185 output_path: parent_path.join(&output_name),
186 parent_path,
187 parent,
188 parent_fingerprint,
189 output_name,
190 temporary_name,
191 directory,
192 directory_fingerprint: sealed,
193 transaction,
194 manifest_bytes: manifest_digest.bytes,
195 manifest_sha256: manifest_digest.sha256,
196 result_bytes: result_digest.bytes,
197 result_sha256: result_digest.sha256,
198 })
199 }
200
201 pub fn publish<F>(
213 self,
214 mut burn: DurablePromotionBurn,
215 revalidate_all_inputs: F,
216 ) -> PromotionResult<PublicationReceipt>
217 where
218 F: FnOnce() -> PromotionResult<()>,
219 {
220 if burn.transaction_identity() != &self.transaction
221 || !self.transaction.eq(&burn.record().transaction_identity())
222 {
223 return Err(PromotionError::new(
224 FailureCode::IdentityJoin,
225 "publication document and durable state burn identify different transactions",
226 ));
227 }
228 burn.revalidate()?;
229 revalidate_all_inputs()?;
230 require_exact_tree(
231 &self.directory,
232 &FileDigest {
233 bytes: self.result_bytes,
234 sha256: self.result_sha256.clone(),
235 },
236 &FileDigest {
237 bytes: self.manifest_bytes,
238 sha256: self.manifest_sha256.clone(),
239 },
240 )?;
241 let now = Fingerprint::from_metadata(
242 &self
243 .directory
244 .metadata()
245 .map_err(|error| publication_error("cannot restat candidate", error))?,
246 );
247 if now != self.directory_fingerprint {
248 return Err(PromotionError::new(
249 FailureCode::Publication,
250 "sealed publication candidate changed before rename",
251 ));
252 }
253 let parent_path_descriptor = open_absolute_directory(&self.parent_path)?;
254 let parent_path_fingerprint = private_parent_fingerprint(&parent_path_descriptor)?;
255 if !parent_path_fingerprint.same_object(&self.parent_fingerprint) {
256 return Err(PromotionError::new(
257 FailureCode::Publication,
258 "publication parent pathname no longer identifies the retained private parent",
259 ));
260 }
261 require_missing(&self.parent, &self.output_name, "output directory")?;
262 let temporary_path = open_directory_beneath(&self.parent, &self.temporary_name)?;
263 let temporary_path_fingerprint = Fingerprint::from_metadata(
264 &temporary_path
265 .metadata()
266 .map_err(|error| publication_error("cannot restat temporary path", error))?,
267 );
268 if temporary_path_fingerprint != self.directory_fingerprint {
269 return Err(PromotionError::new(
270 FailureCode::Publication,
271 "hidden temporary pathname was substituted before rename",
272 ));
273 }
274 require_exact_tree(
275 &temporary_path,
276 &FileDigest {
277 bytes: self.result_bytes,
278 sha256: self.result_sha256.clone(),
279 },
280 &FileDigest {
281 bytes: self.manifest_bytes,
282 sha256: self.manifest_sha256.clone(),
283 },
284 )?;
285 burn.revalidate()?;
290 renameat_with(
291 &self.parent,
292 self.temporary_name.as_str(),
293 &self.parent,
294 self.output_name.as_str(),
295 RenameFlags::NOREPLACE,
296 )
297 .map_err(|error| publication_error("atomic no-replace publication failed", error))?;
298 fsync(&self.parent)
299 .map_err(|error| publication_error("cannot fsync published result parent", error))?;
300 let parent_after = open_absolute_directory(&self.parent_path)?;
301 if !private_parent_fingerprint(&parent_after)?.same_object(&self.parent_fingerprint) {
302 return Err(PromotionError::new(
303 FailureCode::Publication,
304 "publication parent pathname changed during atomic rename",
305 ));
306 }
307 let published = open_directory_beneath(&self.parent, &self.output_name)?;
308 let published_fingerprint = Fingerprint::from_metadata(
309 &published
310 .metadata()
311 .map_err(|error| publication_error("cannot stat published result", error))?,
312 );
313 if !published_fingerprint.same_object(&self.directory_fingerprint)
314 || published_fingerprint.mode & 0o777 != 0o500
315 {
316 return Err(PromotionError::new(
317 FailureCode::Publication,
318 "published result does not name the sealed candidate object",
319 ));
320 }
321 require_missing(
322 &self.parent,
323 &self.temporary_name,
324 "renamed hidden publication candidate",
325 )?;
326 Ok(PublicationReceipt {
327 output_path: self.output_path,
328 manifest_bytes: self.manifest_bytes,
329 manifest_sha256: self.manifest_sha256,
330 promotion_journal_record_sha256: burn.record().record_sha256().to_owned(),
331 })
332 }
333}
334
335#[derive(Clone, Debug, Eq, PartialEq)]
336struct FileDigest {
337 bytes: u64,
338 sha256: String,
339}
340
341fn write_exclusive(directory: &File, name: &str, bytes: &[u8]) -> PromotionResult<FileDigest> {
342 validate_component(name)?;
343 let descriptor = openat2(
344 directory,
345 name,
346 OFlags::RDWR | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
347 Mode::RUSR | Mode::WUSR,
348 ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
349 )
350 .map_err(|error| publication_error(&format!("cannot exclusively create {name}"), error))?;
351 let mut file = File::from(descriptor);
352 file.write_all(bytes)
353 .map_err(|error| publication_error(&format!("cannot write {name}"), error))?;
354 file.sync_all()
355 .map_err(|error| publication_error(&format!("cannot fsync {name}"), error))?;
356 let before = regular_file(&file, name)?;
357 if before.bytes != u64::try_from(bytes.len()).expect("usize fits u64") {
358 return Err(PromotionError::new(
359 FailureCode::Publication,
360 format!("exclusive file {name} has the wrong byte count"),
361 ));
362 }
363 let observed = read_small_at(&file, before.bytes, name)?;
364 if observed != bytes {
365 return Err(PromotionError::new(
366 FailureCode::Publication,
367 format!("exclusive file {name} differs from supplied bytes"),
368 ));
369 }
370 fchmod(&file, Mode::RUSR)
371 .map_err(|error| publication_error(&format!("cannot set {name} mode 0400"), error))?;
372 file.sync_all()
373 .map_err(|error| publication_error(&format!("cannot fsync read-only {name}"), error))?;
374 let after = regular_file(&file, name)?;
375 if !after.same_object(&before) || after.mode & 0o777 != 0o400 {
376 return Err(PromotionError::new(
377 FailureCode::Publication,
378 format!("exclusive file {name} identity or mode changed"),
379 ));
380 }
381 fsync(directory)
382 .map_err(|error| publication_error("cannot fsync candidate directory", error))?;
383 Ok(FileDigest {
384 bytes: before.bytes,
385 sha256: sha256_hex(bytes),
386 })
387}
388
389fn require_exact_tree(
390 directory: &File,
391 expected_result: &FileDigest,
392 expected_manifest: &FileDigest,
393) -> PromotionResult<()> {
394 let scan_descriptor = openat2(
395 directory,
396 ".",
397 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
398 Mode::empty(),
399 ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
400 )
401 .map_err(|error| publication_error("cannot duplicate candidate descriptor", error))?;
402 let mut buffer = [MaybeUninit::<u8>::uninit(); DIRECTORY_BUFFER_BYTES];
403 let mut iterator = RawDir::new(scan_descriptor, &mut buffer);
404 let mut names = BTreeSet::new();
405 while let Some(entry) = iterator.next() {
406 let entry = entry
407 .map_err(|error| publication_error("cannot enumerate publication candidate", error))?;
408 let raw = entry.file_name().to_bytes();
409 if raw == b"." || raw == b".." {
410 continue;
411 }
412 let name = std::str::from_utf8(raw).map_err(|error| {
413 publication_error("publication candidate has a non-UTF-8 name", error)
414 })?;
415 validate_component(name)?;
416 if !names.insert(name.to_owned()) {
417 return Err(PromotionError::new(
418 FailureCode::Publication,
419 "publication candidate contains a duplicate name",
420 ));
421 }
422 let child = openat2(
423 directory,
424 name,
425 OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
426 Mode::empty(),
427 ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
428 )
429 .map_err(|error| publication_error("cannot inspect candidate entry", error))?;
430 let file = File::from(child);
431 let metadata = file
432 .metadata()
433 .map_err(|error| publication_error("cannot stat candidate entry", error))?;
434 if FileType::from_raw_mode(metadata.mode()) != FileType::RegularFile
435 || metadata.nlink() != 1
436 || metadata.mode() & 0o777 != 0o400
437 {
438 return Err(PromotionError::new(
439 FailureCode::Publication,
440 "publication candidate contains a symlink, special file, hard-link alias, or writable file",
441 ));
442 }
443 }
444 let expected = BTreeSet::from([RESULT_FILE.to_owned(), MANIFEST_FILE.to_owned()]);
445 if names != expected {
446 return Err(PromotionError::new(
447 FailureCode::Publication,
448 "publication candidate file set is incomplete or has an extra entry",
449 ));
450 }
451 for (name, expected_digest) in [
452 (RESULT_FILE, expected_result),
453 (MANIFEST_FILE, expected_manifest),
454 ] {
455 let descriptor = openat2(
456 directory,
457 name,
458 OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
459 Mode::empty(),
460 ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
461 )
462 .map_err(|error| publication_error("cannot reopen sealed candidate file", error))?;
463 let file = File::from(descriptor);
464 let fingerprint = regular_file(&file, name)?;
465 if fingerprint.bytes != expected_digest.bytes || fingerprint.mode & 0o777 != 0o400 {
466 return Err(PromotionError::new(
467 FailureCode::Publication,
468 format!("sealed candidate file {name} changed size or mode"),
469 ));
470 }
471 let bytes = read_small_at(&file, fingerprint.bytes, name)?;
472 if sha256_hex(&bytes) != expected_digest.sha256 {
473 return Err(PromotionError::new(
474 FailureCode::Publication,
475 format!("sealed candidate file {name} changed digest"),
476 ));
477 }
478 let after = regular_file(&file, name)?;
479 if after != fingerprint {
480 return Err(PromotionError::new(
481 FailureCode::Publication,
482 format!("sealed candidate file {name} changed while revalidated"),
483 ));
484 }
485 }
486 Ok(())
487}
488
489fn read_small_at(file: &File, bytes: u64, label: &str) -> PromotionResult<Vec<u8>> {
490 let length = usize::try_from(bytes).map_err(|error| {
491 publication_error(&format!("{label} is too large for this host"), error)
492 })?;
493 let mut output = vec![0_u8; length];
494 std::os::unix::fs::FileExt::read_exact_at(file, &mut output, 0)
495 .map_err(|error| publication_error(&format!("cannot reread {label}"), error))?;
496 Ok(output)
497}
498
499fn regular_file(file: &File, label: &str) -> PromotionResult<Fingerprint> {
500 let metadata = file
501 .metadata()
502 .map_err(|error| publication_error(&format!("cannot stat {label}"), error))?;
503 if !metadata.file_type().is_file() || metadata.nlink() != 1 {
504 return Err(PromotionError::new(
505 FailureCode::Publication,
506 format!("{label} is not an unaliased regular file"),
507 ));
508 }
509 Ok(Fingerprint::from_metadata(&metadata))
510}
511
512fn split_output_path(output: &Path) -> PromotionResult<(PathBuf, String)> {
513 if !output.is_absolute() {
514 return Err(PromotionError::new(
515 FailureCode::Publication,
516 "publication output must be an absolute direct child path",
517 ));
518 }
519 let parent = output.parent().ok_or_else(|| {
520 PromotionError::new(FailureCode::Publication, "publication output has no parent")
521 })?;
522 let name = output
523 .file_name()
524 .and_then(|name| name.to_str())
525 .ok_or_else(|| {
526 PromotionError::new(
527 FailureCode::Publication,
528 "publication output name is not canonical UTF-8",
529 )
530 })?
531 .to_owned();
532 Ok((parent.to_owned(), name))
533}
534
535fn validate_absolute_output(output: &Path) -> PromotionResult<()> {
536 let Some(value) = output.to_str() else {
537 return Err(PromotionError::new(
538 FailureCode::Publication,
539 "publication output must be canonical UTF-8",
540 ));
541 };
542 let canonical = value.starts_with('/')
543 && value.len() > 1
544 && !value.ends_with('/')
545 && !value.contains("//")
546 && value
547 .split('/')
548 .skip(1)
549 .all(|component| !component.is_empty() && component != "." && component != "..");
550 if !canonical {
551 return Err(PromotionError::new(
552 FailureCode::Publication,
553 "publication output is not a traversal-free canonical absolute path",
554 ));
555 }
556 Ok(())
557}
558
559fn validate_component(name: &str) -> PromotionResult<()> {
560 let valid = !name.is_empty()
561 && name != "."
562 && name != ".."
563 && name
564 .bytes()
565 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'));
566 if !valid {
567 return Err(PromotionError::new(
568 FailureCode::Publication,
569 "publication path contains a non-canonical or traversal component",
570 ));
571 }
572 Ok(())
573}
574
575fn open_absolute_directory(path: &Path) -> PromotionResult<File> {
576 let descriptor = openat2(
577 CWD,
578 path,
579 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
580 Mode::empty(),
581 ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
582 )
583 .map_err(|error| publication_error("cannot securely open publication parent", error))?;
584 Ok(File::from(descriptor))
585}
586
587fn private_parent_fingerprint(parent: &File) -> PromotionResult<Fingerprint> {
588 let metadata = parent
589 .metadata()
590 .map_err(|error| publication_error("cannot stat publication parent", error))?;
591 if !metadata.file_type().is_dir()
592 || metadata.uid() != geteuid().as_raw()
593 || metadata.mode() & 0o777 != 0o700
594 {
595 return Err(PromotionError::new(
596 FailureCode::Publication,
597 "publication parent must be a direct owner-only mode-0700 directory",
598 ));
599 }
600 Ok(Fingerprint::from_metadata(&metadata))
601}
602
603fn open_directory_beneath(parent: &File, name: &str) -> PromotionResult<File> {
604 validate_component(name)?;
605 let descriptor = openat2(
606 parent,
607 name,
608 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
609 Mode::empty(),
610 ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
611 )
612 .map_err(|error| publication_error("cannot securely open publication child", error))?;
613 Ok(File::from(descriptor))
614}
615
616fn require_missing(parent: &File, name: &str, label: &str) -> PromotionResult<()> {
617 match openat2(
618 parent,
619 name,
620 OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
621 Mode::empty(),
622 ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
623 ) {
624 Err(Errno::NOENT) => Ok(()),
625 Ok(_) => Err(PromotionError::new(
626 FailureCode::Publication,
627 format!("{label} already exists; overwrite is forbidden"),
628 )),
629 Err(error) => Err(publication_error(
630 &format!("cannot safely inspect {label}"),
631 error,
632 )),
633 }
634}
635
636fn publication_error(label: &str, error: impl std::fmt::Display) -> PromotionError {
637 PromotionError::new(FailureCode::Publication, format!("{label}: {error}"))
638}
639
640#[cfg(test)]
641mod tests {
642 use super::{AtomicPublication, RESULT_FILE};
643 use crate::journal::synthetic_identity;
644 use crate::{
645 FailureCode, PromotionDocument, PromotionError, PromotionFailure, burn_promotion_identity,
646 };
647 use std::fs;
648 use std::os::unix::fs::{PermissionsExt, symlink};
649 use std::path::{Path, PathBuf};
650 use std::sync::atomic::{AtomicU64, Ordering};
651
652 static NEXT: AtomicU64 = AtomicU64::new(0);
653
654 struct TestRoot(PathBuf);
655
656 impl TestRoot {
657 fn new() -> Self {
658 let path = std::env::temp_dir().join(format!(
659 "hashsigs-u280-promoter-{}-{}",
660 std::process::id(),
661 NEXT.fetch_add(1, Ordering::Relaxed)
662 ));
663 fs::create_dir(&path).unwrap();
664 fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
665 Self(path)
666 }
667 }
668
669 impl Drop for TestRoot {
670 fn drop(&mut self) {
671 make_writable(&self.0);
672 let _ = fs::remove_dir_all(&self.0);
673 }
674 }
675
676 fn make_writable(path: &Path) {
677 let Ok(metadata) = fs::symlink_metadata(path) else {
678 return;
679 };
680 if metadata.file_type().is_symlink() {
681 let _ = fs::remove_file(path);
682 } else if metadata.is_dir() {
683 let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o700));
684 if let Ok(entries) = fs::read_dir(path) {
685 for entry in entries.flatten() {
686 make_writable(&entry.path());
687 }
688 }
689 } else {
690 let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
691 }
692 }
693
694 fn failure_document(identity: &crate::journal::PromotionIdentity) -> PromotionDocument {
695 let error = PromotionError::new(FailureCode::RouteUnaccepted, "synthetic fixture");
696 PromotionDocument::Failure(PromotionFailure::new(
697 &identity.transaction_identity(),
698 &error,
699 ))
700 }
701
702 #[test]
703 fn durable_burn_precedes_atomic_no_replace_publication() {
704 let root = TestRoot::new();
705 let state = root.0.join("state");
706 fs::create_dir(&state).unwrap();
707 fs::set_permissions(&state, fs::Permissions::from_mode(0o700)).unwrap();
708 let output = root.0.join("result");
709 let identity = synthetic_identity(1);
710 let document = failure_document(&identity);
711 let candidate = AtomicPublication::prepare(&output, &document).unwrap();
712 let burn = burn_promotion_identity(&state, &identity).unwrap();
713 let receipt = candidate.publish(burn, || Ok(())).unwrap();
714 assert_eq!(receipt.output_path(), output);
715 assert!(output.join("SHA256SUMS").is_file());
716 assert_eq!(
717 AtomicPublication::prepare(&output, &document)
718 .unwrap_err()
719 .code(),
720 FailureCode::Publication
721 );
722 }
723
724 #[test]
725 fn existing_symlink_and_late_hardlink_alias_are_rejected() {
726 let root = TestRoot::new();
727 let target = root.0.join("target");
728 fs::create_dir(&target).unwrap();
729 let symlink_output = root.0.join("symlink-output");
730 symlink(&target, &symlink_output).unwrap();
731 let identity = synthetic_identity(2);
732 assert_eq!(
733 AtomicPublication::prepare(&symlink_output, &failure_document(&identity))
734 .unwrap_err()
735 .code(),
736 FailureCode::Publication
737 );
738
739 let state = root.0.join("state");
740 fs::create_dir(&state).unwrap();
741 fs::set_permissions(&state, fs::Permissions::from_mode(0o700)).unwrap();
742 let output = root.0.join("hardlink-output");
743 let candidate = AtomicPublication::prepare(&output, &failure_document(&identity)).unwrap();
744 let hidden = output
745 .parent()
746 .unwrap()
747 .join(&candidate.temporary_name)
748 .join(RESULT_FILE);
749 fs::hard_link(&hidden, root.0.join("alias.json")).unwrap();
750 let burn = burn_promotion_identity(&state, &identity).unwrap();
751 assert_eq!(
752 candidate.publish(burn, || Ok(())).unwrap_err().code(),
753 FailureCode::Publication
754 );
755 assert!(!output.exists());
756 }
757
758 #[test]
759 fn substituted_temporary_path_fails_with_no_final_output() {
760 let root = TestRoot::new();
761 let state = root.0.join("state");
762 fs::create_dir(&state).unwrap();
763 fs::set_permissions(&state, fs::Permissions::from_mode(0o700)).unwrap();
764 let output = root.0.join("substitution-output");
765 let identity = synthetic_identity(3);
766 let candidate = AtomicPublication::prepare(&output, &failure_document(&identity)).unwrap();
767 let hidden = root.0.join(&candidate.temporary_name);
768 let displaced = root.0.join("displaced-candidate");
769 fs::rename(&hidden, &displaced).unwrap();
770 fs::create_dir(&hidden).unwrap();
771 fs::set_permissions(&hidden, fs::Permissions::from_mode(0o500)).unwrap();
772 let burn = burn_promotion_identity(&state, &identity).unwrap();
773 assert_eq!(
774 candidate.publish(burn, || Ok(())).unwrap_err().code(),
775 FailureCode::Publication
776 );
777 assert!(!output.exists());
778 }
779
780 #[test]
781 fn cross_run_document_and_burn_are_rejected_before_rename() {
782 let root = TestRoot::new();
783 let state = root.0.join("state");
784 fs::create_dir(&state).unwrap();
785 fs::set_permissions(&state, fs::Permissions::from_mode(0o700)).unwrap();
786 let output = root.0.join("cross-run-output");
787 let document_identity = synthetic_identity(4);
788 let burn_identity = synthetic_identity(5);
789 let candidate =
790 AtomicPublication::prepare(&output, &failure_document(&document_identity)).unwrap();
791 let burn = burn_promotion_identity(&state, &burn_identity).unwrap();
792 assert_eq!(
793 candidate.publish(burn, || Ok(())).unwrap_err().code(),
794 FailureCode::IdentityJoin
795 );
796 assert!(!output.exists());
797 }
798
799 #[test]
800 fn renamed_state_path_invalidates_durable_token_before_publication() {
801 let root = TestRoot::new();
802 let state = root.0.join("state");
803 fs::create_dir(&state).unwrap();
804 fs::set_permissions(&state, fs::Permissions::from_mode(0o700)).unwrap();
805 let output = root.0.join("state-rename-output");
806 let identity = synthetic_identity(6);
807 let candidate = AtomicPublication::prepare(&output, &failure_document(&identity)).unwrap();
808 let burn = burn_promotion_identity(&state, &identity).unwrap();
809 fs::rename(&state, root.0.join("displaced-state")).unwrap();
810 fs::create_dir(&state).unwrap();
811 fs::set_permissions(&state, fs::Permissions::from_mode(0o700)).unwrap();
812 assert_eq!(
813 candidate.publish(burn, || Ok(())).unwrap_err().code(),
814 FailureCode::StateBurn
815 );
816 assert!(!output.exists());
817 }
818
819 #[test]
820 fn state_replacement_inside_input_callback_is_caught_before_rename() {
821 let root = TestRoot::new();
822 let state = root.0.join("state");
823 fs::create_dir(&state).unwrap();
824 fs::set_permissions(&state, fs::Permissions::from_mode(0o700)).unwrap();
825 let output = root.0.join("callback-state-replacement-output");
826 let identity = synthetic_identity(7);
827 let candidate = AtomicPublication::prepare(&output, &failure_document(&identity)).unwrap();
828 let burn = burn_promotion_identity(&state, &identity).unwrap();
829 let displaced = root.0.join("callback-displaced-state");
830 assert_eq!(
831 candidate
832 .publish(burn, || {
833 fs::rename(&state, &displaced).unwrap();
834 fs::create_dir(&state).unwrap();
835 fs::set_permissions(&state, fs::Permissions::from_mode(0o700)).unwrap();
836 Ok(())
837 })
838 .unwrap_err()
839 .code(),
840 FailureCode::StateBurn
841 );
842 assert!(!output.exists());
843 }
844}