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 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
120fn set_output_permissions(stage: &Path, destination: &Path) -> Result<()> {
121    use std::os::unix::fs::PermissionsExt;
122
123    let permissions = std::fs::metadata(destination)
124        .map(|metadata| metadata.permissions())
125        .unwrap_or_else(|_| std::fs::Permissions::from_mode(0o644));
126    std::fs::set_permissions(stage, permissions).map_err(|e| io(stage, e))
127}
128
129/// A header with ownership and timestamp pinned.
130fn pinned_header(mode: u32, mtime: u64) -> Header {
131    let mut header = Header::new_gnu();
132    header.set_uid(0);
133    header.set_gid(0);
134    header.set_mtime(mtime);
135    header.set_mode(mode);
136    header.set_size(0);
137    header
138}
139
140fn append_regular<W: Write>(
141    writer: &mut tar::Builder<W>,
142    header: &mut Header,
143    name: &str,
144    entry: &PlannedFile,
145    archive: &Path,
146) -> Result<()> {
147    match (&entry.content, &entry.source) {
148        (Some(content), None) => {
149            assert_eq!(content.len() as u64, entry.size);
150            header.set_size(content.len() as u64);
151            writer
152                .append_data(header, name, content.as_slice())
153                .map_err(|e| io(archive, e))
154        }
155        (None, Some(source)) => {
156            let file = std::fs::File::open(source).map_err(|e| io(source, e))?;
157            let mut reader = HashingReader::new(std::io::BufReader::new(file));
158            header.set_size(entry.size);
159            let append_result = writer
160                .append_data(header, name, &mut reader)
161                .map_err(|e| io(archive, e));
162            // `tar` stops after the header's declared size. Continue reading
163            // so a source that grew is detected too, rather than silently
164            // accepting its original-size prefix.
165            let drain_result =
166                std::io::copy(&mut reader, &mut std::io::sink()).map_err(|e| io(source, e));
167            let (digest, size) = reader.finish();
168            let expected = entry
169                .sha256
170                .as_ref()
171                .expect("validated regular files have a digest");
172            ensure_matches_plan(source, expected, entry.size, digest, size)?;
173            append_result?;
174            drain_result?;
175            Ok(())
176        }
177        _ => unreachable!("validated regular files have exactly one content source"),
178    }
179}
180
181/// Tar entries are relative paths: `/app/server` becomes `app/server`.
182fn archive_name(destination: &Path) -> Result<String> {
183    let normalized = crate::paths::normalize_absolute(destination);
184    let relative = normalized.strip_prefix("/").unwrap_or(&normalized);
185    relative
186        .to_str()
187        .map(str::to_string)
188        .filter(|name| !name.is_empty())
189        .ok_or_else(|| Error::PathEscape {
190            path: destination.to_path_buf(),
191            kind: "archive",
192        })
193}