mc-snap 0.6.0

Declarative Minecraft server management. Pin Minecraft, loader, mods, Java, and configs in one YAML file; resolve, install, run, and ship a reproducible bundle.
Documentation
use anyhow::{bail, Context, Result};
use std::io::Read;
use std::path::{Component, Path, PathBuf};

const MAX_BUNDLE_FILE_BYTES: u64 = 256 * 1024 * 1024;

pub async fn run(bundle: &str, force: bool) -> Result<()> {
    let path = PathBuf::from(bundle);
    let f =
        std::fs::File::open(&path).with_context(|| format!("opening bundle {}", path.display()))?;
    let mut zf = zip::ZipArchive::new(f)?;
    let dst = std::env::current_dir()?;
    let dst_canon = dst.canonicalize().unwrap_or(dst.clone());

    // Refuse to clobber existing files unless asked to; a bundle silently
    // overwriting a project's mc-snap.yml is easy to miss.
    if !force {
        let mut collisions = Vec::new();
        for i in 0..zf.len() {
            let entry = zf.by_index(i)?;
            if entry.is_dir() {
                continue;
            }
            if let Some(name) = entry.enclosed_name() {
                if dst_canon.join(&name).exists() {
                    collisions.push(name.display().to_string());
                }
            }
        }
        if !collisions.is_empty() {
            bail!(
                "refusing to overwrite existing file(s): {} (re-run with --force to overwrite)",
                collisions.join(", ")
            );
        }
    }

    for i in 0..zf.len() {
        let mut entry = zf.by_index(i)?;
        let raw_name = entry
            .enclosed_name()
            .with_context(|| format!("entry {} has an unsafe name", i))?;
        validate_entry_path(&raw_name)?;

        let out_path = dst_canon.join(&raw_name);

        if entry.is_dir() {
            std::fs::create_dir_all(&out_path)?;
            continue;
        }

        // Reject symlinks outright — they're a vector for escaping the destination.
        if is_symlink(&entry) {
            bail!(
                "bundle contains a symlink entry ({}), refusing to extract",
                raw_name.display()
            );
        }

        if entry.size() > MAX_BUNDLE_FILE_BYTES {
            bail!(
                "bundle entry {} exceeds size cap ({} > {} bytes)",
                raw_name.display(),
                entry.size(),
                MAX_BUNDLE_FILE_BYTES
            );
        }

        if let Some(parent) = out_path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        ensure_under(&dst_canon, &out_path)?;

        let mut buf = Vec::with_capacity(entry.size() as usize);
        entry.read_to_end(&mut buf)?;
        std::fs::write(&out_path, &buf)?;
    }

    println!(
        "{} extracted {} to {}",
        crate::style::ok(),
        path.display(),
        dst.display()
    );
    Ok(())
}

fn validate_entry_path(p: &Path) -> Result<()> {
    if p.is_absolute() {
        bail!("zip entry has absolute path: {}", p.display());
    }
    for c in p.components() {
        match c {
            Component::Normal(_) => {}
            Component::CurDir => {}
            _ => bail!("zip entry has unsafe component: {}", p.display()),
        }
    }
    Ok(())
}

fn ensure_under(root: &Path, child: &Path) -> Result<()> {
    // Canonicalize the parent that actually exists; the leaf may not exist yet.
    let parent = child.parent().unwrap_or(child);
    let canon = parent
        .canonicalize()
        .with_context(|| format!("canonicalize {}", parent.display()))?;
    if !canon.starts_with(root) {
        bail!("zip entry would escape destination: {}", child.display());
    }
    Ok(())
}

fn is_symlink(entry: &zip::read::ZipFile) -> bool {
    // Unix mode bits encode file type. 0o120000 = symlink.
    entry
        .unix_mode()
        .map(|m| m & 0o170000 == 0o120000)
        .unwrap_or(false)
}