Skip to main content

elfpak_core/rootfs/
copy.rs

1//! Materialization of a [`BundlePlan`] into a directory tree.
2//!
3//! Nothing is written outside the output root and the source filesystem is only
4//! ever read. A clean output contains only planned entries; without `clean`,
5//! pre-existing unplanned entries are deliberately retained.
6
7use crate::{
8    error::{Error, Result, io},
9    hash::{HashingReader, ensure_matches_plan},
10    plan::{BundlePlan, PLAN_ENTRIES_MAX, PlannedFile, PlannedFileKind},
11};
12use std::{
13    os::unix::fs::PermissionsExt,
14    path::{Path, PathBuf},
15};
16
17/// Explicit reproducible-build timestamp, when configured by the caller.
18fn source_date_epoch() -> Result<Option<std::time::SystemTime>> {
19    let Some(seconds) = configured_source_date_epoch_secs()? else {
20        return Ok(None);
21    };
22    std::time::UNIX_EPOCH
23        .checked_add(std::time::Duration::from_secs(seconds))
24        .map(Some)
25        .ok_or_else(|| Error::Config {
26            message: "SOURCE_DATE_EPOCH is outside the supported system-time range".to_string(),
27        })
28}
29
30pub(crate) fn source_date_epoch_secs() -> Result<u64> {
31    Ok(configured_source_date_epoch_secs()?.unwrap_or(0))
32}
33
34fn configured_source_date_epoch_secs() -> Result<Option<u64>> {
35    match std::env::var("SOURCE_DATE_EPOCH") {
36        Ok(value) => value
37            .trim()
38            .parse::<u64>()
39            .map(Some)
40            .map_err(|_| Error::Config {
41                message: format!(
42                    "invalid SOURCE_DATE_EPOCH `{value}` (expected an unsigned integer)"
43                ),
44            }),
45        Err(std::env::VarError::NotPresent) => Ok(None),
46        Err(std::env::VarError::NotUnicode(_)) => Err(Error::Config {
47            message: "SOURCE_DATE_EPOCH is not valid Unicode".to_string(),
48        }),
49    }
50}
51
52#[derive(Debug)]
53pub struct RootFsBuilder {
54    output: PathBuf,
55    clean: bool,
56}
57
58impl RootFsBuilder {
59    pub fn new(output: impl Into<PathBuf>) -> RootFsBuilder {
60        RootFsBuilder {
61            output: output.into(),
62            clean: false,
63        }
64    }
65
66    /// Exclude an existing output directory from the staged replacement.
67    pub fn clean(mut self, clean: bool) -> RootFsBuilder {
68        self.clean = clean;
69        self
70    }
71
72    /// Materialize a plan into a sibling staging directory, then publish it.
73    /// A failed build leaves an existing output untouched and exposes no new
74    /// output when the destination did not exist.
75    pub fn apply(&self, plan: &BundlePlan) -> Result<RootFsReport> {
76        // Capture one time for all planned entries. Reproducible-build callers
77        // can override the ordinary materialization time through SOURCE_DATE_EPOCH.
78        let timestamp = source_date_epoch()?.unwrap_or_else(std::time::SystemTime::now);
79        guard_output(&self.output)?;
80        let parent = output_parent(&self.output);
81        std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
82
83        let stage = tempfile::Builder::new()
84            .prefix(".elfpak-rootfs-")
85            .permissions(std::fs::Permissions::from_mode(STAGE_MODE))
86            .tempdir_in(parent)
87            .map_err(|e| io(parent, e))?;
88
89        if path_exists(&self.output) {
90            ensure_directory(&self.output)?;
91        }
92        if path_exists(&self.output) && !self.clean {
93            clone_tree(&self.output, stage.path())?;
94        } else {
95            // The stage is built private and only widened at publication: the
96            // root of a generated filesystem takes the same normalized mode as
97            // its ordinary directory entries.
98            set_mode(stage.path(), 0o755)?;
99        }
100        if self.clean && path_exists(&self.output) {
101            guard_clean(&self.output)?;
102        }
103
104        let report = self.apply_into(plan, stage.path(), timestamp)?;
105        publish_directory(stage, &self.output)?;
106        Ok(report)
107    }
108
109    /// Apply a plan to an isolated directory that is not externally visible.
110    fn apply_into(
111        &self,
112        plan: &BundlePlan,
113        output: &Path,
114        timestamp: std::time::SystemTime,
115    ) -> Result<RootFsReport> {
116        let output = output.canonicalize().map_err(|e| io(output, e))?;
117
118        let mut report = RootFsReport::default();
119        // Entries are sorted by destination, so parents always precede children.
120        for file in &plan.files {
121            file.assert_well_formed();
122            let target = self.target_path(&output, file)?;
123            assert!(target.starts_with(&output));
124
125            match file.kind {
126                PlannedFileKind::Directory => {
127                    write_directory(&target, file.mode)?;
128                    report.directories += 1;
129                }
130                PlannedFileKind::Symlink => {
131                    write_symlink(&target, file.link_target.as_deref())?;
132                    report.symlinks += 1;
133                }
134                _ => {
135                    // Removing first is what keeps the write inside the output
136                    // root: writing onto a pre-existing symlink would follow it.
137                    remove_existing(&target)?;
138                    report.bytes += write_file(&target, file)?;
139                    set_mode(&target, file.mode)?;
140                    pin_times(&target, timestamp);
141                    report.files += 1;
142                }
143            }
144        }
145
146        // Directory timestamps are pinned last: writing children updates them.
147        // Deepest first, so a parent is not touched again after it is pinned.
148        for file in plan
149            .files
150            .iter()
151            .rev()
152            .filter(|f| f.kind == PlannedFileKind::Directory)
153        {
154            pin_times(
155                &crate::paths::join_under(&output, &file.destination),
156                timestamp,
157            );
158        }
159
160        let entries = report.files + report.directories + report.symlinks;
161        assert_eq!(entries as usize, plan.files.len());
162        Ok(report)
163    }
164
165    /// Resolve a plan destination inside the output root, refusing anything that
166    /// would write through a symlink or outside the root.
167    fn target_path(&self, output: &Path, file: &PlannedFile) -> Result<PathBuf> {
168        assert!(file.destination.is_absolute());
169
170        let target = crate::paths::join_under(output, &file.destination);
171        if !target.starts_with(output) {
172            return Err(Error::PathEscape {
173                path: file.destination.clone(),
174                kind: "output",
175            });
176        }
177        if let Some(parent) = target.parent() {
178            if crate::paths::has_symlinked_ancestor(output, parent) {
179                return Err(Error::PathEscape {
180                    path: file.destination.clone(),
181                    kind: "output",
182                });
183            }
184            std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
185        }
186        Ok(target)
187    }
188}
189
190/// Parent used for sibling staging. A bare relative output such as `rootfs`
191/// lives beside a temporary directory in the current working directory.
192pub(crate) fn output_parent(output: &Path) -> &Path {
193    output
194        .parent()
195        .filter(|parent| !parent.as_os_str().is_empty())
196        .unwrap_or_else(|| Path::new("."))
197}
198
199pub(crate) fn path_exists(path: &Path) -> bool {
200    std::fs::symlink_metadata(path).is_ok()
201}
202
203pub(crate) fn ensure_directory(path: &Path) -> Result<()> {
204    let metadata = std::fs::symlink_metadata(path).map_err(|e| io(path, e))?;
205    if metadata.is_symlink() {
206        return Err(Error::Config {
207            message: format!("output `{}` must not be a symlink", path.display()),
208        });
209    }
210    if metadata.is_dir() {
211        return Ok(());
212    }
213    Err(Error::Config {
214        message: format!("output `{}` is not a directory", path.display()),
215    })
216}
217
218/// Clone an existing output into the stage without following symlinks. Regular
219/// files are copied rather than hard-linked. A hard link would leave an
220/// unplanned file in the newly published rootfs sharing an inode with the old
221/// rootfs, so a later writer of the old tree could mutate the new snapshot.
222fn clone_tree(source: &Path, destination: &Path) -> Result<()> {
223    clone_tree_with_limit(source, destination, PLAN_ENTRIES_MAX)
224}
225
226fn clone_tree_with_limit(source: &Path, destination: &Path, limit: usize) -> Result<()> {
227    let mut stack = vec![(source.to_path_buf(), destination.to_path_buf())];
228    let mut directories = Vec::new();
229    let mut entries = 0usize;
230
231    while let Some((source_dir, destination_dir)) = stack.pop() {
232        let source_metadata = std::fs::metadata(&source_dir).map_err(|e| io(&source_dir, e))?;
233        directories.push((destination_dir.clone(), source_metadata));
234
235        for entry in std::fs::read_dir(&source_dir).map_err(|e| io(&source_dir, e))? {
236            if entries == limit {
237                return Err(Error::LimitExceeded {
238                    resource: "existing output tree",
239                    limit,
240                });
241            }
242            entries += 1;
243            let entry = entry.map_err(|e| io(&source_dir, e))?;
244            let source_path = entry.path();
245            let destination_path = destination_dir.join(entry.file_name());
246            let metadata =
247                std::fs::symlink_metadata(&source_path).map_err(|e| io(&source_path, e))?;
248
249            if metadata.is_symlink() {
250                let target = std::fs::read_link(&source_path).map_err(|e| io(&source_path, e))?;
251                std::os::unix::fs::symlink(target, &destination_path)
252                    .map_err(|e| io(&destination_path, e))?;
253            } else if metadata.is_dir() {
254                std::fs::create_dir(&destination_path).map_err(|e| io(&destination_path, e))?;
255                stack.push((source_path, destination_path));
256            } else if metadata.is_file() {
257                std::fs::copy(&source_path, &destination_path)
258                    .map_err(|e| io(&destination_path, e))?;
259                set_permissions_from(&destination_path, &metadata)?;
260            } else {
261                return Err(Error::Config {
262                    message: format!(
263                        "existing output contains unsupported entry `{}`",
264                        source_path.display()
265                    ),
266                });
267            }
268        }
269    }
270
271    // Creating children changes directory timestamps. Restore metadata from
272    // the bottom up after the complete snapshot has been assembled.
273    for (path, metadata) in directories.into_iter().rev() {
274        set_permissions_from(&path, &metadata)?;
275        set_times_from(&path, &metadata);
276    }
277    Ok(())
278}
279
280/// Replace the visible output only after the complete staged tree exists.
281/// Existing outputs are atomically exchanged when the filesystem supports it;
282/// otherwise a rollback-capable sequence publishes the staged tree.
283pub(crate) fn publish_directory(stage: tempfile::TempDir, output: &Path) -> Result<()> {
284    // Linux can exchange two sibling paths in one rename operation. This
285    // retains a continuously visible output for readers, unlike moving the
286    // old tree aside before publishing the new one. The temporary directory
287    // then names the old tree and `close` removes it after the exchange.
288    if path_exists(output) {
289        return publish_by_exchange(stage, output);
290    }
291
292    use rustix::fs::{CWD, RenameFlags, renameat_with};
293
294    // Do not overwrite a rootfs created between the initial existence check
295    // and publication. A concurrent builder must retry rather than silently
296    // discarding somebody else's output.
297    let publish = renameat_with(CWD, stage.path(), CWD, output, RenameFlags::NOREPLACE);
298    finish_noreplace(stage, output, publish)
299}
300
301fn finish_noreplace(
302    stage: tempfile::TempDir,
303    output: &Path,
304    publish: std::result::Result<(), rustix::io::Errno>,
305) -> Result<()> {
306    if let Err(error) = publish {
307        if matches!(
308            error,
309            rustix::io::Errno::INVAL | rustix::io::Errno::NOSYS | rustix::io::Errno::OPNOTSUPP
310        ) {
311            return publish_new_directory_legacy(stage, output);
312        }
313        return Err(io(output, error.into()));
314    }
315    Ok(())
316}
317
318/// Reserve an absent destination before using plain rename on filesystems
319/// without `RENAME_NOREPLACE`, notably WSL shared mounts.
320fn publish_new_directory_legacy(stage: tempfile::TempDir, output: &Path) -> Result<()> {
321    std::fs::create_dir(output).map_err(|error| io(output, error))?;
322    if let Err(error) = std::fs::rename(stage.path(), output) {
323        // Remove only our empty reservation. If anything appeared inside it,
324        // `remove_dir` refuses and the foreign content remains untouched.
325        let _ = std::fs::remove_dir(output);
326        return Err(io(output, error));
327    }
328    Ok(())
329}
330
331fn publish_by_exchange(stage: tempfile::TempDir, output: &Path) -> Result<()> {
332    use rustix::fs::{CWD, RenameFlags, renameat_with};
333
334    let exchange = renameat_with(CWD, stage.path(), CWD, output, RenameFlags::EXCHANGE);
335    finish_exchange(stage, output, exchange)
336}
337
338fn finish_exchange(
339    stage: tempfile::TempDir,
340    output: &Path,
341    exchange: std::result::Result<(), rustix::io::Errno>,
342) -> Result<()> {
343    if let Err(error) = exchange {
344        if matches!(
345            error,
346            rustix::io::Errno::INVAL | rustix::io::Errno::NOSYS | rustix::io::Errno::OPNOTSUPP
347        ) {
348            return publish_directory_legacy(stage, output);
349        }
350        return Err(io(output, error.into()));
351    }
352
353    let old_path = stage.path().to_path_buf();
354    stage.close().map_err(|e| io(&old_path, e))
355}
356
357/// Portable publication path for filesystems where
358/// `renameat2(RENAME_EXCHANGE)` is unavailable, such as WSL's Windows mounts.
359fn publish_directory_legacy(stage: tempfile::TempDir, output: &Path) -> Result<()> {
360    let backup = if path_exists(output) {
361        let reservation = tempfile::Builder::new()
362            .prefix(".elfpak-backup-")
363            .tempdir_in(output_parent(output))
364            .map_err(|e| io(output, e))?;
365        let path = reservation.path().to_path_buf();
366        reservation.close().map_err(|e| io(&path, e))?;
367        std::fs::rename(output, &path).map_err(|e| io(output, e))?;
368        Some(path)
369    } else {
370        None
371    };
372
373    if let Err(error) = std::fs::rename(stage.path(), output) {
374        if let Some(backup) = &backup
375            && let Err(rollback) = std::fs::rename(backup, output)
376        {
377            return Err(Error::Config {
378                message: format!(
379                    "failed to publish `{}` ({error}) and failed to restore its backup `{}` ({rollback})",
380                    output.display(),
381                    backup.display()
382                ),
383            });
384        }
385        return Err(io(output, error));
386    }
387
388    if let Some(backup) = backup {
389        remove_existing(&backup)?;
390    }
391    Ok(())
392}
393
394/// Create a directory, replacing anything else that occupies the path —
395/// including a symlink, which a later write would silently follow.
396fn write_directory(target: &Path, mode: u32) -> Result<()> {
397    let existing = std::fs::symlink_metadata(target).ok();
398    if !existing.is_some_and(|metadata| metadata.is_dir()) {
399        remove_existing(target)?;
400        std::fs::create_dir_all(target).map_err(|e| io(target, e))?;
401    }
402    set_mode(target, mode)
403}
404
405/// Recreate a symlink verbatim. Plan validation guarantees a target is present.
406fn write_symlink(target: &Path, link_target: Option<&Path>) -> Result<()> {
407    let link_target = link_target.expect("validated symlinks have a target");
408    remove_existing(target)?;
409    std::os::unix::fs::symlink(link_target, target).map_err(|e| io(target, e))
410}
411
412/// Write the contents of a planned entry, returning the number of bytes.
413///
414/// Source-backed files are copied rather than read into memory, so an
415/// `--include` of an arbitrarily large file costs no more than a buffer.
416fn write_file(target: &Path, file: &PlannedFile) -> Result<u64> {
417    match (&file.content, &file.source) {
418        (Some(content), None) => {
419            assert_eq!(content.len() as u64, file.size);
420            std::fs::write(target, content).map_err(|e| io(target, e))?;
421            Ok(content.len() as u64)
422        }
423        (None, Some(source)) => {
424            let input = std::fs::File::open(source).map_err(|e| io(source, e))?;
425            let mut input = HashingReader::new(std::io::BufReader::new(input));
426            let mut output = std::fs::File::create(target).map_err(|e| io(target, e))?;
427            let copy_result = std::io::copy(&mut input, &mut output).map_err(|e| io(source, e));
428            drop(output);
429            let (digest, size) = input.finish();
430
431            if let Err(error) = copy_result {
432                let _ = remove_existing(target);
433                return Err(error);
434            }
435            let expected = file
436                .sha256
437                .as_ref()
438                .expect("validated regular files have a digest");
439            if let Err(error) = ensure_matches_plan(source, expected, file.size, digest, size) {
440                let _ = remove_existing(target);
441                return Err(error);
442            }
443            Ok(size)
444        }
445        _ => unreachable!("validated regular files have exactly one content source"),
446    }
447}
448
449/// Give a staged artifact the mode its destination already had, so replacing a
450/// file does not silently change who can read it. A destination that is not
451/// there yet gets the ordinary `0644`.
452pub(crate) fn set_output_permissions(stage: &Path, destination: &Path) -> Result<()> {
453    let permissions = std::fs::metadata(destination)
454        .map(|metadata| metadata.permissions())
455        .unwrap_or_else(|_| std::fs::Permissions::from_mode(0o644));
456    std::fs::set_permissions(stage, permissions).map_err(|e| io(stage, e))
457}
458
459/// Mode a staging directory is created with.
460///
461/// Staging happens beside the destination, which can be a shared directory such
462/// as `/tmp` or a group-writable CI workspace. `tempfile` would otherwise apply
463/// the ambient umask, leaving a window in which another user could plant an
464/// entry that publication then makes part of the finished output.
465pub(crate) const STAGE_MODE: u32 = 0o700;
466
467/// Refuse the filesystem root as an output even when `--clean` was not
468/// requested. Materializing there would turn logical bundle destinations such
469/// as `/app/server` into writes to the host filesystem.
470pub(crate) fn guard_output(output: &Path) -> Result<()> {
471    // `canonicalize` cannot resolve a path that has not been created yet, so
472    // normalize an absolute spelling first. Otherwise `/new/..` would evade
473    // the guard and become `/` after `create_dir_all`.
474    let absolute = std::path::absolute(output).map_err(|e| io(output, e))?;
475    let lexical = crate::paths::normalize_absolute(&absolute);
476    let resolved = output.canonicalize().unwrap_or(lexical);
477    if resolved.parent().is_none() {
478        return Err(Error::Config {
479            message: format!(
480                "refusing to materialize a bundle at filesystem root `{}`",
481                resolved.display()
482            ),
483        });
484    }
485    Ok(())
486}
487
488/// Refuse to delete something that is obviously not a previous bundle: `--clean`
489/// is meant to replace an output directory, not to wipe a filesystem.
490fn guard_clean(output: &Path) -> Result<()> {
491    let resolved = output
492        .canonicalize()
493        .unwrap_or_else(|_| output.to_path_buf());
494    if resolved.parent().is_none() {
495        return Err(Error::Config {
496            message: format!("refusing to --clean `{}`", resolved.display()),
497        });
498    }
499    Ok(())
500}
501
502/// Remove whatever currently occupies `path`, without following symlinks.
503fn remove_existing(path: &Path) -> Result<()> {
504    match std::fs::symlink_metadata(path) {
505        // `is_dir` is false for a symlink to a directory, so the link itself is
506        // unlinked and its target is left alone.
507        Ok(metadata) if metadata.is_dir() => std::fs::remove_dir_all(path).map_err(|e| io(path, e)),
508        Ok(_) => std::fs::remove_file(path).map_err(|e| io(path, e)),
509        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
510        Err(e) => Err(io(path, e)),
511    }
512}
513
514/// What was written, counted as it was written.
515#[derive(Debug, Default, Clone, Copy)]
516pub struct RootFsReport {
517    pub files: u32,
518    pub directories: u32,
519    pub symlinks: u32,
520    pub bytes: u64,
521}
522
523fn set_mode(path: &Path, mode: u32) -> Result<()> {
524    use std::os::unix::fs::PermissionsExt;
525
526    assert!(mode <= 0o7777);
527    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).map_err(|e| io(path, e))
528}
529
530fn set_permissions_from(path: &Path, metadata: &std::fs::Metadata) -> Result<()> {
531    std::fs::set_permissions(path, metadata.permissions()).map_err(|e| io(path, e))
532}
533
534fn set_times_from(path: &Path, metadata: &std::fs::Metadata) {
535    let (Ok(accessed), Ok(modified), Ok(file)) = (
536        metadata.accessed(),
537        metadata.modified(),
538        std::fs::File::open(path),
539    ) else {
540        return;
541    };
542    let _ = file.set_times(
543        std::fs::FileTimes::new()
544            .set_accessed(accessed)
545            .set_modified(modified),
546    );
547}
548
549/// Give every entry the materialization timestamp. Not every filesystem
550/// supports this, and symlink timestamps cannot be set through `std` at all,
551/// so this is best-effort: the tar backend is the byte-reproducible artifact.
552fn pin_times(path: &Path, time: std::time::SystemTime) {
553    let Ok(file) = std::fs::File::open(path) else {
554        return;
555    };
556    let _ = file.set_times(
557        std::fs::FileTimes::new()
558            .set_accessed(time)
559            .set_modified(time),
560    );
561}
562
563#[cfg(test)]
564mod tests {
565    use super::{
566        clone_tree_with_limit, ensure_directory, finish_exchange, finish_noreplace, guard_clean,
567        guard_output, remove_existing,
568    };
569    use crate::paths::has_symlinked_ancestor;
570    use std::path::Path;
571
572    #[test]
573    fn detects_a_symlink_above_a_missing_parent() {
574        let temp = tempfile::tempdir().unwrap();
575        let output = temp.path().join("output");
576        let outside = temp.path().join("outside");
577        std::fs::create_dir_all(&output).unwrap();
578        std::fs::create_dir(&outside).unwrap();
579        std::os::unix::fs::symlink(&outside, output.join("nested")).unwrap();
580
581        assert!(has_symlinked_ancestor(
582            &output,
583            &output.join("nested/deeper")
584        ));
585    }
586
587    #[test]
588    fn removing_a_symlink_leaves_its_target_alone() {
589        let temp = tempfile::tempdir().unwrap();
590        let target = temp.path().join("target");
591        let link = temp.path().join("link");
592        std::fs::write(&target, b"keep me").unwrap();
593        std::os::unix::fs::symlink(&target, &link).unwrap();
594
595        remove_existing(&link).unwrap();
596        assert!(!link.exists());
597        assert_eq!(std::fs::read(&target).unwrap(), b"keep me");
598
599        // Removing something that is not there is not an error.
600        remove_existing(&link).unwrap();
601    }
602
603    #[test]
604    fn clean_refuses_to_delete_a_filesystem_root() {
605        let err = guard_clean(Path::new("/")).unwrap_err();
606        assert_eq!(err.code(), "E4001");
607        let temp = tempfile::tempdir().unwrap();
608        guard_clean(&temp.path().join("rootfs")).unwrap();
609    }
610
611    #[test]
612    fn materialization_refuses_a_filesystem_root() {
613        let err = guard_output(Path::new("/")).unwrap_err();
614        assert_eq!(err.code(), "E4001");
615        let err = guard_output(Path::new("/new-rootfs/..")).unwrap_err();
616        assert_eq!(err.code(), "E4001");
617        let temp = tempfile::tempdir().unwrap();
618        guard_output(&temp.path().join("rootfs")).unwrap();
619    }
620
621    #[test]
622    fn an_output_root_symlink_is_rejected() {
623        let temp = tempfile::tempdir().unwrap();
624        let target = temp.path().join("target");
625        let output = temp.path().join("output");
626        std::fs::create_dir(&target).unwrap();
627        std::os::unix::fs::symlink(&target, &output).unwrap();
628
629        assert!(ensure_directory(&output).is_err());
630    }
631
632    #[test]
633    fn cloning_an_existing_output_stops_at_its_entry_limit() {
634        let temp = tempfile::tempdir().unwrap();
635        let source = temp.path().join("source");
636        let destination = temp.path().join("destination");
637        std::fs::create_dir(&source).unwrap();
638        std::fs::create_dir(&destination).unwrap();
639        std::fs::write(source.join("one"), b"one").unwrap();
640        std::fs::write(source.join("two"), b"two").unwrap();
641
642        let error = clone_tree_with_limit(&source, &destination, 1).unwrap_err();
643        assert!(matches!(
644            error,
645            crate::Error::LimitExceeded {
646                resource: "existing output tree",
647                limit: 1,
648            }
649        ));
650    }
651
652    #[test]
653    fn unsupported_atomic_exchange_falls_back_to_portable_publication() {
654        let temp = tempfile::tempdir().unwrap();
655        let output = temp.path().join("output");
656        std::fs::create_dir(&output).unwrap();
657        std::fs::write(output.join("old"), b"old").unwrap();
658
659        let stage = tempfile::Builder::new()
660            .prefix(".elfpak-rootfs-")
661            .tempdir_in(temp.path())
662            .unwrap();
663        std::fs::write(stage.path().join("new"), b"new").unwrap();
664
665        finish_exchange(stage, &output, Err(rustix::io::Errno::INVAL)).unwrap();
666
667        assert_eq!(std::fs::read(output.join("new")).unwrap(), b"new");
668        assert!(!output.join("old").exists());
669    }
670
671    #[test]
672    fn unsupported_noreplace_falls_back_to_portable_publication() {
673        let temp = tempfile::tempdir().unwrap();
674        let output = temp.path().join("output");
675        let stage = tempfile::Builder::new()
676            .prefix(".elfpak-rootfs-")
677            .tempdir_in(temp.path())
678            .unwrap();
679        std::fs::write(stage.path().join("new"), b"new").unwrap();
680
681        finish_noreplace(stage, &output, Err(rustix::io::Errno::INVAL)).unwrap();
682
683        assert_eq!(std::fs::read(output.join("new")).unwrap(), b"new");
684    }
685
686    #[test]
687    fn unrelated_exchange_errors_leave_the_existing_output_untouched() {
688        let temp = tempfile::tempdir().unwrap();
689        let output = temp.path().join("output");
690        std::fs::create_dir(&output).unwrap();
691        std::fs::write(output.join("old"), b"old").unwrap();
692
693        let stage = tempfile::Builder::new()
694            .prefix(".elfpak-rootfs-")
695            .tempdir_in(temp.path())
696            .unwrap();
697        std::fs::write(stage.path().join("new"), b"new").unwrap();
698
699        assert!(finish_exchange(stage, &output, Err(rustix::io::Errno::PERM)).is_err());
700
701        assert_eq!(std::fs::read(output.join("old")).unwrap(), b"old");
702        assert!(!output.join("new").exists());
703    }
704}