Skip to main content

fluidattacks_core_git/
tar.rs

1use 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}