diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 499192352..b1583a617 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -151,6 +151,9 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
--strip
Remove symbol and debug payload when linking an executable, including cross-compiled targets. This flag preserves code optimization.
+
--static
+
On Linux, link the C library and the other system libraries into the executable. The executable starts without the dynamic loader, which shortens its startup, and doesn't depend on the run host's glibc version. glibc executables link as static PIE, are about 1 MB larger, and need glibc's static libraries on the build machine (libc6-dev on Debian and Ubuntu, glibc-static on Fedora); they link only for the build machine's own glibc target. A program whose executable includes host, user, or group name lookups, which dns.lookup, net and http connections, fetch, and os.userInfo() use, is refused with SC3002: a static glibc executable loads the NSS modules for those lookups from the run host, which glibc supports only for the glibc version it was linked with. Build such a program without --static, or for an <arch>-linux-musl target. musl executables are always linked statically, and accept the flag. Valid only for executable builds.
+
--optimization <release|dev|speed>
Select native optimization for executable, assembly, or object output. The default is release (-O2), which emits string retains and the retains and releases of dynamic values inline. dev uses -O0 and emits source locations for TypeScript and JavaScript breakpoints and stack frames. LLVM builds include source variable names and native storage descriptions; see Native debugging for the supported representations. macOS executable builds also produce an adjacent .dSYM bundle. speed is -O2 plus optimizations that trade binary size and build time for run-time speed: it emits the remaining reference counting inline and, on x86-64, starts every runtime function on a cache-line boundary. Expect larger executables and longer cold builds than release; how much faster a program runs depends on how much of its time is spent in the runtime. Where a runtime pack has no speed runtime, speed links the release runtime and keeps inline reference counting. Selecting speed never changes what release builds produce, and the two never share cached artifacts. Library profiles accept "optimization": "speed" for inline reference counting. In executables large enough to be divided into partitions, dev compiles each partition on its own, and release and speed add a step that lets partitions import small functions from each other; see Build parallelism.
@@ -373,6 +376,10 @@ For source breakpoints in Xcode or LLDB, build with --optimization=devscriptc run on wasm32-wasi Node.js 24 or later on PATH + + --static on glibc Linux + glibc's static libraries (libc6-dev on Debian and Ubuntu, glibc-static on Fedora) + --sanitize A C compiler and LLVM toolchain diff --git a/docs/content/docs/platforms.mdx b/docs/content/docs/platforms.mdx index 96b91fc2a..72cd65a3c 100644 --- a/docs/content/docs/platforms.mdx +++ b/docs/content/docs/platforms.mdx @@ -49,7 +49,7 @@ $ file fib-linux fib-linux: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 2.0.0, with debug_info, not stripped ``` -`x86_64-linux-gnu.2.36` works the same way. For Alpine and other musl-based systems, use `aarch64-linux-musl` or `x86_64-linux-musl`; these produce statically linked executables. +`x86_64-linux-gnu.2.36` works the same way. For Alpine and other musl-based systems, use `aarch64-linux-musl` or `x86_64-linux-musl`; these produce statically linked executables. On a glibc-based Linux machine, [`--static`](/docs/cli#options) links a static executable for the machine's own target. Linux executables use the native Linux implementations of the event loop (epoll), servers, TLS (with the system's CA bundle), and `fs.watch`, and they are tested against Node.js on Linux, including Alpine. diff --git a/packages/cli/src/bootstrap.ts b/packages/cli/src/bootstrap.ts index 0c09c4435..3ade659c1 100644 --- a/packages/cli/src/bootstrap.ts +++ b/packages/cli/src/bootstrap.ts @@ -144,6 +144,7 @@ async function tryFastPath(): Promise { backend: "llvm", ...(optimization === "dev" || optimization === "speed" ? { optimization } : {}), ...(values.strip ? { strip: true as const } : {}), + ...(values.static ? { staticLink: true as const } : {}), ...(values["detached-exit"] ? { detachedExit: true as const } : {}), ...(values["large-pages"] ? { largePages: true as const } : {}), ...(values["cpu-dispatch"] === true ? { cpuDispatch: true as const } : {}), @@ -229,6 +230,7 @@ async function coverageFastPath( (failOn !== undefined && failOn !== "blockers" && failOn !== "divergences") || values.emit !== undefined || values.strip || + values.static || values["detached-exit"] || values["large-pages"] || values["target-cpu"] !== undefined || diff --git a/packages/cli/test/output-options.test.ts b/packages/cli/test/output-options.test.ts index a00a61667..45d5c55b8 100644 --- a/packages/cli/test/output-options.test.ts +++ b/packages/cli/test/output-options.test.ts @@ -46,6 +46,8 @@ describe("output option compatibility", () => { [{ emit: "llvm", windowsSubsystem: "gui" }, /windows-subsystem/], [{ emit: "obj", strip: true }, /strip/], [{ emit: "llvm", strip: true }, /strip/], + [{ emit: "obj", staticLink: true }, /--static/], + [{ emit: "llvm", staticLink: true }, /--static/], [{ emit: "obj", detachedExit: true }, /detached-exit/], [{ emit: "llvm", detachedExit: true }, /detached-exit/], [{ emit: "obj", largePages: true }, /large-pages/], diff --git a/packages/cli/test/static-link.test.ts b/packages/cli/test/static-link.test.ts new file mode 100644 index 000000000..68fbda064 --- /dev/null +++ b/packages/cli/test/static-link.test.ts @@ -0,0 +1,138 @@ +import { execFile, execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; + +const execFileAsync = promisify(execFile); +const require = createRequire(import.meta.url); +const repoRoot = join(import.meta.dirname, "../../.."); +const cliEntry = join(repoRoot, "packages/cli/src/main.ts"); +const bootstrapEntry = join(repoRoot, "packages/cli/dist/bootstrap.js"); +const tsxLoader = join(dirname(require.resolve("tsx/package.json")), "dist/loader.mjs"); + +const glibcHost = + process.platform === "linux" && + typeof (process.report?.getReport() as { header?: { glibcVersionRuntime?: unknown } })?.header + ?.glibcVersionRuntime === "string"; + +/** Whether the host's clang finds glibc's static C library. */ +function hostHasStaticLibc(): boolean { + try { + const path = execFileSync("clang", ["-print-file-name=libc.a"], { encoding: "utf8" }).trim(); + return isAbsolute(path) && existsSync(path); + } catch { + return false; + } +} + +/** The ELF type and whether the executable names a program interpreter. */ +async function elfLinkage(path: string): Promise<{ type: number; interpreter: boolean }> { + const bytes = await readFile(path); + const type = bytes.readUInt16LE(16); + const phoff = Number(bytes.readBigUInt64LE(32)); + const phentsize = bytes.readUInt16LE(54); + const phnum = bytes.readUInt16LE(56); + let interpreter = false; + for (let i = 0; i < phnum; i++) { + if (bytes.readUInt32LE(phoff + i * phentsize) === 3 /* PT_INTERP */) interpreter = true; + } + return { type, interpreter }; +} + +async function withProject(run: (dir: string, env: NodeJS.ProcessEnv) => Promise) { + const dir = await mkdtemp( + join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-static-"), + ); + try { + return await run(dir, { ...process.env, SCRIPTC_CACHE_DIR: join(dir, "cache") }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function cli(args: string[], env: NodeJS.ProcessEnv, bootstrap = false) { + return execFileAsync( + process.execPath, + [...(bootstrap ? [bootstrapEntry] : ["--import", tsxLoader, cliEntry]), ...args], + { env, maxBuffer: 4 * 1024 * 1024 }, + ); +} + +test.skipIf(!glibcHost || !hostHasStaticLibc())( + "--static links a glibc executable as static PIE without mixing cache variants", + async () => { + await withProject(async (dir, env) => { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "program"); + // The NSS module file name in a string of the program's own is no lookup. + await writeFile( + entry, + 'console.log("static-ok", [1, 2, 3].map((n) => n * 2).join(","), "/lib/libnss_files.so.2");\n', + ); + const build = async (staticLink: boolean, bootstrap = false) => { + await cli( + ["build", entry, "-o", outPath, ...(staticLink ? ["--static"] : [])], + env, + bootstrap, + ); + expect((await execFileAsync(outPath)).stdout).toBe( + "static-ok 2,4,6 /lib/libnss_files.so.2\n", + ); + return elfLinkage(outPath); + }; + // ET_DYN with a program interpreter: the default dynamic PIE link. + expect(await build(false)).toEqual({ type: 3, interpreter: true }); + // ET_DYN without one: static PIE. + expect(await build(true)).toEqual({ type: 3, interpreter: false }); + expect(await build(true, true)).toEqual({ type: 3, interpreter: false }); + expect(await build(false, true)).toEqual({ type: 3, interpreter: true }); + }); + }, + 180_000, +); + +test.skipIf(!glibcHost || !hostHasStaticLibc())( + "--static refuses a glibc executable that includes user name lookups", + async () => { + await withProject(async (dir, env) => { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "program"); + await writeFile( + entry, + 'import { userInfo } from "node:os";\nconsole.log(userInfo().username);\n', + ); + const failure = await cli(["build", entry, "-o", outPath, "--static"], env).then( + () => null, + (error: { code?: number; stderr?: string }) => error, + ); + expect(failure?.code).toBe(1); + expect(failure?.stderr).toContain("error SC3002: --static cannot link this program"); + expect(existsSync(outPath)).toBe(false); + // The same program links without --static. + await cli(["build", entry, "-o", outPath], env); + expect((await execFileAsync(outPath)).stdout).not.toBe(""); + }); + }, + 180_000, +); + +test.skipIf(process.platform === "linux")( + "--static is refused for executables that do not target Linux", + async () => { + await withProject(async (dir, env) => { + const entry = join(dir, "main.ts"); + await writeFile(entry, 'console.log("x");\n'); + const failure = await cli(["build", entry, "-o", join(dir, "program"), "--static"], env).then( + () => null, + (error: { code?: number; stderr?: string }) => error, + ); + expect(failure?.code).toBe(1); + expect(failure?.stderr).toContain("--static is only supported for Linux executables"); + }); + }, + 180_000, +); diff --git a/packages/compiler/src/backend/link-plan-core.ts b/packages/compiler/src/backend/link-plan-core.ts index f8ad20e6f..f810a4a17 100644 --- a/packages/compiler/src/backend/link-plan-core.ts +++ b/packages/compiler/src/backend/link-plan-core.ts @@ -2,6 +2,7 @@ import type { NativeOptimization } from "./optimization.js"; import { executableOptimizationLinkerArgs, + executableStaticLinkerArgs, executableStripLinkerArgs, windowsSubsystemLinkerArgs, type NativeTargetSpec, @@ -28,6 +29,8 @@ export function executableLinkInputs(options: { runtimeSystemLibraries: readonly string[]; optimization: NativeOptimization; strip?: boolean; + /** Link the C library and the other system libraries statically (`--static`). */ + staticLink?: boolean; windowsSubsystem?: WindowsSubsystem; /** Instrumented for profile-guided optimization. */ profileGenerate?: boolean; @@ -65,6 +68,7 @@ export function executableLinkInputs(options: { ), ...executableOptimizationLinkerArgs(options.target.platform, options.optimization), ...executableStripLinkerArgs(options.target.platform, options.strip ?? false), + ...executableStaticLinkerArgs(options.target, options.staticLink ?? false), ...windowsSubsystemLinkerArgs(options.target.platform, options.windowsSubsystem), ...profileGenerateLinkerArgs(options.profileGenerate ?? false), ], diff --git a/packages/compiler/src/backend/link-plan.ts b/packages/compiler/src/backend/link-plan.ts index f0be965be..73139d22a 100644 --- a/packages/compiler/src/backend/link-plan.ts +++ b/packages/compiler/src/backend/link-plan.ts @@ -28,6 +28,8 @@ export interface NativeLinkPlan { programObjectDependencies: NativeArtifactDependency[]; runtimePack: RuntimePackSelection; darwinDebugSymbols?: boolean; + /** Linked with `--static`: the linked executable is checked before use. */ + staticLink?: boolean; } export async function createNativeLinkPlan(options: { @@ -39,6 +41,7 @@ export async function createNativeLinkPlan(options: { ffi: FfiProfile | null; optimization: NativeOptimization; strip?: boolean; + staticLink?: boolean; windowsSubsystem?: WindowsSubsystem; profileGenerate?: boolean; programObjectDependencies?: readonly NativeArtifactDependency[]; @@ -70,6 +73,7 @@ export async function createNativeLinkPlan(options: { runtimeSystemLibraries: runtimePack.systemLibraries, optimization: options.optimization, ...(options.strip === undefined ? {} : { strip: options.strip }), + ...(options.staticLink === true ? { staticLink: true } : {}), ...(options.windowsSubsystem === undefined ? {} : { windowsSubsystem: options.windowsSubsystem }), @@ -78,5 +82,6 @@ export async function createNativeLinkPlan(options: { dependencyPaths: [...runtimePack.dependencyPaths, ...(options.ffi?.libraries ?? [])], programObjectDependencies: [...(options.programObjectDependencies ?? [])], runtimePack, + ...(options.staticLink === true ? { staticLink: true } : {}), }; } diff --git a/packages/compiler/src/backend/linker.ts b/packages/compiler/src/backend/linker.ts index a29a7c947..fe476360d 100644 --- a/packages/compiler/src/backend/linker.ts +++ b/packages/compiler/src/backend/linker.ts @@ -7,11 +7,13 @@ import { execFile } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { existsSync, realpathSync, statSync } from "node:fs"; -import { mkdtemp, rename, rm, stat } from "node:fs/promises"; +import { mkdtemp, readFile, rename, rm, stat } from "node:fs/promises"; import { delimiter } from "node:path"; import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { promisify } from "node:util"; import { CcCompileError, subprocessFailureDetail } from "./native/process.js"; +import { NativeCodegenError } from "./native-codegen-core.js"; +import { staticExecutableRefusal, staticLinkFailureNote, staticLinkMapArgs } from "./targets.js"; import { nativeArtifactDependenciesStillMatch } from "./native/artifact-stamps.js"; import { parseLinkTraceFiles } from "./native/linker-fingerprint.js"; import { @@ -238,6 +240,8 @@ export async function linkNativeExecutable( join(dirname(plan.outputPath), ".scriptc-runtime-pack-link-"), ); const privateOut = join(privateOutRoot, basename(plan.outputPath)); + const linkMap = join(privateOutRoot, "link.map"); + const mapArgs = staticLinkMapArgs(plan.target, plan.staticLink === true, linkMap); let stagedRoot: string | null = null; try { const staged = await stageRuntimePackArtifacts(plan.runtimePack); @@ -250,6 +254,7 @@ export async function linkNativeExecutable( ...(explicitLinker === undefined ? plan.target.defaultLinkerArgs : []), ...(withLld ? lld : []), ...plan.driverFlags, + ...mapArgs, ...(withLld ? inputs : inputsWithoutLld(plan.target.platform, inputs)), ...systemLibraryLinkerArgs(plan.target, plan.systemLibraries), "-o", @@ -293,6 +298,10 @@ export async function linkNativeExecutable( }); const output = await stat(privateOut); if (!output.isFile() || output.size === 0) throw new Error("linker produced no executable"); + if (mapArgs.length !== 0) { + const refusal = staticExecutableRefusal(plan.target, true, await readFile(linkMap, "utf8")); + if (refusal !== null) throw new NativeCodegenError("SC3002", refusal); + } if (plan.darwinDebugSymbols) { await createDarwinDebugSymbols(privateOut); await installDarwinDebugSymbols(await readDarwinDebugSymbols(privateOut), plan.outputPath); @@ -312,12 +321,18 @@ export async function linkNativeExecutable( await options.onArtifactReady({ dependencies: preLinkDependencies }).catch(() => undefined); } } catch (error) { - if (error instanceof CcCompileError || error instanceof RuntimePackError) throw error; + if ( + error instanceof CcCompileError || + error instanceof RuntimePackError || + error instanceof NativeCodegenError + ) + throw error; const detail = subprocessFailureDetail(error); throw new CcCompileError( linker, detail, - `${linker} failed linking ${basename(plan.outputPath)} from the precompiled runtime pack.\n${detail}`, + `${linker} failed linking ${basename(plan.outputPath)} from the precompiled runtime pack.\n${detail}` + + staticLinkFailureNote(plan.target, plan.staticLink === true), ); } finally { await Promise.all([ diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts index b6e3f0ef7..a12c137b2 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -21,7 +21,12 @@ import { platformLinkerSupportsPersistentCache, resolvePlatformLinker, } from "./linker.js"; -import { MACOS_ARM64_TARGET, WINDOWS_X64_MSVC_TARGET, type NativeTargetSpec } from "./targets.js"; +import { + LINUX_X64_GNU_TARGET, + MACOS_ARM64_TARGET, + WINDOWS_X64_MSVC_TARGET, + type NativeTargetSpec, +} from "./targets.js"; const VERSION = compilerReleaseVersion(); @@ -242,6 +247,25 @@ describe("runtime pack manifests", () => { expect(guiPlan.driverFlags).toEqual([...defaultPlan.driverFlags, "-Wl,--subsystem,windows"]); }); + test("Linux runtime-pack links are static PIE only when requested", async () => { + const { packagePath, root } = await fixture(LINUX_X64_GNU_TARGET); + const options = { + target: LINUX_X64_GNU_TARGET, + programObject: join(root, "program.o"), + outPath: join(root, "program"), + features: BASE, + ffi: null, + optimization: "release" as const, + resolver: () => packagePath, + }; + const defaultPlan = await createNativeLinkPlan(options); + const staticPlan = await createNativeLinkPlan({ ...options, staticLink: true }); + expect(defaultPlan.driverFlags).not.toContain("-static-pie"); + expect(defaultPlan.staticLink).toBeUndefined(); + expect(staticPlan.driverFlags).toEqual([...defaultPlan.driverFlags, "-static-pie"]); + expect(staticPlan.staticLink).toBe(true); + }); + test("selection chooses the most-specific variant and feature archive", async () => { const { packagePath } = await fixture(); const resolver = () => packagePath; diff --git a/packages/compiler/src/backend/targets.test.ts b/packages/compiler/src/backend/targets.test.ts index 24d2e31a7..4fb275140 100644 --- a/packages/compiler/src/backend/targets.test.ts +++ b/packages/compiler/src/backend/targets.test.ts @@ -13,9 +13,15 @@ import { WASM32_WASI_TARGET, WINDOWS_X64_MSVC_TARGET, executableOptimizationLinkerArgs, + executableStaticLinkerArgs, ffiExtendsNarrowIntegers, nativeCodegenTarget, nativeCodegenTargetRefusal, + selectNativeTarget, + staticExecutableRefusal, + staticLinkFailureNote, + staticLinkMapArgs, + staticLinkRefusal, windowsSubsystemLinkerArgs, } from "./targets.js"; @@ -232,4 +238,96 @@ describe("native code-generation targets", () => { "unknown Windows subsystem", ); }); + + test("links host glibc executables as static PIE only for --static", () => { + expect(executableStaticLinkerArgs(LINUX_X64_GNU_TARGET, false)).toEqual([]); + expect(executableStaticLinkerArgs(LINUX_X64_GNU_TARGET, true)).toEqual(["-static-pie"]); + expect(executableStaticLinkerArgs(LINUX_ARM64_GNU_TARGET, true)).toEqual(["-static-pie"]); + // musl executables are static already. + expect(executableStaticLinkerArgs(LINUX_X64_MUSL_TARGET, true)).toEqual([]); + expect(executableStaticLinkerArgs(MACOS_ARM64_TARGET, false)).toEqual([]); + expect(() => executableStaticLinkerArgs(MACOS_ARM64_TARGET, true)).toThrow( + "only supported for Linux executables", + ); + }); + + test("refuses --static where glibc cannot be linked statically", () => { + const request = { staticLink: true, outputKind: "exe", sanitize: false }; + expect(staticLinkRefusal({ ...request, staticLink: undefined, target: null })).toBeNull(); + expect(staticLinkRefusal({ ...request, target: LINUX_X64_GNU_TARGET })).toBeNull(); + expect(staticLinkRefusal({ ...request, target: LINUX_X64_MUSL_TARGET })).toBeNull(); + expect(staticLinkRefusal({ ...request, outputKind: "obj", target: LINUX_X64_GNU_TARGET })).toBe( + "--static is only supported for executable output", + ); + expect(staticLinkRefusal({ ...request, sanitize: true, target: LINUX_X64_GNU_TARGET })).toMatch( + "cannot be combined with --sanitize", + ); + for (const target of [MACOS_ARM64_TARGET, WINDOWS_X64_MSVC_TARGET, WASM32_WASI_TARGET, null]) + expect(staticLinkRefusal({ ...request, target })).toBe( + "--static is only supported for Linux executables", + ); + // zig links cross-compiled glibc targets, and links glibc dynamically. + const cross = selectNativeTarget("aarch64-linux-gnu", LINUX_X64_GNU_TARGET, "linux")!; + expect(staticLinkRefusal({ ...request, target: cross })).toMatch("-linux-musl"); + const crossMusl = selectNativeTarget("aarch64-linux-musl", LINUX_X64_GNU_TARGET, "linux")!; + expect(staticLinkRefusal({ ...request, target: crossMusl })).toBeNull(); + }); + + test("refuses static glibc executables whose link map keeps the NSS loader", () => { + const libc = "/lib/x86_64-linux-gnu/libc.a"; + // lld lists the input sections it kept. + const lld = (member: string) => + [ + " VMA LMA Size Align Out In Symbol", + ` 11e1a0 11e1a0 640 16 ${libc}(${member}):(.text)`, + ` 11e7e0 11e7e0 38 1 ${libc}(${member}):(.rodata)`, + ].join("\n"); + const lldPlain = [ + " VMA LMA Size Align Out In Symbol", + ` 2c62c0 2c62c0 a10 16 ${libc}(dl-open.o):(.text)`, + ].join("\n"); + // GNU ld lists discarded sections first, and sections relaxation emptied with size 0. + const bfd = (text: boolean) => + [ + "Discarded input sections", + "", + ` .text 0x0000000000000000 0x640 ${libc}(nss_module.o)`, + "", + "Linker script and memory map", + "", + ` .eh_frame 0x00000000000db750 0x0 ${libc}(nss_module.o)`, + " 0x1b8 (size before relaxing)", + ...(text ? [` .text 0x00000000000a81b0 0x640 ${libc}(nsswitch.o)`] : []), + ].join("\n"); + const refusal = + /^--static cannot link this program: its executable includes host, user or group name lookups/; + expect(staticExecutableRefusal(LINUX_X64_GNU_TARGET, true, lld("nss_module.o"))).toMatch( + refusal, + ); + expect(staticExecutableRefusal(LINUX_X64_GNU_TARGET, true, lld("nsswitch.o"))).toMatch(refusal); + expect(staticExecutableRefusal(LINUX_X64_GNU_TARGET, true, bfd(true))).toMatch(refusal); + expect(staticExecutableRefusal(LINUX_X64_GNU_TARGET, true, bfd(false))).toBeNull(); + expect(staticExecutableRefusal(LINUX_X64_GNU_TARGET, true, lldPlain)).toBeNull(); + // A program's own string never counts. + expect(staticExecutableRefusal(LINUX_X64_GNU_TARGET, true, "libnss_files.so.2")).toBeNull(); + expect(staticExecutableRefusal(LINUX_X64_GNU_TARGET, false, lld("nss_module.o"))).toBeNull(); + expect(staticExecutableRefusal(LINUX_X64_MUSL_TARGET, true, lld("nss_module.o"))).toBeNull(); + }); + + test("writes a link map only for --static glibc links", () => { + expect(staticLinkMapArgs(LINUX_X64_GNU_TARGET, true, "/stage/link.map")).toEqual([ + "-Xlinker", + "-Map", + "-Xlinker", + "/stage/link.map", + ]); + expect(staticLinkMapArgs(LINUX_X64_GNU_TARGET, false, "/stage/link.map")).toEqual([]); + expect(staticLinkMapArgs(LINUX_X64_MUSL_TARGET, true, "/stage/link.map")).toEqual([]); + }); + + test("notes glibc's static libraries on failed --static links", () => { + expect(staticLinkFailureNote(LINUX_X64_GNU_TARGET, true)).toMatch("libc.a"); + expect(staticLinkFailureNote(LINUX_X64_GNU_TARGET, false)).toBe(""); + expect(staticLinkFailureNote(LINUX_X64_MUSL_TARGET, true)).toBe(""); + }); }); diff --git a/packages/compiler/src/backend/targets.ts b/packages/compiler/src/backend/targets.ts index a6713aee3..e4ce3ab5f 100644 --- a/packages/compiler/src/backend/targets.ts +++ b/packages/compiler/src/backend/targets.ts @@ -91,6 +91,96 @@ export function executableStripLinkerArgs(platform: string, strip: boolean): str } } +/** Why a `--static` request cannot be linked, or null when it can. A glibc + * executable links statically only through the host's clang driver: zig, + * which links cross-compiled glibc targets, links glibc dynamically. */ +export function staticLinkRefusal(options: { + staticLink: boolean | undefined; + outputKind: string; + sanitize: boolean; + target: Pick | null; +}): string | null { + if (options.staticLink !== true) return null; + if (options.outputKind !== "exe") return "--static is only supported for executable output"; + if (options.sanitize) + return "--static cannot be combined with --sanitize; AddressSanitizer requires a dynamically linked executable"; + const target = options.target; + if (target === null || target.platform !== "linux") + return "--static is only supported for Linux executables"; + if (!target.name.endsWith("-musl") && target.defaultLinker === "zig") + return "--static links glibc from the build host and is only supported for the host's own glibc target; use an -linux-musl target to cross-compile a static executable"; + return null; +} + +/** Link the C library and the other system libraries into the executable. + * glibc executables link as static PIE, so the kernel still loads them at a + * random address; musl executables are always linked statically. */ +export function executableStaticLinkerArgs( + target: Pick, + staticLink: boolean, +): string[] { + if (!staticLink) return []; + const refusal = staticLinkRefusal({ staticLink, outputKind: "exe", sanitize: false, target }); + if (refusal !== null) throw new Error(refusal); + return target.name.endsWith("-musl") ? [] : ["-static-pie"]; +} + +/** The link map a `--static` glibc link writes to `path`, which + * staticExecutableRefusal reads. `-Xlinker` keeps the path one argument + * through the driver and into the linker's job. */ +export function staticLinkMapArgs( + target: Pick, + staticLink: boolean, + path: string, +): string[] { + return staticLink && !target.name.endsWith("-musl") ? ["-Xlinker", "-Map", "-Xlinker", path] : []; +} + +/** The libc.a members of glibc's NSS loader: nss_module.o (glibc 2.33 and + * later) and nsswitch.o. */ +const GLIBC_NSS_LOADER_MEMBER = /libc\.a\((?:nss_module|nsswitch)\.o\)/; + +/** Whether a link map line places code from an archive member in the + * output: lld lists the input sections it kept (`… libc.a(m.o):(.text)`), + * GNU ld each input section with its size (` .text 0x… 0x… …libc.a(m.o)`), + * and lists sections that relaxation emptied with size 0. */ +function keepsText(line: string): boolean { + return /\):\(\.text\)\s*$/.test(line) || /^\s*\.text\s+0x[0-9a-f]+\s+0x0*[1-9a-f]/.test(line); +} + +/** Why a linked `--static` executable is refused, or null. A static glibc + * executable answers host, user and group lookups with glibc's built-in + * `files` and `dns` sources, and loads every other module the run host's + * nsswitch.conf names from that host, which glibc supports only for the + * glibc version the executable was linked with. `linkMap` is the link's + * map (staticLinkMapArgs): the executable has the NSS loader when the map + * places the loader's code in it. GNU ld lists the input sections that + * garbage collection discarded before its memory map. */ +export function staticExecutableRefusal( + target: Pick, + staticLink: boolean, + linkMap: string, +): string | null { + if (!staticLink || target.name.endsWith("-musl")) return null; + const memoryMap = linkMap.indexOf("Linker script and memory map"); + const kept = memoryMap === -1 ? linkMap : linkMap.slice(memoryMap); + const loader = kept + .split("\n") + .some((line) => GLIBC_NSS_LOADER_MEMBER.test(line) && keepsText(line)); + if (!loader) return null; + return "--static cannot link this program: its executable includes host, user or group name lookups (which dns.lookup, net and http connections, fetch and os.userInfo use), and a statically linked glibc executable loads the NSS modules for those lookups from the run host, which glibc supports only for the glibc version it was linked with. Build without --static, or use an -linux-musl target"; +} + +/** The note a failed `--static` glibc link adds to the linker's error. */ +export function staticLinkFailureNote( + target: Pick, + staticLink: boolean, +): string { + return staticLink && !target.name.endsWith("-musl") + ? "\n--static links glibc's static libraries (libc.a, libm.a), which the libc6-dev package installs on Debian and Ubuntu and glibc-static on Fedora" + : ""; +} + export interface NativeTargetSpec { /** Stable scriptc-facing identity used in cache keys and diagnostics. */ name: NativeTargetName; diff --git a/packages/compiler/src/cli/command.ts b/packages/compiler/src/cli/command.ts index eb72ca14d..fcaaf1d02 100644 --- a/packages/compiler/src/cli/command.ts +++ b/packages/compiler/src/cli/command.ts @@ -115,6 +115,7 @@ async function main(args: string[], host: CliHost): Promise { values.ffi !== undefined || values.profile !== undefined || values.strip || + values.static || values["windows-subsystem"] !== undefined || values["detached-exit"] || values["large-pages"] || @@ -176,6 +177,7 @@ async function main(args: string[], host: CliHost): Promise { values.ffi !== undefined || values.profile !== undefined || values.strip || + values.static || values.sanitize || values["windows-subsystem"] !== undefined || values["detached-exit"] || @@ -269,6 +271,7 @@ async function main(args: string[], host: CliHost): Promise { values["fail-on"] !== undefined || values.optimization !== undefined || values.strip || + values.static || values.ffi !== undefined || values["windows-subsystem"] !== undefined || values["detached-exit"] || @@ -284,7 +287,7 @@ async function main(args: string[], host: CliHost): Promise { hasNodeIslandFlags(values) ) { fail( - "scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--windows-subsystem/--detached-exit/--large-pages/--target-cpu/--cpu-dispatch/--profile-generate/--profile-use/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only", + "scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--static/--windows-subsystem/--detached-exit/--large-pages/--target-cpu/--cpu-dispatch/--profile-generate/--profile-use/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only", ); } const profilePath = resolve(profileArg); @@ -327,6 +330,9 @@ async function main(args: string[], host: CliHost): Promise { if (command === "coverage" && values.strip) { fail(`--strip is a build/run option\n\n${USAGE}`); } + if (command === "coverage" && values.static) { + fail(`--static is a build/run option\n\n${USAGE}`); + } if (command === "coverage" && values["target-cpu"] !== undefined) { fail(`--target-cpu is a build/run option\n\n${USAGE}`); } @@ -515,6 +521,7 @@ async function main(args: string[], host: CliHost): Promise { sanitize: values.sanitize, ...(values.optimization === undefined ? {} : { optimization: values.optimization }), strip: values.strip, + staticLink: values.static, detachedExit: values["detached-exit"], largePages: values["large-pages"], ...(values["cpu-dispatch"] === undefined ? {} : { cpuDispatch: values["cpu-dispatch"] }), @@ -635,6 +642,7 @@ async function main(args: string[], host: CliHost): Promise { ...(backend !== undefined ? { backend } : {}), ...(optimization !== undefined ? { optimization } : {}), ...(values.strip ? { strip: true } : {}), + ...(values.static ? { staticLink: true } : {}), ...(windowsSubsystem !== undefined ? { windowsSubsystem } : {}), ...(values["detached-exit"] ? { detachedExit: true } : {}), ...(values["large-pages"] ? { largePages: true } : {}), diff --git a/packages/compiler/src/cli/output-options.ts b/packages/compiler/src/cli/output-options.ts index d684c884f..207878692 100644 --- a/packages/compiler/src/cli/output-options.ts +++ b/packages/compiler/src/cli/output-options.ts @@ -9,6 +9,7 @@ export interface OutputOptionValues { sanitize: boolean; optimization?: string; strip?: boolean; + staticLink?: boolean; windowsSubsystem?: string; detachedExit?: boolean; largePages?: boolean; @@ -74,6 +75,9 @@ export function resolveOutputOptions( if (values.strip && emit !== "exe") { return { ok: false, message: `--strip is only supported with --emit=exe` }; } + if (values.staticLink && emit !== "exe") { + return { ok: false, message: `--static is only supported with --emit=exe` }; + } if (values.detachedExit && emit !== "exe") { return { ok: false, message: `--detached-exit is only supported with --emit=exe` }; } diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts index 6d01502b5..9f2b62d51 100644 --- a/packages/compiler/src/cli/usage.ts +++ b/packages/compiler/src/cli/usage.ts @@ -56,6 +56,11 @@ Options: counting, cache-line-aligned runtime code on x86-64) --strip remove symbol/debug payload from the linked executable for smaller builds (opt in; --emit=exe only) + --static Linux: link the C library into the executable, which + then starts without the dynamic loader. glibc builds + need its static libraries (libc.a) on the build host + and refuse executables that include host, user or + group name lookups (opt in; --emit=exe only) --windows-subsystem Windows executable subsystem (default: console). gui prevents Windows from opening a console window @@ -176,6 +181,7 @@ export const CLI_OPTIONS = { backend: { type: "string" }, optimization: { type: "string" }, strip: { type: "boolean", default: false }, + static: { type: "boolean", default: false }, "windows-subsystem": { type: "string" }, "detached-exit": { type: "boolean", default: false }, "large-pages": { type: "boolean", default: false }, diff --git a/packages/compiler/src/compile-types.ts b/packages/compiler/src/compile-types.ts index 2ef7a703a..6ea532b67 100644 --- a/packages/compiler/src/compile-types.ts +++ b/packages/compiler/src/compile-types.ts @@ -31,6 +31,11 @@ export interface CompileBaseOptions { optimization?: NativeOptimization; /** Remove symbol/debug payload from an executable at link time. */ strip?: boolean; + /** Link the C library and the other system libraries into the executable + * (`--static`). glibc executables link as static PIE and need the C + * library's static archives on the build host; musl executables are always + * static. Linux executables only. */ + staticLink?: boolean; /** Windows PE executable subsystem. Console is the default; GUI suppresses * automatic console-window creation. Only valid for Windows executables. */ windowsSubsystem?: WindowsSubsystem; diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 8f90a9d0c..737a78b98 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -148,6 +148,8 @@ export interface EarlyExecutableCacheOptions { optimization?: "dev" | "speed"; /** Omitted retains the unstripped executable's historical cache key. */ strip?: true; + /** Omitted unless --static is set. */ + staticLink?: true; /** Omitted for the default Windows console subsystem. */ windowsSubsystem?: "gui"; /** Omitted unless --detached-exit is set. */ @@ -317,6 +319,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string { options.backend, ...optimizationKeyParts(options.optimization), ...(options.strip ? ["strip"] : []), + ...(options.staticLink ? ["static-link"] : []), ...(options.windowsSubsystem === "gui" ? ["windows-subsystem-gui"] : []), ...(options.detachedExit ? ["detached-exit"] : []), ...(options.largePages ? ["large-pages"] : []), @@ -364,6 +367,7 @@ function routeKey(options: Omit = null, programObjectDependencies: readonly NativeArtifactDependency[] = [], onArtifactReady?: NonNullable[0]["onArtifactReady"]>, @@ -627,6 +632,7 @@ async function compileExecutableNative( ffi, optimization: features.optimization ?? "release", ...(strip ? { strip: true } : {}), + ...(staticLink ? { staticLink: true } : {}), ...(windowsSubsystem === undefined ? {} : { windowsSubsystem }), ...(profileGenerate ? { profileGenerate: true } : {}), programObjectDependencies, @@ -652,6 +658,8 @@ async function compileExecutableNative( : null); if (programIsObject) throw new InternalCompilerError("program objects must link against a precompiled runtime pack"); + if (staticLink) + throw new InternalCompilerError("--static executables link against a precompiled runtime pack"); await compileExternalC({ ...(llvmVisiblePath === undefined || llvmVisiblePath === llvmPath ? { cPath: llvmPath } @@ -1633,6 +1641,19 @@ async function compileTracked( sourceTexts: new Map(), }; } + const staticRefusal = staticLinkRefusal({ + staticLink: opts.staticLink, + outputKind, + sanitize: opts.sanitize ?? false, + target: nativeCodegenTarget(), + }); + if (staticRefusal !== null) { + return { + ok: false, + diagnostics: [nativeCodegenDiag("SC3002", staticRefusal, entryPath)], + sourceTexts: new Map(), + }; + } try { windowsSubsystemLinkerArgs(buildPlatform, opts.windowsSubsystem); } catch (err) { @@ -1687,6 +1708,7 @@ async function compileTracked( backend: "llvm", ...optimizationField(opts.optimization), ...(opts.strip ? { strip: true as const } : {}), + ...(opts.staticLink === true ? { staticLink: true as const } : {}), ...(opts.windowsSubsystem === "gui" ? { windowsSubsystem: "gui" as const } : {}), ...(opts.detachedExit === true ? { detachedExit: true as const } : {}), ...(opts.largePages === true ? { largePages: true as const } : {}), @@ -1835,6 +1857,7 @@ async function compileTracked( ffi, opts.windowsSubsystem, opts.strip, + opts.staticLink === true, null, nativeProgramObject?.dependencies, opts.nativeProgramObject === true @@ -1876,6 +1899,13 @@ async function compileTracked( sourceTexts: new Map(), }; } + if (err instanceof NativeCodegenError) { + return { + ok: false, + diagnostics: [nativeCodegenDiag(err.diagnosticCode, err.message, entryPath)], + sourceTexts: new Map(), + }; + } throw err; } finally { if (nativeProgramObject !== null) { @@ -1974,6 +2004,7 @@ async function compileTracked( ffi, opts.windowsSubsystem, opts.strip, + opts.staticLink === true, programSplit, nativeProgramObject?.dependencies, opts.nativeProgramObject === true @@ -2023,6 +2054,13 @@ async function compileTracked( sourceTexts, }; } + if (err instanceof NativeCodegenError) { + return { + ok: false, + diagnostics: [nativeCodegenDiag(err.diagnosticCode, err.message, entryPath)], + sourceTexts, + }; + } throw err; } finally { if (nativeProgramObject !== null) { diff --git a/packages/compiler/src/native/compiler.ts b/packages/compiler/src/native/compiler.ts index 2ed2cbc16..0dec85cbd 100644 --- a/packages/compiler/src/native/compiler.ts +++ b/packages/compiler/src/native/compiler.ts @@ -76,6 +76,12 @@ import { inputsWithoutLld, lldLinkerArgs } from "../backend/lld.js"; import { openNativeExecutableCache } from "./executable-cache.js"; import { LinkerJobs } from "./linker-job.js"; import { nativeFileIdentity } from "./file-identity.js"; +import { + staticExecutableRefusal, + staticLinkFailureNote, + staticLinkMapArgs, + staticLinkRefusal, +} from "../backend/targets.js"; /** The first partition keeps the whole-object key used before partitioning. */ function partitionCacheKey(key: string, index: number): string { @@ -440,6 +446,13 @@ export class NativeCompiler { platform: target.platform, }); if (profileRefusal !== null) throw new NativeCodegenError("SC3002", profileRefusal); + const staticRefusal = staticLinkRefusal({ + staticLink: options.staticLink, + outputKind, + sanitize: options.sanitize ?? false, + target, + }); + if (staticRefusal !== null) throw new NativeCodegenError("SC3002", staticRefusal); const profile = nativeProfile(options); let ffi: FfiProfile | null = null; if (options.ffiProfilePath !== undefined) { @@ -607,6 +620,7 @@ export class NativeCompiler { runtimeSystemLibraries: runtime.systemLibraries, optimization, strip: options.strip ?? false, + staticLink: options.staticLink ?? false, ...(options.windowsSubsystem === undefined ? {} : { windowsSubsystem: options.windowsSubsystem }), @@ -614,10 +628,13 @@ export class NativeCompiler { }); // Linux links with lld when the driver finds it. const lld = options.sanitize ? [] : lldLinkerArgs(target.platform, toolchain.linker); + const linkMap = join(stage, "link.map"); + const mapArgs = staticLinkMapArgs(target, options.staticLink === true, linkMap); const linkArgsWith = (withLld: boolean): string[] => [ ...(options.sanitize ? ["-fsanitize=address"] : toolchain.linkerArgs), ...(withLld ? lld : []), ...plan.driverFlags, + ...mapArgs, ...(withLld ? plan.inputs : inputsWithoutLld(target.platform, plan.inputs)), ...systemLibraryLinkerArgs(target, plan.systemLibraries), "-o", @@ -626,7 +643,10 @@ export class NativeCompiler { const linkArgs = linkArgsWith(lld.length !== 0); // Host Linux links with lld run the linker job the driver would. const jobs = LinkerJobs.open(this.cache, toolchain, lld, options.sanitize ?? false); - const job = jobs?.link([...plan.inputs, stagedOutput], stage); + const job = jobs?.link( + [...plan.inputs, stagedOutput, ...(mapArgs.length === 0 ? [] : [linkMap])], + stage, + ); const cacheable = executableCache?.trace(linkArgs, stage, job) ?? false; timing("link-inputs"); // A successful dependency trace already produced this executable. @@ -634,16 +654,26 @@ export class NativeCompiler { if (!cacheable && (job?.run(linkArgs, true) ?? null) === null) { const driver = options.sanitize ? sanitizerDriver(toolchain) : toolchain.linker; try { - runNativeTool(driver, linkArgs); + try { + runNativeTool(driver, linkArgs); + } catch (error) { + // A failed link with lld runs again with the driver's default + // linker, whose error (if any) is the one reported. + if (lld.length === 0) throw error; + runNativeTool(driver, linkArgsWith(false)); + } } catch (error) { - // A failed link with lld runs again with the driver's default - // linker, whose error (if any) is the one reported. - if (lld.length === 0) throw error; - runNativeTool(driver, linkArgsWith(false)); + const note = staticLinkFailureNote(target, options.staticLink === true); + if (note === "") throw error; + throw new Error(`${error instanceof Error ? error.message : String(error)}${note}`); } } timing("native-link"); requireNativeArtifact(stagedOutput); + if (mapArgs.length !== 0) { + const refusal = staticExecutableRefusal(target, true, readFileSync(linkMap, "utf8")); + if (refusal !== null) throw new NativeCodegenError("SC3002", refusal); + } if (target.platform === "darwin" && optimization === "dev" && !options.strip) { runNativeTool(toolchain.dsymutil, [stagedOutput, "-o", stagedOutput + ".dSYM"]); }