diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48d75455..91be4cab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,9 +77,13 @@ jobs: for (const run of runs) { if (String(run.id) === process.env.GITHUB_RUN_ID || run.head_branch !== 'main' || run.head_repository?.full_name !== repo || !['push', 'workflow_dispatch'].includes(run.event)) continue; - // Only workflow edits may differ from the source of reused packages. + // Release orchestration, packaging validation, and their tests do + // not change the compiled payloads in native build artifacts. if (spawnSync('git', ['merge-base', '--is-ancestor', run.head_sha, 'HEAD']).status !== 0 || - spawnSync('git', ['diff', '--quiet', run.head_sha, 'HEAD', '--', '.', ':(exclude).github/workflows/release.yml']).status !== 0) continue; + spawnSync('git', ['diff', '--quiet', run.head_sha, 'HEAD', '--', '.', + ':(exclude).github/workflows/release.yml', ':(exclude)scripts/verify-native-cli.mjs', + ':(exclude)scripts/package-native-cli.mjs', ':(exclude)tests/harness/native-cli-packaging.test.ts', + ]).status !== 0) continue; const artifacts = api(`repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`).artifacts .filter((artifact) => !artifact.expired && artifact.size_in_bytes > 0); if (!artifactRun && expected.every((name) => artifacts.filter((artifact) => artifact.name === name).length === 1)) { diff --git a/scripts/package-native-cli.mjs b/scripts/package-native-cli.mjs index 9597d4d1..900589e1 100644 --- a/scripts/package-native-cli.mjs +++ b/scripts/package-native-cli.mjs @@ -25,6 +25,7 @@ for (const name of readdirSync(packages).filter((name) => name.startsWith("cli-" const bin = join(distribution, "bin"); const binary = join(bin, name.includes("win32-") ? "scriptc.exe" : "scriptc"); const manifest = JSON.parse(readFileSync(binary + ".json", "utf8")); + const hostPath = (value) => name.includes("win32-") ? value.replaceAll("\\", "/") : value; const packs = new Map(); for (const packageName of runtimes) { const directory = packageName.replace("@scriptc/", ""); @@ -41,8 +42,8 @@ for (const name of readdirSync(packages).filter((name) => name.startsWith("cli-" writeFileSync(binary + ".json", JSON.stringify(manifest, null, 2) + "\n"); // GitHub artifact transport drops executable modes. Restore the native // tools before validating and creating the standalone archive. - for (const path of [binary, resolve(bin, manifest.ts7), resolve(bin, manifest.comptime), - join(resolve(bin, manifest.llvm_package), "bin", name.includes("win32-") ? "scriptc-llvm-codegen.exe" : "scriptc-llvm-codegen")]) { + for (const path of [binary, resolve(bin, hostPath(manifest.ts7)), resolve(bin, hostPath(manifest.comptime)), + join(resolve(bin, hostPath(manifest.llvm_package)), "bin", name.includes("win32-") ? "scriptc-llvm-codegen.exe" : "scriptc-llvm-codegen")]) { chmodSync(path, 0o755); } execFileSync(process.execPath, [join(repository, "scripts/verify-native-cli.mjs"), stage], { stdio: "inherit" }); diff --git a/scripts/verify-native-cli.mjs b/scripts/verify-native-cli.mjs index 58fd1aae..1747f371 100644 --- a/scripts/verify-native-cli.mjs +++ b/scripts/verify-native-cli.mjs @@ -1,10 +1,13 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; -import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path"; const root = realpathSync(resolve(process.argv[2] ?? ".")); const identity = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); const target = identity.name.replace("@scriptc/cli-", ""); +const windows = target.startsWith("win32-"); +const hostPath = (value) => windows ? value.replaceAll("\\", "/") : value; +const absolutePath = (value) => isAbsolute(value) || (windows && win32.parse(value).root !== ""); const distribution = join(root, "dist"); const binary = join(distribution, "bin", target.startsWith("win32-") ? "scriptc.exe" : "scriptc"); const toolchain = JSON.parse(readFileSync(binary + ".json", "utf8")); @@ -19,17 +22,17 @@ const within = (path) => { }; for (const name of ["ts7", "llvm_package", "runtime_pack", "runtime_sources", "declarations", "comptime", "wasi_node_runner"]) { const value = toolchain[name]; - if (typeof value !== "string" || isAbsolute(value)) throw new Error(`native toolchain needs a relative ${name}`); - within(resolve(dirname(binary), value)); + if (typeof value !== "string" || absolutePath(value)) throw new Error(`native toolchain needs a relative ${name}`); + within(resolve(dirname(binary), hostPath(value))); } -const pack = JSON.parse(readFileSync(resolve(dirname(binary), toolchain.runtime_pack, "runtime-pack.json"), "utf8")); +const pack = JSON.parse(readFileSync(resolve(dirname(binary), hostPath(toolchain.runtime_pack), "runtime-pack.json"), "utf8")); if (pack.version !== identity.version || pack.target.name !== toolchain.target) throw new Error("native compiler runtime identity mismatch"); const seen = new Set(); for (const runtime of toolchain.runtime_packs ?? []) { if (seen.has(runtime.target)) throw new Error(`duplicate runtime target: ${runtime.target}`); seen.add(runtime.target); - if (isAbsolute(runtime.path)) throw new Error("native runtime pack paths must be relative"); - const directory = resolve(dirname(binary), runtime.path); + if (typeof runtime.path !== "string" || absolutePath(runtime.path)) throw new Error("native runtime pack paths must be relative"); + const directory = resolve(dirname(binary), hostPath(runtime.path)); within(directory); const manifest = JSON.parse(readFileSync(join(directory, "runtime-pack.json"), "utf8")); const packageIdentity = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); diff --git a/tests/harness/native-cli-packaging.test.ts b/tests/harness/native-cli-packaging.test.ts new file mode 100644 index 00000000..48122845 --- /dev/null +++ b/tests/harness/native-cli-packaging.test.ts @@ -0,0 +1,106 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, expect, test } from "vitest"; + +const repository = join(import.meta.dirname, "../.."); +const directories: string[] = []; +afterEach(() => { for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); }); + +function fixture(windows = true) { + const directory = mkdtempSync(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-package-paths-")); + directories.push(directory); + const packages = join(directory, "packages"); + const platform = windows ? "win32-x64-msvc" : "linux-x64-gnu"; + const target = windows ? "x86_64-windows-msvc" : "x86_64-linux-gnu"; + const cli = join(packages, `cli-${platform}`); + const distribution = join(cli, "dist"); + const executable = windows ? "scriptc.exe" : "scriptc"; + const manifestPath = join(distribution, "bin", executable + ".json"); + const write = (path: string, data: string | Buffer) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, data); }; + const identity = { name: `@scriptc/cli-${platform}`, version: "1.2.3" }; + const runtimeIdentity = { name: `@scriptc/runtime-${platform}`, version: identity.version }; + const runtime = { schema: "scriptc.runtime-pack.v1", package: runtimeIdentity.name, version: identity.version, target: { name: target } }; + const asset = (path: string) => windows ? path.replaceAll("/", "\\") : path; + const manifest = { + schema: "scriptc.native-toolchain.v1", compiler_version: identity.version, target, + ts7: asset(`../lib/typescript/lib/tsc${windows ? ".exe" : ""}`), + llvm_package: asset("../lib/llvm"), runtime_pack: asset("../lib/runtime"), + runtime_sources: asset("../lib/runtime-sources"), declarations: asset("../lib/declarations"), + comptime: asset(`../lib/scriptc-comptime${windows ? ".exe" : ""}`), + wasi_node_runner: asset("../lib/wasi/wasi-runner.js"), + runtime_packs: [{ target, path: asset("../lib/runtime") }], + }; + write(join(cli, "package.json"), JSON.stringify(identity)); + write(join(distribution, "bin", executable), Buffer.alloc(2048, 1)); + chmodSync(join(distribution, "bin", executable), 0o755); + write(manifestPath, JSON.stringify(manifest)); + for (const path of [manifest.ts7, manifest.comptime, manifest.wasi_node_runner]) { + write(join(distribution, "bin", path.replaceAll("\\", "/")), "fixture"); + } + write(join(distribution, "lib/llvm/bin", windows ? "scriptc-llvm-codegen.exe" : "scriptc-llvm-codegen"), "fixture"); + mkdirSync(join(distribution, "lib/runtime-sources/vendor"), { recursive: true }); + mkdirSync(join(distribution, "lib/declarations"), { recursive: true }); + for (const path of [join(distribution, "lib/runtime"), join(packages, `runtime-${platform}`)]) { + write(join(path, "package.json"), JSON.stringify(runtimeIdentity)); + write(join(path, "runtime-pack.json"), JSON.stringify(runtime)); + write(join(path, "artifacts/runtime.o"), "runtime object"); + } + for (const path of ["LICENSE", "THIRD_PARTY_NOTICES"]) write(join(distribution, path), "license"); + write(join(packages, "compiler/package.json"), JSON.stringify({ version: identity.version, optionalDependencies: { [runtimeIdentity.name]: identity.version } })); + return { directory, packages, cli, platform, executable, manifestPath, manifest }; +} + +function run(script: string, ...args: string[]) { + return spawnSync(process.execPath, [join(repository, "scripts", script), ...args], { encoding: "utf8", timeout: 30_000 }); +} + +test.each([true, false])("verify and archive a native distribution across packaging hosts (Windows=%s)", (windows) => { + const f = fixture(windows); + const original = readFileSync(f.manifestPath); + const verified = run("verify-native-cli.mjs", f.cli); + expect(verified.status, verified.stderr).toBe(0); + const output = join(f.directory, "archives"); + const packed = run("package-native-cli.mjs", f.packages, output); + expect(packed.status, packed.stderr).toBe(0); + const filename = `scriptc-1.2.3-${f.platform}.tar.gz`; + const archive = join(output, filename); + const digest = createHash("sha256").update(readFileSync(archive)).digest("hex"); + expect(readFileSync(join(output, "SHA256SUMS"), "utf8")).toBe(`${digest} ${filename}\n`); + const unpacked = join(f.directory, "unpacked"); + mkdirSync(unpacked); + const extracted = spawnSync("tar", ["-xzf", archive, "-C", unpacked], { encoding: "utf8" }); + expect(extracted.status, extracted.stderr).toBe(0); + const manifest = JSON.parse(readFileSync(join(unpacked, "bin", f.executable + ".json"), "utf8")); + expect(manifest.ts7).toBe(f.manifest.ts7); + const runtime = manifest.runtime_packs[0]; + expect(JSON.parse(readFileSync(join(unpacked, "bin", runtime.path, "runtime-pack.json"), "utf8")).target.name).toBe(f.manifest.target); + expect(readFileSync(f.manifestPath)).toEqual(original); +}); + +test.each(["C:\\outside\\tsc.exe", "C:tsc.exe", "\\outside\\tsc.exe", "\\\\server\\share\\tsc.exe", "/outside/tsc.exe"])("reject Windows rooted asset paths: %s", (path) => { + const f = fixture(); + writeFileSync(f.manifestPath, JSON.stringify({ ...f.manifest, ts7: path })); + const result = run("verify-native-cli.mjs", f.cli); + expect(result.status).toBe(1); + expect(result.stderr).toContain("native toolchain needs a relative ts7"); +}); + +test("reject Windows traversal outside the distribution", () => { + const f = fixture(); + writeFileSync(join(f.cli, "outside.exe"), "outside"); + writeFileSync(f.manifestPath, JSON.stringify({ ...f.manifest, ts7: "..\\..\\outside.exe" })); + const result = run("verify-native-cli.mjs", f.cli); + expect(result.status).toBe(1); + expect(result.stderr).toContain("distribution references external asset"); +}); + +test("reject Windows rooted runtime-pack paths", () => { + const f = fixture(); + writeFileSync(f.manifestPath, JSON.stringify({ ...f.manifest, runtime_packs: [{ target: f.manifest.target, path: "C:\\runtime" }] })); + const result = run("verify-native-cli.mjs", f.cli); + expect(result.status).toBe(1); + expect(result.stderr).toContain("native runtime pack paths must be relative"); +});