1use std::io::Write;
13use std::path::{Path, PathBuf};
14
15use anyhow::{bail, Context, Result};
16use sha2::{Digest, Sha256};
17use zip::write::SimpleFileOptions;
18use zip::{CompressionMethod, ZipWriter};
19
20pub const PINNED_BUILD: &str = "20250317";
23pub const PINNED_PYTHON: &str = "3.12.9";
24
25pub const ARCHIVE_ROOT: &str = "python";
27
28const RELEASE_BASE: &str = "https://github.com/astral-sh/python-build-standalone/releases/download";
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct BundleSpec {
33 pub platform: &'static str,
34 pub file: &'static str,
35 pub sha256: &'static str,
36 pub lib: &'static str,
38 pub stdlib: &'static str,
40 pub zip_name: &'static str,
42 pub pth_name: Option<&'static str>,
45}
46
47const LINUX: BundleSpec = BundleSpec {
52 platform: "linux",
53 file: "cpython-3.12.9+20250317-x86_64-unknown-linux-gnu-install_only.tar.gz",
54 sha256: "ef382fb88cbb41a3b0801690bd716b8a1aec07a6c6471010bcc6bd14cd575226",
55 lib: "lib/libpython3.12.so.1.0",
56 stdlib: "lib/python3.12",
57 zip_name: "python312.zip",
58 pth_name: None,
59};
60
61const WINDOWS: BundleSpec = BundleSpec {
62 platform: "windows",
63 file: "cpython-3.12.9+20250317-x86_64-pc-windows-msvc-install_only.tar.gz",
64 sha256: "d15361fd202dd74ae9c3eece1abdab7655f1eba90bf6255cad1d7c53d463ed4d",
65 lib: "python312.dll",
66 stdlib: "Lib",
67 zip_name: "python312.zip",
68 pth_name: Some("python312._pth"),
69};
70
71#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
72const MACOS: BundleSpec = BundleSpec {
73 platform: "macos",
74 file: "cpython-3.12.9+20250317-x86_64-apple-darwin-install_only.tar.gz",
75 sha256: "1ee1b1bb9fbce5c145c4bec9a3c98d7a4fa22543e09a7c1d932bc8599283c2dc",
76 lib: "lib/libpython3.12.dylib",
77 stdlib: "lib/python3.12",
78 zip_name: "python312.zip",
79 pth_name: None,
80};
81
82#[cfg(not(all(target_os = "macos", target_arch = "x86_64")))]
83const MACOS: BundleSpec = BundleSpec {
84 platform: "macos",
85 file: "cpython-3.12.9+20250317-aarch64-apple-darwin-install_only.tar.gz",
86 sha256: "7c7fd9809da0382a601a79287b5d62d61ce0b15f5a5ee836233727a516e85381",
87 lib: "lib/libpython3.12.dylib",
88 stdlib: "lib/python3.12",
89 zip_name: "python312.zip",
90 pth_name: None,
91};
92
93pub fn pinned(platform: &str) -> Option<BundleSpec> {
95 match platform {
96 "linux" => Some(LINUX),
97 "windows" => Some(WINDOWS),
98 "macos" => Some(MACOS),
99 _ => None,
100 }
101}
102
103pub fn pinned_platforms() -> Vec<&'static str> {
105 vec!["linux", "windows", "macos"]
106}
107
108pub fn toolchain_dir() -> PathBuf {
110 if let Some(dir) = std::env::var_os("PITE_TOOLCHAIN_DIR") {
111 return PathBuf::from(dir);
112 }
113 let base = std::env::var_os("XDG_CACHE_HOME")
114 .map(PathBuf::from)
115 .filter(|p| !p.as_os_str().is_empty())
116 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
117 .unwrap_or_else(|| PathBuf::from(".pitetoolchain"));
118 base.join("pite").join("toolchains")
119}
120
121pub fn archive_path(spec: &BundleSpec) -> PathBuf {
122 toolchain_dir()
123 .join(spec.platform)
124 .join(PINNED_PYTHON)
125 .join(&spec.file)
126}
127
128pub fn ensure_archive(spec: &BundleSpec) -> Result<PathBuf> {
131 let path = archive_path(spec);
132 if path.is_file() {
133 verify(spec, &path)?;
134 return Ok(path);
135 }
136 if let Some(parent) = path.parent() {
137 std::fs::create_dir_all(parent)
138 .with_context(|| format!("cannot create {}", parent.display()))?;
139 }
140 eprintln!(
141 "info: downloading pinned Python {} runtime for {} ({})...",
142 PINNED_PYTHON, spec.platform, spec.file
143 );
144 let url = format!("{RELEASE_BASE}/{PINNED_BUILD}/{}", spec.file);
145 download(&url, &path)?;
146 if let Err(e) = verify(spec, &path) {
147 let _ = std::fs::remove_file(&path);
148 return Err(e);
149 }
150 eprintln!("info: verified Python runtime ({})", spec.file);
151 Ok(path)
152}
153
154pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
156 Sha256::digest(bytes)
157 .iter()
158 .map(|b| format!("{b:02x}"))
159 .collect()
160}
161
162pub fn verify(spec: &BundleSpec, path: &Path) -> Result<()> {
164 let bytes = std::fs::read(path).with_context(|| format!("cannot read {}", path.display()))?;
165 let actual = sha256_hex(&bytes);
166 if actual.eq_ignore_ascii_case(spec.sha256) {
167 return Ok(());
168 }
169 if spec.sha256 == "REPLACE_WINDOWS_SHA256" {
170 bail!(
171 "no pinned checksum recorded for {} (expected placeholder); refusing to use {}",
172 spec.file,
173 path.display()
174 );
175 }
176 bail!(
177 "checksum mismatch for {}: expected {}, got {}. Delete the cached file and retry.",
178 path.display(),
179 spec.sha256,
180 actual
181 )
182}
183
184pub(crate) fn download(url: &str, dest: &Path) -> Result<()> {
185 use std::io::IsTerminal;
186 let tool = downloader()?;
187 let mut cmd = std::process::Command::new(&tool);
188 cmd.args(["--fail", "--location", "--retry", "3", "--retry-delay", "3"]);
189 if std::io::stderr().is_terminal() {
190 cmd.arg("--progress-bar");
191 } else {
192 cmd.args(["--silent", "--show-error"]);
193 }
194 let result = cmd.arg(url).arg("--output").arg(dest).status();
195 match result {
196 Ok(status) if status.success() => Ok(()),
197 Ok(status) => bail!(
198 "download of {url} failed ({}). Export needs network access to fetch the \
199 pinned Python; retry with network, or pass --no-bundle-python to keep the \
200 system-Python requirement.",
201 status
202 ),
203 Err(e) => bail!(
204 "cannot run {tool} to download {url}: {e}. Export needs network access to \
205 fetch the pinned Python; or pass --no-bundle-python to keep the \
206 system-Python requirement."
207 ),
208 }
209}
210
211fn downloader() -> Result<&'static str> {
212 for tool in ["curl", "curl.exe"] {
213 if Command::new(tool)
214 .arg("--version")
215 .output()
216 .is_ok_and(|o| o.status.success())
217 {
218 return Ok(tool);
219 }
220 }
221 bail!(
222 "no downloader found (need curl). Fetch {} manually into {} , or pass \
223 --no-bundle-python to keep the system-Python requirement.",
224 LINUX.file,
225 toolchain_dir().display()
226 )
227}
228
229use std::process::Command;
230
231#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct StagedBundle {
234 pub root: PathBuf,
235 pub lib: PathBuf,
236 pub stdlib_zip: PathBuf,
237}
238
239pub fn stage(spec: &BundleSpec, archive: &Path, dest: &Path) -> Result<StagedBundle> {
242 let work = dest.join(".staging");
243 let _ = std::fs::remove_dir_all(&work);
244 std::fs::create_dir_all(&work).with_context(|| format!("cannot create {}", work.display()))?;
245 unpack(spec, archive, &work)?;
246
247 let lib_src = work.join(ARCHIVE_ROOT).join(&spec.lib);
248 if !lib_src.is_file() {
249 bail!(
250 "archive {} does not contain {ARCHIVE_ROOT}/{}",
251 archive.display(),
252 spec.lib
253 );
254 }
255 let lib_dir = dest.join("lib");
256 std::fs::create_dir_all(&lib_dir)?;
257 let lib_name = lib_src
258 .file_name()
259 .expect("lib file has a name")
260 .to_string_lossy()
261 .into_owned();
262 let lib = lib_dir.join(&lib_name);
263 std::fs::copy(&lib_src, &lib).with_context(|| format!("cannot copy {}", lib_src.display()))?;
264 strip_debug(&lib);
265
266 let stdlib_dir = work.join(ARCHIVE_ROOT).join(&spec.stdlib);
267 if !stdlib_dir.is_dir() {
268 bail!(
269 "archive {} does not contain {ARCHIVE_ROOT}/{}",
270 archive.display(),
271 spec.stdlib
272 );
273 }
274 let stdlib_zip = dest.join(spec.zip_name);
275 zip_dir(&stdlib_dir, &stdlib_zip)?;
276 let _ = std::fs::remove_dir_all(&work);
277 Ok(StagedBundle {
278 root: dest.to_path_buf(),
279 lib,
280 stdlib_zip,
281 })
282}
283
284pub(crate) fn strip_debug(lib: &Path) {
290 let mut magic = [0u8; 4];
291 let is_elf = std::fs::File::open(lib)
292 .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut magic))
293 .is_ok()
294 && magic == [0x7f, b'E', b'L', b'F'];
295 if !is_elf {
296 return;
297 }
298 let _ = std::process::Command::new("strip")
299 .arg("--strip-unneeded")
300 .arg(lib)
301 .output();
302}
303
304const EXCLUDED: &[&str] = &[
309 "site-packages",
310 "test",
311 "idlelib",
312 "tkinter",
313 "ensurepip",
314 "__pycache__",
315 "venv",
316 "lib2to3",
317 "pydoc_data",
318 "turtledemo",
319 "msilib",
320];
321
322#[cfg(test)]
325const FORWARDERS: &[&str] = &["python3.dll", "libpython3.so", "libpython3.dylib"];
326
327fn unpack(_spec: &BundleSpec, archive: &Path, work: &Path) -> Result<()> {
328 let tar = if cfg!(windows) { "tar.exe" } else { "tar" };
329 let out = std::process::Command::new(tar)
330 .arg("-xf")
331 .arg(archive)
332 .arg("-C")
333 .arg(work)
334 .output();
335 match out {
336 Ok(o) if o.status.success() => Ok(()),
337 Ok(o) => bail!(
338 "cannot unpack {} with tar ({}); install tar or pass --no-bundle-python",
339 archive.display(),
340 o.status
341 ),
342 Err(e) => bail!(
343 "cannot run {tar} to unpack {}: {e}; or pass --no-bundle-python",
344 archive.display()
345 ),
346 }
347}
348
349fn zip_dir(dir: &Path, zip_path: &Path) -> Result<()> {
353 let mut entries: Vec<(String, PathBuf)> = Vec::new();
354 collect(dir, dir, &mut entries)?;
355 entries.sort_by(|a, b| a.0.cmp(&b.0));
356 let file = std::fs::File::create(zip_path)
357 .with_context(|| format!("cannot write {}", zip_path.display()))?;
358 let mut zip = ZipWriter::new(file);
359 let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
360 for (name, path) in &entries {
361 let data =
362 std::fs::read(path).with_context(|| format!("cannot read {}", path.display()))?;
363 zip.start_file(name, options)?;
364 zip.write_all(&data)?;
365 }
366 zip.finish().context("cannot finish the stdlib zip")?;
367 Ok(())
368}
369
370fn collect(base: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> Result<()> {
371 for entry in std::fs::read_dir(dir).with_context(|| format!("cannot read {}", dir.display()))? {
372 let entry = entry?;
373 let name = entry.file_name().to_string_lossy().into_owned();
374 if EXCLUDED.contains(&name.as_str()) {
375 continue;
376 }
377 let path = entry.path();
378 if path.is_dir() {
379 collect(base, &path, out)?;
380 } else if path.is_file() {
381 let rel = path
382 .strip_prefix(base)
383 .expect("entry under base")
384 .to_string_lossy()
385 .replace('\\', "/");
386 out.push((rel, path));
387 }
388 }
389 Ok(())
390}
391
392pub fn launcher_env(bundle: &StagedBundle) -> Vec<(String, String)> {
395 vec![
396 (
397 "LD_LIBRARY_PATH".to_string(),
398 bundle
399 .lib
400 .parent()
401 .unwrap_or(&bundle.root)
402 .to_string_lossy()
403 .into_owned(),
404 ),
405 (
406 "DYLD_LIBRARY_PATH".to_string(),
407 bundle
408 .lib
409 .parent()
410 .unwrap_or(&bundle.root)
411 .to_string_lossy()
412 .into_owned(),
413 ),
414 (
415 "PYTHONPATH".to_string(),
416 bundle.stdlib_zip.to_string_lossy().into_owned(),
417 ),
418 ]
419}
420
421#[cfg(test)]
422mod tests {
423 use std::io::Read;
424
425 use super::*;
426
427 fn tmpdir(tag: &str) -> PathBuf {
428 let n = std::time::SystemTime::now()
429 .duration_since(std::time::UNIX_EPOCH)
430 .map(|d| d.subsec_nanos())
431 .unwrap_or(0);
432 let dir =
433 std::env::temp_dir().join(format!("pite-bundle-{tag}-{}-{n}", std::process::id()));
434 let _ = std::fs::remove_dir_all(&dir);
435 std::fs::create_dir_all(&dir).unwrap();
436 dir
437 }
438
439 #[test]
440 fn pinned_specs_exist_for_supported_platforms() {
441 for platform in pinned_platforms() {
442 let spec = pinned(platform).expect("pinned");
443 assert_eq!(spec.platform, platform);
444 assert!(
445 !spec.sha256.starts_with("REPLACE"),
446 "{platform} must ship a real checksum"
447 );
448 assert!(spec.file.starts_with("cpython-3.12"));
449 }
450 assert!(pinned("ps5").is_none());
451 }
452
453 #[test]
454 fn sha256_matches_published_vectors() {
455 assert_eq!(
456 sha256_hex(b""),
457 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
458 );
459 assert_eq!(
460 sha256_hex(b"abc"),
461 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
462 );
463 assert_eq!(
464 sha256_hex(&b"a".repeat(1_000_000)),
465 "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0"
466 );
467 }
468
469 #[test]
470 fn checksum_mismatch_is_loud_and_leaves_nothing_usable() {
471 let dir = tmpdir("sum");
472 let archive = dir.join("fake.tar.gz");
473 std::fs::write(&archive, b"not the pinned archive").unwrap();
474 let err = verify(&LINUX, &archive).unwrap_err().to_string();
475 assert!(err.contains("checksum mismatch"), "got: {err}");
476 assert!(err.contains(LINUX.sha256), "got: {err}");
477 std::fs::remove_dir_all(&dir).ok();
478 }
479
480 #[test]
481 fn offline_download_is_loud_and_suggests_the_opt_out() {
482 let _guard = crate::SERIAL.lock().unwrap();
483 let mut spec = LINUX;
484 spec.file = "cpython-offline-probe.tar.gz";
485 let dir = tmpdir("offline");
486 std::env::set_var("PITE_TOOLCHAIN_DIR", &dir);
487 let err = ensure_archive(&spec).unwrap_err().to_string();
488 assert!(err.contains("--no-bundle-python"), "got: {err}");
489 std::env::remove_var("PITE_TOOLCHAIN_DIR");
490 std::fs::remove_dir_all(&dir).ok();
491 }
492
493 #[test]
494 fn stdlib_zip_is_deflated_and_sorted_with_spec_crcs() {
495 let dir = tmpdir("stored");
496 let stdlib = dir.join("stdlib");
497 std::fs::create_dir_all(stdlib.join("sub")).unwrap();
498 std::fs::write(stdlib.join("b.py"), b"b = 1\n").unwrap();
499 std::fs::write(stdlib.join("sub").join("a.py"), b"a = 2\n").unwrap();
500 let zip = dir.join("python312.zip");
501 zip_dir(&stdlib, &zip).unwrap();
502 let mut archive = zip::ZipArchive::new(std::fs::File::open(&zip).unwrap()).unwrap();
503 let names: Vec<String> = (0..archive.len())
504 .map(|i| archive.by_index(i).unwrap().name().to_string())
505 .collect();
506 assert_eq!(names, ["b.py", "sub/a.py"]);
507 for i in 0..archive.len() {
508 let mut entry = archive.by_index(i).unwrap();
509 assert_eq!(entry.compression(), CompressionMethod::Deflated);
510 let mut data = Vec::new();
511 entry.read_to_end(&mut data).unwrap();
512 assert_eq!(entry.crc32(), crc32fast::hash(&data));
513 }
514 assert_eq!(crc32fast::hash(b"123456789"), 0xCBF43926);
515 std::fs::remove_dir_all(&dir).ok();
516 }
517
518 #[test]
519 fn stdlib_zip_excludes_site_packages() {
520 let dir = tmpdir("zip");
521 let stdlib = dir.join("stdlib");
522 std::fs::create_dir_all(stdlib.join("site-packages")).unwrap();
523 std::fs::create_dir_all(stdlib.join("__pycache__")).unwrap();
524 std::fs::write(stdlib.join("os.py"), b"import sys\n").unwrap();
525 std::fs::write(stdlib.join("site-packages").join("junk.py"), b"x = 1\n").unwrap();
526 std::fs::write(stdlib.join("__pycache__").join("os.pyc"), b"junk").unwrap();
527 let zip = dir.join("python312.zip");
528 zip_dir(&stdlib, &zip).unwrap();
529 let out = std::process::Command::new("python3")
530 .arg("-c")
531 .arg("import zipfile,sys; print(sorted(zipfile.ZipFile(sys.argv[1]).namelist()))")
532 .arg(&zip)
533 .output()
534 .expect("python3 available to list the zip");
535 let listing = String::from_utf8_lossy(&out.stdout).into_owned();
536 assert!(listing.contains("os.py"), "got: {listing}");
537 assert!(!listing.contains("site-packages"), "got: {listing}");
538 assert!(!listing.contains("__pycache__"), "got: {listing}");
539 std::fs::remove_dir_all(&dir).ok();
540 }
541
542 #[test]
547 fn pinned_layouts_match_the_cached_real_archives() {
548 for platform in pinned_platforms() {
549 let spec = pinned(platform).expect("pinned");
550 let archive = archive_path(&spec);
551 if !archive.is_file() {
552 eprintln!(
553 "skipped {platform}: nothing cached at {} (run an export once to populate it)",
554 archive.display()
555 );
556 continue;
557 }
558 let out = std::process::Command::new("tar")
559 .arg("-tf")
560 .arg(&archive)
561 .output()
562 .expect("tar runs");
563 assert!(out.status.success(), "cannot list {}", archive.display());
564 let listing = String::from_utf8_lossy(&out.stdout);
565 let lib = format!("{ARCHIVE_ROOT}/{}", spec.lib);
566 let stdlib = format!("{ARCHIVE_ROOT}/{}", spec.stdlib);
567 let has = |needle: &str| {
568 listing
569 .lines()
570 .map(|l| l.trim_end_matches('/'))
571 .any(|l| l == needle || l.starts_with(&format!("{needle}/")))
572 };
573 assert!(has(&lib), "{platform}: archive has no interpreter at {lib}");
574 assert!(
575 has(&stdlib),
576 "{platform}: archive has no stdlib at {stdlib}"
577 );
578 let stem = spec.lib.rsplit('/').next().unwrap_or(spec.lib);
579 assert!(
580 !FORWARDERS.contains(&stem),
581 "{platform}: {} is an ABI forwarder, not the interpreter \
582 (it dispatches to the versioned library instead of being one)",
583 spec.lib
584 );
585 }
586 }
587
588 #[test]
589 fn launcher_env_points_at_lib_and_stdlib() {
590 let bundle = StagedBundle {
591 root: PathBuf::from("/game"),
592 lib: PathBuf::from("/game/python/lib/libpython3.12.so.1.0"),
593 stdlib_zip: PathBuf::from("/game/python/python312.zip"),
594 };
595 let env = launcher_env(&bundle);
596 assert!(env.contains(&("LD_LIBRARY_PATH".into(), "/game/python/lib".into())));
597 assert!(env.contains(&("PYTHONPATH".into(), "/game/python/python312.zip".into())));
598 }
599}