use super::PythonError;
use sha2::{Digest, Sha256};
use std::fs::{self, File};
use std::io::{self, BufReader, Read};
use std::path::PathBuf;
const PYTHON_VERSION: &str = "3.14.2";
const DOWNLOAD_URL: &str = "https://github.com/brettcannon/cpython-wasi-build/releases/download/v3.14.2/python-3.14.2-wasi_sdk-24.zip";
const EXPECTED_SHA256: &str = "af31d6d63f8833fbaba7cd8013893fc06fdf9af3f6abfb14223d061858ac4a4f";
const DOWNLOAD_SIZE_MB: f64 = 14.0;
fn cache_dir() -> Result<PathBuf, PythonError> {
let base = dirs::cache_dir()
.ok_or_else(|| PythonError::Download("could not determine cache directory".to_string()))?;
Ok(base.join("rustledger").join("python-wasi"))
}
pub fn python_wasm_path() -> Result<PathBuf, PythonError> {
let dir = cache_dir()?;
Ok(dir.join("python.wasm"))
}
pub fn python_stdlib_path() -> Result<PathBuf, PythonError> {
let dir = cache_dir()?;
Ok(dir.join("lib"))
}
pub fn ensure_runtime() -> Result<PathBuf, PythonError> {
let wasm_path = python_wasm_path()?;
if wasm_path.exists() {
return Ok(wasm_path);
}
eprintln!("⚠️ Python plugin runtime not found.");
eprintln!("⚠️ Downloading CPython {PYTHON_VERSION} for WASI (~{DOWNLOAD_SIZE_MB:.0}MB)...");
eprintln!("⚠️ This is a one-time download.");
eprintln!();
download_and_extract()?;
if !wasm_path.exists() {
return Err(PythonError::Download(
"python.wasm not found after extraction".to_string(),
));
}
eprintln!("✓ Python WASI runtime installed.");
eprintln!();
Ok(wasm_path)
}
fn download_and_extract() -> Result<(), PythonError> {
let cache = cache_dir()?;
fs::create_dir_all(&cache)?;
let zip_path = cache.join("download.zip.tmp");
let mut response = ureq::get(DOWNLOAD_URL)
.call()
.map_err(|e| PythonError::Download(format!("HTTP request failed: {e}")))?;
{
let mut zip_file = File::create(&zip_path)
.map_err(|e| PythonError::Download(format!("failed to create temp file: {e}")))?;
let mut reader = response.body_mut().as_reader();
io::copy(&mut reader, &mut zip_file)
.map_err(|e| PythonError::Download(format!("failed to download: {e}")))?;
}
let actual_hash = {
let file = File::open(&zip_path)
.map_err(|e| PythonError::Download(format!("failed to open temp file: {e}")))?;
let mut reader = BufReader::new(file);
let mut hasher = Sha256::new();
let mut buffer = [0u8; 8192];
loop {
let bytes_read = reader
.read(&mut buffer)
.map_err(|e| PythonError::Download(format!("failed to read temp file: {e}")))?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
hex::encode(hasher.finalize())
};
if actual_hash != EXPECTED_SHA256 {
let _ = fs::remove_file(&zip_path); return Err(PythonError::ChecksumMismatch {
expected: EXPECTED_SHA256.to_string(),
actual: actual_hash,
});
}
eprintln!(" ✓ Checksum verified");
let zip_file = File::open(&zip_path)
.map_err(|e| PythonError::Download(format!("failed to open zip: {e}")))?;
let mut archive = zip::ZipArchive::new(zip_file)
.map_err(|e| PythonError::Download(format!("failed to open zip: {e}")))?;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| PythonError::Download(format!("failed to read zip entry: {e}")))?;
let outpath = match file.enclosed_name() {
Some(path) => cache.join(path),
None => continue,
};
if file.is_dir() {
fs::create_dir_all(&outpath)?;
} else {
if let Some(parent) = outpath.parent() {
fs::create_dir_all(parent)?;
}
let mut outfile = File::create(&outpath)?;
io::copy(&mut file, &mut outfile)?;
}
}
eprintln!(" ✓ Extracted to {}", cache.display());
let _ = fs::remove_file(&zip_path);
Ok(())
}
mod hex {
use std::fmt::Write;
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
bytes.as_ref().iter().fold(String::new(), |mut acc, b| {
let _ = write!(acc, "{b:02x}");
acc
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_dir() {
let dir = cache_dir().unwrap();
assert!(dir.to_string_lossy().contains("rustledger"));
assert!(dir.to_string_lossy().contains("python-wasi"));
}
#[test]
fn test_python_wasm_path() {
let path = python_wasm_path().unwrap();
assert!(path.to_string_lossy().ends_with("python.wasm"));
}
#[test]
fn test_python_stdlib_path() {
let path = python_stdlib_path().unwrap();
assert!(path.to_string_lossy().ends_with("lib"));
}
#[test]
fn test_hex_encode() {
assert_eq!(hex::encode([0xde, 0xad, 0xbe, 0xef]), "deadbeef");
}
#[test]
fn test_hex_encode_empty() {
assert_eq!(hex::encode([]), "");
}
#[test]
fn test_hex_encode_single_byte() {
assert_eq!(hex::encode([0x00]), "00");
assert_eq!(hex::encode([0xff]), "ff");
assert_eq!(hex::encode([0x0a]), "0a");
}
#[test]
fn test_constants() {
assert!(PYTHON_VERSION.starts_with("3."));
assert!(DOWNLOAD_URL.contains("cpython-wasi"));
assert_eq!(EXPECTED_SHA256.len(), 64); }
}