1use 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
27pub 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 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
115fn 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
128fn 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 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 #[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 #[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 #[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}