| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | |
| 14 | |
| 15 | |
| 16 | |
| 17 | use std::collections::{HashMap, HashSet}; |
| 18 | use std::ffi::{OsStr, OsString}; |
| 19 | use std::fs::{self, File, OpenOptions, Permissions}; |
| 20 | use std::io::{BufReader, Read, Seek, SeekFrom}; |
| 21 | use std::os::unix::ffi::OsStrExt; |
| 22 | use std::os::unix::fs::{PermissionsExt, lchown, symlink}; |
| 23 | use std::path::{Path, PathBuf}; |
| 24 | |
| 25 | use flate2::read::GzDecoder; |
| 26 | use serde::{Deserialize, Serialize}; |
| 27 | use tar::{Archive, EntryType}; |
| 28 | |
| 29 | const GZIP_MAGIC: &[u8] = &[0x1f, 0x8b]; |
| 30 | const ZSTD_MAGIC: &[u8] = &[0x28, 0xb5, 0x2f, 0xfd]; |
| 31 | const WHITEOUT_PREFIX: &[u8] = b".wh."; |
| 32 | |
| 33 | const OPAQUE_MARKER: &[u8] = b".wh..opq"; |
| 34 | const COPY_BUF: usize = 64 * 1024; |
| 35 | |
| 36 | |
| 37 | |
| 38 | #[derive(Debug, thiserror::Error)] |
| 39 | pub 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 | |
| 45 | #[error("unpack helper failed: {0}")] |
| 46 | Helper(String), |
| 47 | } |
| 48 | |
| 49 | |
| 50 | #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 51 | pub struct UnpackStats { |
| 52 | pub files: u64, |
| 53 | pub dirs: u64, |
| 54 | pub symlinks: u64, |
| 55 | pub hardlinks: u64, |
| 56 | pub whiteouts: u64, |
| 57 | |
| 58 | pub skipped: u64, |
| 59 | |
| 60 | pub rejected: u64, |
| 61 | |
| 62 | pub bytes: u64, |
| 63 | |
| 64 | pub remapped_ids: u64, |
| 65 | } |
| 66 | |
| 67 | |
| 68 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 69 | pub enum Ownership { |
| 70 | |
| 71 | Flatten, |
| 72 | |
| 73 | |
| 74 | Preserve { size: u32 }, |
| 75 | } |
| 76 | |
| 77 | |
| 78 | |
| 79 | pub 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 | |
| 87 | |
| 88 | |
| 89 | pub 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 | |
| 114 | struct Unpacker { |
| 115 | root: PathBuf, |
| 116 | limit: u64, |
| 117 | remaining: u64, |
| 118 | |
| 119 | created: HashSet<PathBuf>, |
| 120 | ownership: Ownership, |
| 121 | |
| 122 | owner: Option<(u32, u32)>, |
| 123 | |
| 124 | dir_modes: HashMap<PathBuf, u32>, |
| 125 | stats: UnpackStats, |
| 126 | } |
| 127 | |
| 128 | impl 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 | |
| 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 | |
| 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 | |
| 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 | |
| 180 | |
| 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 | |
| 189 | |
| 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 | |
| 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 | |
| 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 | |
| 309 | self.own(&path)?; |
| 310 | self.created.insert(rel); |
| 311 | self.stats.hardlinks += 1; |
| 312 | } |
| 313 | |
| 314 | _ => self.stats.skipped += 1, |
| 315 | } |
| 316 | Ok(()) |
| 317 | } |
| 318 | |
| 319 | |
| 320 | |
| 321 | fn whiteout(&mut self, parents: &[OsString], name: &[u8]) -> Result<(), UnpackError> { |
| 322 | |
| 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 | |
| 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 | |
| 353 | |
| 354 | |
| 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 | |
| 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 | |
| 407 | |
| 408 | fn 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 | |
| 424 | pub(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)] |
| 434 | mod 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | } |