Skip to main content

elfpak_core/rootfs/
archive.rs

1//! Deterministic tar output.
2//!
3//! The archive is written straight from the [`BundlePlan`], never from a
4//! materialized directory, so a tar and a rootfs built from the same plan
5//! describe exactly the same tree. Every metadata field that could vary between
6//! machines is pinned: ownership is root:root, timestamps come from
7//! `SOURCE_DATE_EPOCH`, and entries are emitted in plan order.
8
9use crate::{
10    error::{Error, Result, io},
11    hash::{HashingReader, ensure_matches_plan},
12    plan::{BundlePlan, PlannedFile, PlannedFileKind},
13};
14use std::{
15    io::Write,
16    path::{Path, PathBuf},
17};
18use tar::{EntryType, Header};
19
20#[derive(Debug)]
21pub struct TarBuilder {
22    path: PathBuf,
23}
24
25#[derive(Debug, Default, Clone, Copy)]
26pub struct TarReport {
27    pub files: u32,
28    pub directories: u32,
29    pub symlinks: u32,
30    /// Uncompressed payload, excluding tar headers and padding.
31    pub bytes: u64,
32}
33
34impl TarBuilder {
35    pub fn new(path: impl Into<PathBuf>) -> TarBuilder {
36        TarBuilder { path: path.into() }
37    }
38
39    pub fn apply(&self, plan: &BundlePlan) -> Result<TarReport> {
40        let parent = self
41            .path
42            .parent()
43            .filter(|parent| !parent.as_os_str().is_empty())
44            .unwrap_or_else(|| Path::new("."));
45        std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
46        let mut stage = tempfile::Builder::new()
47            .prefix(".elfpak-tar-")
48            .tempfile_in(parent)
49            .map_err(|e| io(parent, e))?;
50        set_output_permissions(stage.path(), &self.path)?;
51        let mut writer = tar::Builder::new(std::io::BufWriter::new(stage.as_file_mut()));
52        // Long paths and link targets get GNU extension records rather than
53        // being silently truncated.
54        writer.mode(tar::HeaderMode::Complete);
55
56        let mut report = TarReport::default();
57        let mtime = super::copy::source_date_epoch_secs()?;
58
59        for entry in &plan.files {
60            entry.assert_well_formed();
61            let name = archive_name(&entry.destination)?;
62
63            let mut header = pinned_header(entry.mode, mtime);
64
65            match entry.kind {
66                PlannedFileKind::Directory => {
67                    header.set_entry_type(EntryType::Directory);
68                    writer
69                        .append_data(&mut header, format!("{name}/"), std::io::empty())
70                        .map_err(|e| io(&self.path, e))?;
71                    report.directories += 1;
72                }
73                PlannedFileKind::Symlink => {
74                    let target = entry
75                        .link_target
76                        .clone()
77                        .expect("validated symlinks have a target");
78                    header.set_entry_type(EntryType::Symlink);
79                    writer
80                        .append_link(&mut header, &name, &target)
81                        .map_err(|e| io(&self.path, e))?;
82                    report.symlinks += 1;
83                }
84                _ => {
85                    header.set_entry_type(EntryType::Regular);
86                    append_regular(&mut writer, &mut header, &name, entry, &self.path)?;
87                    report.files += 1;
88                    report.bytes += entry.size;
89                }
90            }
91        }
92
93        writer.finish().map_err(|e| io(&self.path, e))?;
94        writer
95            .into_inner()
96            .map_err(|e| io(&self.path, e))?
97            .flush()
98            .map_err(|e| io(&self.path, e))?;
99        stage.as_file().sync_all().map_err(|e| io(&self.path, e))?;
100
101        let entries = report.files + report.directories + report.symlinks;
102        assert_eq!(
103            entries as usize,
104            plan.files.len(),
105            "every entry is archived"
106        );
107        stage
108            .persist(&self.path)
109            .map_err(|e| io(&self.path, e.error))?;
110        Ok(report)
111    }
112}
113
114fn set_output_permissions(stage: &Path, destination: &Path) -> Result<()> {
115    use std::os::unix::fs::PermissionsExt;
116
117    let permissions = std::fs::metadata(destination)
118        .map(|metadata| metadata.permissions())
119        .unwrap_or_else(|_| std::fs::Permissions::from_mode(0o644));
120    std::fs::set_permissions(stage, permissions).map_err(|e| io(stage, e))
121}
122
123/// A header with ownership and timestamp pinned.
124fn pinned_header(mode: u32, mtime: u64) -> Header {
125    let mut header = Header::new_gnu();
126    header.set_uid(0);
127    header.set_gid(0);
128    header.set_mtime(mtime);
129    header.set_mode(mode);
130    header.set_size(0);
131    header
132}
133
134fn append_regular<W: Write>(
135    writer: &mut tar::Builder<W>,
136    header: &mut Header,
137    name: &str,
138    entry: &PlannedFile,
139    archive: &Path,
140) -> Result<()> {
141    match (&entry.content, &entry.source) {
142        (Some(content), None) => {
143            assert_eq!(content.len() as u64, entry.size);
144            header.set_size(content.len() as u64);
145            writer
146                .append_data(header, name, content.as_slice())
147                .map_err(|e| io(archive, e))
148        }
149        (None, Some(source)) => {
150            let file = std::fs::File::open(source).map_err(|e| io(source, e))?;
151            let mut reader = HashingReader::new(std::io::BufReader::new(file));
152            header.set_size(entry.size);
153            let append_result = writer
154                .append_data(header, name, &mut reader)
155                .map_err(|e| io(archive, e));
156            // `tar` stops after the header's declared size. Continue reading
157            // so a source that grew is detected too, rather than silently
158            // accepting its original-size prefix.
159            let drain_result =
160                std::io::copy(&mut reader, &mut std::io::sink()).map_err(|e| io(source, e));
161            let (digest, size) = reader.finish();
162            let expected = entry
163                .sha256
164                .as_ref()
165                .expect("validated regular files have a digest");
166            ensure_matches_plan(source, expected, entry.size, digest, size)?;
167            append_result?;
168            drain_result?;
169            Ok(())
170        }
171        _ => unreachable!("validated regular files have exactly one content source"),
172    }
173}
174
175/// Tar entries are relative paths: `/app/server` becomes `app/server`.
176fn archive_name(destination: &Path) -> Result<String> {
177    let normalized = crate::paths::normalize_absolute(destination);
178    let relative = normalized.strip_prefix("/").unwrap_or(&normalized);
179    relative
180        .to_str()
181        .map(str::to_string)
182        .filter(|name| !name.is_empty())
183        .ok_or_else(|| Error::PathEscape {
184            path: destination.to_path_buf(),
185            kind: "archive",
186        })
187}