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        super::set_output_permissions(stage.path(), &self.path)?;
51        let report = {
52            let writer = std::io::BufWriter::new(stage.as_file_mut());
53            let (_, report) = self.write_to(writer, plan)?;
54            report
55        };
56        stage.as_file().sync_all().map_err(|e| io(&self.path, e))?;
57        stage
58            .persist(&self.path)
59            .map_err(|e| io(&self.path, e.error))?;
60        Ok(report)
61    }
62
63    /// Write the deterministic rootfs tar stream to an arbitrary writer.
64    pub fn write_to<W: Write>(&self, writer: W, plan: &BundlePlan) -> Result<(W, TarReport)> {
65        let mut writer = tar::Builder::new(writer);
66        // Long paths and link targets get GNU extension records rather than
67        // being silently truncated.
68        writer.mode(tar::HeaderMode::Complete);
69
70        let mut report = TarReport::default();
71        let mtime = super::copy::source_date_epoch_secs()?;
72
73        for entry in &plan.files {
74            entry.assert_well_formed();
75            let name = archive_name(&entry.destination)?;
76
77            let mut header = pinned_header(entry.mode, mtime);
78
79            match entry.kind {
80                PlannedFileKind::Directory => {
81                    header.set_entry_type(EntryType::Directory);
82                    writer
83                        .append_data(&mut header, format!("{name}/"), std::io::empty())
84                        .map_err(|e| io(&self.path, e))?;
85                    report.directories += 1;
86                }
87                PlannedFileKind::Symlink => {
88                    let target = entry
89                        .link_target
90                        .clone()
91                        .expect("validated symlinks have a target");
92                    header.set_entry_type(EntryType::Symlink);
93                    writer
94                        .append_link(&mut header, &name, &target)
95                        .map_err(|e| io(&self.path, e))?;
96                    report.symlinks += 1;
97                }
98                _ => {
99                    header.set_entry_type(EntryType::Regular);
100                    append_regular(&mut writer, &mut header, &name, entry, &self.path)?;
101                    report.files += 1;
102                    report.bytes += entry.size;
103                }
104            }
105        }
106
107        let entries = report.files + report.directories + report.symlinks;
108        assert_eq!(
109            entries as usize,
110            plan.files.len(),
111            "every entry is archived"
112        );
113        writer.finish().map_err(|e| io(&self.path, e))?;
114        let mut writer = writer.into_inner().map_err(|e| io(&self.path, e))?;
115        writer.flush().map_err(|e| io(&self.path, e))?;
116        Ok((writer, report))
117    }
118}
119
120/// A header with ownership and timestamp pinned.
121fn pinned_header(mode: u32, mtime: u64) -> Header {
122    let mut header = Header::new_gnu();
123    header.set_uid(0);
124    header.set_gid(0);
125    header.set_mtime(mtime);
126    header.set_mode(mode);
127    header.set_size(0);
128    header
129}
130
131fn append_regular<W: Write>(
132    writer: &mut tar::Builder<W>,
133    header: &mut Header,
134    name: &str,
135    entry: &PlannedFile,
136    archive: &Path,
137) -> Result<()> {
138    match (&entry.content, &entry.source) {
139        (Some(content), None) => {
140            assert_eq!(content.len() as u64, entry.size);
141            header.set_size(content.len() as u64);
142            writer
143                .append_data(header, name, content.as_slice())
144                .map_err(|e| io(archive, e))
145        }
146        (None, Some(source)) => {
147            let file = std::fs::File::open(source).map_err(|e| io(source, e))?;
148            let mut reader = HashingReader::new(std::io::BufReader::new(file));
149            header.set_size(entry.size);
150            let append_result = writer
151                .append_data(header, name, &mut reader)
152                .map_err(|e| io(archive, e));
153            // `tar` stops after the header's declared size. Continue reading
154            // so a source that grew is detected too, rather than silently
155            // accepting its original-size prefix.
156            let drain_result =
157                std::io::copy(&mut reader, &mut std::io::sink()).map_err(|e| io(source, e));
158            let (digest, size) = reader.finish();
159            let expected = entry
160                .sha256
161                .as_ref()
162                .expect("validated regular files have a digest");
163            ensure_matches_plan(source, expected, entry.size, digest, size)?;
164            append_result?;
165            drain_result?;
166            Ok(())
167        }
168        _ => unreachable!("validated regular files have exactly one content source"),
169    }
170}
171
172/// Tar entries are relative paths: `/app/server` becomes `app/server`.
173///
174/// A destination that is not valid Unicode is reported for what it is. The
175/// directory backend writes such a name without complaint, so calling it a path
176/// escape would send the reader looking for a containment bug that is not there.
177fn archive_name(destination: &Path) -> Result<String> {
178    let normalized = crate::paths::normalize_absolute(destination);
179    let relative = normalized.strip_prefix("/").unwrap_or(&normalized);
180    if relative.as_os_str().is_empty() {
181        return Err(Error::PathEscape {
182            path: destination.to_path_buf(),
183            kind: "archive",
184        });
185    }
186    relative
187        .to_str()
188        .map(str::to_string)
189        .ok_or_else(|| Error::Config {
190            message: format!(
191                "`{}` is not valid Unicode and cannot be named in a tar or OCI archive; \
192             directory output has no such restriction",
193                destination.display()
194            ),
195        })
196}