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. An
243/// An existing output is atomically exchanged with the staged tree; publishing
244/// a previously absent output is one atomic rename.
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    renameat_with(CWD, stage.path(), CWD, output, RenameFlags::EXCHANGE)
267        .map_err(|e| io(output, e.into()))?;
268    let old_path = stage.path().to_path_buf();
269    stage.close().map_err(|e| io(&old_path, e))
270}
271
272/// Legacy publication path retained only for platforms/filesystems where
273/// `renameat2(RENAME_EXCHANGE)` is unavailable. This is never selected on
274/// supported Linux builds: returning an error is safer than exposing a gap.
275#[allow(dead_code)]
276fn publish_directory_legacy(stage: tempfile::TempDir, output: &Path) -> Result<()> {
277    let backup = if path_exists(output) {
278        let reservation = tempfile::Builder::new()
279            .prefix(".elfpak-backup-")
280            .tempdir_in(output_parent(output))
281            .map_err(|e| io(output, e))?;
282        let path = reservation.path().to_path_buf();
283        reservation.close().map_err(|e| io(&path, e))?;
284        std::fs::rename(output, &path).map_err(|e| io(output, e))?;
285        Some(path)
286    } else {
287        None
288    };
289
290    if let Err(error) = std::fs::rename(stage.path(), output) {
291        if let Some(backup) = &backup
292            && let Err(rollback) = std::fs::rename(backup, output)
293        {
294            return Err(Error::Config {
295                message: format!(
296                    "failed to publish `{}` ({error}) and failed to restore its backup `{}` ({rollback})",
297                    output.display(),
298                    backup.display()
299                ),
300            });
301        }
302        return Err(io(output, error));
303    }
304
305    if let Some(backup) = backup {
306        remove_existing(&backup)?;
307    }
308    Ok(())
309}
310
311/// Create a directory, replacing anything else that occupies the path —
312/// including a symlink, which a later write would silently follow.
313fn write_directory(target: &Path, mode: u32) -> Result<()> {
314    let existing = std::fs::symlink_metadata(target).ok();
315    if !existing.is_some_and(|metadata| metadata.is_dir()) {
316        remove_existing(target)?;
317        std::fs::create_dir_all(target).map_err(|e| io(target, e))?;
318    }
319    set_mode(target, mode)
320}
321
322/// Recreate a symlink verbatim. Plan validation guarantees a target is present.
323fn write_symlink(target: &Path, link_target: Option<&Path>) -> Result<()> {
324    let link_target = link_target.expect("validated symlinks have a target");
325    remove_existing(target)?;
326    std::os::unix::fs::symlink(link_target, target).map_err(|e| io(target, e))
327}
328
329/// Write the contents of a planned entry, returning the number of bytes.
330///
331/// Source-backed files are copied rather than read into memory, so an
332/// `--include` of an arbitrarily large file costs no more than a buffer.
333fn write_file(target: &Path, file: &PlannedFile) -> Result<u64> {
334    match (&file.content, &file.source) {
335        (Some(content), None) => {
336            assert_eq!(content.len() as u64, file.size);
337            std::fs::write(target, content).map_err(|e| io(target, e))?;
338            Ok(content.len() as u64)
339        }
340        (None, Some(source)) => {
341            let input = std::fs::File::open(source).map_err(|e| io(source, e))?;
342            let mut input = HashingReader::new(std::io::BufReader::new(input));
343            let mut output = std::fs::File::create(target).map_err(|e| io(target, e))?;
344            let copy_result = std::io::copy(&mut input, &mut output).map_err(|e| io(source, e));
345            drop(output);
346            let (digest, size) = input.finish();
347
348            if let Err(error) = copy_result {
349                let _ = remove_existing(target);
350                return Err(error);
351            }
352            let expected = file
353                .sha256
354                .as_ref()
355                .expect("validated regular files have a digest");
356            if let Err(error) = ensure_matches_plan(source, expected, file.size, digest, size) {
357                let _ = remove_existing(target);
358                return Err(error);
359            }
360            Ok(size)
361        }
362        _ => unreachable!("validated regular files have exactly one content source"),
363    }
364}
365
366/// Refuse the filesystem root as an output even when `--clean` was not
367/// requested. Materializing there would turn logical bundle destinations such
368/// as `/app/server` into writes to the host filesystem.
369fn guard_output(output: &Path) -> Result<()> {
370    // `canonicalize` cannot resolve a path that has not been created yet, so
371    // normalize an absolute spelling first. Otherwise `/new/..` would evade
372    // the guard and become `/` after `create_dir_all`.
373    let absolute = std::path::absolute(output).map_err(|e| io(output, e))?;
374    let lexical = crate::paths::normalize_absolute(&absolute);
375    let resolved = output.canonicalize().unwrap_or(lexical);
376    if resolved.parent().is_none() {
377        return Err(Error::Config {
378            message: format!(
379                "refusing to materialize a bundle at filesystem root `{}`",
380                resolved.display()
381            ),
382        });
383    }
384    Ok(())
385}
386
387/// Refuse to delete something that is obviously not a previous bundle: `--clean`
388/// is meant to replace an output directory, not to wipe a filesystem.
389fn guard_clean(output: &Path) -> Result<()> {
390    let resolved = output
391        .canonicalize()
392        .unwrap_or_else(|_| output.to_path_buf());
393    if resolved.parent().is_none() {
394        return Err(Error::Config {
395            message: format!("refusing to --clean `{}`", resolved.display()),
396        });
397    }
398    Ok(())
399}
400
401/// Remove whatever currently occupies `path`, without following symlinks.
402fn remove_existing(path: &Path) -> Result<()> {
403    match std::fs::symlink_metadata(path) {
404        // `is_dir` is false for a symlink to a directory, so the link itself is
405        // unlinked and its target is left alone.
406        Ok(metadata) if metadata.is_dir() => std::fs::remove_dir_all(path).map_err(|e| io(path, e)),
407        Ok(_) => std::fs::remove_file(path).map_err(|e| io(path, e)),
408        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
409        Err(e) => Err(io(path, e)),
410    }
411}
412
413/// Reject any existing symlink between the output root and `path`. Checking
414/// only the immediate parent lets `create_dir_all` follow a pre-existing
415/// symlink higher in the path.
416fn has_symlinked_ancestor(output: &Path, path: &Path) -> bool {
417    // Every step drops one component, so the walk is bounded by the depth of
418    // the path it starts from.
419    let depth = path.components().count();
420    let mut steps = 0usize;
421    let mut current = path;
422    while current != output {
423        steps += 1;
424        assert!(steps <= depth);
425
426        match std::fs::symlink_metadata(current) {
427            Ok(metadata) if metadata.is_symlink() => return true,
428            Ok(_) | Err(_) => {}
429        }
430        let Some(parent) = current.parent() else {
431            break;
432        };
433        current = parent;
434    }
435    false
436}
437
438/// What was written, counted as it was written.
439#[derive(Debug, Default, Clone, Copy)]
440pub struct RootFsReport {
441    pub files: u32,
442    pub directories: u32,
443    pub symlinks: u32,
444    pub bytes: u64,
445}
446
447fn set_mode(path: &Path, mode: u32) -> Result<()> {
448    use std::os::unix::fs::PermissionsExt;
449
450    assert!(mode <= 0o7777);
451    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).map_err(|e| io(path, e))
452}
453
454fn set_permissions_from(path: &Path, metadata: &std::fs::Metadata) -> Result<()> {
455    std::fs::set_permissions(path, metadata.permissions()).map_err(|e| io(path, e))
456}
457
458fn set_times_from(path: &Path, metadata: &std::fs::Metadata) {
459    let (Ok(accessed), Ok(modified), Ok(file)) = (
460        metadata.accessed(),
461        metadata.modified(),
462        std::fs::File::open(path),
463    ) else {
464        return;
465    };
466    let _ = file.set_times(
467        std::fs::FileTimes::new()
468            .set_accessed(accessed)
469            .set_modified(modified),
470    );
471}
472
473/// Pin access and modification times. Not every filesystem supports this, and
474/// symlink timestamps cannot be set through `std` at all, so this is
475/// best-effort: the tar backend is the byte-reproducible artifact.
476fn pin_times(path: &Path) {
477    let Ok(file) = std::fs::File::open(path) else {
478        return;
479    };
480    let Ok(time) = source_date_epoch() else {
481        return;
482    };
483    let _ = file.set_times(
484        std::fs::FileTimes::new()
485            .set_accessed(time)
486            .set_modified(time),
487    );
488}
489
490#[cfg(test)]
491mod tests {
492    use super::{
493        ensure_directory, guard_clean, guard_output, has_symlinked_ancestor, remove_existing,
494    };
495    use std::path::Path;
496
497    #[test]
498    fn detects_a_symlink_above_a_missing_parent() {
499        let temp = tempfile::tempdir().unwrap();
500        let output = temp.path().join("output");
501        let outside = temp.path().join("outside");
502        std::fs::create_dir_all(&output).unwrap();
503        std::fs::create_dir(&outside).unwrap();
504        std::os::unix::fs::symlink(&outside, output.join("nested")).unwrap();
505
506        assert!(has_symlinked_ancestor(
507            &output,
508            &output.join("nested/deeper")
509        ));
510    }
511
512    #[test]
513    fn removing_a_symlink_leaves_its_target_alone() {
514        let temp = tempfile::tempdir().unwrap();
515        let target = temp.path().join("target");
516        let link = temp.path().join("link");
517        std::fs::write(&target, b"keep me").unwrap();
518        std::os::unix::fs::symlink(&target, &link).unwrap();
519
520        remove_existing(&link).unwrap();
521        assert!(!link.exists());
522        assert_eq!(std::fs::read(&target).unwrap(), b"keep me");
523
524        // Removing something that is not there is not an error.
525        remove_existing(&link).unwrap();
526    }
527
528    #[test]
529    fn clean_refuses_to_delete_a_filesystem_root() {
530        let err = guard_clean(Path::new("/")).unwrap_err();
531        assert_eq!(err.code(), "E4001");
532        let temp = tempfile::tempdir().unwrap();
533        guard_clean(&temp.path().join("rootfs")).unwrap();
534    }
535
536    #[test]
537    fn materialization_refuses_a_filesystem_root() {
538        let err = guard_output(Path::new("/")).unwrap_err();
539        assert_eq!(err.code(), "E4001");
540        let err = guard_output(Path::new("/new-rootfs/..")).unwrap_err();
541        assert_eq!(err.code(), "E4001");
542        let temp = tempfile::tempdir().unwrap();
543        guard_output(&temp.path().join("rootfs")).unwrap();
544    }
545
546    #[test]
547    fn an_output_root_symlink_is_rejected() {
548        let temp = tempfile::tempdir().unwrap();
549        let target = temp.path().join("target");
550        let output = temp.path().join("output");
551        std::fs::create_dir(&target).unwrap();
552        std::os::unix::fs::symlink(&target, &output).unwrap();
553
554        assert!(ensure_directory(&output).is_err());
555    }
556}