fluidattacks_core_git/
tar.rs1use anyhow::{Context, Result};
2use flate2::read::GzDecoder;
3use std::fs;
4use std::io::Read;
5use std::path::{Component, Path, PathBuf};
6use std::time::{Duration, SystemTime};
7use tar::Archive;
8use tracing::{debug, error, info};
9
10pub fn extract<R: Read>(reader: R, dest_dir: &Path) -> Result<()> {
11 let abs_dest = dest_dir.canonicalize().context("resolving dest dir")?;
12
13 let decoder = GzDecoder::new(reader);
14 let mut archive = Archive::new(decoder);
15
16 let mut entry_count = 0u64;
17 for entry_result in archive.entries().context("reading tar entries")? {
18 let mut entry = entry_result.context("reading tar entry")?;
19 entry_count = entry_count.saturating_add(1);
20
21 let path = entry.path().context("reading entry path")?.into_owned();
22
23 if entry_count <= 10 {
24 info!(
25 name = %path.display(),
26 kind = entry.header().entry_type().as_byte(),
27 size = entry.header().size().unwrap_or(0),
28 "tar entry"
29 );
30 }
31
32 match entry_action(&path, &entry, &abs_dest) {
33 EntryAction::Skip => {
34 debug!(name = %path.display(), "skipping tar entry");
35 continue;
36 }
37 EntryAction::Process => {}
38 }
39
40 let target = abs_dest.join(clean_path(&path));
41 write_entry(&mut entry, &target)?;
42 }
43
44 info!(total_entries = entry_count, "tar extraction complete");
45 Ok(())
46}
47
48fn write_entry(entry: &mut tar::Entry<impl Read>, target: &Path) -> Result<()> {
49 match entry.header().entry_type() {
50 tar::EntryType::Directory => {
51 fs::create_dir_all(target)
52 .with_context(|| format!("creating directory {}", target.display()))?;
53 }
54 tar::EntryType::Regular | tar::EntryType::Continuous => write_file(entry, target)?,
55 _ => {}
56 }
57 Ok(())
58}
59
60fn write_file(entry: &mut tar::Entry<impl Read>, target: &Path) -> Result<()> {
61 if let Some(parent) = target.parent() {
62 fs::create_dir_all(parent)
63 .with_context(|| format!("creating parent dir for {}", target.display()))?;
64 }
65
66 let mode = entry.header().mode().unwrap_or(0o644);
67 let mtime = entry.header().mtime().ok();
68 let mut file =
69 fs::File::create(target).with_context(|| format!("creating file {}", target.display()))?;
70 std::io::copy(entry, &mut file)
71 .with_context(|| format!("writing file {}", target.display()))?;
72
73 if let Some(modified) =
74 mtime.and_then(|secs| SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(secs)))
75 {
76 file.set_modified(modified).ok();
77 }
78 drop(file);
79
80 #[cfg(unix)]
81 {
82 use std::os::unix::fs::PermissionsExt;
83 fs::set_permissions(target, fs::Permissions::from_mode(mode)).ok();
84 }
85 Ok(())
86}
87
88fn clean_path(path: &Path) -> PathBuf {
89 let mut clean = PathBuf::new();
90 for component in path.components() {
91 if let Component::Normal(c) = component {
92 clean.push(c);
93 }
94 }
95 clean
96}
97
98enum EntryAction {
99 Process,
100 Skip,
101}
102
103fn entry_action(path: &Path, entry: &tar::Entry<impl Read>, dest_dir: &Path) -> EntryAction {
104 let entry_type = entry.header().entry_type();
105 if entry_type == tar::EntryType::Symlink || entry_type == tar::EntryType::Link {
106 return EntryAction::Skip;
107 }
108
109 let target = dest_dir.join(clean_path(path));
110 if path.is_absolute()
111 || path
112 .components()
113 .any(|component| matches!(component, Component::ParentDir))
114 || !target.starts_with(dest_dir)
115 {
116 error!(name = %path.display(), "unsafe path detected");
117 return EntryAction::Skip;
118 }
119
120 EntryAction::Process
121}
122
123#[cfg(test)]
124mod tests {
125 use super::extract;
126 use flate2::write::GzEncoder;
127 use flate2::Compression;
128 use std::io::BufReader;
129 use std::time::{Duration, SystemTime};
130
131 const MTIME: u64 = 1_700_000_000;
132
133 fn tar_gz(entries: &[(&str, &[u8])]) -> Vec<u8> {
134 let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
135 builder.preserve_absolute(true);
136 for (name, contents) in entries {
137 let mut header = tar::Header::new_gnu();
138 header.set_size(u64::try_from(contents.len()).unwrap());
139 header.set_mode(0o644);
140 header.set_mtime(MTIME);
141 builder.append_data(&mut header, name, *contents).unwrap();
142 }
143 builder.into_inner().unwrap().finish().unwrap()
144 }
145
146 fn extract_to_temp(archive: &[u8]) -> tempfile::TempDir {
147 let dir = tempfile::tempdir().unwrap();
148 let path = dir.path().join("repo.tar.gz");
149 std::fs::write(&path, archive).unwrap();
150
151 extract(
152 BufReader::new(std::fs::File::open(&path).unwrap()),
153 dir.path(),
154 )
155 .unwrap();
156
157 dir
158 }
159
160 #[test]
161 fn an_archive_is_extracted_from_a_reader_over_a_file() {
162 let dir = extract_to_temp(&tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")]));
163
164 assert_eq!(
165 std::fs::read_to_string(dir.path().join("repo/HEAD")).unwrap(),
166 "ref: refs/heads/trunk\n"
167 );
168 }
169
170 #[test]
171 fn an_absolute_entry_is_skipped_and_the_rest_is_extracted() {
172 let dir = extract_to_temp(&tar_gz(&[
173 ("/etc/passwd", b"root::0:0:::\n"),
174 ("repo/HEAD", b"ref: refs/heads/trunk\n"),
175 ]));
176
177 assert!(!dir.path().join("etc/passwd").exists());
178 assert!(dir.path().join("repo/HEAD").exists());
179 }
180
181 #[test]
182 fn a_name_holding_two_dots_is_not_a_traversal() {
183 let dir = extract_to_temp(&tar_gz(&[("repo/a..b", b"kept\n")]));
184
185 assert_eq!(
186 std::fs::read_to_string(dir.path().join("repo/a..b")).unwrap(),
187 "kept\n"
188 );
189 }
190
191 #[test]
192 fn the_entry_mtime_is_preserved() {
193 let dir = extract_to_temp(&tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")]));
194
195 let modified = std::fs::metadata(dir.path().join("repo/HEAD"))
196 .unwrap()
197 .modified()
198 .unwrap();
199 assert_eq!(
200 modified,
201 SystemTime::UNIX_EPOCH
202 .checked_add(Duration::from_secs(MTIME))
203 .unwrap()
204 );
205 }
206}