tests/standalonePython.test.ts
Rama main · réplica de lectura del repositorio original.
// The standalone Python the Install button fetches when a machine has neither
// uv nor a Python 3.10–3.13 (server/standalonePython.ts) — the packaged
// desktop app's usual case on Windows and Linux.
//
// The Windows half cannot be RUN here, so its path logic is proved as logic:
// the lookup that replaced `where` (PATHEXT, the `Path` spelling, quoted
// entries), the venv's Scripts\python.exe, the standalone's python.exe, and
// the Windows asset in the pinned table. The unpacker is driven with archives
// the system tar writes in both of its formats, plus a hand-made hostile one.
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, lstatSync, mkdtempSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync, mkdirSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { gzipSync } from "node:zlib";
import { after, describe, it } from "node:test";
import {
extractTarGz,
fetchStandalonePython,
findOnPath,
PYTHON_BUILDS,
pythonBuildFor,
standalonePythonExe,
venvPythonIn,
type PythonBuild,
} from "../server/standalonePython.ts";
const tmp = mkdtempSync(path.join(os.tmpdir(), "astrolabe-pbs-"));
after(() => rmSync(tmp, { recursive: true, force: true }));
describe("finding a program without which or where", () => {
it("walks a POSIX PATH in order", () => {
const files = new Set(["/opt/uv/bin/uv", "/usr/bin/uv"]);
assert.equal(findOnPath("uv", { PATH: "/nope:/opt/uv/bin:/usr/bin" }, "linux", (p) => files.has(p)), "/opt/uv/bin/uv");
assert.equal(findOnPath("python3", { PATH: "/usr/bin:/bin" }, "linux", (p) => files.has(p)), null);
assert.equal(findOnPath("uv", {}, "linux", () => true), null, "no PATH, nothing found");
});
it("on Windows: PATHEXT, the `Path` spelling, quoted entries, case-blind extensions", () => {
const files = new Set(["C:\\Users\\r\\.local\\bin\\uv.exe", "C:\\Program Files\\Python312\\python.exe"]);
const env = { Path: 'C:\\Windows\\System32;"C:\\Program Files\\Python312";C:\\Users\\r\\.local\\bin', PATHEXT: ".COM;.EXE;.BAT" };
assert.equal(findOnPath("uv", env, "win32", (p) => files.has(p)), "C:\\Users\\r\\.local\\bin\\uv.exe");
assert.equal(findOnPath("python", env, "win32", (p) => files.has(p)), "C:\\Program Files\\Python312\\python.exe");
assert.equal(findOnPath("python.exe", env, "win32", (p) => files.has(p)), "C:\\Program Files\\Python312\\python.exe");
assert.equal(findOnPath("py", env, "win32", (p) => files.has(p)), null);
});
});
describe("where the interpreters are", () => {
it("the venv: Scripts\\python.exe on Windows, bin/python elsewhere", () => {
assert.equal(venvPythonIn("C:\\data\\tts\\venv", "win32"), "C:\\data\\tts\\venv\\Scripts\\python.exe");
assert.equal(venvPythonIn("/data/tts/venv", "linux"), "/data/tts/venv/bin/python");
});
it("the standalone build: python.exe at its root on Windows, bin/python3 elsewhere", () => {
assert.equal(standalonePythonExe("C:\\data\\tts\\python", "win32"), "C:\\data\\tts\\python\\python.exe");
assert.equal(standalonePythonExe("/data/tts/python", "darwin"), "/data/tts/python/bin/python3");
});
});
describe("the pinned builds", () => {
it("covers the desktop app's platforms, each an install-only stripped CPython 3.12 with a size and a digest", () => {
for (const key of ["linux-x64", "linux-arm64", "win32-x64", "win32-arm64", "darwin-x64", "darwin-arm64"]) {
const b = PYTHON_BUILDS[key];
assert.ok(b, key);
assert.match(b.file, /^cpython-3\.12\.\d+\+\d{8}-.+-install_only_stripped\.tar\.gz$/, key);
assert.match(b.sha256, /^[0-9a-f]{64}$/, key);
assert.ok(b.bytes > 15_000_000 && b.bytes < 40_000_000, `${key}: ${b.bytes}`);
assert.ok(b.url.startsWith("https://github.com/astral-sh/python-build-standalone/releases/download/"), key);
assert.ok(b.url.includes("%2B"), `${key}: the + in the name is escaped`);
}
assert.match(pythonBuildFor("win32", "x64")!.file, /x86_64-pc-windows-msvc/);
assert.match(pythonBuildFor("linux", "x64")!.file, /x86_64-unknown-linux-gnu/);
assert.equal(pythonBuildFor("linux", "ia32"), null);
assert.equal(pythonBuildFor("freebsd", "x64"), null);
});
});
/** A python-build-standalone-shaped tree: python/bin/python3 (a script
* standing in), a symlink beside it, a long path, a Windows python.exe. */
function makeTree(root: string): void {
const long = path.join(root, "python", "lib", "a".repeat(60), "b".repeat(60));
mkdirSync(path.join(root, "python", "bin"), { recursive: true });
mkdirSync(long, { recursive: true });
writeFileSync(path.join(root, "python", "bin", "python3.12"), "#!/bin/sh\necho 3 12\n", { mode: 0o755 });
spawnSync("ln", ["-s", "python3.12", path.join(root, "python", "bin", "python3")]);
writeFileSync(path.join(long, "module.py"), "x = 1\n");
writeFileSync(path.join(root, "python", "python.exe"), "MZ");
}
function tarOf(format: "gnu" | "pax"): string {
const src = path.join(tmp, `src-${format}`);
makeTree(src);
const out = path.join(tmp, `py-${format}.tar.gz`);
const r = spawnSync("tar", [`--format=${format}`, "-czf", out, "-C", src, "python"], { encoding: "utf8" });
assert.equal(r.status, 0, r.stderr);
return out;
}
describe("unpacking", () => {
const hasTar = spawnSync("tar", ["--version"]).status === 0;
for (const format of ["gnu", "pax"] as const) {
it(`unpacks a ${format} archive: files keep their mode, symlinks stay links, long names arrive`, { skip: !hasTar }, async () => {
const dest = path.join(tmp, `out-${format}`);
await extractTarGz(tarOf(format), dest, "linux");
const exe = path.join(dest, "python", "bin", "python3.12");
assert.equal(readFileSync(exe, "utf8"), "#!/bin/sh\necho 3 12\n");
assert.ok((statSync(exe).mode & 0o111) !== 0, "executable");
const link = path.join(dest, "python", "bin", "python3");
assert.ok(lstatSync(link).isSymbolicLink());
assert.equal(readlinkSync(link), "python3.12");
assert.ok(existsSync(path.join(dest, "python", "lib", "a".repeat(60), "b".repeat(60), "module.py")));
});
}
it("on Windows a symlink is unpacked as a copy of its target", { skip: !hasTar }, async () => {
const dest = path.join(tmp, "out-win");
await extractTarGz(tarOf("gnu"), dest, "win32");
const link = path.join(dest, "python", "bin", "python3");
assert.ok(!lstatSync(link).isSymbolicLink());
assert.equal(readFileSync(link, "utf8"), "#!/bin/sh\necho 3 12\n");
});
it("refuses an archive that names a path outside its folder", async () => {
const header = Buffer.alloc(512);
header.write("../escaped.txt", 0, "utf8");
header.write("0000644\0", 100, "latin1");
header.write("00000000005\0", 124, "latin1");
header.write("0", 156, "latin1");
header.write("ustar\0", 257, "latin1");
header.write("00", 263, "latin1");
header.fill(" ", 148, 156);
let sum = 0;
for (const b of header) sum += b;
header.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, "latin1");
const body = Buffer.alloc(512);
body.write("evil\n");
const archive = path.join(tmp, "evil.tar.gz");
writeFileSync(archive, gzipSync(Buffer.concat([header, body, Buffer.alloc(1024)])));
const dest = path.join(tmp, "out-evil");
await assert.rejects(extractTarGz(archive, dest, "linux"), /outside its folder/);
assert.ok(!existsSync(path.join(tmp, "escaped.txt")));
});
});
describe("fetching", () => {
const hasTar = spawnSync("tar", ["--version"]).status === 0;
const serve = (bytes: Buffer): typeof fetch =>
(async () => new Response(new Uint8Array(bytes), { status: 200 })) as unknown as typeof fetch;
it("checks the size and the digest, unpacks into the root, and answers the interpreter", { skip: !hasTar }, async () => {
const bytes = readFileSync(tarOf("gnu"));
const spec: PythonBuild = { file: "py.tar.gz", url: "https://example.invalid/py.tar.gz", bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") };
const root = path.join(tmp, "data", "tts", "python");
const heard: number[] = [];
const exe = await fetchStandalonePython(root, (p) => heard.push(p), { platform: "linux", arch: "x64", fetcher: serve(bytes), spec });
assert.equal(exe, path.join(root, "bin", "python3"));
assert.ok(existsSync(exe));
assert.equal(heard[heard.length - 1], 100, "the progress reaches 100");
assert.ok(!existsSync(`${root}.part`), "no staging left behind");
assert.deepEqual(
(await import("node:fs")).readdirSync(path.dirname(root)).filter((n) => n.endsWith(".part")),
[],
"no tarball left behind",
);
// A second call finds it and fetches nothing.
const again = await fetchStandalonePython(root, () => {}, { platform: "linux", arch: "x64", fetcher: (() => { throw new Error("fetched twice"); }) as unknown as typeof fetch, spec });
assert.equal(again, exe);
});
it("refuses a download of the wrong size or the wrong digest, and keeps nothing", async () => {
const bytes = Buffer.from("not a python");
const root = path.join(tmp, "data2", "tts", "python");
const wrongSize: PythonBuild = { file: "py.tar.gz", url: "https://example.invalid/py", bytes: 999, sha256: "0".repeat(64) };
await assert.rejects(fetchStandalonePython(root, () => {}, { platform: "linux", arch: "x64", fetcher: serve(bytes), spec: wrongSize }), /arrived at 12 bytes, not 999/);
const wrongSum: PythonBuild = { ...wrongSize, bytes: bytes.length };
await assert.rejects(fetchStandalonePython(root, () => {}, { platform: "linux", arch: "x64", fetcher: serve(bytes), spec: wrongSum }), /checksum/);
assert.ok(!existsSync(root));
});
it("says so when the platform has no build", async () => {
await assert.rejects(fetchStandalonePython(path.join(tmp, "x"), () => {}, { platform: "linux", arch: "ia32" }), /No standalone Python is offered for linux-ia32/);
});
});