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) | 0o600) & 0o755;
68 let mtime = entry.header().mtime().ok();
69 let mut file =
70 fs::File::create(target).with_context(|| format!("creating file {}", target.display()))?;
71 std::io::copy(entry, &mut file)
72 .with_context(|| format!("writing file {}", target.display()))?;
73
74 if let Some(modified) =
75 mtime.and_then(|secs| SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(secs)))
76 {
77 file.set_modified(modified).ok();
78 }
79 drop(file);
80
81 #[cfg(unix)]
82 {
83 use std::os::unix::fs::PermissionsExt;
84 fs::set_permissions(target, fs::Permissions::from_mode(mode)).ok();
85 }
86 Ok(())
87}
88
89fn clean_path(path: &Path) -> PathBuf {
90 let mut clean = PathBuf::new();
91 for component in path.components() {
92 if let Component::Normal(c) = component {
93 clean.push(c);
94 }
95 }
96 clean
97}
98
99enum EntryAction {
100 Process,
101 Skip,
102}
103
104fn entry_action(path: &Path, entry: &tar::Entry<impl Read>, dest_dir: &Path) -> EntryAction {
105 let entry_type = entry.header().entry_type();
106 if entry_type == tar::EntryType::Symlink || entry_type == tar::EntryType::Link {
107 return EntryAction::Skip;
108 }
109
110 let target = dest_dir.join(clean_path(path));
111 if path.is_absolute()
112 || path
113 .components()
114 .any(|component| matches!(component, Component::ParentDir))
115 || !target.starts_with(dest_dir)
116 {
117 error!(name = %path.display(), "unsafe path detected");
118 return EntryAction::Skip;
119 }
120
121 EntryAction::Process
122}
123
124#[cfg(test)]
125mod tests {
126 use super::extract;
127 use flate2::write::GzEncoder;
128 use flate2::Compression;
129 use std::io::BufReader;
130 use std::time::{Duration, SystemTime};
131
132 const MTIME: u64 = 1_700_000_000;
133
134 fn tar_gz(entries: &[(&str, &[u8])]) -> Vec<u8> {
135 let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
136 builder.preserve_absolute(true);
137 for (name, contents) in entries {
138 let mut header = tar::Header::new_gnu();
139 header.set_size(u64::try_from(contents.len()).unwrap());
140 header.set_mode(0o644);
141 header.set_mtime(MTIME);
142 builder.append_data(&mut header, name, *contents).unwrap();
143 }
144 builder.into_inner().unwrap().finish().unwrap()
145 }
146
147 fn extract_to_temp(archive: &[u8]) -> tempfile::TempDir {
148 let dir = tempfile::tempdir().unwrap();
149 let path = dir.path().join("repo.tar.gz");
150 std::fs::write(&path, archive).unwrap();
151
152 extract(
153 BufReader::new(std::fs::File::open(&path).unwrap()),
154 dir.path(),
155 )
156 .unwrap();
157
158 dir
159 }
160
161 #[test]
162 fn an_archive_is_extracted_from_a_reader_over_a_file() {
163 let dir = extract_to_temp(&tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")]));
164
165 assert_eq!(
166 std::fs::read_to_string(dir.path().join("repo/HEAD")).unwrap(),
167 "ref: refs/heads/trunk\n"
168 );
169 }
170
171 #[test]
172 fn an_absolute_entry_is_skipped_and_the_rest_is_extracted() {
173 let dir = extract_to_temp(&tar_gz(&[
174 ("/etc/passwd", b"root::0:0:::\n"),
175 ("repo/HEAD", b"ref: refs/heads/trunk\n"),
176 ]));
177
178 assert!(!dir.path().join("etc/passwd").exists());
179 assert!(dir.path().join("repo/HEAD").exists());
180 }
181
182 #[test]
183 fn a_name_holding_two_dots_is_not_a_traversal() {
184 let dir = extract_to_temp(&tar_gz(&[("repo/a..b", b"kept\n")]));
185
186 assert_eq!(
187 std::fs::read_to_string(dir.path().join("repo/a..b")).unwrap(),
188 "kept\n"
189 );
190 }
191
192 #[cfg(unix)]
193 #[test]
194 fn an_entry_keeps_no_setuid_bit_nor_write_for_others() {
195 use std::os::unix::fs::PermissionsExt;
196 let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
197 let mut header = tar::Header::new_gnu();
198 header.set_size(0);
199 header.set_mode(0o4777);
200 builder
201 .append_data(&mut header, "repo/run", std::io::empty())
202 .unwrap();
203 let dir = extract_to_temp(&builder.into_inner().unwrap().finish().unwrap());
204
205 let mode = std::fs::metadata(dir.path().join("repo/run"))
206 .unwrap()
207 .permissions()
208 .mode();
209 assert_eq!(mode & 0o7777, 0o755);
210 }
211
212 #[test]
213 fn the_entry_mtime_is_preserved() {
214 let dir = extract_to_temp(&tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")]));
215
216 let modified = std::fs::metadata(dir.path().join("repo/HEAD"))
217 .unwrap()
218 .modified()
219 .unwrap();
220 assert_eq!(
221 modified,
222 SystemTime::UNIX_EPOCH
223 .checked_add(Duration::from_secs(MTIME))
224 .unwrap()
225 );
226 }
227}