Skip to main content

onelf_sysroot/
archive.rs

1//! Materializing a sysroot from a rootfs archive.
2//!
3//! No privileges are needed: ownership is not restored, and setuid and
4//! setgid bits are dropped, since the bundle never needs either. Such a
5//! file also gains owner read, because a mode like `---s--x---` leaves
6//! the packer unable to read the sysroot it just wrote. Every
7//! entry path is checked before the entry is written, so an archive cannot
8//! reach outside the directory it is unpacked into.
9//!
10//! The archive is unpacked into a sibling directory that takes the
11//! target's name only once every entry is in place. A refused or broken
12//! archive leaves nothing at the target, so a later build never mistakes
13//! a truncated rootfs for a materialized one.
14//!
15//! Directory permissions are applied last. A rootfs holds directories a
16//! distribution made read-only, and giving one its archived mode as soon
17//! as it appears locks out every entry underneath it that has not been
18//! written yet.
19
20use std::fs::File;
21use std::io::{self, BufReader, Read};
22use std::os::unix::fs::PermissionsExt;
23use std::path::{Component, Path, PathBuf};
24
25const ZSTD_MAGIC: [u8; 4] = [0x28, 0xb5, 0x2f, 0xfd];
26
27/// Unpack the `.tar` or `.tar.zst` archive at `archive` into `into`,
28/// which must not exist yet or be empty.
29pub fn materialize(archive: &Path, into: &Path) -> io::Result<()> {
30    let Some(name) = into.file_name() else {
31        return Err(io::Error::new(
32            io::ErrorKind::InvalidInput,
33            format!(
34                "{}: not a directory name to materialize into",
35                into.display()
36            ),
37        ));
38    };
39    let staging = into.with_file_name(format!(
40        ".{}.partial-{}",
41        name.to_string_lossy(),
42        std::process::id()
43    ));
44    let _ = std::fs::remove_dir_all(&staging);
45    let result = unpack(archive, &staging).and_then(|()| {
46        std::fs::rename(&staging, into).map_err(|e| {
47            io::Error::new(
48                e.kind(),
49                format!("{}: cannot put the sysroot in place: {e}", into.display()),
50            )
51        })
52    });
53    if result.is_err() {
54        let _ = std::fs::remove_dir_all(&staging);
55    }
56    result
57}
58
59fn unpack(archive: &Path, into: &Path) -> io::Result<()> {
60    let mut file = BufReader::new(File::open(archive)?);
61    let mut magic = [0u8; 4];
62    let n = file.read(&mut magic)?;
63    let reader: Box<dyn Read> = if n == 4 && magic == ZSTD_MAGIC {
64        let file = File::open(archive)?;
65        Box::new(zstd::stream::read::Decoder::new(file)?)
66    } else {
67        Box::new(BufReader::new(File::open(archive)?))
68    };
69
70    std::fs::create_dir_all(into)?;
71    let mut deferred: Vec<(PathBuf, u32)> = Vec::new();
72    let mut tar = tar::Archive::new(reader);
73    tar.set_preserve_permissions(true);
74    tar.set_preserve_ownerships(false);
75    tar.set_unpack_xattrs(false);
76    for entry in tar.entries()? {
77        let mut entry = entry?;
78        let path = entry.path()?.into_owned();
79        if !is_contained(&path) {
80            return Err(io::Error::new(
81                io::ErrorKind::InvalidData,
82                format!("{}: entry escapes the target directory", path.display()),
83            ));
84        }
85        let placed = entry
86            .unpack_in(into)
87            .map_err(|e| io::Error::new(e.kind(), format!("{}: {}", path.display(), chain(&e))))?;
88        if !placed {
89            return Err(io::Error::new(
90                io::ErrorKind::InvalidData,
91                format!("{}: entry could not be placed", path.display()),
92            ));
93        }
94        let unpacked = into.join(&path);
95        let Ok(md) = std::fs::symlink_metadata(&unpacked) else {
96            continue;
97        };
98        let mode = md.permissions().mode();
99        if md.file_type().is_dir() && mode & 0o300 != 0o300 {
100            std::fs::set_permissions(&unpacked, std::fs::Permissions::from_mode(mode | 0o300))?;
101            deferred.push((unpacked, mode & 0o7777));
102        } else if md.file_type().is_file() && mode & 0o6000 != 0 {
103            let stripped = (mode & 0o777) | 0o400;
104            std::fs::set_permissions(&unpacked, std::fs::Permissions::from_mode(stripped))?;
105        }
106    }
107    // Deepest first, so a read-only parent is closed after its children.
108    deferred.sort_by(|a, b| b.0.cmp(&a.0));
109    for (dir, mode) in deferred {
110        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(mode))?;
111    }
112    Ok(())
113}
114
115/// An error and everything under it, joined. The tar crate reports the
116/// path it could not place and keeps the reason it could not as the
117/// source, which is the half worth reading.
118fn chain(e: &dyn std::error::Error) -> String {
119    let mut parts = vec![e.to_string()];
120    let mut source = e.source();
121    while let Some(inner) = source {
122        parts.push(inner.to_string());
123        source = inner.source();
124    }
125    parts.join(": ")
126}
127
128/// A relative path with no `..` and no root.
129fn is_contained(path: &Path) -> bool {
130    path.components().all(|c| match c {
131        Component::Normal(_) | Component::CurDir => true,
132        Component::ParentDir | Component::RootDir | Component::Prefix(_) => false,
133    })
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::db::fixture::temp_root;
140
141    fn archive_with(entries: &[(&str, &[u8], u32)], symlinks: &[(&str, &str)]) -> Vec<u8> {
142        let mut builder = tar::Builder::new(Vec::new());
143        for (path, data, mode) in entries {
144            let mut header = tar::Header::new_gnu();
145            header.set_size(data.len() as u64);
146            header.set_mode(*mode);
147            header.set_uid(0);
148            header.set_gid(0);
149            if path.contains("..") {
150                // The builder refuses such a name, which is exactly why the
151                // reader has to: written straight into the header instead.
152                let name = &mut header.as_old_mut().name;
153                name[..path.len()].copy_from_slice(path.as_bytes());
154                header.set_cksum();
155                builder.append(&header, *data).unwrap();
156            } else {
157                header.set_cksum();
158                builder.append_data(&mut header, path, *data).unwrap();
159            }
160        }
161        for (path, target) in symlinks {
162            let mut header = tar::Header::new_gnu();
163            header.set_entry_type(tar::EntryType::Symlink);
164            header.set_size(0);
165            header.set_mode(0o777);
166            header.set_cksum();
167            builder.append_link(&mut header, path, target).unwrap();
168        }
169        builder.into_inner().unwrap()
170    }
171
172    /// D-Bus ships its launch helper `---s--x---`. Dropping the setuid
173    /// bit alone would leave a file the packer cannot read out of the
174    /// sysroot it just wrote, so owner read comes with it.
175    #[test]
176    fn a_setuid_only_binary_is_left_readable() {
177        let root = temp_root("tarsuid");
178        let bytes = archive_with(
179            &[("usr/lib/dbus-daemon-launch-helper", b"elf", 0o4110)],
180            &[],
181        );
182        let archive = root.join("root.tar");
183        std::fs::write(&archive, &bytes).unwrap();
184        let into = root.join("sysroot");
185        materialize(&archive, &into).unwrap();
186
187        let helper = into.join("usr/lib/dbus-daemon-launch-helper");
188        assert_eq!(std::fs::read(&helper).unwrap(), b"elf");
189        let mode = std::fs::metadata(&helper).unwrap().permissions().mode() & 0o7777;
190        assert_eq!(mode, 0o510, "setuid dropped, owner read added");
191        let _ = std::fs::remove_dir_all(&root);
192    }
193
194    /// A distribution ships directories nothing may write to, and their
195    /// contents come after them in the archive. Applying the mode as
196    /// soon as the directory appears makes the rest of it unwritable, so
197    /// the mode waits until the end.
198    #[test]
199    fn a_read_only_directory_still_receives_its_contents() {
200        let root = temp_root("tarro");
201        let mut builder = tar::Builder::new(Vec::new());
202        let mut header = tar::Header::new_gnu();
203        header.set_entry_type(tar::EntryType::Directory);
204        header.set_size(0);
205        header.set_mode(0o555);
206        header.set_cksum();
207        builder
208            .append_data(&mut header, "etc/certs/", &[][..])
209            .unwrap();
210        let mut header = tar::Header::new_gnu();
211        header.set_size(3);
212        header.set_mode(0o444);
213        header.set_cksum();
214        builder
215            .append_data(&mut header, "etc/certs/ca.pem", &b"pem"[..])
216            .unwrap();
217        let mut header = tar::Header::new_gnu();
218        header.set_entry_type(tar::EntryType::Symlink);
219        header.set_size(0);
220        header.set_mode(0o777);
221        header.set_cksum();
222        builder
223            .append_link(&mut header, "etc/certs/0a1b2c3d.0", "ca.pem")
224            .unwrap();
225        let archive = root.join("root.tar");
226        std::fs::write(&archive, builder.into_inner().unwrap()).unwrap();
227
228        let into = root.join("sysroot");
229        materialize(&archive, &into).unwrap();
230
231        assert_eq!(
232            std::fs::read(into.join("etc/certs/ca.pem")).unwrap(),
233            b"pem"
234        );
235        assert!(into.join("etc/certs/0a1b2c3d.0").is_symlink());
236        let mode = std::fs::metadata(into.join("etc/certs"))
237            .unwrap()
238            .permissions()
239            .mode();
240        assert_eq!(mode & 0o777, 0o555, "the archived mode is restored");
241        let _ = std::fs::remove_dir_all(&root);
242    }
243
244    #[test]
245    fn unpacks_files_modes_and_symlinks_without_ownership() {
246        let root = temp_root("tar");
247        let bytes = archive_with(
248            &[
249                ("usr/bin/app", b"#!/bin/sh\n", 0o4755),
250                ("usr/lib/libfoo.so.1", b"elf", 0o644),
251            ],
252            &[("usr/lib/libfoo.so", "libfoo.so.1")],
253        );
254        let archive = root.join("root.tar");
255        std::fs::write(&archive, &bytes).unwrap();
256        let into = root.join("sysroot");
257        materialize(&archive, &into).unwrap();
258        assert_eq!(
259            std::fs::read(into.join("usr/bin/app")).unwrap(),
260            b"#!/bin/sh\n"
261        );
262        let mode = std::fs::metadata(into.join("usr/bin/app"))
263            .unwrap()
264            .permissions()
265            .mode()
266            & 0o7777;
267        assert_eq!(mode, 0o755, "setuid is dropped, the rest is kept");
268        assert_eq!(
269            std::fs::read_link(into.join("usr/lib/libfoo.so")).unwrap(),
270            Path::new("libfoo.so.1")
271        );
272
273        let zst = root.join("root.tar.zst");
274        std::fs::write(&zst, zstd::encode_all(&bytes[..], 3).unwrap()).unwrap();
275        let into = root.join("sysroot2");
276        materialize(&zst, &into).unwrap();
277        assert!(into.join("usr/lib/libfoo.so.1").is_file());
278    }
279
280    #[test]
281    fn a_traversing_entry_is_refused_before_anything_is_written() {
282        let root = temp_root("tarescape");
283        let bytes = archive_with(&[("../escape", b"x", 0o644)], &[]);
284        let archive = root.join("bad.tar");
285        std::fs::write(&archive, &bytes).unwrap();
286        let into = root.join("sysroot");
287        let err = materialize(&archive, &into).unwrap_err();
288        assert!(err.to_string().contains("escape"), "{err}");
289        assert!(!root.join("escape").exists());
290    }
291
292    /// An archive that fails partway leaves no sysroot behind, so the
293    /// next build cannot take a truncated rootfs for a complete one.
294    #[test]
295    fn a_failed_archive_leaves_nothing_at_the_target() {
296        let root = temp_root("tarpartial");
297        let bytes = archive_with(
298            &[("usr/bin/app", b"elf", 0o755), ("../escape", b"x", 0o644)],
299            &[],
300        );
301        let archive = root.join("bad.tar");
302        std::fs::write(&archive, &bytes).unwrap();
303        let into = root.join("sysroot");
304        assert!(materialize(&archive, &into).is_err());
305        assert!(!into.exists(), "nothing is left at the target");
306        let left: Vec<_> = std::fs::read_dir(&*root)
307            .unwrap()
308            .flatten()
309            .map(|e| e.file_name())
310            .collect();
311        assert_eq!(left, ["bad.tar"], "the staging directory is gone");
312    }
313}