1use async_compression::tokio::bufread::GzipDecoder;
2use std::future::poll_fn;
3use std::io;
4use std::path::{Component, Path, PathBuf};
5use std::pin::Pin;
6use std::time::{Duration, SystemTime};
7use tokio::io::{AsyncRead, AsyncWriteExt, BufReader};
8use tokio_tar::{Archive, Entry, EntryType};
9use tracing::{debug, error, info};
10
11use crate::errors::ExtractError;
12
13pub async fn extract<R: AsyncRead + Unpin + Send>(
21 reader: R,
22 dest_dir: &Path,
23) -> Result<(), ExtractError> {
24 let mut decoder = GzipDecoder::new(BufReader::new(reader));
25 decoder.multiple_members(true);
26 let mut archive = Archive::new(decoder);
27 let mut entries = archive.entries().map_err(unreadable)?;
28
29 let mut entry_count = 0u64;
30 while let Some(entry) =
31 poll_fn(|cx| futures_core::Stream::poll_next(Pin::new(&mut entries), cx)).await
32 {
33 let mut entry = entry.map_err(unreadable)?;
34 entry_count = entry_count.saturating_add(1);
35 let path = entry.path().map_err(unreadable)?.into_owned();
36
37 if entry_count <= 10 {
38 info!(
39 name = %path.display(),
40 kind = entry.header().entry_type().as_byte(),
41 size = entry.effective_size(),
42 "tar entry"
43 );
44 }
45 if !is_safe(&path, entry.header().entry_type()) {
46 debug!(name = %path.display(), "skipping tar entry");
47 continue;
48 }
49
50 write_entry(&mut entry, &dest_dir.join(clean_path(&path))).await?;
51 }
52 drop(entries);
53
54 tokio::io::copy(&mut archive, &mut tokio::io::sink())
56 .await
57 .map_err(unreadable)?;
58
59 info!(total_entries = entry_count, "tar extraction complete");
60 Ok(())
61}
62
63fn unreadable(error: io::Error) -> ExtractError {
64 if error.kind() == io::ErrorKind::UnexpectedEof {
65 ExtractError::Truncated(error)
66 } else {
67 ExtractError::Corrupt(error)
68 }
69}
70
71async fn write_entry<R: AsyncRead + Unpin>(
72 entry: &mut Entry<Archive<R>>,
73 target: &Path,
74) -> Result<(), ExtractError> {
75 match entry.header().entry_type() {
76 EntryType::Directory => tokio::fs::create_dir_all(target)
77 .await
78 .map_err(ExtractError::Io),
79 EntryType::Regular | EntryType::Continuous => write_file(entry, target).await,
80 _ => Ok(()),
81 }
82}
83
84async fn write_file<R: AsyncRead + Unpin>(
85 entry: &mut Entry<Archive<R>>,
86 target: &Path,
87) -> Result<(), ExtractError> {
88 if let Some(parent) = target.parent() {
89 tokio::fs::create_dir_all(parent)
90 .await
91 .map_err(ExtractError::Io)?;
92 }
93
94 let mode = (entry.header().mode().unwrap_or(0o644) | 0o600) & 0o755;
96 let mtime = entry.header().mtime().ok();
97 let mut file = tokio::fs::File::create(target)
98 .await
99 .map_err(ExtractError::Io)?;
100 copy_member(entry, &mut file).await?;
101 file.flush().await.map_err(ExtractError::Io)?;
102
103 let file = file.into_std().await;
104 if let Some(modified) =
105 mtime.and_then(|secs| SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(secs)))
106 {
107 file.set_modified(modified).ok();
108 }
109 drop(file);
110
111 #[cfg(unix)]
112 {
113 use std::os::unix::fs::PermissionsExt;
114 tokio::fs::set_permissions(target, std::fs::Permissions::from_mode(mode))
115 .await
116 .ok();
117 }
118 Ok(())
119}
120
121async fn copy_member<R: AsyncRead + Unpin>(
124 entry: &mut Entry<Archive<R>>,
125 file: &mut tokio::fs::File,
126) -> Result<(), ExtractError> {
127 use tokio::io::AsyncReadExt;
128 let mut buffer = vec![0u8; 64 * 1024];
129 loop {
130 let read = entry.read(&mut buffer).await.map_err(unreadable)?;
131 if read == 0 {
132 return Ok(());
133 }
134 let chunk = buffer.get(..read).unwrap_or_default();
135 file.write_all(chunk).await.map_err(ExtractError::Io)?;
136 }
137}
138
139fn clean_path(path: &Path) -> PathBuf {
140 path.components()
141 .filter_map(|component| match component {
142 Component::Normal(part) => Some(part),
143 _ => None,
144 })
145 .collect()
146}
147
148fn is_safe(path: &Path, kind: EntryType) -> bool {
149 if kind == EntryType::Symlink || kind == EntryType::Link {
150 return false;
151 }
152 if path.is_absolute()
153 || path
154 .components()
155 .any(|component| matches!(component, Component::ParentDir))
156 {
157 error!(name = %path.display(), "unsafe path detected");
158 return false;
159 }
160 true
161}
162
163#[cfg(test)]
164mod tests {
165 use super::extract;
166 use crate::errors::ExtractError;
167 use flate2::write::GzEncoder;
168 use flate2::Compression;
169 use std::path::Path;
170 use std::time::{Duration, SystemTime};
171
172 const MTIME: u64 = 1_700_000_000;
173
174 fn tar_gz(entries: &[(&str, &[u8])]) -> Vec<u8> {
175 let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
176 builder.preserve_absolute(true);
177 for (name, contents) in entries {
178 let mut header = tar::Header::new_gnu();
179 header.set_size(u64::try_from(contents.len()).unwrap());
180 header.set_mode(0o644);
181 header.set_mtime(MTIME);
182 builder.append_data(&mut header, name, *contents).unwrap();
183 }
184 builder.into_inner().unwrap().finish().unwrap()
185 }
186
187 fn hostile_tar_gz(kind: tar::EntryType, name: &str, link: &str, contents: &[u8]) -> Vec<u8> {
190 let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
191 let mut header = tar::Header::new_old();
192 header
193 .as_old_mut()
194 .name
195 .get_mut(..name.len())
196 .unwrap()
197 .copy_from_slice(name.as_bytes());
198 header
199 .as_old_mut()
200 .linkname
201 .get_mut(..link.len())
202 .unwrap()
203 .copy_from_slice(link.as_bytes());
204 header.set_entry_type(kind);
205 header.set_size(u64::try_from(contents.len()).unwrap());
206 header.set_mode(0o644);
207 header.set_cksum();
208 builder.append(&header, contents).unwrap();
209 builder.into_inner().unwrap().finish().unwrap()
210 }
211
212 async fn extract_into(archive: &[u8], dest: &Path) {
213 extract(archive, dest).await.unwrap();
214 }
215
216 async fn extract_to_temp(archive: &[u8]) -> tempfile::TempDir {
217 let dir = tempfile::tempdir().unwrap();
218 extract_into(archive, dir.path()).await;
219 dir
220 }
221
222 fn entries_under(dir: &Path) -> Vec<String> {
223 let mut found: Vec<String> = walkdir::WalkDir::new(dir)
224 .min_depth(1)
225 .into_iter()
226 .map(|entry| {
227 entry
228 .unwrap()
229 .path()
230 .strip_prefix(dir)
231 .unwrap()
232 .display()
233 .to_string()
234 })
235 .collect();
236 found.sort();
237 found
238 }
239
240 #[tokio::test]
241 async fn an_archive_is_extracted_from_a_reader() {
242 let dir = extract_to_temp(&tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")])).await;
243
244 assert_eq!(
245 std::fs::read_to_string(dir.path().join("repo/HEAD")).unwrap(),
246 "ref: refs/heads/trunk\n"
247 );
248 }
249
250 #[tokio::test]
251 async fn an_absolute_entry_is_skipped_and_the_rest_is_extracted() {
252 let dir = extract_to_temp(&tar_gz(&[
253 ("/etc/passwd", b"root::0:0:::\n"),
254 ("repo/HEAD", b"ref: refs/heads/trunk\n"),
255 ]))
256 .await;
257
258 assert_eq!(entries_under(dir.path()), ["repo", "repo/HEAD"]);
259 }
260
261 #[tokio::test]
262 async fn a_name_holding_two_dots_is_not_a_traversal() {
263 let dir = extract_to_temp(&tar_gz(&[("repo/v1../a..b", b"kept\n")])).await;
264
265 assert_eq!(
266 std::fs::read_to_string(dir.path().join("repo/v1../a..b")).unwrap(),
267 "kept\n"
268 );
269 }
270
271 #[tokio::test]
272 async fn an_entry_that_climbs_out_writes_nothing_anywhere() {
273 for name in ["../x", "repo/../../x", "repo/.."] {
274 let root = tempfile::tempdir().unwrap();
275 let dest = root.path().join("a/b");
276 std::fs::create_dir_all(&dest).unwrap();
277
278 extract_into(
279 &hostile_tar_gz(tar::EntryType::Regular, name, "", b"escaped\n"),
280 &dest,
281 )
282 .await;
283
284 assert_eq!(entries_under(root.path()), ["a", "a/b"], "{name}");
285 }
286 }
287
288 #[tokio::test]
289 async fn a_link_entry_writes_nothing_anywhere() {
290 let outside = tempfile::tempdir().unwrap();
291 let target = outside.path().join("target");
292 std::fs::write(&target, b"outside\n").unwrap();
293 let target = target.to_string_lossy();
294
295 for kind in [tar::EntryType::Symlink, tar::EntryType::Link] {
296 let dest = tempfile::tempdir().unwrap();
297
298 extract_into(
299 &hostile_tar_gz(kind, "repo/link", &target, b""),
300 dest.path(),
301 )
302 .await;
303
304 assert!(entries_under(dest.path()).is_empty(), "{kind:?}");
305 assert_eq!(
306 std::fs::read_to_string(outside.path().join("target")).unwrap(),
307 "outside\n"
308 );
309 }
310 }
311
312 #[tokio::test]
313 async fn a_checksum_that_does_not_match_is_a_corrupt_archive() {
314 let mut archive = tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")]);
315 let crc = archive.len() - 8;
317 for byte in archive.get_mut(crc..crc + 4).unwrap() {
318 *byte ^= 0xFF;
319 }
320 let dir = tempfile::tempdir().unwrap();
321
322 let outcome = extract(archive.as_slice(), dir.path()).await;
323
324 assert!(
325 matches!(outcome, Err(ExtractError::Corrupt(_))),
326 "{outcome:?}"
327 );
328 }
329
330 #[tokio::test]
331 async fn a_stream_cut_short_is_a_truncated_archive() {
332 let archive = tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")]);
333 let dir = tempfile::tempdir().unwrap();
334
335 let outcome = extract(archive.get(..archive.len() - 4).unwrap(), dir.path()).await;
336
337 assert!(
338 matches!(outcome, Err(ExtractError::Truncated(_))),
339 "{outcome:?}"
340 );
341 }
342
343 #[tokio::test]
344 async fn a_member_under_a_file_is_an_io_failure() {
345 let archive = tar_gz(&[
346 ("repo", b"a file"),
347 ("repo/HEAD", b"ref: refs/heads/trunk\n"),
348 ]);
349 let dir = tempfile::tempdir().unwrap();
350
351 let outcome = extract(archive.as_slice(), dir.path()).await;
352
353 assert!(matches!(outcome, Err(ExtractError::Io(_))), "{outcome:?}");
354 }
355
356 #[cfg(unix)]
357 #[tokio::test]
358 async fn an_entry_keeps_no_setuid_bit_nor_write_for_others() {
359 use std::os::unix::fs::PermissionsExt;
360 let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
361 let mut header = tar::Header::new_gnu();
362 header.set_size(0);
363 header.set_mode(0o4777);
364 builder
365 .append_data(&mut header, "repo/run", std::io::empty())
366 .unwrap();
367 let dir = extract_to_temp(&builder.into_inner().unwrap().finish().unwrap()).await;
368
369 let mode = std::fs::metadata(dir.path().join("repo/run"))
370 .unwrap()
371 .permissions()
372 .mode();
373 assert_eq!(mode & 0o7777, 0o755);
374 }
375
376 #[tokio::test]
377 async fn the_entry_mtime_is_preserved() {
378 let dir = extract_to_temp(&tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")])).await;
379
380 let modified = std::fs::metadata(dir.path().join("repo/HEAD"))
381 .unwrap()
382 .modified()
383 .unwrap();
384 assert_eq!(
385 modified,
386 SystemTime::UNIX_EPOCH
387 .checked_add(Duration::from_secs(MTIME))
388 .unwrap()
389 );
390 }
391}