fluidattacks-core-git 0.39.0

Fluid Attacks Core Git
Documentation
use anyhow::{Context, Result};
use flate2::read::GzDecoder;
use std::fs;
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use std::time::{Duration, SystemTime};
use tar::Archive;
use tracing::{debug, error, info};

pub fn extract<R: Read>(reader: R, dest_dir: &Path) -> Result<()> {
    let abs_dest = dest_dir.canonicalize().context("resolving dest dir")?;

    let decoder = GzDecoder::new(reader);
    let mut archive = Archive::new(decoder);

    let mut entry_count = 0u64;
    for entry_result in archive.entries().context("reading tar entries")? {
        let mut entry = entry_result.context("reading tar entry")?;
        entry_count = entry_count.saturating_add(1);

        let path = entry.path().context("reading entry path")?.into_owned();

        if entry_count <= 10 {
            info!(
                name = %path.display(),
                kind = entry.header().entry_type().as_byte(),
                size = entry.header().size().unwrap_or(0),
                "tar entry"
            );
        }

        match entry_action(&path, &entry, &abs_dest) {
            EntryAction::Skip => {
                debug!(name = %path.display(), "skipping tar entry");
                continue;
            }
            EntryAction::Process => {}
        }

        let target = abs_dest.join(clean_path(&path));
        write_entry(&mut entry, &target)?;
    }

    info!(total_entries = entry_count, "tar extraction complete");
    Ok(())
}

fn write_entry(entry: &mut tar::Entry<impl Read>, target: &Path) -> Result<()> {
    match entry.header().entry_type() {
        tar::EntryType::Directory => {
            fs::create_dir_all(target)
                .with_context(|| format!("creating directory {}", target.display()))?;
        }
        tar::EntryType::Regular | tar::EntryType::Continuous => write_file(entry, target)?,
        _ => {}
    }
    Ok(())
}

fn write_file(entry: &mut tar::Entry<impl Read>, target: &Path) -> Result<()> {
    if let Some(parent) = target.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("creating parent dir for {}", target.display()))?;
    }

    let mode = entry.header().mode().unwrap_or(0o644);
    let mtime = entry.header().mtime().ok();
    let mut file =
        fs::File::create(target).with_context(|| format!("creating file {}", target.display()))?;
    std::io::copy(entry, &mut file)
        .with_context(|| format!("writing file {}", target.display()))?;

    if let Some(modified) =
        mtime.and_then(|secs| SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(secs)))
    {
        file.set_modified(modified).ok();
    }
    drop(file);

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(target, fs::Permissions::from_mode(mode)).ok();
    }
    Ok(())
}

fn clean_path(path: &Path) -> PathBuf {
    let mut clean = PathBuf::new();
    for component in path.components() {
        if let Component::Normal(c) = component {
            clean.push(c);
        }
    }
    clean
}

enum EntryAction {
    Process,
    Skip,
}

fn entry_action(path: &Path, entry: &tar::Entry<impl Read>, dest_dir: &Path) -> EntryAction {
    let entry_type = entry.header().entry_type();
    if entry_type == tar::EntryType::Symlink || entry_type == tar::EntryType::Link {
        return EntryAction::Skip;
    }

    let target = dest_dir.join(clean_path(path));
    if path.is_absolute()
        || path
            .components()
            .any(|component| matches!(component, Component::ParentDir))
        || !target.starts_with(dest_dir)
    {
        error!(name = %path.display(), "unsafe path detected");
        return EntryAction::Skip;
    }

    EntryAction::Process
}

#[cfg(test)]
mod tests {
    use super::extract;
    use flate2::write::GzEncoder;
    use flate2::Compression;
    use std::io::BufReader;
    use std::time::{Duration, SystemTime};

    const MTIME: u64 = 1_700_000_000;

    fn tar_gz(entries: &[(&str, &[u8])]) -> Vec<u8> {
        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
        builder.preserve_absolute(true);
        for (name, contents) in entries {
            let mut header = tar::Header::new_gnu();
            header.set_size(u64::try_from(contents.len()).unwrap());
            header.set_mode(0o644);
            header.set_mtime(MTIME);
            builder.append_data(&mut header, name, *contents).unwrap();
        }
        builder.into_inner().unwrap().finish().unwrap()
    }

    fn extract_to_temp(archive: &[u8]) -> tempfile::TempDir {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("repo.tar.gz");
        std::fs::write(&path, archive).unwrap();

        extract(
            BufReader::new(std::fs::File::open(&path).unwrap()),
            dir.path(),
        )
        .unwrap();

        dir
    }

    #[test]
    fn an_archive_is_extracted_from_a_reader_over_a_file() {
        let dir = extract_to_temp(&tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")]));

        assert_eq!(
            std::fs::read_to_string(dir.path().join("repo/HEAD")).unwrap(),
            "ref: refs/heads/trunk\n"
        );
    }

    #[test]
    fn an_absolute_entry_is_skipped_and_the_rest_is_extracted() {
        let dir = extract_to_temp(&tar_gz(&[
            ("/etc/passwd", b"root::0:0:::\n"),
            ("repo/HEAD", b"ref: refs/heads/trunk\n"),
        ]));

        assert!(!dir.path().join("etc/passwd").exists());
        assert!(dir.path().join("repo/HEAD").exists());
    }

    #[test]
    fn a_name_holding_two_dots_is_not_a_traversal() {
        let dir = extract_to_temp(&tar_gz(&[("repo/a..b", b"kept\n")]));

        assert_eq!(
            std::fs::read_to_string(dir.path().join("repo/a..b")).unwrap(),
            "kept\n"
        );
    }

    #[test]
    fn the_entry_mtime_is_preserved() {
        let dir = extract_to_temp(&tar_gz(&[("repo/HEAD", b"ref: refs/heads/trunk\n")]));

        let modified = std::fs::metadata(dir.path().join("repo/HEAD"))
            .unwrap()
            .modified()
            .unwrap();
        assert_eq!(
            modified,
            SystemTime::UNIX_EPOCH
                .checked_add(Duration::from_secs(MTIME))
                .unwrap()
        );
    }
}