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