fluidattacks-core 0.1.5

Fluid Attacks Core Library
Documentation
use anyhow::{bail, Context, Result};
use flate2::read::GzDecoder;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use tar::Archive;
use tracing::{debug, info};

pub fn extract(data: &[u8], dest_dir: &Path) -> Result<()> {
    let abs_dest = dest_dir.canonicalize().context("resolving dest dir")?;

    let decoder = GzDecoder::new(data);
    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 += 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 validate_entry(&path, &entry, &abs_dest)? {
            EntryAction::Skip => {
                debug!(name = %path.display(), "skipping tar entry");
                continue;
            }
            EntryAction::Process => {}
        }

        let target = abs_dest.join(clean_path(&path));

        match entry.header().entry_type() {
            tar::EntryType::Directory => {
                fs::create_dir_all(&target)
                    .with_context(|| format!("creating directory {}", target.display()))?;
            }
            tar::EntryType::Regular => {
                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 mut contents = Vec::new();
                entry
                    .read_to_end(&mut contents)
                    .with_context(|| format!("reading file {}", target.display()))?;
                fs::write(&target, &contents)
                    .with_context(|| format!("writing file {}", target.display()))?;
                #[cfg(unix)]
                {
                    use std::os::unix::fs::PermissionsExt;
                    fs::set_permissions(&target, fs::Permissions::from_mode(mode)).ok();
                }
            }
            _ => {
                continue;
            }
        }
    }

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

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

enum EntryAction {
    Process,
    Skip,
}

fn validate_entry(
    path: &Path,
    entry: &tar::Entry<impl Read>,
    dest_dir: &Path,
) -> Result<EntryAction> {
    let path_str = path.to_string_lossy();

    if path.is_absolute() {
        bail!("tar contains absolute path: {path_str}");
    }

    if path_str.contains("..") {
        bail!("tar contains path traversal: {path_str}");
    }

    let target = dest_dir.join(clean_path(path));
    if !target.starts_with(dest_dir) {
        bail!("tar entry escapes destination: {path_str}");
    }

    let entry_type = entry.header().entry_type();
    if entry_type == tar::EntryType::Symlink || entry_type == tar::EntryType::Link {
        return Ok(EntryAction::Skip);
    }

    Ok(EntryAction::Process)
}