Skip to main content

u280_hardware_promoter/
publication.rs

1//! Exclusive hidden-sibling construction and atomic no-replace publication.
2
3use 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/// Private hidden-sibling candidate that has not yet been published.
54#[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/// Receipt for one successful `RENAME_NOREPLACE` publication.
72#[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    /// Newly published immutable result directory.
82    #[must_use]
83    pub fn output_path(&self) -> &Path {
84        &self.output_path
85    }
86
87    /// Byte count of the result directory's canonical `SHA256SUMS`.
88    #[must_use]
89    pub const fn manifest_bytes(&self) -> u64 {
90        self.manifest_bytes
91    }
92
93    /// SHA-256 of the canonical `SHA256SUMS`.
94    #[must_use]
95    pub fn manifest_sha256(&self) -> &str {
96        &self.manifest_sha256
97    }
98
99    /// Durable private-state record that preceded publication.
100    #[must_use]
101    pub fn promotion_journal_record_sha256(&self) -> &str {
102        &self.promotion_journal_record_sha256
103    }
104}
105
106impl AtomicPublication {
107    /// Constructs and seals a candidate in a brand-new hidden sibling.
108    ///
109    /// The parent and candidate are descriptor-rooted with symlink resolution
110    /// disabled. Both files use `O_EXCL`, are fsynced, rehashed, checked for a
111    /// hard-link count of one, and changed to mode 0400; the directory becomes
112    /// mode 0500. Existing output or temporary names are rejected.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`FailureCode::Publication`] for any path, identity, write,
117    /// recheck, permission, or exclusivity failure.
118    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    /// Revalidates the durable burn and every caller-owned immutable input,
202    /// then atomically renames the sealed candidate with `RENAME_NOREPLACE`.
203    ///
204    /// State was already appended and fsynced before this method can be called.
205    /// Any failure or crash leaves the identity consumed and no success output.
206    ///
207    /// # Errors
208    ///
209    /// Returns the revalidation failure unchanged, or
210    /// [`FailureCode::Publication`] if the candidate cannot be atomically
211    /// published without overwrite.
212    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        // The callback is a transitional boundary until the final evidence
286        // combiner supplies a typed retained-input token. Revalidate private
287        // state again after that arbitrary code and every other fallible
288        // preflight, leaving no unchecked callback window before rename.
289        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}