use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result, bail};
use futures_util::StreamExt;
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;
use crate::shared::i18n::Locale;
use crate::shared::sandbox::{SandboxRunner, WasmerSandbox, locate_wasmer};
pub const WASMER_VERSION: &str = "7.2.0";
const PYTHON_PACKAGE: &str = "python/python@=3.13.5";
const PYTHON_PACKAGE_SHA256: &str =
"c03ebe0946e66edf598fd7a1f192101f60e4e9c0095aecd04e049989692bdcab";
const USER_AGENT: &str = "mindfork-sandbox-setup";
struct PlatformArchive {
os: &'static str,
arch: &'static str,
url: &'static str,
sha256: &'static str,
}
const ARCHIVES: &[PlatformArchive] = &[
PlatformArchive {
os: "windows",
arch: "x86_64",
url: "https://github.com/wasmerio/wasmer/releases/download/v7.2.0/wasmer-windows-amd64.tar.gz",
sha256: "140cc0085f97f2b0150c6803051fad4fe379d7b79ade819283e1f174d0148c48",
},
PlatformArchive {
os: "linux",
arch: "x86_64",
url: "https://github.com/wasmerio/wasmer/releases/download/v7.2.0/wasmer-linux-amd64.tar.gz",
sha256: "fce71a4b0d504b9925e2461d1368b24cce60001111edb3fa871df8187a8a40f2",
},
PlatformArchive {
os: "linux",
arch: "aarch64",
url: "https://github.com/wasmerio/wasmer/releases/download/v7.2.0/wasmer-linux-aarch64.tar.gz",
sha256: "e0033919790592b38410ea38e9b327f3c17ebb355e40f3f6db5191bad5ec3b31",
},
PlatformArchive {
os: "macos",
arch: "aarch64",
url: "https://github.com/wasmerio/wasmer/releases/download/v7.2.0/wasmer-darwin-arm64.tar.gz",
sha256: "b1f087a7472d30e4a61cd7918925a69daacf304b50d67d511326b09e1847b2a4",
},
];
struct Wheel {
dir: &'static str,
url: &'static str,
sha256: &'static str,
}
const WHEEL_LOCK: &str = r"
# numpy and pandas (native), with pandas' pure dependencies
numpy f2abcba47de3063e00fd960b17058bf14954fb3485e58153ba6925447d28af55 https://pythonindex.wasix.org/packages/numpy-2.3.2-cp313-cp313-wasix_wasm32.whl
pandas 9b7d0e64cd3bebe36dedb4a2d888a0df6dbc50a53011c2d6e96d4dac95eadd67 https://pythonindex.wasix.org/packages/pandas-2.3.2-cp313-cp313-wasix_wasm32.whl
dateutil a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
six.py 4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl
pytz 04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126 https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl
tzdata dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931 https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl
# requests and its stack
requests 2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl
urllib3 9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl
certifi 2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl
idna 7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl
charset_normalizer 68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl
# beautifulsoup4 (the package directory is bs4) with its dependencies, and lxml, its fast parser
bs4 d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9 https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl
soupsieve 4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl
typing_extensions.py 481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl
lxml e291262d4c8b3ff77b96192b1792217172a5b7ed9bc72d116681eb1cc1b3b0f2 https://pythonindex.wasix.org/packages/lxml-6.0.0-cp313-cp313-wasix_wasm32.whl
# symbolic maths (mpmath stays below 1.4: sympy 1.14 requires it), graphs, text tables
sympy e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5 https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl
mpmath a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl
networkx d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762 https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl
tabulate f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3 https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl
# documents and data formats
openpyxl 5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl
et_xmlfile 7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl
pypdf ee93a2665670ecf57ee81d197a4ca548f3dc15f9cefc56e59b8140866aaa3de5 https://files.pythonhosted.org/packages/58/13/645df3995075112cb3cce15e8797c205f0f88fb50acc11012b84b071bc22/pypdf-6.18.1-py3-none-any.whl
yaml 4f50fc46ee0b3cf07ec2fd1ac9c2673112f612d7620d3e6e9cbb4ee936f09613 https://pythonindex.wasix.org/packages/pyyaml-6.0.2-cp313-cp313-wasix_wasm32.whl
regex e34127cee917b9e253a6491a9bf97933c0dd8cc7a780ae2f330b3a4155fa6942 https://pythonindex.wasix.org/packages/regex-2025.7.31-cp313-cp313-wasix_wasm32.whl
feedparser e35e3f760151b0c3b22cac9684155cae186a233e16c49bcbc6c49e91e3131137 https://files.pythonhosted.org/packages/7f/61/f04912e63702e73fb2a378f9c0a1ad9eb17a334a11a6b3fe1daa593903c2/feedparser-6.0.14-py3-none-any.whl
feedparser_sgmllib 2cab2d43b95a954f920f18aebce7a4dbbb3f539780b127e2aa114f579821e01d https://files.pythonhosted.org/packages/85/a0/79a31f898092e145bd66e2b338fb0656979acb2bbbcae8220940fbfcd820/feedparser_sgmllib-2.1.0-py3-none-any.whl
# images and charts: pillow (the package directory is PIL), matplotlib with its dependencies
PIL 01027bc1330d8e42ac00bb48f7529b4615b1fe7569ac224ac24454cbed15102d https://pythonindex.wasix.org/packages/pillow-11.3.0-cp313-cp313-wasix_wasm32.whl
matplotlib fa226ffdbd88bbda1ef5f894f944d4abaffbfe66b134259331e7fed8e960e86f https://pythonindex.wasix.org/packages/matplotlib-3.10.6-cp313-cp313-wasix_wasm32.whl
contourpy 25d7b4511bda7f6a70a3c0094d915a40be4794ca7cc05366bdd71351b45507db https://pythonindex.wasix.org/packages/contourpy-1.3.3-cp313-cp313-wasix_wasm32.whl
kiwisolver 2c0a040c9d944a12eefb9a086c2037443f617085af0434516cfe254fb5220ec8 https://pythonindex.wasix.org/packages/kiwisolver-1.4.9-cp313-cp313-wasix_wasm32.whl
cycler 85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30 https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl
fontTools 3060b8c1fc2329fa20265b7c138614143ea7c1624e26c5c180c76aeb74deae6f https://files.pythonhosted.org/packages/e6/35/f894ceb867118c0261d0f69a9bd516b045a3754238f76c88a49513ac7a83/fonttools-4.65.0-py3-none-any.whl
packaging d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl
pyparsing 850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl
";
fn wheels() -> impl Iterator<Item = Wheel> {
WHEEL_LOCK
.lines()
.map(str::trim)
.filter(|row| !row.is_empty() && !row.starts_with('#'))
.filter_map(|row| {
let mut fields = row.split_whitespace();
let (dir, sha256, url) = (fields.next()?, fields.next()?, fields.next()?);
fields
.next()
.is_none()
.then_some(Wheel { dir, url, sha256 })
})
}
#[derive(Debug, Clone, Default)]
pub struct SetupOptions {
pub force: bool,
}
pub async fn setup(
dir: &Path,
opts: &SetupOptions,
loc: &Locale,
mut progress: impl FnMut(&str),
) -> Result<()> {
tokio::fs::create_dir_all(dir).await.with_context(|| {
loc.tf(
"sandbox.setup.mkdir",
&[("path", &dir.display().to_string())],
)
})?;
let client = http_client(loc)?;
let wasmer = ensure_wasmer(&client, dir, opts, loc, &mut progress).await?;
ensure_python_webc(dir, &wasmer, opts, loc, &mut progress).await?;
ensure_wheels(&client, dir, opts, loc, &mut progress).await?;
warmup(dir, loc, &mut progress).await;
pack_image(dir, &wasmer, loc, &mut progress).await?;
verify_image(dir, loc, &mut progress).await?;
install_image(dir, loc, &mut progress).await?;
progress(loc.t("sandbox.setup.done"));
Ok(())
}
const WARMUP_SCRIPT: &str = r"
import compileall, io, os, re
compileall.compile_dir(os.environ['PYTHONPATH'], quiet=2, workers=1, rx=re.compile(r'/tests?/'))
import pandas, lxml.etree, lxml.html, yaml, regex, PIL.Image, kiwisolver, contourpy
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.set_title('warmup')
fig.savefig(io.BytesIO(), format='png')
";
const WARMUP_TIMEOUT: Duration = Duration::from_secs(600);
const PACK_STEP_TIMEOUT: Duration = Duration::from_secs(600);
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(1800);
async fn warmup(dir: &Path, loc: &Locale, progress: &mut impl FnMut(&str)) {
progress(loc.t("sandbox.setup.warmup.start"));
let sb = WasmerSandbox::for_provisioning(dir.to_path_buf());
let job = crate::shared::sandbox::SandboxJob::new(WARMUP_SCRIPT, false, WARMUP_TIMEOUT);
match sb.run(job, loc).await {
Ok(out) if out.exit_code == Some(0) => progress(loc.t("sandbox.setup.warmup.ok")),
Ok(_) => progress(loc.t("sandbox.setup.warmup.partial")),
Err(e) => progress(&loc.tf("sandbox.setup.warmup.skipped", &[("err", &e.to_string())])),
}
}
const IMAGE_STAGING: &str = "image-build";
const IMAGE_PACKAGE_ID: &str = "name = \"mindfork/sandbox\"\nversion = \"1.0.0\"\n";
const VERIFY_SCRIPT: &str = "import numpy, pandas, lxml.etree, matplotlib.pyplot";
async fn pack_image(
dir: &Path,
wasmer: &Path,
loc: &Locale,
progress: &mut impl FnMut(&str),
) -> Result<()> {
use std::ffi::OsStr;
progress(loc.t("sandbox.setup.pack.start"));
let io = |path: &str| loc.tf("sandbox.setup.pack.io", &[("path", path)]);
let staging = dir.join(IMAGE_STAGING);
let _ = tokio::fs::remove_dir_all(&staging).await;
let python = dir.join("python.webc");
let unpack: [&OsStr; 7] = [
"package".as_ref(),
"unpack".as_ref(),
"--format".as_ref(),
"package".as_ref(),
"--out-dir".as_ref(),
staging.as_os_str(),
python.as_os_str(),
];
wasmer_command(wasmer, &unpack, PACK_STEP_TIMEOUT, loc).await?;
let manifest_path = staging.join("wasmer.toml");
let shown = manifest_path.display().to_string();
let manifest = tokio::fs::read_to_string(&manifest_path)
.await
.with_context(|| io(&shown))?;
let manifest = with_site_packages(&manifest).ok_or_else(|| {
anyhow::anyhow!(
"{}",
loc.tf("sandbox.setup.pack.manifest", &[("path", &shown)])
)
})?;
tokio::fs::write(&manifest_path, manifest)
.await
.with_context(|| io(&shown))?;
let partial = dir.join(partial_image());
let build: [&OsStr; 5] = [
"package".as_ref(),
"build".as_ref(),
staging.as_os_str(),
"-o".as_ref(),
partial.as_os_str(),
];
wasmer_command(wasmer, &build, PACK_STEP_TIMEOUT, loc).await?;
let _ = tokio::fs::remove_dir_all(&staging).await;
progress(&loc.tf(
"sandbox.setup.pack.done",
&[("path", &partial.display().to_string())],
));
Ok(())
}
fn partial_image() -> String {
format!("{}.partial", crate::shared::sandbox::SANDBOX_IMAGE)
}
async fn install_image(dir: &Path, loc: &Locale, progress: &mut impl FnMut(&str)) -> Result<()> {
use crate::shared::sandbox::SANDBOX_IMAGE;
let image = dir.join(SANDBOX_IMAGE);
let shown = image.display().to_string();
tokio::fs::rename(dir.join(partial_image()), &image)
.await
.with_context(|| loc.tf("sandbox.setup.pack.io", &[("path", &shown.clone() as &str)]))?;
progress(&loc.tf("sandbox.setup.install.done", &[("path", &shown)]));
Ok(())
}
fn with_site_packages(manifest: &str) -> Option<String> {
use crate::shared::sandbox::GUEST_SITE;
let named = package_is_named(manifest);
let mut out = String::with_capacity(manifest.len() + 128);
let mut volume = false;
for line in manifest.lines() {
out.push_str(line);
out.push('\n');
match line.trim() {
"[package]" if !named => out.push_str(IMAGE_PACKAGE_ID),
"[fs]" => {
out.push_str(&format!("\"{GUEST_SITE}\" = \"../site-packages\"\n"));
volume = true;
}
_ => {}
}
}
volume.then_some(out)
}
fn package_is_named(manifest: &str) -> bool {
manifest
.lines()
.map(str::trim)
.skip_while(|l| *l != "[package]")
.skip(1)
.take_while(|l| !l.starts_with('['))
.any(|l| l.starts_with("name"))
}
async fn verify_image(dir: &Path, loc: &Locale, progress: &mut impl FnMut(&str)) -> Result<()> {
let candidate = partial_image();
let sb = WasmerSandbox::for_candidate(dir.to_path_buf(), &candidate);
let job = crate::shared::sandbox::SandboxJob::new(VERIFY_SCRIPT, false, WARMUP_TIMEOUT);
let out = sb.run(job, loc).await?;
if out.exit_code == Some(0) {
progress(loc.t("sandbox.setup.verify.ok"));
return Ok(());
}
let _ = tokio::fs::remove_file(dir.join(&candidate)).await;
if out.timed_out {
bail!(
"{}",
loc.tf(
"sandbox.setup.verify.timeout",
&[("secs", &WARMUP_TIMEOUT.as_secs().to_string())]
)
);
}
let detail = match out.stderr.trim() {
"" => out.stdout.trim(),
stderr => stderr,
};
bail!(
"{}",
loc.tf("sandbox.setup.verify.failed", &[("detail", detail)])
);
}
async fn bounded_output(
mut cmd: tokio::process::Command,
limit: Duration,
) -> Option<std::io::Result<std::process::Output>> {
cmd.kill_on_drop(true);
tokio::time::timeout(limit, cmd.output()).await.ok()
}
fn stopped(command: &str, limit: Duration, loc: &Locale) -> anyhow::Error {
anyhow::anyhow!(
"{}",
loc.tf(
"sandbox.setup.wasmer.timeout",
&[("command", command), ("secs", &limit.as_secs().to_string())],
)
)
}
async fn wasmer_command(
wasmer: &Path,
args: &[&std::ffi::OsStr],
limit: Duration,
loc: &Locale,
) -> Result<()> {
let command: Vec<_> = args.iter().take(2).map(|a| a.to_string_lossy()).collect();
let command = command.join(" ");
let mut cmd = tokio::process::Command::new(wasmer);
cmd.args(args);
let out = bounded_output(cmd, limit)
.await
.ok_or_else(|| stopped(&command, limit, loc))?
.with_context(|| {
loc.tf(
"sandbox.setup.pack.run",
&[("path", &wasmer.display().to_string())],
)
})?;
if !out.status.success() {
bail!(
"{}",
loc.tf(
"sandbox.setup.pack.failed",
&[
("command", &command),
("stderr", String::from_utf8_lossy(&out.stderr).trim()),
],
)
);
}
Ok(())
}
fn archive_for(os: &str, arch: &str) -> Option<&'static PlatformArchive> {
ARCHIVES.iter().find(|a| a.os == os && a.arch == arch)
}
async fn ensure_wasmer(
client: &reqwest::Client,
dir: &Path,
opts: &SetupOptions,
loc: &Locale,
progress: &mut impl FnMut(&str),
) -> Result<PathBuf> {
if !opts.force
&& let Some(bin) = locate_wasmer(dir)
{
progress(&loc.tf(
"sandbox.setup.wasmer.present",
&[("path", &bin.display().to_string())],
));
return Ok(bin);
}
let arch = archive_for(std::env::consts::OS, std::env::consts::ARCH).ok_or_else(|| {
anyhow::anyhow!(
"{}",
loc.tf(
"sandbox.setup.wasmer.no_platform",
&[
("os", std::env::consts::OS),
("arch", std::env::consts::ARCH),
],
)
)
})?;
let archive_path = dir.join("wasmer.tar.gz");
progress(&loc.tf(
"sandbox.setup.wasmer.downloading",
&[
("version", WASMER_VERSION),
("os", arch.os),
("arch", arch.arch),
],
));
download_to_file(client, arch.url, &archive_path, arch.sha256, loc, progress).await?;
let dist = dir.join("wasmer-dist");
progress(loc.t("sandbox.setup.wasmer.extracting"));
let _ = tokio::fs::remove_dir_all(&dist).await;
extract_targz(&archive_path, &dist, loc)
.with_context(|| loc.t("sandbox.setup.wasmer.extract_ctx").to_string())?;
let _ = tokio::fs::remove_file(&archive_path).await;
locate_wasmer(dir).ok_or_else(|| {
anyhow::anyhow!(
"{}",
loc.tf(
"sandbox.setup.wasmer.not_found",
&[("path", &dist.display().to_string())],
)
)
})
}
async fn ensure_python_webc(
dir: &Path,
wasmer: &Path,
opts: &SetupOptions,
loc: &Locale,
progress: &mut impl FnMut(&str),
) -> Result<()> {
let webc = dir.join("python.webc");
if webc.is_file() && !opts.force {
match verify_file_sha256(&webc, PYTHON_PACKAGE_SHA256, PYTHON_PACKAGE, loc).await {
Ok(()) => {
progress(loc.t("sandbox.setup.webc.present"));
return Ok(());
}
Err(err) => {
progress(&loc.tf("sandbox.setup.webc.stale", &[("reason", &err.to_string())]))
}
}
}
progress(&loc.tf("sandbox.setup.webc.downloading", &[("pkg", PYTHON_PACKAGE)]));
let home = dir.join("wasmer-home");
tokio::fs::create_dir_all(&home).await.ok();
let mut download = tokio::process::Command::new(wasmer);
download
.arg("package")
.arg("download")
.arg(PYTHON_PACKAGE)
.arg("-o")
.arg(&webc)
.arg("--wasmer-dir")
.arg(&home);
let out = bounded_output(download, DOWNLOAD_TIMEOUT)
.await
.ok_or_else(|| stopped("package download", DOWNLOAD_TIMEOUT, loc))?
.with_context(|| {
loc.tf(
"sandbox.setup.webc.run",
&[("path", &wasmer.display().to_string())],
)
})?;
if !out.status.success() {
bail!(
"{}",
loc.tf(
"sandbox.setup.webc.failed",
&[("stderr", String::from_utf8_lossy(&out.stderr).trim())],
)
);
}
anyhow::ensure!(
webc.is_file(),
"{}",
loc.t("sandbox.setup.webc.not_created")
);
verify_file_sha256(&webc, PYTHON_PACKAGE_SHA256, PYTHON_PACKAGE, loc).await
}
async fn ensure_wheels(
client: &reqwest::Client,
dir: &Path,
opts: &SetupOptions,
loc: &Locale,
progress: &mut impl FnMut(&str),
) -> Result<()> {
let site = dir.join("site-packages");
tokio::fs::create_dir_all(&site)
.await
.with_context(|| loc.t("sandbox.setup.wheels.mksite").to_string())?;
for w in wheels() {
if site.join(w.dir).exists() && !opts.force {
progress(&loc.tf("sandbox.setup.wheels.present", &[("name", w.dir)]));
continue;
}
progress(&loc.tf("sandbox.setup.wheels.downloading", &[("name", w.dir)]));
let bytes = download_bytes(client, w.url, w.sha256, loc).await?;
progress(&loc.tf("sandbox.setup.wheels.extracting", &[("name", w.dir)]));
unpack_wheel(&bytes, &site, loc)
.with_context(|| loc.tf("sandbox.setup.wheels.unpack", &[("name", w.dir)]))?;
}
Ok(())
}
fn http_client(loc: &Locale) -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent(USER_AGENT)
.build()
.with_context(|| loc.t("sandbox.setup.http_client").to_string())
}
async fn download_to_file(
client: &reqwest::Client,
url: &str,
dest: &Path,
expected: &str,
loc: &Locale,
progress: &mut impl FnMut(&str),
) -> Result<()> {
let resp = client
.get(url)
.send()
.await
.with_context(|| loc.tf("sandbox.setup.request", &[("url", url)]))?
.error_for_status()
.with_context(|| loc.tf("sandbox.setup.download", &[("url", url)]))?;
let total = resp.content_length();
let mut file = tokio::fs::File::create(dest).await.with_context(|| {
loc.tf(
"sandbox.setup.create_file",
&[("path", &dest.display().to_string())],
)
})?;
let mut hasher = Sha256::new();
let mut stream = resp.bytes_stream();
let mut done: u64 = 0;
let mut next_report: u64 = 32 * 1024 * 1024; while let Some(chunk) = stream.next().await {
let chunk = chunk.with_context(|| loc.t("sandbox.setup.read_stream").to_string())?;
hasher.update(&chunk);
file.write_all(&chunk)
.await
.with_context(|| loc.t("sandbox.setup.write_file").to_string())?;
done += chunk.len() as u64;
if done >= next_report {
match total {
Some(t) => progress(&loc.tf(
"sandbox.setup.progress_bytes",
&[
("done", &(done >> 20).to_string()),
("total", &(t >> 20).to_string()),
],
)),
None => progress(&loc.tf(
"sandbox.setup.progress_bytes_unknown",
&[("done", &(done >> 20).to_string())],
)),
}
next_report += 32 * 1024 * 1024;
}
}
file.flush()
.await
.with_context(|| loc.t("sandbox.setup.flush").to_string())?;
verify_sha256(&hasher.finalize(), expected, url, loc)
}
async fn download_bytes(
client: &reqwest::Client,
url: &str,
expected: &str,
loc: &Locale,
) -> Result<Vec<u8>> {
let bytes = client
.get(url)
.send()
.await
.with_context(|| loc.tf("sandbox.setup.request", &[("url", url)]))?
.error_for_status()
.with_context(|| loc.tf("sandbox.setup.download", &[("url", url)]))?
.bytes()
.await
.with_context(|| loc.t("sandbox.setup.read_body").to_string())?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
verify_sha256(&hasher.finalize(), expected, url, loc)?;
Ok(bytes.to_vec())
}
async fn verify_file_sha256(path: &Path, expected: &str, label: &str, loc: &Locale) -> Result<()> {
use tokio::io::AsyncReadExt;
let mut file = tokio::fs::File::open(path).await.with_context(|| {
loc.tf(
"sandbox.setup.open_file",
&[("path", &path.display().to_string())],
)
})?;
let mut hasher = Sha256::new();
let mut buf = vec![0u8; 64 * 1024];
loop {
let read = file
.read(&mut buf)
.await
.with_context(|| loc.t("sandbox.setup.read_file").to_string())?;
if read == 0 {
break;
}
hasher.update(&buf[..read]);
}
verify_sha256(&hasher.finalize(), expected, label, loc)
}
fn verify_sha256(digest: &[u8], expected: &str, url: &str, loc: &Locale) -> Result<()> {
let got = hex_lower(digest);
if got.eq_ignore_ascii_case(expected) {
Ok(())
} else {
bail!(
"{}",
loc.tf(
"sandbox.setup.sha_mismatch",
&[("url", url), ("expected", expected), ("got", &got)],
)
)
}
}
fn hex_lower(bytes: &[u8]) -> String {
use std::fmt::Write;
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
fn extract_targz(archive: &Path, dest: &Path, loc: &Locale) -> Result<()> {
let file = std::fs::File::open(archive).with_context(|| {
loc.tf(
"sandbox.setup.open_archive",
&[("path", &archive.display().to_string())],
)
})?;
let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
let mut ar = tar::Archive::new(gz);
std::fs::create_dir_all(dest)?;
ar.unpack(dest).with_context(|| {
loc.tf(
"sandbox.setup.extract_to",
&[("path", &dest.display().to_string())],
)
})?;
Ok(())
}
fn unpack_wheel(bytes: &[u8], site: &Path, loc: &Locale) -> Result<()> {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes))
.with_context(|| loc.t("sandbox.setup.open_wheel").to_string())?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i)?;
let Some(rel) = entry.enclosed_name() else {
bail!(
"{}",
loc.tf("sandbox.setup.unsafe_wheel", &[("name", entry.name())])
);
};
let out = site.join(rel);
if entry.is_dir() {
std::fs::create_dir_all(&out)?;
continue;
}
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf)?;
std::fs::write(&out, &buf)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
#[test]
fn the_python_package_is_pinned_exactly() {
let (_name, version) = PYTHON_PACKAGE
.split_once('@')
.expect("the package must carry a version selector");
assert!(
version.starts_with('='),
"`{PYTHON_PACKAGE}` is a semver *range*, not a pin - it resolves to the \
newest matching version. Use `@=<version>`."
);
}
use super::*;
use crate::shared::i18n::{Lang, locale};
fn ru() -> &'static Locale {
locale(Lang::Ru)
}
#[test]
fn archive_selection_by_platform() {
assert!(archive_for("windows", "x86_64").is_some());
assert!(archive_for("linux", "x86_64").is_some());
assert!(archive_for("linux", "aarch64").is_some());
assert!(archive_for("macos", "aarch64").is_some());
assert!(archive_for("plan9", "x86_64").is_none());
assert!(archive_for("windows", "riscv64").is_none());
}
#[test]
fn hex_encoding_is_lowercase() {
assert_eq!(hex_lower(&[0x00, 0x0f, 0xff, 0xa5]), "000fffa5");
}
#[test]
fn verify_sha256_matches_case_insensitively() {
let digest = Sha256::digest(b"hello");
let hex = hex_lower(&digest);
assert!(verify_sha256(&digest, &hex, "u", ru()).is_ok());
assert!(verify_sha256(&digest, &hex.to_uppercase(), "u", ru()).is_ok());
assert!(verify_sha256(&digest, "deadbeef", "u", ru()).is_err());
}
#[test]
fn sha_mismatch_error_is_localized() {
let digest = Sha256::digest(b"hello");
let en = verify_sha256(&digest, "deadbeef", "u", locale(Lang::En))
.unwrap_err()
.to_string();
assert!(en.contains("sha256 mismatch"), "{en}");
assert!(!en.chars().any(|c| ('а'..='я').contains(&c)), "{en}");
let r = verify_sha256(&digest, "deadbeef", "u", ru())
.unwrap_err()
.to_string();
assert!(r.contains("не совпал"), "{r}");
}
#[test]
fn lockfile_covers_the_starter_set() {
let dirs: Vec<&str> = wheels().map(|w| w.dir).collect();
let expected = "numpy pandas dateutil pytz requests urllib3 certifi idna bs4 soupsieve \
typing_extensions.py lxml sympy mpmath networkx tabulate openpyxl \
et_xmlfile pypdf yaml regex feedparser feedparser_sgmllib PIL matplotlib \
contourpy kiwisolver cycler fontTools packaging pyparsing";
for dir in expected.split_whitespace() {
assert!(dirs.contains(&dir), "missing wheel {dir}");
}
}
#[test]
fn every_lock_row_parses() {
let rows = WHEEL_LOCK
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.count();
let parsed: Vec<Wheel> = wheels().collect();
assert_eq!(parsed.len(), rows, "a lock row did not parse");
let mut dirs = std::collections::HashSet::new();
for w in &parsed {
assert!(
w.url.starts_with("https://") && w.url.ends_with(".whl"),
"{}",
w.url
);
let hex = w.sha256.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'));
assert!(w.sha256.len() == 64 && hex, "{}: {}", w.dir, w.sha256);
assert!(dirs.insert(w.dir), "{} is claimed twice", w.dir);
}
}
const UNPACKED_PYTHON_MANIFEST: &str = r#"[package]
entrypoint = "python"
[fs]
"/lib" = "./root/lib"
"/usr/local" = "./root/usr/local"
[[module]]
name = "python"
source = "./modules/python"
[[command]]
name = "python"
module = "python"
runner = "https://webc.org/runner/wasi"
[command.annotations.wasi]
atom = "python"
env = ["PYTHONEXECUTABLE=/bin/python", "TERM=dumb"]
"#;
#[test]
fn the_image_manifest_adds_site_packages_and_a_name() {
let out =
with_site_packages(UNPACKED_PYTHON_MANIFEST).expect("the pinned manifest has [fs]");
let fs_table: Vec<&str> = out
.split("[fs]\n")
.nth(1)
.unwrap()
.lines()
.take_while(|l| !l.is_empty())
.collect();
assert!(
fs_table.contains(&"\"/sp\" = \"../site-packages\""),
"{out}"
);
assert!(fs_table.contains(&"\"/lib\" = \"./root/lib\""), "{out}");
assert!(package_is_named(&out), "{out}");
assert_eq!(out.matches("name = ").count(), 3, "{out}");
assert!(out.contains("entrypoint = \"python\""), "{out}");
}
#[test]
fn the_image_manifest_keeps_a_name_and_refuses_a_manifest_without_fs() {
let named = "[package]\nname = \"x/y\"\nversion = \"1\"\n\n[fs]\n\"/lib\" = \"./lib\"\n";
let out = with_site_packages(named).unwrap();
assert_eq!(out.matches("name = ").count(), 1, "a second name: {out}");
assert!(with_site_packages("[package]\nentrypoint = \"python\"\n").is_none());
}
#[test]
fn unpack_wheel_extracts_into_site_packages() {
let mut buf = Vec::new();
{
let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
let opts = zip::write::SimpleFileOptions::default();
use std::io::Write as _;
w.start_file("mypkg/__init__.py", opts).unwrap();
w.write_all(b"x = 1\n").unwrap();
w.start_file("mypkg-1.0.dist-info/METADATA", opts).unwrap();
w.write_all(b"Name: mypkg\n").unwrap();
w.finish().unwrap();
}
let dir = tempfile::tempdir().unwrap();
unpack_wheel(&buf, dir.path(), ru()).unwrap();
assert!(dir.path().join("mypkg/__init__.py").is_file());
assert!(dir.path().join("mypkg-1.0.dist-info/METADATA").is_file());
}
#[test]
fn extract_targz_roundtrip() {
let mut gz = Vec::new();
{
let enc = flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default());
let mut tb = tar::Builder::new(enc);
let data = b"#!fake wasmer\n";
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o750);
header.set_cksum();
tb.append_data(&mut header, "bin/wasmer", &data[..])
.unwrap();
tb.into_inner().unwrap().finish().unwrap();
}
let archive = tempfile::tempdir().unwrap();
let apath = archive.path().join("a.tar.gz");
std::fs::write(&apath, &gz).unwrap();
let dest = tempfile::tempdir().unwrap();
extract_targz(&apath, dest.path(), ru()).unwrap();
assert!(dest.path().join("bin/wasmer").is_file());
}
#[test]
fn the_python_package_digest_is_a_well_formed_sha256() {
assert_eq!(PYTHON_PACKAGE_SHA256.len(), 64);
assert!(
PYTHON_PACKAGE_SHA256
.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
"expected 64 lowercase hex characters, got `{PYTHON_PACKAGE_SHA256}`"
);
}
#[tokio::test]
async fn a_file_is_verified_against_its_own_digest() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("payload.bin");
let body: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
std::fs::write(&path, &body).unwrap();
let digest = hex_lower(&Sha256::digest(&body));
verify_file_sha256(&path, &digest, "payload", ru())
.await
.expect("the digest of the bytes just written must match");
let err = verify_file_sha256(&path, &"0".repeat(64), "payload", ru())
.await
.expect_err("a wrong digest must be refused");
assert!(err.to_string().contains("payload"), "got: {err}");
}
#[tokio::test]
#[ignore = "downloads ~45 MB from the Wasmer registry"]
async fn live_the_registry_still_serves_the_pinned_python_build() {
let host = std::env::var("MINDFORK_SANDBOX_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("target/debug/data/sandbox"));
let Some(wasmer) = locate_wasmer(&host) else {
eprintln!("skip: no wasmer under {}", host.display());
return;
};
let fresh = tempfile::tempdir().unwrap();
ensure_python_webc(
fresh.path(),
&wasmer,
&SetupOptions { force: false },
ru(),
&mut |m: &str| eprintln!("{m}"),
)
.await
.expect("a fresh download must match PYTHON_PACKAGE_SHA256");
}
#[tokio::test]
async fn verifying_a_missing_file_names_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("absent.bin");
let err = verify_file_sha256(&path, &"0".repeat(64), "absent", ru())
.await
.expect_err("a missing file cannot verify");
assert!(err.to_string().contains("absent.bin"), "got: {err:#}");
}
#[tokio::test]
async fn a_step_that_does_not_finish_is_stopped_at_its_limit() {
let cmd = if cfg!(windows) {
let mut c = tokio::process::Command::new("ping");
c.args(["-n", "30", "127.0.0.1"]);
c
} else {
let mut c = tokio::process::Command::new("sleep");
c.arg("30");
c
};
let started = std::time::Instant::now();
let out = bounded_output(cmd, Duration::from_millis(300)).await;
assert!(
out.is_none(),
"past its limit the step is reported as stopped"
);
assert!(
started.elapsed() < Duration::from_secs(10),
"and not waited on: {:?}",
started.elapsed()
);
}
#[cfg(unix)]
#[tokio::test]
async fn a_wasmer_step_that_hangs_fails_naming_the_step_and_its_limit() {
use crate::shared::i18n::{Lang, locale};
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let wasmer = dir.path().join("wasmer");
let mut f = std::fs::File::create(&wasmer).unwrap();
writeln!(f, "#!/bin/sh").unwrap();
writeln!(f, "exec sleep 30").unwrap();
drop(f);
std::fs::set_permissions(&wasmer, std::fs::Permissions::from_mode(0o755)).unwrap();
let args: [&std::ffi::OsStr; 2] = ["package".as_ref(), "build".as_ref()];
let err = wasmer_command(&wasmer, &args, Duration::from_secs(1), locale(Lang::En))
.await
.expect_err("a step that hangs fails");
let msg = err.to_string();
assert!(
msg.contains("package build") && msg.contains("1 s"),
"{msg}"
);
}
#[cfg(unix)]
fn stub_wasmer(at: &Path) -> PathBuf {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let script = at.join("wasmer");
let mut f = std::fs::File::create(&script).unwrap();
writeln!(f, "#!/bin/sh").unwrap();
writeln!(f, "case \"$2\" in").unwrap();
writeln!(f, " unpack) mkdir -p \"$6\";").unwrap();
writeln!(
f,
" printf '[package]\\nname = \"a/b\"\\nversion = \"1.0.0\"\\n\\n[fs]\\n' > \"$6/wasmer.toml\" ;;"
)
.unwrap();
writeln!(f, " build) printf 'FRESHLY-PACKED' > \"$5\" ;;").unwrap();
writeln!(f, "esac").unwrap();
drop(f);
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
script
}
#[cfg(unix)]
#[tokio::test]
async fn packing_leaves_the_installed_image_alone_and_installing_replaces_it() {
use crate::shared::i18n::{Lang, locale};
use crate::shared::sandbox::SANDBOX_IMAGE;
const WORKING: &[u8] = b"the sandbox that works today";
let dir = tempfile::tempdir().unwrap();
let at = dir.path();
std::fs::write(at.join(SANDBOX_IMAGE), WORKING).unwrap();
std::fs::write(at.join("python.webc"), b"webc").unwrap();
let wasmer = stub_wasmer(at);
let loc = locale(Lang::En);
pack_image(at, &wasmer, loc, &mut |_| {}).await.unwrap();
assert_eq!(
std::fs::read(at.join(SANDBOX_IMAGE)).unwrap(),
WORKING,
"packing must not touch the image the runtime is using"
);
assert_eq!(
std::fs::read(at.join(partial_image())).unwrap(),
b"FRESHLY-PACKED",
"the candidate is what packing produces"
);
install_image(at, loc, &mut |_| {}).await.unwrap();
assert_eq!(
std::fs::read(at.join(SANDBOX_IMAGE)).unwrap(),
b"FRESHLY-PACKED",
"installing is the step that replaces it"
);
assert!(
!at.join(partial_image()).exists(),
"the candidate is consumed by the install, not left beside it"
);
}
#[tokio::test]
#[ignore = "requires a real wasmer binary (MINDFORK_SANDBOX_WASMER)"]
async fn a_candidate_that_does_not_start_leaves_the_installed_image_alone_live() {
use crate::shared::i18n::{Lang, locale};
use crate::shared::sandbox::SANDBOX_IMAGE;
if std::env::var_os("MINDFORK_SANDBOX_WASMER").is_none() {
eprintln!("skip: MINDFORK_SANDBOX_WASMER not set");
return;
}
const WORKING: &[u8] = b"the sandbox that works today";
let dir = tempfile::tempdir().unwrap();
let at = dir.path();
std::fs::write(at.join(SANDBOX_IMAGE), WORKING).unwrap();
std::fs::write(at.join(partial_image()), b"not a webc at all").unwrap();
let err = verify_image(at, locale(Lang::En), &mut |_| {})
.await
.expect_err("junk must not pass verification");
eprintln!("verification said: {err}");
assert_eq!(
std::fs::read(at.join(SANDBOX_IMAGE)).unwrap(),
WORKING,
"a failed verification must not cost the user the sandbox they had"
);
assert!(
!at.join(partial_image()).exists(),
"the rejected candidate is cleaned up, not left as dead weight"
);
}
}