1use flate2::read::GzDecoder;
22use std::fs::{create_dir_all, File};
23use std::io::{Cursor, Read, Write};
24use std::path::Path;
25use tar::Archive;
26
27use crate::path_safety::{contained_target, safe_join};
28
29pub enum Select<'a> {
31 All,
34 Files(&'a [(&'a str, &'a str)]),
37 Matching(&'a dyn Fn(&str) -> Option<String>),
40}
41
42impl Select<'_> {
43 fn dest_for(&self, rel: &str) -> Option<String> {
46 match self {
47 Select::All => Some(rel.to_string()),
48 Select::Files(files) => files
49 .iter()
50 .find(|(src, _)| *src == rel)
51 .map(|(_, dst)| dst.to_string()),
52 Select::Matching(f) => f(rel),
53 }
54 }
55}
56
57pub fn tar_gz(
59 bytes: &[u8],
60 dest: &Path,
61 strip_prefix: Option<&str>,
62 select: Select<'_>,
63) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
64 let mut archive = Archive::new(GzDecoder::new(Cursor::new(bytes)));
65 let mut count = 0;
66 let mut total: u64 = 0;
67 let mut entries: u64 = 0;
68 create_dir_all(dest)?;
70 let root = dest.canonicalize()?;
71 for entry in archive.entries()? {
72 let mut entry = entry?;
73 entries += 1;
74 if entries > MAX_ENTRIES {
75 return Err(too_many_entries());
76 }
77 let entry_type = entry.header().entry_type();
78 let is_dir = entry_type.is_dir();
79 if !is_dir && !entry_type.is_file() {
83 continue;
84 }
85 let path_str = {
88 let entry_path = entry.path()?;
89 match entry_path.to_str() {
90 Some(s) => s.to_owned(),
91 None => return Err(non_utf8_entry(&entry_path)),
92 }
93 };
94 let rel = strip(&path_str, strip_prefix);
95 if is_root_entry(rel) {
98 continue;
99 }
100 if is_dir {
101 if matches!(select, Select::All) {
102 let target = contained_target(&root, &safe_join(dest, rel)?)?;
105 create_dir_all(target)?;
106 }
107 continue;
108 }
109 let Some(dest_rel) = select.dest_for(rel) else {
110 continue;
111 };
112 let out = safe_join(dest, &dest_rel)?;
113 let target = contained_target(&root, &out)?;
114 let mut file = File::create(&target)?;
115 total += copy_capped(&mut entry, &mut file, MAX_TOTAL_BYTES.saturating_sub(total))?;
116 count += 1;
117 }
118 Ok(count)
119}
120
121pub fn zip(
123 bytes: &[u8],
124 dest: &Path,
125 strip_prefix: Option<&str>,
126 select: Select<'_>,
127) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
128 let mut archive = zip::ZipArchive::new(Cursor::new(bytes))?;
129 if archive.len() as u64 > MAX_ENTRIES {
130 return Err(too_many_entries());
131 }
132 let mut count = 0;
133 let mut total: u64 = 0;
134 create_dir_all(dest)?;
136 let root = dest.canonicalize()?;
137 for i in 0..archive.len() {
138 let mut file = archive.by_index(i)?;
139 if file.is_dir() || file.is_symlink() {
140 continue;
141 }
142 let name = match file.enclosed_name() {
143 Some(n) => match n.to_str() {
144 Some(s) => s.to_owned(),
145 None => return Err(non_utf8_entry(&n)),
146 },
147 None => return Err("unsafe zip entry name (escapes destination)".into()),
148 };
149 let rel = strip(&name, strip_prefix);
150 if is_root_entry(rel) {
152 continue;
153 }
154 let Some(dest_rel) = select.dest_for(rel) else {
155 continue;
156 };
157 let out = safe_join(dest, &dest_rel)?;
158 let target = contained_target(&root, &out)?;
159 let mut writer = File::create(&target)?;
160 total += copy_capped(
161 &mut file,
162 &mut writer,
163 MAX_TOTAL_BYTES.saturating_sub(total),
164 )?;
165 count += 1;
166 }
167 Ok(count)
168}
169
170fn strip<'a>(path: &'a str, prefix: Option<&str>) -> &'a str {
171 match prefix {
172 Some(p) => path.strip_prefix(p).unwrap_or(path),
173 None => path,
174 }
175}
176
177fn is_root_entry(rel: &str) -> bool {
181 rel.is_empty() || rel == "."
182}
183
184const MAX_TOTAL_BYTES: u64 = 4 * 1024 * 1024 * 1024; const MAX_ENTRIES: u64 = 200_000;
194
195fn too_many_entries() -> Box<dyn std::error::Error + Send + Sync> {
196 format!("archive has more than {MAX_ENTRIES} entries (possible archive bomb)").into()
197}
198
199fn non_utf8_entry(path: &Path) -> Box<dyn std::error::Error + Send + Sync> {
201 format!("archive entry path is not valid UTF-8: {path:?}").into()
202}
203
204fn copy_capped<R: Read, W: Write>(
209 reader: &mut R,
210 writer: &mut W,
211 budget: u64,
212) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
213 let written = std::io::copy(&mut reader.take(budget.saturating_add(1)), writer)?;
216 if written > budget {
217 return Err(
218 "archive exceeds the extraction size limit (possible decompression bomb)".into(),
219 );
220 }
221 Ok(written)
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use flate2::write::GzEncoder;
228 use flate2::Compression;
229 use std::io::Cursor as IoCursor;
230 use tempfile::tempdir;
231
232 fn make_tar_gz(entries: &[(&str, &[u8])]) -> Vec<u8> {
234 let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast()));
235 for (path, contents) in entries {
236 let mut header = tar::Header::new_gnu();
237 header.set_size(contents.len() as u64);
238 header.set_mode(0o644);
239 header.set_entry_type(tar::EntryType::Regular);
240 builder
241 .append_data(&mut header, *path, IoCursor::new(*contents))
242 .unwrap();
243 }
244 builder.finish().unwrap();
245 builder.into_inner().unwrap().finish().unwrap()
246 }
247
248 fn make_tar_gz_dir(path: &str) -> Vec<u8> {
250 let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast()));
251 let mut header = tar::Header::new_gnu();
252 header.set_size(0);
253 header.set_mode(0o755);
254 header.set_entry_type(tar::EntryType::Directory);
255 builder
256 .append_data(&mut header, path, IoCursor::new(&b""[..]))
257 .unwrap();
258 builder.finish().unwrap();
259 builder.into_inner().unwrap().finish().unwrap()
260 }
261
262 #[test]
263 #[cfg(unix)]
264 fn rejects_creating_a_dir_through_a_preexisting_symlink() {
265 use std::os::unix::fs::symlink;
266 let tmp = tempdir().unwrap();
269 let dest = tmp.path().join("dest");
270 let outside = tmp.path().join("outside");
271 std::fs::create_dir_all(&dest).unwrap();
272 std::fs::create_dir_all(&outside).unwrap();
273 symlink(&outside, dest.join("link")).unwrap();
274
275 let tgz = make_tar_gz_dir("package/link/sub");
276 let result = tar_gz(&tgz, &dest, Some("package/"), Select::All);
277 assert!(
278 result.is_err(),
279 "must refuse to create a dir through a symlink"
280 );
281 assert!(
282 !outside.join("sub").exists(),
283 "nothing created outside dest"
284 );
285 }
286
287 #[test]
288 #[cfg(unix)]
289 fn rejects_non_utf8_entry_names() {
290 use std::os::unix::ffi::OsStrExt;
291 let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast()));
293 let mut header = tar::Header::new_gnu();
294 header.set_size(3);
295 header.set_mode(0o644);
296 header.set_entry_type(tar::EntryType::Regular);
297 let bad = std::ffi::OsStr::from_bytes(b"bad\xff.txt");
298 builder
299 .append_data(&mut header, bad, IoCursor::new(&b"abc"[..]))
300 .unwrap();
301 builder.finish().unwrap();
302 let tgz = builder.into_inner().unwrap().finish().unwrap();
303
304 let tmp = tempdir().unwrap();
305 assert!(
306 tar_gz(&tgz, tmp.path(), None, Select::All).is_err(),
307 "a non-UTF-8 entry path must be rejected"
308 );
309 }
310
311 #[test]
312 fn tar_gz_all_strips_prefix() {
313 let tgz = make_tar_gz(&[("package/index.js", b"a"), ("package/sub/util.js", b"b")]);
314 let tmp = tempdir().unwrap();
315 let n = tar_gz(&tgz, tmp.path(), Some("package/"), Select::All).unwrap();
316 assert_eq!(n, 2);
317 assert!(tmp.path().join("index.js").exists());
318 assert!(tmp.path().join("sub/util.js").exists());
319 }
320
321 #[test]
322 fn tar_gz_files_picks_named_entries() {
323 let tgz = make_tar_gz(&[
324 ("package/dist/sprite.svg", b"<svg/>"),
325 ("package/readme.md", b"x"),
326 ]);
327 let tmp = tempdir().unwrap();
328 let n = tar_gz(
329 &tgz,
330 tmp.path(),
331 Some("package/"),
332 Select::Files(&[("dist/sprite.svg", "icons/sprite.svg")]),
333 )
334 .unwrap();
335 assert_eq!(n, 1);
336 assert!(tmp.path().join("icons/sprite.svg").exists());
337 assert!(!tmp.path().join("readme.md").exists());
338 }
339
340 #[test]
341 fn tar_gz_matching_predicate_and_prefix() {
342 let tgz = make_tar_gz(&[
343 ("package/a.js", b"x"),
344 ("package/b.css", b"y"),
345 ("package/c.mjs", b"z"),
346 ]);
347 let tmp = tempdir().unwrap();
348 let keep_js = |rel: &str| -> Option<String> {
349 (rel.ends_with(".js") || rel.ends_with(".mjs")).then(|| format!("lit/{rel}"))
350 };
351 let n = tar_gz(
352 &tgz,
353 tmp.path(),
354 Some("package/"),
355 Select::Matching(&keep_js),
356 )
357 .unwrap();
358 assert_eq!(n, 2);
359 assert!(tmp.path().join("lit/a.js").exists());
360 assert!(tmp.path().join("lit/c.mjs").exists());
361 assert!(!tmp.path().join("lit/b.css").exists());
362 }
363
364 #[test]
365 fn tar_gz_errors_when_selection_escapes_dest() {
366 let tgz = make_tar_gz(&[("package/x.js", b"x")]);
369 let tmp = tempdir().unwrap();
370 let escape = |_rel: &str| -> Option<String> { Some("../escape.js".to_string()) };
371 let result = tar_gz(
372 &tgz,
373 tmp.path(),
374 Some("package/"),
375 Select::Matching(&escape),
376 );
377 assert!(result.is_err(), "extraction must error when a dest escapes");
378 }
379
380 #[test]
381 #[cfg(unix)]
382 fn rejects_writing_through_a_preexisting_symlink() {
383 use std::os::unix::fs::symlink;
384 let tmp = tempdir().unwrap();
388 let dest = tmp.path().join("dest");
389 let outside = tmp.path().join("outside");
390 std::fs::create_dir_all(&dest).unwrap();
391 std::fs::create_dir_all(&outside).unwrap();
392 symlink(&outside, dest.join("evil")).unwrap();
393
394 let tgz = make_tar_gz(&[("package/evil/pwned", b"owned")]);
395 let result = tar_gz(&tgz, &dest, Some("package/"), Select::All);
396
397 assert!(
398 result.is_err(),
399 "must refuse to write through an escaping symlink"
400 );
401 assert!(
402 !outside.join("pwned").exists(),
403 "nothing may be written outside the extract dir"
404 );
405 }
406
407 #[test]
408 #[cfg(unix)]
409 fn rejects_writing_through_a_preexisting_leaf_symlink() {
410 use std::os::unix::fs::symlink;
411 let tmp = tempdir().unwrap();
415 let dest = tmp.path().join("dest");
416 let outside = tmp.path().join("outside.txt");
417 std::fs::create_dir_all(&dest).unwrap();
418 std::fs::write(&outside, b"original").unwrap();
419 symlink(&outside, dest.join("evil")).unwrap();
420
421 let tgz = make_tar_gz(&[("package/evil", b"owned")]);
422 let result = tar_gz(&tgz, &dest, Some("package/"), Select::All);
423
424 assert!(
425 result.is_err(),
426 "must refuse to write through a leaf symlink"
427 );
428 assert_eq!(
429 std::fs::read(&outside).unwrap(),
430 b"original",
431 "the symlink's target must be untouched"
432 );
433 }
434
435 #[test]
436 fn odd_but_legal_entry_names_stay_contained() {
437 let tmp = tempdir().unwrap();
441 let dest = tmp.path().join("dest");
442 let tgz = make_tar_gz(&[
443 (".../flag.txt", b"a"),
444 ("~/flag.txt", b"b"),
445 ("file:///tmp/flag.txt", b"c"),
446 ]);
447 let n = tar_gz(&tgz, &dest, None, Select::All).unwrap();
448 assert_eq!(n, 3);
449 assert!(dest.join("...").join("flag.txt").is_file());
450 assert!(dest.join("~").join("flag.txt").is_file());
451 assert!(dest.join("file:").join("tmp").join("flag.txt").is_file());
453 assert!(!tmp.path().join("flag.txt").exists());
455 }
456
457 fn tar_with_links() -> Vec<u8> {
459 let mut b = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::fast()));
460 let mut reg = tar::Header::new_gnu();
461 reg.set_size(4);
462 reg.set_mode(0o644);
463 reg.set_entry_type(tar::EntryType::Regular);
464 b.append_data(&mut reg, "real.txt", IoCursor::new(&b"data"[..]))
465 .unwrap();
466
467 let mut sym = tar::Header::new_gnu();
468 sym.set_size(0);
469 sym.set_mode(0o777);
470 sym.set_entry_type(tar::EntryType::Symlink);
471 b.append_link(&mut sym, "evil-symlink", "real.txt").unwrap();
472
473 let mut hard = tar::Header::new_gnu();
474 hard.set_size(0);
475 hard.set_mode(0o644);
476 hard.set_entry_type(tar::EntryType::Link);
477 b.append_link(&mut hard, "evil-hardlink", "real.txt")
478 .unwrap();
479
480 b.finish().unwrap();
481 b.into_inner().unwrap().finish().unwrap()
482 }
483
484 #[test]
485 fn skips_symlink_and_hardlink_entries() {
486 let tmp = tempdir().unwrap();
489 let dest = tmp.path().join("dest");
490 let n = tar_gz(&tar_with_links(), &dest, None, Select::All).unwrap();
491 assert_eq!(n, 1, "only the regular file is written");
492 assert!(dest.join("real.txt").is_file());
493 assert!(!dest.join("evil-symlink").exists());
494 assert!(!dest.join("evil-hardlink").exists());
495 }
496
497 #[test]
498 fn copy_capped_streams_within_budget_and_rejects_a_bomb() {
499 let src = vec![7u8; 1000];
500 let mut ok = Vec::new();
502 assert_eq!(
503 copy_capped(&mut src.as_slice(), &mut ok, 2000).unwrap(),
504 1000
505 );
506 assert_eq!(ok, src);
507 let mut overflow = Vec::new();
509 assert!(copy_capped(&mut src.as_slice(), &mut overflow, 100).is_err());
510 }
511
512 #[test]
513 fn is_root_entry_flags_dot_and_empty() {
514 assert!(is_root_entry("."));
517 assert!(is_root_entry(""));
518 assert!(!is_root_entry("index.js"));
519 assert!(!is_root_entry("./index.js"));
520 assert!(!is_root_entry("..."));
521 }
522
523 #[test]
524 fn refuses_to_write_at_the_destination_root() {
525 let tmp = tempdir().unwrap();
529 let dest = tmp.path().join("dest");
530 let tgz = make_tar_gz(&[("package/x.js", b"x")]);
531 let onto_root = |_rel: &str| -> Option<String> { Some(".".to_string()) };
532 let result = tar_gz(&tgz, &dest, Some("package/"), Select::Matching(&onto_root));
533 assert!(result.is_err(), "writing onto the root must be refused");
534 assert!(
535 dest.is_dir(),
536 "the destination root remains a real directory"
537 );
538 }
539}