forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
32.1 KiB
1//! Unpacks OCI image layers into a rootfs directory.
2//!
3//! Layers come from untrusted registries, so every rule here is about never writing outside the
4//! rootfs. Paths are normalized to plain components first. Each parent component is then walked
5//! one level at a time with `symlink_metadata`, and an entry is rejected when a parent is a
6//! symlink or any other non-directory. Nothing is ever written through a link. Files are created
7//! with `create_new`, after the old leaf has been removed, so an existing symlink is replaced
8//! rather than followed.
9//!
10//! Ownership depends on [`Ownership`]. `Flatten` gives every file to the unpacking user, which is
11//! what the single-uid user namespace expects. `Preserve` gives every entry the owner from its tar
12//! header, with ids mapped into the user namespace. Either way setuid and setgid bits are stripped.
13//! Extended attributes are never written, so file capabilities from the image do not apply. Device
14//! nodes and fifos are skipped. Directories are kept owner-writable (`| 0o700`) while layers are
15//! applied. `Preserve` sets their exact modes at the end, deepest first.
16
17use std::collections::{HashMap, HashSet};
18use std::ffi::{OsStr, OsString};
19use std::fs::{self, File, OpenOptions, Permissions};
20use std::io::{BufReader, Read, Seek, SeekFrom};
21use std::os::unix::ffi::OsStrExt;
22use std::os::unix::fs::{PermissionsExt, lchown, symlink};
23use std::path::{Path, PathBuf};
24
25use flate2::read::GzDecoder;
26use serde::{Deserialize, Serialize};
27use tar::{Archive, EntryType};
28
29const GZIP_MAGIC: &[u8] = &[0x1f, 0x8b];
30const ZSTD_MAGIC: &[u8] = &[0x28, 0xb5, 0x2f, 0xfd];
31const WHITEOUT_PREFIX: &[u8] = b".wh.";
32/// The opaque marker, after the `.wh.` prefix has been stripped.
33const OPAQUE_MARKER: &[u8] = b".wh..opq";
34const COPY_BUF: usize = 64 * 1024;
35
36/// Errors from unpacking. Entries that fail the path rules are not errors: they are counted in
37/// `UnpackStats::rejected` and skipped.
38#[derive(Debug, thiserror::Error)]
39pub enum UnpackError {
40 #[error("unpack I/O error: {0}")]
41 Io(#[from] std::io::Error),
42 #[error("unpacked size exceeds the {limit} byte limit")]
43 TooLarge { limit: u64 },
44 /// The `__sandbox-unpack` helper failed. The message is its stderr, or a parse error.
45 #[error("unpack helper failed: {0}")]
46 Helper(String),
47}
48
49/// What unpacking did, for logging.
50#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct UnpackStats {
52 pub files: u64,
53 pub dirs: u64,
54 pub symlinks: u64,
55 pub hardlinks: u64,
56 pub whiteouts: u64,
57 /// Device nodes, fifos and entry types that are not supported.
58 pub skipped: u64,
59 /// Entries whose path or link target failed the containment rules.
60 pub rejected: u64,
61 /// Total bytes of regular file content written.
62 pub bytes: u64,
63 /// Owner ids (uid or gid) at or above the map size, which became 0. Only set by `Preserve`.
64 pub remapped_ids: u64,
65}
66
67/// How entries get their owner.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum Ownership {
70 /// Every entry belongs to the unpacking user. This is the single-uid behaviour.
71 Flatten,
72 /// Every entry gets its tar header owner, with ids mapped into a user namespace of `size` ids.
73 /// Ids `>= size` become 0 and are counted in `UnpackStats::remapped_ids`.
74 Preserve { size: u32 },
75}
76
77/// Maps a tar owner id into a user namespace of `size` ids. Ids below `size` are kept, and the
78/// rest become 0. The flag is true when the id was changed.
79pub fn map_id(id: u64, size: u32) -> (u32, bool) {
80 match u32::try_from(id) {
81 Ok(id) if id < size => (id, false),
82 _ => (0, true),
83 }
84}
85
86/// Unpacks `layers` in order into `rootfs`, which must already exist and be empty. Each layer may
87/// be a plain, gzip or zstd tar; the compression is detected from the first bytes. `max_bytes` caps
88/// the total regular file content across all layers. `ownership` decides who owns the entries.
89pub fn unpack_layers(
90 rootfs: &Path,
91 layers: &[PathBuf],
92 max_bytes: u64,
93 ownership: Ownership,
94) -> Result<UnpackStats, UnpackError> {
95 let mut unpacker = Unpacker {
96 root: rootfs.to_path_buf(),
97 limit: max_bytes,
98 remaining: max_bytes,
99 created: HashSet::new(),
100 ownership,
101 owner: None,
102 dir_modes: HashMap::new(),
103 stats: UnpackStats::default(),
104 };
105 for layer in layers {
106 unpacker.created.clear();
107 unpacker.unpack_layer(layer)?;
108 }
109 unpacker.finish_dir_modes()?;
110 unpacker.stats.bytes = max_bytes - unpacker.remaining;
111 Ok(unpacker.stats)
112}
113
114struct Unpacker {
115 root: PathBuf,
116 limit: u64,
117 remaining: u64,
118 /// Relative paths written by the current layer. Opaque markers keep these and drop the rest.
119 created: HashSet<PathBuf>,
120 ownership: Ownership,
121 /// The owner of the entry being applied, with ids mapped. None when flattening.
122 owner: Option<(u32, u32)>,
123 /// Exact directory modes for `Preserve`, keyed by path. Set by `finish_dir_modes`.
124 dir_modes: HashMap<PathBuf, u32>,
125 stats: UnpackStats,
126}
127
128impl Unpacker {
129 fn unpack_layer(&mut self, layer: &Path) -> Result<(), UnpackError> {
130 let mut file = File::open(layer)?;
131 let mut magic = Vec::with_capacity(4);
132 (&mut file).take(4).read_to_end(&mut magic)?;
133 file.seek(SeekFrom::Start(0))?;
134 if magic.starts_with(GZIP_MAGIC) {
135 self.unpack_stream(GzDecoder::new(BufReader::new(file)))
136 } else if magic.starts_with(ZSTD_MAGIC) {
137 let decoder = zstd::stream::read::Decoder::new(BufReader::new(file))?;
138 self.unpack_stream(decoder)
139 } else {
140 self.unpack_stream(BufReader::new(file))
141 }
142 }
143
144 fn unpack_stream<R: Read>(&mut self, reader: R) -> Result<(), UnpackError> {
145 let mut archive = Archive::new(reader);
146 for entry in archive.entries()? {
147 let mut entry = entry?;
148 let kind = entry.header().entry_type();
149 // Keep the permission bits but never setuid, setgid. Sticky bit is kept.
150 let mode = entry.header().mode()? & 0o1777;
151 let size = entry.header().size()?;
152 let raw_path = entry.path_bytes().into_owned();
153 let raw_link = entry.link_name_bytes().map(|link| link.into_owned());
154 let Some(comps) = normalize(&raw_path) else {
155 self.stats.rejected += 1;
156 continue;
157 };
158 if comps.is_empty() {
159 // "." or "./" is the rootfs itself.
160 continue;
161 }
162 self.owner = self.owner_of(entry.header())?;
163 self.apply(&comps, kind, mode, size, raw_link.as_deref(), &mut entry)?;
164 }
165 Ok(())
166 }
167
168 /// The owner for an entry, with ids mapped. None when flattening.
169 fn owner_of(&mut self, header: &tar::Header) -> Result<Option<(u32, u32)>, UnpackError> {
170 let Ownership::Preserve { size } = self.ownership else {
171 return Ok(None);
172 };
173 let (uid, uid_remapped) = map_id(header.uid()?, size);
174 let (gid, gid_remapped) = map_id(header.gid()?, size);
175 self.stats.remapped_ids += u64::from(uid_remapped) + u64::from(gid_remapped);
176 Ok(Some((uid, gid)))
177 }
178
179 /// Gives `path` the owner of the current entry, without following a symlink. Does nothing when
180 /// flattening.
181 fn own(&self, path: &Path) -> Result<(), UnpackError> {
182 if let Some((uid, gid)) = self.owner {
183 lchown(path, Some(uid), Some(gid))?;
184 }
185 Ok(())
186 }
187
188 /// Sets each directory to the exact mode it was last given, deepest first. A path that is no
189 /// longer a directory, because a later layer removed or replaced it, is skipped.
190 fn finish_dir_modes(&mut self) -> Result<(), UnpackError> {
191 let mut dirs: Vec<(PathBuf, u32)> = self.dir_modes.drain().collect();
192 dirs.sort_by_key(|(path, _)| std::cmp::Reverse(path.components().count()));
193 for (path, mode) in dirs {
194 match fs::symlink_metadata(&path) {
195 Ok(meta) if meta.is_dir() => {
196 fs::set_permissions(&path, Permissions::from_mode(mode))?;
197 }
198 Ok(_) => {}
199 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
200 Err(err) => return Err(err.into()),
201 }
202 }
203 Ok(())
204 }
205
206 fn apply<R: Read>(
207 &mut self,
208 comps: &[OsString],
209 kind: EntryType,
210 mode: u32,
211 size: u64,
212 link: Option<&[u8]>,
213 data: &mut R,
214 ) -> Result<(), UnpackError> {
215 let Some((leaf, parents)) = comps.split_last() else {
216 return Ok(());
217 };
218 let rel: PathBuf = comps.iter().collect();
219
220 if let Some(name) = leaf.as_bytes().strip_prefix(WHITEOUT_PREFIX) {
221 return self.whiteout(parents, name);
222 }
223
224 match kind {
225 EntryType::Directory => {
226 let Some(parent) = self.resolve_dir(parents, true)? else {
227 return self.reject();
228 };
229 let path = parent.join(leaf);
230 match fs::symlink_metadata(&path) {
231 Ok(meta) if meta.is_dir() => {}
232 Ok(_) => {
233 fs::remove_file(&path)?;
234 fs::create_dir(&path)?;
235 }
236 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
237 fs::create_dir(&path)?;
238 }
239 Err(err) => return Err(err.into()),
240 }
241 self.own(&path)?;
242 fs::set_permissions(&path, Permissions::from_mode(mode | 0o700))?;
243 if matches!(self.ownership, Ownership::Preserve { .. }) {
244 self.dir_modes.insert(path, mode);
245 }
246 self.created.insert(rel);
247 self.stats.dirs += 1;
248 }
249 EntryType::Regular | EntryType::Continuous => {
250 if size > self.remaining {
251 return Err(UnpackError::TooLarge { limit: self.limit });
252 }
253 let Some(parent) = self.resolve_dir(parents, true)? else {
254 return self.reject();
255 };
256 let path = parent.join(leaf);
257 remove_path(&path)?;
258 let mut file = OpenOptions::new()
259 .write(true)
260 .create_new(true)
261 .open(&path)?;
262 self.copy_capped(data, &mut file)?;
263 self.own(&path)?;
264 file.set_permissions(Permissions::from_mode(mode))?;
265 self.created.insert(rel);
266 self.stats.files += 1;
267 }
268 EntryType::Symlink => {
269 // The target is stored as is. It is never followed while unpacking.
270 let Some(target) = link.filter(|target| !target.contains(&0)) else {
271 return self.reject();
272 };
273 let Some(parent) = self.resolve_dir(parents, true)? else {
274 return self.reject();
275 };
276 let path = parent.join(leaf);
277 remove_path(&path)?;
278 symlink(OsStr::from_bytes(target), &path)?;
279 self.own(&path)?;
280 self.created.insert(rel);
281 self.stats.symlinks += 1;
282 }
283 EntryType::Link => {
284 // A hardlink must name a regular file inside the rootfs.
285 let Some(target) = link.and_then(normalize) else {
286 return self.reject();
287 };
288 let Some((target_leaf, target_parents)) = target.split_last() else {
289 return self.reject();
290 };
291 let Some(target_dir) = self.resolve_dir(target_parents, false)? else {
292 return self.reject();
293 };
294 let source = target_dir.join(target_leaf);
295 let is_file = matches!(fs::symlink_metadata(&source), Ok(meta) if meta.is_file());
296 if !is_file {
297 return self.reject();
298 }
299 let Some(parent) = self.resolve_dir(parents, true)? else {
300 return self.reject();
301 };
302 let path = parent.join(leaf);
303 if path == source {
304 return self.reject();
305 }
306 remove_path(&path)?;
307 fs::hard_link(&source, &path)?;
308 // A hardlink shares its inode, so this also sets the owner of the source.
309 self.own(&path)?;
310 self.created.insert(rel);
311 self.stats.hardlinks += 1;
312 }
313 // Devices, fifos, sparse files and anything else. Skipped and counted.
314 _ => self.stats.skipped += 1,
315 }
316 Ok(())
317 }
318
319 /// Handles `.wh.<name>` (delete `<name>`) and `.wh..wh..opq` (clear the directory's contents
320 /// from earlier layers).
321 fn whiteout(&mut self, parents: &[OsString], name: &[u8]) -> Result<(), UnpackError> {
322 // A whiteout under a missing or linked parent has nothing to delete.
323 let Some(dir) = self.resolve_dir(parents, false)? else {
324 return Ok(());
325 };
326 if name == OPAQUE_MARKER {
327 let rel_dir: PathBuf = parents.iter().collect();
328 self.clear_dir(&dir, &rel_dir)?;
329 } else {
330 if name.is_empty() || name == b"." || name == b".." || name.contains(&0) {
331 return self.reject();
332 }
333 remove_path(&dir.join(OsStr::from_bytes(name)))?;
334 }
335 self.stats.whiteouts += 1;
336 Ok(())
337 }
338
339 /// Removes everything in `dir` that this layer did not write.
340 fn clear_dir(&mut self, dir: &Path, rel_dir: &Path) -> Result<(), UnpackError> {
341 for child in fs::read_dir(dir)? {
342 let child = child?;
343 let child_rel = rel_dir.join(child.file_name());
344 if self.created.contains(&child_rel) {
345 continue;
346 }
347 remove_path(&child.path())?;
348 }
349 Ok(())
350 }
351
352 /// Walks `parents` from the root, one component at a time. Missing directories are created
353 /// when `create` is set, and are otherwise treated as absent. Returns `None` if any component
354 /// is a symlink or any other non-directory, or is missing with `create` unset.
355 fn resolve_dir(
356 &mut self,
357 parents: &[OsString],
358 create: bool,
359 ) -> Result<Option<PathBuf>, UnpackError> {
360 let mut cur = self.root.clone();
361 let mut rel = PathBuf::new();
362 for comp in parents {
363 cur.push(comp);
364 rel.push(comp);
365 match fs::symlink_metadata(&cur) {
366 Ok(meta) if meta.is_dir() => {}
367 Ok(_) => return Ok(None),
368 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
369 if !create {
370 return Ok(None);
371 }
372 fs::create_dir(&cur)?;
373 fs::set_permissions(&cur, Permissions::from_mode(0o755))?;
374 self.created.insert(rel.clone());
375 }
376 Err(err) => return Err(err.into()),
377 }
378 }
379 Ok(Some(cur))
380 }
381
382 /// Copies `src` to `dst`, failing as soon as the budget would be exceeded.
383 fn copy_capped<R: Read>(&mut self, src: &mut R, dst: &mut File) -> Result<(), UnpackError> {
384 use std::io::Write;
385 let mut buf = vec![0u8; COPY_BUF];
386 loop {
387 let n = src.read(&mut buf)?;
388 if n == 0 {
389 return Ok(());
390 }
391 let n = n as u64;
392 if n > self.remaining {
393 return Err(UnpackError::TooLarge { limit: self.limit });
394 }
395 self.remaining -= n;
396 dst.write_all(&buf[..n as usize])?;
397 }
398 }
399
400 fn reject(&mut self) -> Result<(), UnpackError> {
401 self.stats.rejected += 1;
402 Ok(())
403 }
404}
405
406/// Turns an archive path into plain components. Returns `None` for absolute paths, paths with
407/// `..`, and paths with NUL bytes. `.` components are dropped, so `./a` and `a` are the same.
408fn normalize(raw: &[u8]) -> Option<Vec<OsString>> {
409 if raw.first() == Some(&b'/') || raw.contains(&0) {
410 return None;
411 }
412 let mut out = Vec::new();
413 for comp in Path::new(OsStr::from_bytes(raw)).components() {
414 match comp {
415 std::path::Component::Normal(name) => out.push(name.to_owned()),
416 std::path::Component::CurDir => {}
417 _ => return None,
418 }
419 }
420 Some(out)
421}
422
423/// Removes whatever is at `path` without following a symlink. Missing paths are fine.
424pub(super) fn remove_path(path: &Path) -> std::io::Result<()> {
425 match fs::symlink_metadata(path) {
426 Ok(meta) if meta.is_dir() => fs::remove_dir_all(path),
427 Ok(_) => fs::remove_file(path),
428 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
429 Err(err) => Err(err),
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use std::io::Write;
437 use std::os::unix::fs::MetadataExt;
438 use tar::{Builder, Header};
439
440 fn write_field(field: &mut [u8], value: &str) {
441 field[..value.len()].copy_from_slice(value.as_bytes());
442 }
443
444 /// Appends one entry with a raw name, so tests can write names that `tar` would refuse.
445 fn put(
446 builder: &mut Builder<Vec<u8>>,
447 kind: EntryType,
448 name: &str,
449 link: Option<&str>,
450 mode: u32,
451 data: &[u8],
452 ) {
453 let mut header = Header::new_old();
454 header.set_entry_type(kind);
455 header.set_mode(mode);
456 header.set_size(data.len() as u64);
457 write_field(&mut header.as_old_mut().name, name);
458 if let Some(link) = link {
459 write_field(&mut header.as_old_mut().linkname, link);
460 }
461 header.set_cksum();
462 builder.append(&header, data).expect("append");
463 }
464
465 fn file(builder: &mut Builder<Vec<u8>>, name: &str, data: &[u8]) {
466 put(builder, EntryType::Regular, name, None, 0o644, data);
467 }
468
469 fn dir(builder: &mut Builder<Vec<u8>>, name: &str) {
470 put(builder, EntryType::Directory, name, None, 0o755, b"");
471 }
472
473 fn tar_of(build: impl FnOnce(&mut Builder<Vec<u8>>)) -> Vec<u8> {
474 let mut builder = Builder::new(Vec::new());
475 build(&mut builder);
476 builder.into_inner().expect("finish tar")
477 }
478
479 fn gzip(bytes: &[u8]) -> Vec<u8> {
480 let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
481 encoder.write_all(bytes).expect("gzip");
482 encoder.finish().expect("gzip finish")
483 }
484
485 fn zstd_of(bytes: &[u8]) -> Vec<u8> {
486 zstd::encode_all(bytes, 0).expect("zstd")
487 }
488
489 /// A temp dir with an empty `rootfs` and an `outside` dir that escapes must never reach.
490 struct Case {
491 dir: tempfile::TempDir,
492 rootfs: PathBuf,
493 outside: PathBuf,
494 }
495
496 fn case() -> Case {
497 let dir = tempfile::tempdir().expect("tempdir");
498 let rootfs = dir.path().join("rootfs");
499 let outside = dir.path().join("outside");
500 fs::create_dir(&rootfs).expect("rootfs");
501 fs::create_dir(&outside).expect("outside");
502 Case {
503 dir,
504 rootfs,
505 outside,
506 }
507 }
508
509 fn run(case: &Case, layers: &[Vec<u8>]) -> Result<UnpackStats, UnpackError> {
510 let paths: Vec<PathBuf> = layers
511 .iter()
512 .enumerate()
513 .map(|(i, bytes)| {
514 let path = case.dir.path().join(format!("layer{i}.tar"));
515 fs::write(&path, bytes).expect("write layer");
516 path
517 })
518 .collect();
519 unpack_layers(&case.rootfs, &paths, 1 << 30, Ownership::Flatten)
520 }
521
522 fn mode_of(path: &Path) -> u32 {
523 fs::symlink_metadata(path)
524 .expect("metadata")
525 .permissions()
526 .mode()
527 & 0o7777
528 }
529
530 #[test]
531 fn normalize_accepts_relative_and_rejects_escapes() {
532 assert_eq!(
533 normalize(b"./etc/x"),
534 Some(vec![OsString::from("etc"), OsString::from("x")])
535 );
536 assert_eq!(
537 normalize(b"a/./b"),
538 Some(vec![OsString::from("a"), OsString::from("b")])
539 );
540 assert!(normalize(b"../x").is_none());
541 assert!(normalize(b"a/../../x").is_none());
542 assert!(normalize(b"/etc/passwd").is_none());
543 assert!(normalize(b"a\0b").is_none());
544 }
545
546 #[test]
547 fn plain_gzip_and_zstd_layers_unpack() {
548 let bytes = tar_of(|b| {
549 dir(b, "etc/");
550 file(b, "etc/motd", b"hello\n");
551 });
552 for layer in [bytes.clone(), gzip(&bytes), zstd_of(&bytes)] {
553 let c = case();
554 let stats = run(&c, &[layer]).expect("unpack");
555 assert_eq!(
556 fs::read(c.rootfs.join("etc/motd")).expect("read"),
557 b"hello\n"
558 );
559 assert_eq!(stats.files, 1);
560 assert_eq!(stats.bytes, 6);
561 }
562 }
563
564 #[test]
565 fn leading_dot_slash_names_are_accepted() {
566 let c = case();
567 let layer = tar_of(|b| file(b, "./bin/tool", b"x"));
568 run(&c, &[layer]).expect("unpack");
569 assert!(c.rootfs.join("bin/tool").is_file());
570 }
571
572 #[test]
573 fn traversal_and_absolute_names_are_rejected() {
574 let c = case();
575 let layer = tar_of(|b| {
576 file(b, "../escape.txt", b"nope");
577 file(b, "a/../../escape2.txt", b"nope");
578 file(b, "/abs-escape.txt", b"nope");
579 file(b, "ok.txt", b"yes");
580 });
581 let stats = run(&c, &[layer]).expect("unpack");
582 assert_eq!(stats.rejected, 3);
583 assert!(!c.dir.path().join("escape.txt").exists());
584 assert!(!c.dir.path().join("escape2.txt").exists());
585 assert!(c.rootfs.join("ok.txt").is_file());
586 }
587
588 #[test]
589 fn symlink_parent_is_never_followed() {
590 let c = case();
591 let target = c.outside.to_str().expect("utf8").to_owned();
592 let first = tar_of(|b| put(b, EntryType::Symlink, "a", Some(&target), 0o777, b""));
593 let second = tar_of(|b| file(b, "a/passwd", b"owned"));
594 let stats = run(&c, &[first, second]).expect("unpack");
595 assert!(!c.outside.join("passwd").exists());
596 assert_eq!(stats.rejected, 1);
597 // The link itself is still there, and is not replaced by a directory.
598 assert!(
599 fs::symlink_metadata(c.rootfs.join("a"))
600 .expect("meta")
601 .file_type()
602 .is_symlink()
603 );
604 }
605
606 #[test]
607 fn symlink_leaf_is_replaced_not_followed() {
608 let c = case();
609 let target = c.outside.join("victim");
610 fs::write(&target, "original").expect("write victim");
611 let first = tar_of(|b| {
612 put(
613 b,
614 EntryType::Symlink,
615 "f",
616 Some(target.to_str().expect("utf8")),
617 0o777,
618 b"",
619 )
620 });
621 let second = tar_of(|b| file(b, "f", b"new"));
622 run(&c, &[first, second]).expect("unpack");
623 assert_eq!(fs::read_to_string(&target).expect("read"), "original");
624 assert_eq!(fs::read_to_string(c.rootfs.join("f")).expect("read"), "new");
625 }
626
627 #[test]
628 fn hardlink_inside_rootfs_works() {
629 let c = case();
630 let layer = tar_of(|b| {
631 file(b, "a", b"shared");
632 put(b, EntryType::Link, "b", Some("a"), 0o644, b"");
633 });
634 let stats = run(&c, &[layer]).expect("unpack");
635 assert_eq!(stats.hardlinks, 1);
636 assert_eq!(fs::read(c.rootfs.join("b")).expect("read"), b"shared");
637 }
638
639 #[test]
640 fn hardlink_escape_is_rejected() {
641 let c = case();
642 let secret = c.outside.join("secret");
643 fs::write(&secret, "secret").expect("write");
644 let outside_abs = secret.to_str().expect("utf8").to_owned();
645 let layer = tar_of(|b| {
646 put(b, EntryType::Link, "abs", Some(&outside_abs), 0o644, b"");
647 put(
648 b,
649 EntryType::Link,
650 "rel",
651 Some("../outside/secret"),
652 0o644,
653 b"",
654 );
655 put(
656 b,
657 EntryType::Link,
658 "missing",
659 Some("nothing-here"),
660 0o644,
661 b"",
662 );
663 });
664 let stats = run(&c, &[layer]).expect("unpack");
665 assert_eq!(stats.rejected, 3);
666 assert!(!c.rootfs.join("abs").exists());
667 assert!(!c.rootfs.join("rel").exists());
668 }
669
670 #[test]
671 fn hardlink_to_symlink_is_rejected() {
672 let c = case();
673 let layer = tar_of(|b| {
674 put(
675 b,
676 EntryType::Symlink,
677 "link",
678 Some("/etc/passwd"),
679 0o777,
680 b"",
681 );
682 put(b, EntryType::Link, "copy", Some("link"), 0o644, b"");
683 });
684 let stats = run(&c, &[layer]).expect("unpack");
685 assert_eq!(stats.rejected, 1);
686 assert!(!c.rootfs.join("copy").exists());
687 }
688
689 #[test]
690 fn whiteout_deletes_earlier_file() {
691 let c = case();
692 let first = tar_of(|b| {
693 dir(b, "etc/");
694 file(b, "etc/old", b"1");
695 file(b, "etc/keep", b"2");
696 });
697 let second = tar_of(|b| file(b, "etc/.wh.old", b""));
698 let stats = run(&c, &[first, second]).expect("unpack");
699 assert!(!c.rootfs.join("etc/old").exists());
700 assert!(c.rootfs.join("etc/keep").is_file());
701 assert_eq!(stats.whiteouts, 1);
702 // The whiteout marker itself is never written.
703 assert!(!c.rootfs.join("etc/.wh.old").exists());
704 }
705
706 #[test]
707 fn whiteout_deletes_directory_tree() {
708 let c = case();
709 let first = tar_of(|b| {
710 dir(b, "opt/");
711 dir(b, "opt/app/");
712 file(b, "opt/app/bin", b"x");
713 });
714 let second = tar_of(|b| file(b, "opt/.wh.app", b""));
715 run(&c, &[first, second]).expect("unpack");
716 assert!(!c.rootfs.join("opt/app").exists());
717 }
718
719 #[test]
720 fn opaque_dir_clears_lower_layers_and_keeps_this_layer() {
721 let c = case();
722 let first = tar_of(|b| {
723 dir(b, "etc/");
724 file(b, "etc/old1", b"1");
725 dir(b, "etc/sub/");
726 file(b, "etc/sub/old2", b"2");
727 file(b, "keep-outside-etc", b"3");
728 });
729 let second = tar_of(|b| {
730 file(b, "etc/.wh..wh..opq", b"");
731 file(b, "etc/new", b"4");
732 });
733 run(&c, &[first, second]).expect("unpack");
734 assert!(!c.rootfs.join("etc/old1").exists());
735 assert!(!c.rootfs.join("etc/sub").exists());
736 assert!(c.rootfs.join("etc/new").is_file());
737 assert!(c.rootfs.join("keep-outside-etc").is_file());
738 }
739
740 #[test]
741 fn setuid_and_setgid_are_stripped() {
742 let c = case();
743 let layer = tar_of(|b| {
744 put(b, EntryType::Regular, "su", None, 0o4755, b"x");
745 put(b, EntryType::Regular, "sg", None, 0o2750, b"x");
746 put(b, EntryType::Directory, "d/", None, 0o6777, b"");
747 });
748 run(&c, &[layer]).expect("unpack");
749 assert_eq!(mode_of(&c.rootfs.join("su")), 0o755);
750 assert_eq!(mode_of(&c.rootfs.join("sg")), 0o750);
751 assert_eq!(mode_of(&c.rootfs.join("d")) & 0o6000, 0);
752 }
753
754 #[test]
755 fn devices_and_fifos_are_skipped() {
756 let c = case();
757 let layer = tar_of(|b| {
758 put(b, EntryType::Char, "dev/null", None, 0o666, b"");
759 put(b, EntryType::Block, "dev/sda", None, 0o660, b"");
760 put(b, EntryType::Fifo, "dev/pipe", None, 0o644, b"");
761 file(b, "dev/ok", b"x");
762 });
763 let stats = run(&c, &[layer]).expect("unpack");
764 assert_eq!(stats.skipped, 3);
765 assert!(!c.rootfs.join("dev/null").exists());
766 assert!(!c.rootfs.join("dev/pipe").exists());
767 assert!(c.rootfs.join("dev/ok").is_file());
768 }
769
770 #[test]
771 fn size_cap_stops_unpacking() {
772 let c = case();
773 let layer = tar_of(|b| {
774 file(b, "a", &[1u8; 100]);
775 file(b, "b", &[2u8; 100]);
776 });
777 let paths = {
778 let path = c.dir.path().join("big.tar");
779 fs::write(&path, &layer).expect("write");
780 vec![path]
781 };
782 let err =
783 unpack_layers(&c.rootfs, &paths, 150, Ownership::Flatten).expect_err("over the cap");
784 assert!(matches!(err, UnpackError::TooLarge { limit: 150 }));
785 }
786
787 #[test]
788 fn map_id_keeps_ids_inside_the_map_and_zeroes_the_rest() {
789 assert_eq!(map_id(0, 65), (0, false));
790 assert_eq!(map_id(1, 65), (1, false));
791 assert_eq!(map_id(64, 65), (64, false));
792 assert_eq!(map_id(65, 65), (0, true));
793 assert_eq!(map_id(1000, 1), (0, true));
794 assert_eq!(map_id(u64::MAX, u32::MAX), (0, true));
795 assert_eq!(map_id(u64::from(u32::MAX), u32::MAX), (0, true));
796 assert_eq!(
797 map_id(u64::from(u32::MAX) - 1, u32::MAX),
798 (u32::MAX - 1, false)
799 );
800 }
801
802 /// Writes one tar entry with an explicit owner and mode, and no data.
803 fn put_owned(
804 builder: &mut Builder<Vec<u8>>,
805 kind: EntryType,
806 name: &str,
807 link: Option<&str>,
808 mode: u32,
809 owner: (u64, u64),
810 ) {
811 let mut header = Header::new_old();
812 header.set_entry_type(kind);
813 header.set_mode(mode);
814 header.set_size(0);
815 header.set_uid(owner.0);
816 header.set_gid(owner.1);
817 write_field(&mut header.as_old_mut().name, name);
818 if let Some(link) = link {
819 write_field(&mut header.as_old_mut().linkname, link);
820 }
821 header.set_cksum();
822 builder.append(&header, &b""[..]).expect("append");
823 }
824
825 #[test]
826 fn preserve_sets_owner_and_exact_directory_modes() {
827 // Owned by the test user, so the test can chown to it without privileges.
828 let c = case();
829 let probe = c.dir.path().join("probe");
830 fs::write(&probe, b"").expect("probe");
831 let meta = fs::metadata(&probe).expect("probe metadata");
832 let owner = (u64::from(meta.uid()), u64::from(meta.gid()));
833
834 let layer = tar_of(|b| {
835 put_owned(b, EntryType::Directory, "d/", None, 0o710, owner);
836 put_owned(b, EntryType::Directory, "d/sub/", None, 0o751, owner);
837 put_owned(b, EntryType::Regular, "d/sub/f", None, 0o640, owner);
838 put_owned(b, EntryType::Symlink, "d/link", Some("sub/f"), 0o777, owner);
839 });
840 let paths = {
841 let path = c.dir.path().join("preserve.tar");
842 fs::write(&path, &layer).expect("write");
843 vec![path]
844 };
845 let stats = unpack_layers(
846 &c.rootfs,
847 &paths,
848 1 << 30,
849 Ownership::Preserve { size: u32::MAX },
850 )
851 .expect("unpack");
852
853 assert_eq!(stats.remapped_ids, 0);
854 assert_eq!(stats.dirs, 2);
855 assert_eq!(mode_of(&c.rootfs.join("d")), 0o710);
856 assert_eq!(mode_of(&c.rootfs.join("d/sub")), 0o751);
857 assert_eq!(mode_of(&c.rootfs.join("d/sub/f")), 0o640);
858 let file_meta = fs::symlink_metadata(c.rootfs.join("d/sub/f")).expect("file meta");
859 assert_eq!((file_meta.uid(), file_meta.gid()), (meta.uid(), meta.gid()));
860 assert!(
861 fs::symlink_metadata(c.rootfs.join("d/link"))
862 .expect("link")
863 .file_type()
864 .is_symlink()
865 );
866 }
867
868 #[test]
869 fn flatten_keeps_directories_owner_writable() {
870 let c = case();
871 let layer = tar_of(|b| {
872 put(b, EntryType::Directory, "d/", None, 0o550, b"");
873 });
874 run(&c, &[layer]).expect("unpack");
875 assert_eq!(mode_of(&c.rootfs.join("d")), 0o750);
876 }
877
878 #[test]
879 fn later_layer_replaces_file_with_directory() {
880 let c = case();
881 let first = tar_of(|b| file(b, "x", b"file"));
882 let second = tar_of(|b| {
883 dir(b, "x/");
884 file(b, "x/inner", b"inner");
885 });
886 run(&c, &[first, second]).expect("unpack");
887 assert_eq!(fs::read(c.rootfs.join("x/inner")).expect("read"), b"inner");
888 }
889}