use anyhow::{Context, Result};
use colored::Colorize;
use sha2::{Digest, Sha256};
use std::io::Read;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BundleFormat {
TarZst,
TarGz,
}
impl BundleFormat {
pub fn extension(&self) -> &str {
match self {
Self::TarZst => "tar.zst",
Self::TarGz => "tar.gz",
}
}
pub fn from_path(path: &Path) -> Option<Self> {
let s = path.to_string_lossy();
if s.ends_with(".tar.zst") {
Some(Self::TarZst)
} else if s.ends_with(".tar.gz") || s.ends_with(".tgz") {
Some(Self::TarGz)
} else {
None
}
}
pub fn parse(s: &str) -> Result<Self> {
match s {
"tar.zst" | "zst" => Ok(Self::TarZst),
"tar.gz" | "gz" | "tgz" => Ok(Self::TarGz),
_ => anyhow::bail!("unsupported format '{}'. Use 'tar.zst' or 'tar.gz'", s),
}
}
}
pub fn extract_and_verify_bundle(bundle_path: &Path) -> Result<(PathBuf, PathBuf)> {
let bundle_abs = std::path::absolute(bundle_path)
.with_context(|| format!("failed to resolve bundle path: {}", bundle_path.display()))?;
let extract_dir = tempfile::Builder::new()
.prefix("oxo-bundle-")
.tempdir()
.context("failed to create temporary directory for bundle extraction")?;
let format = BundleFormat::from_path(&bundle_abs).unwrap_or(BundleFormat::TarZst); let file = std::fs::File::open(&bundle_abs)
.with_context(|| format!("failed to open bundle: {}", bundle_abs.display()))?;
let reader: Box<dyn Read> = match format {
BundleFormat::TarZst => Box::new(
zstd::stream::read::Decoder::new(file).context("failed to decompress bundle (zstd)")?,
),
BundleFormat::TarGz => Box::new(flate2::read::GzDecoder::new(file)),
};
let mut archive = tar::Archive::new(reader);
archive
.unpack(extract_dir.path())
.context("failed to extract bundle")?;
let manifest_path = extract_dir.path().join("manifest.json");
let manifest_json = std::fs::read_to_string(&manifest_path)
.context("bundle is missing manifest.json — not a valid oxo-flow bundle")?;
let manifest: serde_json::Value =
serde_json::from_str(&manifest_json).context("failed to parse manifest.json")?;
let format = manifest["format"].as_str().unwrap_or("unknown");
if format != "oxoflow-bundle-v1" {
anyhow::bail!(
"unsupported bundle format '{}' — expected 'oxoflow-bundle-v1'",
format
);
}
let entrypoint = manifest["entrypoint"]
.as_str()
.context("manifest missing 'entrypoint' field")?;
let workflow_path = extract_dir.path().join(entrypoint);
if !workflow_path.exists() {
anyhow::bail!(
"bundle entrypoint '{}' not found in archive",
workflow_path.display()
);
}
if let Some(resources) = manifest.get("resources") {
if let Some(recommendations) = resources.get("recommendations") {
eprintln!("{}", "Bundle resource requirements:".bold().underline());
if let Some(t) = recommendations["min_threads"].as_u64() {
eprintln!(" Min threads: {}", t.to_string().cyan());
}
if let Some(m) = recommendations["min_memory_mb"].as_u64() {
let gb = m as f64 / 1024.0;
eprintln!(" Min memory: {} ({:.1} GB)", m.to_string().cyan(), gb);
}
if let Some(g) = recommendations["min_gpu"].as_u64()
&& g > 0
{
eprintln!(" Min GPU: {}", g.to_string().cyan());
}
}
if let Some(rules) = resources.get("rules").and_then(|r| r.as_array()) {
eprintln!(
" {} rules with resource declarations",
rules.len().to_string().cyan()
);
}
eprintln!();
}
let files = manifest["files"]
.as_array()
.context("manifest missing 'files' array")?;
let mut verified = 0usize;
for file_entry in files {
let path = file_entry["path"]
.as_str()
.context("file entry missing 'path'")?;
let expected_sha = file_entry["sha256"]
.as_str()
.context("file entry missing 'sha256'")?;
let file_path = extract_dir.path().join(path);
if !file_path.exists() {
anyhow::bail!(
"file '{}' declared in manifest but missing from archive",
path
);
}
let actual_sha = compute_sha256(&file_path)?;
if actual_sha != expected_sha {
anyhow::bail!(
"checksum mismatch for '{}':\n expected: {}\n actual: {}\nBundle verification failed.",
path,
expected_sha,
actual_sha
);
}
verified += 1;
}
eprintln!(
"{} Bundle verified: {}/{} files OK",
"✓".green(),
verified,
files.len()
);
Ok((workflow_path, extract_dir.keep()))
}
fn compute_sha256(path: &Path) -> Result<String> {
let file = std::fs::File::open(path)?;
let mut reader = std::io::BufReader::with_capacity(65536, file);
let mut hasher = Sha256::new();
let mut buf = [0u8; 65536];
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(format!("sha256:{:x}", hasher.finalize()))
}
pub fn find_manifest_in_dir(dir: &Path) -> Result<PathBuf> {
let manifest_path = dir.join("manifest.json");
if manifest_path.exists() {
Ok(manifest_path)
} else {
anyhow::bail!(
"manifest.json not found in extracted bundle: {}",
dir.display()
)
}
}
pub fn can_prompt_for_confirmation(json: bool, stderr_is_tty: bool, stdin_is_tty: bool) -> bool {
!json && stderr_is_tty && stdin_is_tty
}
#[cfg(test)]
mod tests {
use super::can_prompt_for_confirmation;
#[test]
fn prompts_only_on_a_fully_interactive_terminal() {
assert!(can_prompt_for_confirmation(false, true, true));
}
#[test]
fn redirected_stdin_is_not_promptable() {
assert!(!can_prompt_for_confirmation(false, true, false));
}
#[test]
fn piped_stderr_is_not_promptable() {
assert!(!can_prompt_for_confirmation(false, false, true));
}
#[test]
fn json_is_never_promptable() {
assert!(!can_prompt_for_confirmation(true, true, true));
}
}