From 8a5fc8977f646799ca6512361f8b15ef21076838 Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Fri, 21 Aug 2026 12:57:55 +0900 Subject: [PATCH] ww: preserve original environment for directory tests --- docs/build-system.md | 212 ++++++++++++++++++++++++++++++---- docs/spec.md | 11 ++ docs/test-system-v2.md | 10 ++ internal/wwpackage/package.ww | 158 ++++++++++++++++++++----- test/package/package_test.ww | 100 +++++++++++++--- 5 files changed, 424 insertions(+), 67 deletions(-) diff --git a/docs/build-system.md b/docs/build-system.md index 107cef1e..a89a9815 100644 --- a/docs/build-system.md +++ b/docs/build-system.md @@ -5850,25 +5850,24 @@ coordinator publication paths are absolute, so the child directory cannot reinterpret them. No runtime coordinator-global `chdir` was added; its cwd and `PWD` remain unchanged. -Each started product also receives a newly allocated run environment. It -retains inherited entries in order except exact `TMPDIR=`, `LC_ALL=`, uppercase -`PATH=`, and uppercase `PWD=` entries, then appends `LC_ALL=C`, the -product-local absolute `TMPDIR`, the selected toolchain `PATH`, and -`PWD=`. Removing prior `PWD` and `PATH` entries reproduces Go's -observable last-wins result because WW's executor intentionally preserves -duplicates and WW `os.getenv` returns the first one. Case-distinct and malformed -entries remain untouched. The product observes exactly one uppercase `PWD`, at -the appended position, and one effective uppercase `PATH`; section 11.32 owns -the latter rule. +Each started product also receives a newly allocated run environment. Section +11.36 supersedes this slice's former test-process locale and temporary-directory +policy: the vector is now a Go-like original-environment snapshot. It keeps the +first occurrence of each normal case-sensitive key, omits later normal +duplicates and raw empty entries, preserves nonempty malformed entries, excludes +inherited uppercase `PATH` and `PWD`, and then appends the selected toolchain +`PATH` and `PWD=`. Caller `LC_ALL` and `TMPDIR` therefore reach the +user test; build-plan tools retain their separate pinned values. -The vector and its generated strings are product-local, dynamically sized, -and published only after every checked allocation succeeds. Partial failure -frees only initialized owned storage and never frees borrowed inherited -strings. `exec.start` synchronously deep-copies the command before returning, -after which the coordinator frees its run vector, generated `TMPDIR`, generated -`PATH`, generated `PWD`, generated `-package` argument, and argv vector. -Concurrent children therefore hold independent fork snapshots; no shared -environment vector or process-global state is mutated. +The vector, normalization table, and generated strings are product-local, +dynamically sized, and published only after every checked allocation succeeds. +Partial failure frees only initialized owned storage and never frees borrowed +inherited strings. The normalization table is gone before launch. `exec.start` +synchronously deep-copies the command before returning, after which the +coordinator frees its run vector, generated `PATH`, generated `PWD`, generated +`-package` argument, and argv vector. Concurrent children therefore hold +independent fork snapshots; no shared environment vector or process-global +state is mutated. All dependency initialization occurs inside that product process. A production or test-only dependency reached by package `p` sees `p`'s directory. If the @@ -5931,8 +5930,8 @@ production and test-only dependency initialization; recompiled external self-import; direct/recursive/redundant/absolute/symlink roots; reversed roots and creation order; `-j 1`/parallel execution; filters/list/no-match; failure and timeout; no-test and build paths; `-c`, `-c -o`, running retained tests, -direct binaries, and raw single files; exact tool cwd/argv/locale/TMPDIR/PATH; -persistent data-only reuse and +direct binaries, and raw single files; original test variables and exact tool +cwd/argv/locale/TMPDIR/PATH; persistent data-only reuse and artifact/binary identity; and a deterministic post-build directory removal where the affected product reports `ENOENT` while its sibling succeeds. Checked command-global allocation-failure parity remains owned by @@ -7006,11 +7005,11 @@ The environment is synthesized at the three true test-process launch owners: `run_test_bin`, and WWstage `runsingletest` for raw single-file tests. Each uses the selected driver directory's canonical absolute spelling. The first effective uppercase inherited value is the suffix; absent and empty values -have no suffix; ordinary uppercase duplicates are removed. Unrelated entries, -including case-distinct and malformed names, retain their previous order and -bytes. Directory products retain their established appended locale, temporary -directory, and final `PWD`; raw tests retain caller cwd, `PWD`, stdin, and split -streams. +have no suffix; ordinary uppercase duplicates are removed. For directory +products, section 11.36 owns unrelated entries: a Go-like original-environment +snapshot retains caller locale and temporary-directory values, case-distinct +and nonempty malformed entries, and the final package `PWD`. Raw tests retain +caller cwd, `PWD`, other environment entries, stdin, and split streams. The rule covers internal, external, and combined directory products; dependency initialization; filters and list mode; running retained tests; and @@ -7466,6 +7465,169 @@ large scheduling sets, failure rollback, stage parity, and artifact-byte identity. No persisted-byte contract changed: build workdir format remains `18`, test workdir format remains `19`, and semantic storage format remains `3`. +### 11.36 Implemented original environment for directory test processes + +Every directory-owned test binary actually started by `ww test` now receives a +Go-like snapshot of the caller environment instead of the package build plan's +locale and temporary directory. On the supported Unix boundary, the snapshot +keeps the first occurrence of every normal case-sensitive `key=value`, omits +later normal duplicates and raw empty entries, and preserves nonempty malformed +entries in order. The existing selected-toolchain `PATH` and package-directory +`PWD` are then appended as the only test-command overrides. Caller `LC_ALL`, +`TMPDIR`, empty-valued variables, case-distinct keys, and arbitrary variables +therefore reach initialization and test code. + +#### Pinned Go evidence and fact classification + +The sole authority is official Go 1.26.5 at commit +`c19862e5f8415b4f24b189d065ed739517c548ba`: + +- `invoke` initializes `cfg.OrigEnv` from + `toolchain.FilterEnv(os.Environ())` before command work + ([`cmd/go/main.go`, lines 290–305](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/cmd/go/main.go#L290-L305)). + `FilterEnv` removes only Go's internal toolchain-switch count variable + ([`cmd/go/internal/toolchain/select.go`, lines 50–59 and 74–85](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/cmd/go/internal/toolchain/select.go#L50-L85)); + WW has no corresponding switch state. +- `OrigEnv` is the startup environment and user binaries during `go test` use + it instead of build-tool `CmdEnv` + ([`cmd/go/internal/cfg/cfg.go`, lines 328–333](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/cmd/go/internal/cfg/cfg.go#L328-L333)). +- Unix `copyenv`, `Getenv`, and `Environ` retain the first occurrence of a + normal case-sensitive key, clear later duplicates, omit cleared or empty + entries from `Environ`, and leave nonempty malformed entries present + ([`syscall/env_unix.go`, lines 20–50](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/syscall/env_unix.go#L20-L50), + [66–84](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/syscall/env_unix.go#L66-L84), + and [135–145](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/syscall/env_unix.go#L135-L145)). +- `(*runTestActor).Act` clips `cfg.OrigEnv`, applies `AppendPATH` and + `AppendPWD`, assigns the result to the package-directory command, and runs it + ([`cmd/go/internal/test/test.go`, lines 1661–1697](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/cmd/go/internal/test/test.go#L1661-L1697)). + The two appenders are defined at + [`cmd/go/internal/base/env.go`, lines 15–45](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/cmd/go/internal/base/env.go#L15-L45), + and explicit-command duplicate removal prefers their later `PATH` and `PWD` + values + ([`os/exec/exec.go`, lines 1231–1308](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/os/exec/exec.go#L1231-L1308)). +- Official `test_env_term.txt` passes an explicitly empty caller `TERM` to a + test and requires it to remain empty + ([lines 1–14](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/cmd/go/testdata/script/test_env_term.txt#L1-L14)). + `test_cache_inputs.txt` changes caller `TESTKEY` and its `TestLookupEnv` + requires the arbitrary variable to be present + ([lines 19–38](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/cmd/go/testdata/script/test_cache_inputs.txt#L19-L38) + and [269–280](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/cmd/go/testdata/script/test_cache_inputs.txt#L269-L280)). + +Those source rules and official assertions are behavior directly implemented or +asserted by pinned Go. That an ordinary caller `LC_ALL`, `TMPDIR`, case-distinct +key, or other variable survives unchanged is behavior derived from the pinned +pipeline: none is removed or replaced after the original snapshot. First-value +normalization, malformed-entry retention, raw-empty omission, and the separation +from build-tool `CmdEnv` are directly implemented by the cited source. + +#### Fresh four-axis audit and direct pre-fix measurements + +The bounded audit considered all four permanent axes and selected only this +test-runtime difference: + +- **Go-like build:** pinned unresolved relocation handling gives missing + `main.main` its dedicated link failure + ([`cmd/link/internal/ld/errors.go`, lines 45–65](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/cmd/link/internal/ld/errors.go#L45-L65)), + asserted by `TestUndefinedRelocErrors` + ([`cmd/link/internal/ld/ld_test.go`, lines 20–45](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/cmd/link/internal/ld/ld_test.go#L20-L45)). + Both WW stages rejected a selected `package main` without `fn main`, emitted + identical linker diagnostics, and created no output. This candidate was + aligned. +- **Go-like test:** a direct `execve` arranger supplied duplicate `LC_ALL`, + `TMPDIR`, arbitrary, and `PWD` variables; one empty-valued normal variable; a + case-distinct key; repeated nonempty malformed entries; and one raw empty + entry. Both stages exited `0` with empty stderr and byte-identical stdout + (SHA-256 + `5b541239c8e9109c512b6ec7b8c59d4b18bf79391096fb14891d8de108786993`). + The test reported caller arbitrary/empty/case-distinct values, but reported + `LC_ALL` and `TMPDIR` as changed, two visible occurrences of the arbitrary + normal key, and the raw empty entry still present. These are directly measured + pre-fix WW facts and establish the selected difference. +- **Go-like package:** `MultiplePackageError` and the directory scan reject + conflicting selected declarations + ([`go/build/build.go`, lines 538–549](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/go/build/build.go#L538-L549) + and [939–967](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/go/build/build.go#L939-L967)); + `TestMultiplePackageImport` plus official `testdata/multi` asserts the result + ([`go/build/build_test.go`, lines 105–124](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/go/build/build_test.go#L105-L124)). + Both WW stages rejected an `alpha`/`beta` source directory with byte-identical + diagnostics and no output. This candidate was aligned. +- **Go-like import:** `unusedImports` and `errorUnusedPkg` require a nonblank + renamed import to be used + ([`cmd/compile/internal/types2/resolver.go`, lines 706–740](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/cmd/compile/internal/types2/resolver.go#L706-L740)); + official `importdecl0` asserts the alias case + ([`internal/types/testdata/check/importdecl0/importdecl0a.go`, lines 5–31](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/internal/types/testdata/check/importdecl0/importdecl0a.go#L5-L31)). + Both WW stages rejected an unused local alias for dotted import `fmt` with + the same semantic diagnostic and no output. This candidate was aligned. + +The command observations above are directly measured WW behavior. The linked +rules are behavior directly implemented or asserted by pinned Go. Applying the +original-environment pipeline at WW's directory-product launcher while leaving +its raw single-file compatibility route intact is behavior derived from the +pinned launch boundary and WW's local input model. + +#### Ownership, final behavior, and preserved boundaries + +`internal/wwpackage.runenv`, called only by `pkgstartrun`, is the semantic owner. +It walks the coordinator's inherited vector in order, uses a bounded fallible +open-addressed key table to retain the first normal case-sensitive occurrence, +omits raw empty entries, retains nonempty malformed entries, and excludes exact +uppercase `PATH` and `PWD`. It then appends the existing canonical selected +driver `PATH` and `PWD=`. The table is freed before launch; +`exec.start` deep-copies the command, after which the product-local vector and +its two generated strings are freed. Concurrent products share no writable +environment storage and the coordinator process is never mutated. + +`toolenv` remains the separate build-plan owner. Compiler, assembler, in-driver +archiver, linker, support generation, generated-main construction, request +scratch, cwd, argv, stdin, diagnostics, and failure precedence are unchanged; +those tools still receive their established `LC_ALL=C` and request-private +`TMPDIR`. The raw single-file route already preserved caller locale and +temporary-directory values and remains outside this directory-owned +normalization slice. A directly invoked retained binary still inherits its +invoker's concrete environment without coordinator policy. + +Loading and platform source selection are unchanged. Production, internal-test, +external-test, recompiled-for-test, support, and generated-main graph/action +identity remain separate and unchanged. Canonical dotted package/import +identity, declared names, file import bindings, physical-directory metadata, +symbols, `.wwi`, source units, assembly, objects, archives, executables, modes, +and artifact bytes do not contain the run environment. Imported or dependency +initialization code observes the corrected values only inside the selected +product process; no physical path or environment value becomes package, import, +graph, action, artifact, publication, or persistence identity. + +`ww build`, `ww run`, directory `ww test -c` or `-S`, no-test products, and +loading/compiler/linker rejection start no test process, allocate no run +environment, and retain their prior diagnostics and outputs. A started test +observes the corrected snapshot before success, assertion failure, signal, +timeout, interruption, or child-created descendants. Those outcomes continue +through the existing process-group, capture, ordered-result, cancellation, and +cleanup owners. For a running retained request, private build and run still +precede guarded installation: runtime failure publishes nothing and preserves +prior bytes, while success installs the same private executable bytes. Producer +failure, output guard failure, and cleanup-only failure retain their existing +rollback and diagnostic precedence. + +Persistent work records and artifact invalidation are unchanged; test results +are never cached. Environment-only changes perform the established warm final +link and always run the private test, without changing committed action bytes. +No `.new`, `.install`, `.wwtxn.*`, capture, result, process, or request scratch +survives its existing cleanup boundary. This runtime-only metadata change alters +no persisted-byte contract, so build workdir format remains `18`, test workdir +format remains `19`, and semantic storage format remains `3`. + +The expanded WW-native `directory_test_execution_working_directory` observer +proves both-stage equality for nonempty and empty-valued variables; caller +`LC_ALL` and `TMPDIR`; first-wins normal duplicates; case-distinct and repeated +malformed entries; raw-empty omission; package `PWD` and selected-toolchain +`PATH`; production and test-only dependency initialization; internal, external, +combined, and test-only products; filters/list/no-match; recursive/equivalent +selection; serial/parallel scheduling; failure, signal, timeout, and child setup +failure; retained success and rollback; cold/warm/data-only persistence; +unchanged artifact and binary bytes; build/no-test/compile-only/rejection +nonexecution; unchanged raw/direct compatibility; exact tool locale/TMPDIR; and +stage, transaction, capture, and workdir cleanup. + ## 12. Candidate architectures and hard-gate decision Five candidates were developed as coherent systems, not as feature bins. diff --git a/docs/spec.md b/docs/spec.md index deeb6550..ee5401e9 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -770,6 +770,17 @@ compatibility route. It does not apply to build tools, `ww build`, `ww run`, compile-only or assembly-only test requests, no-test products, or later direct execution of a retained binary. +An executed directory product otherwise receives the caller's original Unix +environment, not the build plan's tool environment. The snapshot keeps the +first occurrence of each normal case-sensitive `key=value`, omits later normal +duplicates and raw empty entries, and retains every nonempty malformed entry in +order. It then applies only the effective `PATH` above and the package `PWD`. +Consequently caller `LC_ALL`, `TMPDIR`, empty-valued variables, case-distinct +keys, and arbitrary variables remain visible to production or test-only +initialization and test bodies. The compiler, assembler, archiver, linker, and +coordinator scratch continue to use their separate build-plan locale and +temporary directory. + The same coordinator-executed product reads standard input from the null device. Its first read observes EOF regardless of the terminal, pipe, or file connected to the invoking command. Each parallel product owns a separate null diff --git a/docs/test-system-v2.md b/docs/test-system-v2.md index c3a97798..5d8708f8 100644 --- a/docs/test-system-v2.md +++ b/docs/test-system-v2.md @@ -305,6 +305,16 @@ the private run and install only after success. Compile-only, assembly-only, no-test, rejected, and build requests start no test process and therefore have no test-PATH state. +For a directory product, the rest of the child vector is a Go-like snapshot of +the caller environment rather than the build-tool environment: the first +case-sensitive occurrence of every normal `key=value` survives, later normal +duplicates and raw empty entries do not, and nonempty malformed entries remain +in order. Only the effective `PATH` and package `PWD` are then appended. +Caller `LC_ALL`, `TMPDIR`, empty values, case-distinct keys, and arbitrary +variables therefore reach dependency initialization and selected tests. +Build-plan compiler, assembler, archiver, linker, and scratch environments keep +their existing pinned locale and request-private temporary directory. + Every such product also receives an independently opened null device as fd 0. Caller terminal, pipe, and file bytes remain with the coordinator; serial and parallel products observe immediate EOF rather than consuming a shared input diff --git a/internal/wwpackage/package.ww b/internal/wwpackage/package.ww index 235a2bd8..202dfcef 100644 --- a/internal/wwpackage/package.ww +++ b/internal/wwpackage/package.ww @@ -116,6 +116,11 @@ fn pkgallocstrs(cap: i32) ([]str | nomem) = { return value; }; +fn pkgalloci32(cap: i32) ([]i32 | nomem) = { + let value: []i32 = alloc([], cap: u64)?; + return value; +}; + fn pkgallocbytes(cap: i32) ([]u8 | nomem) = { let value: []u8 = alloc([], cap: u64)?; return value; @@ -268,11 +273,81 @@ fn pkgfreestrs(values: []str) void = { }; }; -// Go's AppendPATH and AppendPWD append, then os/exec keeps the last duplicate -// values. WW's executor preserves duplicates, so discard earlier exact entries -// and materialize the selected driver's sibling directory first in PATH. -fn runenv(tmpdir: str, pwd: str, builder: str, out: *[]str, tmpowned: *str, - pathowned: *str, pwdowned: *str) bool = { +fn pkgenvkeylen(entry: str) i32 = { + let i: i32 = 0; + for (i < entry.len) { + if (entry[i] == '=') { return i; }; + i += 1; + }; + return -1; +}; + +fn pkgenvkeyequal(a: str, an: i32, b: str, bn: i32) bool = { + if (an != bn) { return false; }; + let i: i32 = 0; + for (i < an) { + if (a[i] != b[i]) { return false; }; + i += 1; + }; + return true; +}; + +fn pkgenvkeyis(entry: str, n: i32, name: str) bool = { + return pkgenvkeyequal(entry, n, name, name.len); +}; + +fn pkgenvhash(entry: str, n: i32) u64 = { + let h: u64 = 14695981039346656037u64; + let i: i32 = 0; + for (i < n) { + h = h ^ (entry[i]: u64); + h = h * 1099511628211u64; + i += 1; + }; + return h; +}; + +fn pkgenvtablecap(entries: i32) i32 = { + if (entries == 0) { return 0; }; + if (entries > PKG_COUNT_MAX / 2) { return -1; }; + let need: i32 = entries * 2; + let cap: i32 = PKG_INITIAL_CAP; + for (cap < need) { + if (cap > PKG_COUNT_MAX / 2) { return -1; }; + cap *= 2; + }; + return cap; +}; + +// Go snapshots user-test variables through Unix os.Environ before appending +// PATH and PWD. WW's raw environment walker and executor do no such duplicate +// normalization, so the launch owner must materialize that snapshot itself. +fn pkgenvfirst(slots: []i32, inherited: []str, at: i32, n: i32) bool = { + let slot: i32 = (pkgenvhash(inherited[at], n) + % (slots.len: u64)): i32; + let probes: i32 = 0; + for (probes < slots.len) { + let prior: i32 = slots[slot]; + if (prior < 0) { + slots[slot] = at; + return true; + }; + let priorn: i32 = pkgenvkeylen(inherited[prior]); + if (pkgenvkeyequal(inherited[at], n, + inherited[prior], priorn)) { + return false; + }; + slot += 1; + if (slot == slots.len) { slot = 0; }; + probes += 1; + }; + return false; +}; + +// Go's appended PATH and PWD survive os/exec's later-value selection. WW's +// executor preserves its concrete vector, so inherited copies are excluded. +fn runenv(pwd: str, builder: str, out: *[]str, pathowned: *str, + pwdowned: *str) bool = { let toolbin: str; let oom: bool = false; if (!pkgcanonicaldir(pkgdirname(builder), &toolbin, &oom)) { @@ -283,35 +358,71 @@ fn runenv(tmpdir: str, pwd: str, builder: str, out: *[]str, tmpowned: *str, return false; }; let inherited: []str = os.getenvs(); - if (inherited.len > PKG_COUNT_MAX - 4) { + if (inherited.len > PKG_COUNT_MAX - 2) { pkgputln(os.STDERR_FILENO, "wwtest package: package graph is too large"); return false; }; - let allocation: ([]str | nomem) = pkgallocstrs(inherited.len + 4); + let allocation: ([]str | nomem) = pkgallocstrs(inherited.len + 2); let env: []str; match (allocation) { case let value: []str => env = value; case nomem => { pkgputln(os.STDERR_FILENO, "wwtest package: out of memory"); return false; + }; }; - }; + let keyed: i32 = 0; let i: i32 = 0; for (i < inherited.len) { - if (!strings.hasprefix(inherited[i], "TMPDIR=") - && !strings.hasprefix(inherited[i], "LC_ALL=") - && !strings.hasprefix(inherited[i], "PATH=") - && !strings.hasprefix(inherited[i], "PWD=")) { + let n: i32 = pkgenvkeylen(inherited[i]); + if (n >= 0 && !pkgenvkeyis(inherited[i], n, "PATH") + && !pkgenvkeyis(inherited[i], n, "PWD")) { + keyed += 1; + }; + i += 1; + }; + let tablecap: i32 = pkgenvtablecap(keyed); + if (tablecap < 0) { + pkgputln(os.STDERR_FILENO, + "wwtest package: package graph is too large"); + pkgfreestrs(env); + return false; + }; + let slots: []i32; + slots.ptr = nil: *i32; + slots.len = 0; + slots.cap = 0; + if (tablecap != 0) { + let tableallocation: ([]i32 | nomem) = pkgalloci32(tablecap); + match (tableallocation) { + case let value: []i32 => slots = value; + case nomem => { + pkgputln(os.STDERR_FILENO, + "wwtest package: out of memory"); + pkgfreestrs(env); + return false; + }; + }; + slots.len = tablecap; + i = 0; + for (i < slots.len) { slots[i] = -1; i += 1; }; + }; + i = 0; + for (i < inherited.len) { + let n: i32 = pkgenvkeylen(inherited[i]); + if (n < 0) { + if (inherited[i].len != 0) { append(env, inherited[i]); }; + } else if (!pkgenvkeyis(inherited[i], n, "PATH") + && !pkgenvkeyis(inherited[i], n, "PWD") + && pkgenvfirst(slots, inherited, i, n)) { append(env, inherited[i]); }; i += 1; }; - append(env, "LC_ALL=C"); - let tmpenv: str; - if (!pkgstring(&tmpenv, "TMPDIR=", tmpdir)) { - pkgfreestrs(env); - return false; + if (slots.ptr != nil) { + os.free(slots.ptr: *void, + (slots.cap: u64) * (size(i32): u64)); }; let pathenv: str; let pathok: bool = false; @@ -327,29 +438,24 @@ fn runenv(tmpdir: str, pwd: str, builder: str, out: *[]str, tmpowned: *str, case void => pathok = pkgstring(&pathenv, "PATH=", toolbin); }; if (!pathok) { - pkgfreeownedstr(tmpenv); pkgfreestrs(env); return false; }; let pwdenv: str; if (!pkgstring(&pwdenv, "PWD=", pwd)) { - pkgfreeownedstr(tmpenv); pkgfreeownedstr(pathenv); pkgfreestrs(env); return false; }; - append(env, tmpenv); append(env, pathenv); append(env, pwdenv); *out = env; - *tmpowned = tmpenv; *pathowned = pathenv; *pwdowned = pwdenv; return true; }; -fn freerunenv(env: []str, tmpowned: str, pathowned: str, pwdowned: str) void = { - pkgfreeownedstr(tmpowned); +fn freerunenv(env: []str, pathowned: str, pwdowned: str) void = { pkgfreeownedstr(pathowned); pkgfreeownedstr(pwdowned); pkgfreestrs(env); @@ -1624,11 +1730,9 @@ fn pkgstartrun(g: *pkggroup, filters: []str, timeoutarg: str, let i: i32 = 0; for (i < filters.len) { append(ra, filters[i]); i += 1; }; let env: []str; - let tmpowned: str; let pathowned: str; let pwdowned: str; - if (!runenv(g.root, g.dir, builder, &env, &tmpowned, - &pathowned, &pwdowned)) { + if (!runenv(g.dir, builder, &env, &pathowned, &pwdowned)) { pkgfreeownedstr(packagearg); pkgfreestrs(ra); return false; @@ -1644,7 +1748,7 @@ fn pkgstartrun(g: *pkggroup, filters: []str, timeoutarg: str, rcmd.deadline.nsec = 0i64; rcmd.grace = 0i64: time.duration; exec.start(h, &rcmd); - freerunenv(env, tmpowned, pathowned, pwdowned); + freerunenv(env, pathowned, pwdowned); pkgfreeownedstr(packagearg); pkgfreestrs(ra); return true; diff --git a/test/package/package_test.ww b/test/package/package_test.ww index 31d3e112..dce8bc23 100644 --- a/test/package/package_test.ww +++ b/test/package/package_test.ww @@ -633,11 +633,16 @@ fn hexbytes(value: str) str = { fn cwdtestenv(stage: str) []str = { let inherited: []str = os.getenvs(); - let env: []str = alloc([], (inherited.len + 10): u64)!; + let env: []str = alloc([], (inherited.len + 24): u64)!; let i: i32 = 0; for (i < inherited.len) { if (!strings.hasprefix(inherited[i], "PWD=") && !strings.hasprefix(inherited[i], "PATH=") + && !strings.hasprefix(inherited[i], "LC_ALL=") + && !strings.hasprefix(inherited[i], "TMPDIR=") + && !strings.hasprefix(inherited[i], "WW_CWD_ORIGINAL=") + && !strings.hasprefix(inherited[i], "WW_CWD_EMPTY=") + && !strings.hasprefix(inherited[i], "lc_all=") && !strings.hasprefix(inherited[i], "WW_W6C=") && !strings.hasprefix(inherited[i], "WW_W6A=") && !strings.hasprefix(inherited[i], "WW_W6L=") @@ -649,6 +654,14 @@ fn cwdtestenv(stage: str) []str = { }; append(env, "PATH=/ww-path-inherited-first"); append(env, "PWD=/ww-cwd-inherited-first"); + append(env, "LC_ALL=C.UTF-8"); + append(env, "TMPDIR=/ww-caller-tmp-first"); + append(env, "WW_CWD_ORIGINAL=first"); + append(env, "WW_CWD_EMPTY="); + append(env, "lc_all=case-kept"); + append(env, "WW_CWD_MALFORMED"); + append(env, ""); + append(env, "WW_CWD_MALFORMED"); if (same(stage, "ww")) { append(env, strings.concat("WW_W6C=", driver("w6c"))); append(env, strings.concat("WW_W6A=", driver("w6a"))); @@ -660,6 +673,9 @@ fn cwdtestenv(stage: str) []str = { append(env, strings.concat("WW_W6L=", driver("w6l_ww"))); }; append(env, strings.concat("WW_SRCLIB=", repo(), "/lib")); + append(env, "LC_ALL=C"); + append(env, "TMPDIR=/ww-caller-tmp-last"); + append(env, "WW_CWD_ORIGINAL=last"); append(env, "PATH=/ww-path-inherited-last"); append(env, "PWD=/ww-cwd-inherited-last"); return env; @@ -679,6 +695,42 @@ fn cwdassertcallerpath(text: str, label: str) void = { cwdassertpath(text, label, "/ww-path-inherited-first", "2"); }; +fn cwdassertenvvalues(text: str, label: str) void = { + assert(has(text, strings.concat(label, " LC_ALL=C.UTF-8\n"))); + assert(has(text, strings.concat(label, + " TMPDIR=/ww-caller-tmp-first\n"))); + assert(has(text, strings.concat(label, + " WW_CWD_ORIGINAL=first\n"))); + assert(has(text, strings.concat(label, " WW_CWD_EMPTY=\n"))); + assert(has(text, strings.concat(label, " lc_all=case-kept\n"))); +}; + +fn cwdassertdirectoryenv(text: str, label: str) void = { + cwdassertenvvalues(text, label); + assert(has(text, strings.concat(label, " LC_ALL-count=1\n"))); + assert(has(text, strings.concat(label, " TMPDIR-count=1\n"))); + assert(has(text, strings.concat(label, + " WW_CWD_ORIGINAL-count=1\n"))); + assert(has(text, strings.concat(label, + " WW_CWD_EMPTY-count=1\n"))); + assert(has(text, strings.concat(label, " lc_all-count=1\n"))); + assert(has(text, strings.concat(label, " malformed-count=2\n"))); + assert(has(text, strings.concat(label, " empty-entry-count=0\n"))); +}; + +fn cwdassertcallerenv(text: str, label: str) void = { + cwdassertenvvalues(text, label); + assert(has(text, strings.concat(label, " LC_ALL-count=2\n"))); + assert(has(text, strings.concat(label, " TMPDIR-count=2\n"))); + assert(has(text, strings.concat(label, + " WW_CWD_ORIGINAL-count=2\n"))); + assert(has(text, strings.concat(label, + " WW_CWD_EMPTY-count=1\n"))); + assert(has(text, strings.concat(label, " lc_all-count=1\n"))); + assert(has(text, strings.concat(label, " malformed-count=2\n"))); + assert(has(text, strings.concat(label, " empty-entry-count=1\n"))); +}; + fn cwdassertrecord(text: str, label: str, cwd: str, pwd: str, pwdcount: str, pwdlast: str, data: str, testdata: str, input: str) void = { @@ -724,6 +776,7 @@ fn cwdassertpackage(text: str, label: str, dir: str, data: str, testdata: str) void = { cwdassertrecord(text, label, dir, dir, "1", "yes", data, testdata, "eof"); + cwdassertdirectoryenv(text, label); cwdasserttestpath(text, label); cwdassertmergedstreams(text, label, dir); }; @@ -795,10 +848,10 @@ fn cwdwritedata(dir: str, label: str) void = { clean(root); }; -// Directory-package execution is one product-level runtime property. This -// fixture keeps every source tree disposable while covering the variant, -// dependency, routing, concurrency, persistence, and failure boundaries that -// could otherwise accidentally derive a cwd from an action or request name. +// Directory-package execution context is one product-level runtime property. +// This fixture keeps every source tree disposable while covering the variant, +// dependency, environment, routing, concurrency, persistence, and failure +// boundaries that could otherwise leak build-plan or caller process state. @test fn directory_test_execution_working_directory() void = { let root: str = fresh(); let caller: str = strings.concat(root, "/caller"); @@ -872,12 +925,22 @@ fn cwdwritedata(dir: str, label: str) void = { "};\n", "fn put(value: str) void = { putfd(os.STDOUT_FILENO, value); };\n", "fn puterr(value: str) void = { putfd(os.STDERR_FILENO, value); };\n", + "fn putcount(value: i32) void = {\n", + " if (value == 0) { put(\"0\\n\"); } else { if (value == 1) {\n", + " put(\"1\\n\"); } else { if (value == 2) { put(\"2\\n\");\n", + " } else { put(\"many\\n\"); }; }; };\n", + "};\n", "export fn streams(label: str, context: str) void = {\n", " put(strings.concat(\"STREAM \", label, \" out-1 cwd=\", context, \"\\n\"));\n", " puterr(strings.concat(\"STREAM \", label, \" err-1 cwd=\", context, \"\\n\"));\n", " put(strings.concat(\"STREAM \", label, \" out-2 cwd=\", context, \"\\n\"));\n", " puterr(strings.concat(\"STREAM \", label, \" err-2 cwd=\", context, \"\\n\"));\n", "};\n"); + let envsource: str = strings.concat( + " put(label); put(\" LC_ALL=\");\n match (os.getenv(\"LC_ALL\")) {\n case let value: str => put(value); case void => put(\"\");\n }; put(\"\\n\");\n put(label); put(\" TMPDIR=\");\n match (os.getenv(\"TMPDIR\")) {\n case let value: str => put(value); case void => put(\"\");\n }; put(\"\\n\");\n put(label); put(\" WW_CWD_ORIGINAL=\");\n match (os.getenv(\"WW_CWD_ORIGINAL\")) {\n case let value: str => put(value); case void => put(\"\");\n }; put(\"\\n\");\n put(label); put(\" WW_CWD_EMPTY=\");\n match (os.getenv(\"WW_CWD_EMPTY\")) {\n case let value: str => put(value); case void => put(\"\");\n }; put(\"\\n\");\n put(label); put(\" lc_all=\");\n match (os.getenv(\"lc_all\")) {\n case let value: str => put(value); case void => put(\"\");\n }; put(\"\\n\");\n", + " let lccount: i32 = 0; let tmpcount: i32 = 0;\n let originalcount: i32 = 0; let emptyvaluecount: i32 = 0;\n let lowercount: i32 = 0; let malformedcount: i32 = 0;\n let emptyentrycount: i32 = 0;\n i = 0; for (i < env.len) {\n if (strings.hasprefix(env[i], \"LC_ALL=\")) { lccount += 1; };\n if (strings.hasprefix(env[i], \"TMPDIR=\")) { tmpcount += 1; };\n if (strings.hasprefix(env[i], \"WW_CWD_ORIGINAL=\")) { originalcount += 1; };\n if (strings.hasprefix(env[i], \"WW_CWD_EMPTY=\")) { emptyvaluecount += 1; };\n if (strings.hasprefix(env[i], \"lc_all=\")) { lowercount += 1; };\n if (strings.compare(env[i], \"WW_CWD_MALFORMED\") == 0) { malformedcount += 1; };\n if (env[i].len == 0) { emptyentrycount += 1; };\n i += 1;\n };\n", + " put(label); put(\" PWD-count=\"); putcount(count);\n put(label); put(\" PWD-last=\");\n if (last == env.len - 1) { put(\"yes\\n\"); } else { put(\"no\\n\"); };\n put(label); put(\" PATH-count=\"); putcount(pathcount);\n", + " put(label); put(\" LC_ALL-count=\"); putcount(lccount);\n put(label); put(\" TMPDIR-count=\"); putcount(tmpcount);\n put(label); put(\" WW_CWD_ORIGINAL-count=\"); putcount(originalcount);\n put(label); put(\" WW_CWD_EMPTY-count=\"); putcount(emptyvaluecount);\n put(label); put(\" lc_all-count=\"); putcount(lowercount);\n put(label); put(\" malformed-count=\"); putcount(malformedcount);\n put(label); put(\" empty-entry-count=\"); putcount(emptyentrycount);\n"); writefile(strings.concat(probe, "/probe.ww"), strings.concat( "package probe;\n", "import os;\n", @@ -902,16 +965,7 @@ fn cwdwritedata(dir: str, label: str) void = { " if (body.len == 0 || body[body.len - 1] != '\\n') { put(\"\\n\"); };\n", "};\n", "export fn run(label: str) void = {\n let buf: [4096]u8;\n let n: i64 = os.getcwd(&buf[0], size([4096]u8)); assert(n > 1i64);\n let cwd: str; cwd.ptr = &buf[0]; cwd.len = (n - 1i64): i32;\n streams(label, cwd);\n put(label); put(\" cwd=\"); put(cwd); put(\"\\n\");\n put(label); put(\" PWD=\");\n match (os.getenv(\"PWD\")) {\n case let value: str => put(value); case void => put(\"\");\n }; put(\"\\n\");\n put(label); put(\" PATH=\");\n match (os.getenv(\"PATH\")) {\n case let value: str => put(value); case void => put(\"\");\n }; put(\"\\n\");\n let env: []str = os.getenvs(); let count: i32 = 0; let pathcount: i32 = 0; let last: i32 = -1;\n let i: i32 = 0; for (i < env.len) {\n if (strings.hasprefix(env[i], \"PWD=\")) { count += 1; last = i; };\n if (strings.hasprefix(env[i], \"PATH=\")) { pathcount += 1; };\n i += 1;\n };\n", - " put(label); put(\" PWD-count=\");\n", - " if (count == 0) { put(\"0\\n\"); } else { if (count == 1) {\n", - " put(\"1\\n\"); } else { if (count == 2) { put(\"2\\n\");\n", - " } else { put(\"many\\n\"); }; }; };\n", - " put(label); put(\" PWD-last=\");\n", - " if (last == env.len - 1) { put(\"yes\\n\"); } else { put(\"no\\n\"); };\n", - " put(label); put(\" PATH-count=\");\n", - " if (pathcount == 0) { put(\"0\\n\"); } else { if (pathcount == 1) {\n", - " put(\"1\\n\"); } else { if (pathcount == 2) { put(\"2\\n\");\n", - " } else { put(\"many\\n\"); }; }; };\n", + envsource, " emitfile(label, \"data\", \"data.txt\");\n", " emitfile(label, \"testdata\", \"testdata/input.txt\");\n", " let input: [1]u8;\n let inputn: i64 = os.read(os.STDIN_FILENO, &input[0], 1u64);\n put(label); put(\" stdin=\");\n if (inputn == 0i64) { put(\"eof\\n\"); } else {\n if (inputn == 1i64) { put(\"data\\n\"); } else { put(\"\\n\"); };\n };\n", @@ -1078,6 +1132,7 @@ fn cwdwritedata(dir: str, label: str) void = { expectexit(&outc, 0); cwdassertrecord(outc.stdout, "p4-internal-only", p4, p4, "1", "yes", "p4-data", "p4-testdata", "eof"); + cwdassertdirectoryenv(outc.stdout, "p4-internal-only"); cwdassertpath(outc.stdout, "p4-internal-only", strings.concat(repo(), "/out/bin"), "1"); assert(os.remove(strings.concat(p4, "/created.txt")) == 0); @@ -1224,6 +1279,8 @@ fn cwdwritedata(dir: str, label: str) void = { assert(has(outc.stderr, "test package must match production package or _test\n")); assert(!has(outc.stdout, " PATH=") && !has(outw.stdout, " PATH=")); + assert(!has(outc.stdout, " LC_ALL=") + && !has(outw.stdout, " LC_ALL=")); assert(directoryisempty(rejectcwork)); assert(directoryisempty(rejectwwork)); @@ -1239,6 +1296,8 @@ fn cwdwritedata(dir: str, label: str) void = { assert(!has(out.stdout, "cmdno-init")); assert(!has(out.stdout, "cmdno-main")); assert(!has(out.stdout, " PATH=")); + assert(!has(out.stdout, " LC_ALL=") + && !has(out.stdout, " TMPDIR=")); assert(!os.exists(strings.concat(plain, "/created.txt"))); assert(!os.exists(strings.concat(cmdno, "/created.txt"))); let commandbin: str = strings.concat(root, "/cmdno.bin"); @@ -1251,6 +1310,8 @@ fn cwdwritedata(dir: str, label: str) void = { assert(os.exists(commandbin)); assert(!has(out.stdout, "cmdno-init") && !has(out.stdout, "cmdno-main")); assert(!has(out.stdout, " PATH=")); + assert(!has(out.stdout, " LC_ALL=") + && !has(out.stdout, " TMPDIR=")); assert(!os.exists(strings.concat(cmdno, "/created.txt"))); // Compile-only paths do not run. A published binary has no embedded cwd or @@ -1267,6 +1328,8 @@ fn cwdwritedata(dir: str, label: str) void = { assert(!os.exists(strings.concat(p2, "/created.txt"))); assert(!has(out.stdout, "dep-init") && !has(out.stdout, "p2-external")); assert(!has(out.stdout, " PATH=")); + assert(!has(out.stdout, " LC_ALL=") + && !has(out.stdout, " TMPDIR=")); assert(os.remove(defaultbin) == 0); assert(os.remove(strings.concat(p1, "/created.txt")) == 0); @@ -1288,6 +1351,8 @@ fn cwdwritedata(dir: str, label: str) void = { assert(same(readfile(p1cbin), readfile(p1wbin))); assert(!has(outc.stdout, "dep-init") && !has(outw.stdout, "dep-init")); assert(!has(outc.stdout, " PATH=") && !has(outw.stdout, " PATH=")); + assert(!has(outc.stdout, " LC_ALL=") + && !has(outw.stdout, " LC_ALL=")); let directbin: []str = [p1cbin, "-package=p1"]; runcommandinputenvdir(root, "cwd-published-direct", directbin, cenv, @@ -1300,6 +1365,8 @@ fn cwdwritedata(dir: str, label: str) void = { cwdassertrecord(out.stdout, "p1-internal", caller, "/ww-cwd-inherited-first", "2", "no", "caller-data", "caller-testdata", "data"); + cwdassertcallerenv(out.stdout, "dep-init"); + cwdassertcallerenv(out.stdout, "p1-internal"); cwdassertcallerpath(out.stdout, "dep-init"); cwdassertcallerpath(out.stdout, "p1-internal"); cwdassertsplitstreams(out.stdout, out.stderr, "dep-init", caller); @@ -1328,6 +1395,7 @@ fn cwdwritedata(dir: str, label: str) void = { cwdassertrecord(outc.stdout, "p4-internal-only", caller, "/ww-cwd-inherited-first", "2", "yes", "caller-data", "caller-testdata", "data"); + cwdassertcallerenv(outc.stdout, "p4-internal-only"); cwdasserttestpath(outc.stdout, "p4-internal-only"); cwdassertsplitstreams(outc.stdout, outc.stderr, "p4-internal-only", caller); @@ -1666,6 +1734,8 @@ fn cwdwritedata(dir: str, label: str) void = { "p2-data", "p2-testdata", "eof"); cwdassertrecord(out.stdout, "p2-external", p2, p2, "1", "yes", "p2-data", "p2-testdata", "eof"); + cwdassertdirectoryenv(out.stdout, "dep-init"); + cwdassertdirectoryenv(out.stdout, "p2-external"); cwdassertpath(out.stdout, "dep-init", strings.concat(root, ":/ww-path-inherited-first"), "1"); cwdassertpath(out.stdout, "p2-external", strings.concat(root,