rustploy 0.1.21

Rustploy PaaS daemon — lightweight self-hosted deployment platform
//! Upload de `.zip` para serviço com fonte Archive: valida o tamanho e extrai
//! com proteção contra path traversal.

use std::{
    fs,
    io::{self, Cursor},
    path::{Component, Path, PathBuf},
};

use anyhow::{Context, Result, anyhow};
use bytes::Bytes;
use shared::{ArchiveSource, Response as RpResponse, ServiceSource};
use tracing::{info, warn};
use ulid::Ulid;

use crate::bootstrap::state::AppState;

pub const MAX_ZIP_BYTES: usize = 100 * 1024 * 1024;

pub async fn handle(
    state: AppState,
    service_id: String,
    bytes: Bytes,
    original_filename: Option<String>,
) -> RpResponse {
    if bytes.is_empty() {
        return RpResponse::err("InvalidArchive", "zip vazio");
    }
    if bytes.len() > MAX_ZIP_BYTES {
        return RpResponse::err("ArchiveTooLarge", "zip excede 100 MiB");
    }

    let svc = match crate::store::services::get(&state.db, &service_id).await {
        Ok(Some(s)) => s,
        Ok(None) => return RpResponse::err("NotFound", "service not found"),
        Err(e) => return RpResponse::err("DatabaseError", e.to_string()),
    };

    if matches!(svc.spec.source, ServiceSource::Compose(_)) {
        return RpResponse::err(
            "InvalidServiceType",
            "upload de zip só é permitido para services do tipo application",
        );
    }

    let archive_id = format!("arc_{}", Ulid::new());
    let extract_dir = archive_extract_dir(&state.db_path, &service_id, &archive_id);
    if let Err(e) = extract_zip(&bytes, &extract_dir) {
        let _ = fs::remove_dir_all(&extract_dir);
        warn!(service_id = %service_id, archive_id = %archive_id, error = %e, "service_archive_upload: zip inválido");
        return RpResponse::err("InvalidArchive", e.to_string());
    }

    if !extract_dir.join("Dockerfile").is_file() {
        let _ = fs::remove_dir_all(&extract_dir);
        return RpResponse::err(
            "MissingDockerfile",
            "o zip precisa conter um Dockerfile na raiz",
        );
    }

    let mut spec = svc.spec.clone();
    spec.source = ServiceSource::Archive(ArchiveSource {
        archive_id: archive_id.clone(),
        original_filename,
        dockerfile_path: "Dockerfile".into(),
        build_context: ".".into(),
    });

    match crate::store::services::update_spec(&state.db, &service_id, spec).await {
        Ok(Some(updated)) => {
            info!(service_id = %service_id, archive_id = %archive_id, "service_archive_upload: archive associado ao serviço");
            RpResponse::Service(Box::new(updated))
        }
        Ok(None) => RpResponse::err("NotFound", "service not found"),
        Err(e) => {
            let _ = fs::remove_dir_all(&extract_dir);
            RpResponse::err("DatabaseError", e.to_string())
        }
    }
}

pub fn archive_extract_dir(db_path: &Path, service_id: &str, archive_id: &str) -> PathBuf {
    db_path
        .join("uploads")
        .join("services")
        .join(service_id)
        .join(archive_id)
}

pub fn extract_zip(bytes: &[u8], dest: &Path) -> Result<()> {
    fs::create_dir_all(dest).with_context(|| format!("criando {}", dest.display()))?;
    let reader = Cursor::new(bytes);
    let mut archive = zip::ZipArchive::new(reader).context("arquivo não é um zip válido")?;

    for i in 0..archive.len() {
        let mut file = archive.by_index(i)?;
        let Some(rel) = safe_zip_path(file.name()) else {
            return Err(anyhow!("zip contém caminho inseguro: {}", file.name()));
        };
        if rel.as_os_str().is_empty() {
            continue;
        }
        let out = dest.join(rel);
        if file.is_dir() {
            fs::create_dir_all(&out)?;
            continue;
        }
        if let Some(parent) = out.parent() {
            fs::create_dir_all(parent)?;
        }
        let mut output = fs::File::create(&out)?;
        io::copy(&mut file, &mut output)?;
    }

    Ok(())
}

fn safe_zip_path(name: &str) -> Option<PathBuf> {
    let path = Path::new(name);
    let mut out = PathBuf::new();
    for component in path.components() {
        match component {
            Component::Normal(part) => out.push(part),
            Component::CurDir => {}
            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
        }
    }
    Some(out)
}