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