From 3fac2e5766333dc04aa612331d43924d7ea61eda Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 29 Oct 2026 01:11:37 -0700 Subject: [PATCH] Alias a value held from process.env to the live environment `const env = process.env` compiled the hold to a snapshot record copy of the environment, so `env.X = "1"` wrote the copy and was lost while Node's write reached the real environment table (undefined natively, "1" in Node; `repros/d3-env-write.ts`). Node's held value IS the environment object. A hold whose type is the env record now aliases the live table for every member access: dotted and computed reads lower to process.envGet, writes to process.envSet, `delete` to process.envUnset, and the absence probe (`env[k] ?? fallback`, `||`, `if`) reads getenv instead of reconstructing presence over the snapshot. A BARE use of the hold (spread, Object.keys, argument, return) takes a fresh %env.snapshot at that site, so it observes the environment as it is there rather than as it was when the binding was initialized. Two holds keep copy semantics because they cannot alias safely, and a write through either is refused with SC2014 rather than lost: a hold that escapes into another function, and a hold whose declared type is not the env record. Compound and update writes through a hold (`env.X += v`, `env.X++`) are refused the same way. tests/corpus/held-env-value-writes.ts covers writes, computed writes, deletes, child inheritance, direct writes seen through the hold, a copy staying a copy, and bare-use re-snapshotting. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/diagnostics/codes.ts | 1 + .../compiler/src/diagnostics/diagnostic.ts | 20 ++++ .../frontend/lowering/builtins/environment.ts | 108 +++++++++++++++++- .../lowering/expressions/element-access.ts | 30 ++++- .../lowering/expressions/identifiers.ts | 45 ++++++++ .../lowering/expressions/property-access.ts | 16 +++ .../src/frontend/lowering/lower-exprs.ts | 7 ++ .../src/frontend/lowering/lower-stmts.ts | 23 +++- .../compiler/src/frontend/lowering/lowerer.ts | 8 +- .../corpus/held-env-value-writes.ts.json | 6 + tests/corpus/held-env-value-writes.ts | 58 ++++++++++ .../held-env-value-writes.ts.json | 1 + 12 files changed, 314 insertions(+), 9 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/held-env-value-writes.ts.json create mode 100644 tests/corpus/held-env-value-writes.ts create mode 100644 tests/harness/corpus-costs/held-env-value-writes.ts.json diff --git a/packages/compiler/src/diagnostics/codes.ts b/packages/compiler/src/diagnostics/codes.ts index f3d7681d59..26839425be 100644 --- a/packages/compiler/src/diagnostics/codes.ts +++ b/packages/compiler/src/diagnostics/codes.ts @@ -57,6 +57,7 @@ export const DIAGNOSTIC_CODES = { SC2011: "'any'-typed values and their operations", SC2012: "standard-library form without native lowering", SC2013: "npm package import or value without native lowering", + SC2014: "write through a value held from process.env", SC2020: "standard-library or @types/node surface with no lowering", SC3001: "LLVM IR tier refusal", SC3002: "target or capability refusal", diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 0dfd8bd02c..a13d237d56 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -768,6 +768,10 @@ export const FENCE_CODES: Record= 4) return false; + const symbol = lowerer.checker.getSymbolAtLocation(inner); + const decl = symbol?.valueDeclaration ?? symbol?.declarations[0]; + if (decl === undefined) return false; + // `const env = process.env` (or an identifier alias of one). The symbol's + // value declaration is the `const` itself; the written identifier here is + // a different node than the declared name, so no node identity check. + if (ts.isVariableDeclaration(decl)) { + return holdsEnvSnapshot(lowerer, decl.initializer, depth + 1); + } + // `const { env } = process` (also `{ env: held }`). + if (ts.isBindingElement(decl) && ts.isObjectBindingPattern(decl.parent)) { + const declaration = decl.parent.parent; + const named = decl.propertyName ?? decl.name; + if ( + ts.isVariableDeclaration(declaration) && + ts.isIdentifier(named) && + named.text === "env" && + declaration.initializer !== undefined + ) { + return lowerer.isStdlibGlobal(declaration.initializer, "process"); + } + } + return false; +} + +/** The receiver expression of a write target — the assignment's or delete's + * access (`env.X`, `env[k]`), through any compound/update wrapping. Null for + * targets that write no entry of a receiver. */ +function writeTargetReceiver(node: ts.Node): ts.Expression | null { + if (ts.isBinaryExpression(node)) return writeTargetReceiver(node.left); + if (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) { + return writeTargetReceiver(node.operand); + } + if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) { + return node.expression; + } + return null; +} + +/** Whether a MEMBER access (`env.X`, `env[k]`, and their writes) through a + * value HELD from `process.env` can go to the live environment table: the + * held binding's type must be exactly the env snapshot record (a pure + * index-signature shape whose value slot is `string | undefined`, what + * `process.env` maps to). Returns that value type, or null — a hold whose + * type differs keeps the snapshot record for reads and refuses writes (see + * refuseHeldEnvWrite). Member accesses alias the live table the way Node's + * do. A BARE use of the held value (a spread source, Object.keys, an + * argument, a return) is not a member access and takes a fresh snapshot of + * the environment at that use site (lowerIdentifierExpr). */ +export function heldEnvValueType(lowerer: Lowerer, receiver: ts.Expression): IrType | null { + if (!holdsEnvSnapshot(lowerer, receiver)) return null; + const mapped = lowerer.mapTypeOf(lowerer.typeOf(receiver)); + if (mapped?.kind !== "record") return null; + const shape = lowerer.shapes.get(mapped.shapeId); + if (!shape || shape.tuple || shape.fields.length > 0 || shape.indexValue === undefined) + return null; + const value = lowerer.envValueType(); + return typeEquals(shape.indexValue, value) ? value : null; +} + +/** Refuse a write through a value HELD from `process.env` that cannot take + * the live path: the compound and update forms (`env.X += v`, `env.X++`) + * reach this wrapper, and so does any write through a hold whose type is + * not the env record. The held value is a snapshot record, so such a write + * would land on the copy while the real environment kept its old value; + * Node writes setenv(3) through the very same object, so a program relying + * on it would silently diverge. Plain `=`, computed-key and `delete` writes + * through the env record alias the live table instead (lower-stmts.ts, + * element-access.ts); `target` is the write's or delete's access node. */ +export function refuseHeldEnvWrite(lowerer: Lowerer, target: ts.Node): void { + const receiver = writeTargetReceiver(target); + if (receiver === null || !holdsEnvSnapshot(lowerer, receiver)) return; + lowerer.pushDiag(heldEnvWriteDiag(locOf(target))); + throw new PoisonError(); +} + /** The interned `string | undefined` union — the type every env read * produces (withUndefinedArm interns [string, undefined] in canonical * order, so the string arm's tag is 0 and the undefined arm's is 1, diff --git a/packages/compiler/src/frontend/lowering/expressions/element-access.ts b/packages/compiler/src/frontend/lowering/expressions/element-access.ts index 5cb9776f6d..488bde997a 100644 --- a/packages/compiler/src/frontend/lowering/expressions/element-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/element-access.ts @@ -32,6 +32,7 @@ import { unionContainerArmsOk, } from "../../../ir/ir.js"; import { importMetaField, isImportMeta } from "../import-meta.js"; +import { heldEnvValueType } from "../builtins/environment.js"; import { hasOptionalChainGuard, isOptionalChainTail } from "./optional-chains.js"; import { lowerEnumAccess } from "../lower-enums.js"; import { fenceNodeModuleMutation, lowerRequireCacheElement } from "../lower-node-module.js"; @@ -234,6 +235,25 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre }; return lowerer.maybeNarrow(get, expr); } + // `held[expr]` for a value HELD from process.env (`const env = + // process.env`): the hold aliases the live table, so the read is the same + // getenv the direct spelling uses. + { + const live = heldEnvValueType(lowerer, expr.expression); + if (live !== null) { + const key = lowerEnvironmentKey(lowerer, expr.argumentExpression); + if (key.type.kind === "string") { + const get: IrExpr = { + kind: "libCall", + fn: "process.envGet", + args: [key], + type: live, + loc: locOf(expr), + }; + return lowerer.maybeNarrow(get, expr); + } + } + } // Expando function members in element clothing (`foo[strMem]`, // `foo[_private]` where foo is a module-level function/callable const // and the key folds or is a unique-symbol const): the member's module @@ -2217,8 +2237,14 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): }; } // `process.env[key] = v` — the computed twin of the dotted env write: - // setenv(3), string keys and string values only. - if (lowerer.isProcessEnv(target.expression) && !target.questionDotToken) { + // setenv(3), string keys and string values only. A key write through a + // value HELD from process.env takes the same path: the hold aliases the + // live table. + if ( + !target.questionDotToken && + (lowerer.isProcessEnv(target.expression) || + heldEnvValueType(lowerer, target.expression) !== null) + ) { const loc = locOf(expr); const key = lowerEnvironmentKey(lowerer, target.argumentExpression); if (key.type.kind !== "string") { diff --git a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts index ebe7e25563..35f04de986 100644 --- a/packages/compiler/src/frontend/lowering/expressions/identifiers.ts +++ b/packages/compiler/src/frontend/lowering/expressions/identifiers.ts @@ -95,6 +95,33 @@ import { fenceEnumObjectValue } from "../lower-enums.js"; import { mixinFnOfCallee } from "../lower-mixins.js"; import { isNavigatorGlobal, navigatorValue } from "../builtins/navigator.js"; import { moduleFileName } from "../import-meta.js"; +import { envSnapshotHelper, heldEnvValueType } from "../builtins/environment.js"; + +/** Whether this identifier is the RECEIVER of a member access — the dotted + * or bracketed `held.X` / `held[k]`, read or written, through any erasure + * wrapper. Those uses answer through the live environment table themselves + * (heldEnvValueType); every other use of a held value is a bare one and + * reads the environment as it is at that site. */ +function memberReceiverUse(expr: ts.Identifier): boolean { + let node: ts.Node = expr; + for (;;) { + const parent: ts.Node | undefined = node.parent; + if (parent === undefined) return false; + if ( + ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isNonNullExpression(parent) || + ts.isSatisfiesExpression(parent) + ) { + node = parent; + continue; + } + return ( + (ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) && + parent.expression === node + ); + } +} export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: SrcLoc): IrExpr { if (lowerer.isStdlibGlobal(expr, "console")) { @@ -183,6 +210,24 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc: // when the checker types this USE as a single arm, maybeNarrow // bridges the tagged representation with a unionNarrow. const local = lowerer.resolveLocal(expr); + // A BARE use of a value HELD from process.env (`{ ...env }`, Object.keys, + // an argument, a return) reads the environment as it is HERE: Node's hold + // IS the environment object, so its contents at use time are the live + // table's. Member accesses (`env.X`, `env[k]`, their writes) reroute to + // getenv/setenv directly (heldEnvValueType) and never come through a bare + // read, so they materialize no snapshot; every other use takes a fresh + // one at this site instead of the copy the hold was given. + { + const mapped = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if ( + mapped?.kind === "record" && + !memberReceiverUse(expr) && + heldEnvValueType(lowerer, expr) !== null + ) { + const helper = envSnapshotHelper(lowerer, mapped.shapeId, loc); + if (helper !== null) return { kind: "call", callee: helper, args: [], type: mapped, loc }; + } + } // A Date local holding its time value reads as that Date: only uses // that read the time value reach here (date-values.ts). if (local && lowerer.timeValueDateLocals.has(local)) { diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 3bcaf10133..6921c9a9ea 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -209,6 +209,7 @@ import { isZlibStreamMember, lowerZlibStreamBytesWritten } from "../builtins/com import { isWebCryptoReceiver } from "../builtins/crypto.js"; import { isFsStreamMember, lowerFsStreamProperty } from "../builtins/fs-streams.js"; import { keyedFieldRead } from "./keyed-field-targets.js"; +import { heldEnvValueType } from "../builtins/environment.js"; import { promisePrototypeValue } from "../thenables.js"; import { lowerChildPropertyRead } from "../handle-properties.js"; @@ -4441,6 +4442,21 @@ export function fieldGetExpr( // construction), typed as the index value armed with undefined // (mirroring lowerRecordKeyRead). if (target.container === "recordOvf") { + // A dot read through a value HELD from process.env (`const env = + // process.env; env.X`): the hold aliases the live table, so the read is + // the same getenv the direct spelling lowers to. + if (ts.isPropertyAccessExpression(blame)) { + const live = heldEnvValueType(lowerer, blame.expression); + if (live !== null) { + return { + kind: "libCall", + fn: "process.envGet", + args: [{ kind: "strLit", value: target.field, type: STRING, loc }], + type: live, + loc, + }; + } + } let obj = target.obj; if (obj.type.kind === "union" && ts.isPropertyAccessExpression(blame)) { const present = lowerer.stripUndefinedArm(obj.type); diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 306fbcb2cb..30187a9766 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -110,6 +110,7 @@ import { } from "./expressions/property-access.js"; import { lowerNullishPair } from "./expressions/binary.js"; import { lowerElementCompoundParts, recordKeyLiteralText } from "./expressions/element-access.js"; +import { heldEnvValueType } from "./builtins/environment.js"; import { OBJECT_PROTOTYPE_MEMBERS } from "../shape-inference/domain.js"; export { DEFERRED_READ_MESSAGE, @@ -2517,6 +2518,12 @@ export function lowerAbsenceProbe(lowerer: Lowerer, node: ts.Expression): IrExpr const header = lowerer.lowerHttpHeadersElement(expr); if (header) return header; + // A keyed read through a value HELD from process.env (`const env = + // process.env; env[k] ?? fallback`) aliases the live table: the ordinary + // lowering is getenv, whose `string | undefined` result already carries + // the missing arm this probe reconstructs for record keys. + if (heldEnvValueType(lowerer, expr.expression) !== null) return null; + const receiverT = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); if (receiverT?.kind === "record") { const shape = lowerer.shapes.get(receiverT.shapeId); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 377b08917b..923a8e3282 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -45,6 +45,7 @@ import { isWebStreamValueInJavaScript, typeInvolvesWebStream } from "./builtins/ import * as ts from "../ts7/adapter.js"; import { lowerOptionalIndex } from "./undefined-index.js"; import { processDataMemberValue, programReadsExitCode } from "./builtins/process.js"; +import { heldEnvValueType } from "./builtins/environment.js"; import { requireInitFlagStmt } from "./require-initialization.js"; import { isJsonRequireDecl } from "./json-require.js"; import { isTimeValueDateDeclaration } from "./date-values.js"; @@ -7764,9 +7765,11 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt return { kind: "strLit", value: target.name.text, type: STRING, loc: locOf(target.name) }; } const keyNode = (target as ts.ElementAccessExpression).argumentExpression; - let key = lowerer.isProcessEnv(target.expression) - ? lowerEnvironmentKey(lowerer, keyNode) - : lowerer.lowerExpr(keyNode); + let key = + lowerer.isProcessEnv(target.expression) || + heldEnvValueType(lowerer, target.expression) !== null + ? lowerEnvironmentKey(lowerer, keyNode) + : lowerer.lowerExpr(keyNode); if (key.type.kind === "dyn") { key = { kind: "libCall", fn: "dyn.toStringCoerce", args: [key], type: STRING, loc: key.loc }; } @@ -7782,7 +7785,10 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt } return key; }; - if (lowerer.isProcessEnv(target.expression)) { + if ( + lowerer.isProcessEnv(target.expression) || + heldEnvValueType(lowerer, target.expression) !== null + ) { return { kind: "exprStmt", expr: { kind: "libCall", fn: "process.envUnset", args: [lowerKey()], type: VOID, loc }, @@ -8710,7 +8716,14 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm // (Node stringifies everything; a non-string RHS fences instead // of diverging from Node). The computed-key form lands in // lowerElementWrite. - if (lowerer.isProcessEnv(expr.left.expression) && !expr.left.questionDotToken) { + // The same setenv for a value HELD from process.env (`const env = + // process.env; env.X = v`): the hold aliases the live table, as + // Node's does. + if ( + !expr.left.questionDotToken && + (lowerer.isProcessEnv(expr.left.expression) || + heldEnvValueType(lowerer, expr.left.expression) !== null) + ) { const loc = locOf(expr); const key: IrExpr = { kind: "strLit", diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b928055eb1..77a16b5cc4 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -583,7 +583,12 @@ import { } from "./builtins/filesystem.js"; import { lowerSpawnSyncCall, lowerSpawnCall, lowerExecFileCall } from "./builtins/child-process.js"; import { lowerExecSyncCall } from "./builtins/child-process-sync.js"; -import { isProcessEnv, envValueType, lowerProcessEnvGet } from "./builtins/environment.js"; +import { + isProcessEnv, + envValueType, + lowerProcessEnvGet, + refuseHeldEnvWrite, +} from "./builtins/environment.js"; import { lowerJsonMethodCall, lowerJsonProperty } from "./builtins/json.js"; import { lowerCryptoComposedCall, lowerCryptoModuleCall } from "./builtins/crypto.js"; import { lowerUrlMethodCall, lowerSearchParamsMethodCall } from "./builtins/url.js"; @@ -16460,6 +16465,7 @@ export class Lowerer { } fieldSetStmt(target: FieldTarget, value: IrExpr, loc: SrcLoc, blame: ts.Node): IrStmt { + refuseHeldEnvWrite(this, blame); return markSloppyRecordWrites(this, fieldSetStmt(this, target, value, loc, blame), blame); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/held-env-value-writes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/held-env-value-writes.ts.json new file mode 100644 index 0000000000..61d10efe63 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/held-env-value-writes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/held-env-value-writes.ts" + ], + "diags": [] +} diff --git a/tests/corpus/held-env-value-writes.ts b/tests/corpus/held-env-value-writes.ts new file mode 100644 index 0000000000..f4bbce6e46 --- /dev/null +++ b/tests/corpus/held-env-value-writes.ts @@ -0,0 +1,58 @@ +// A value HELD from process.env aliases the live environment, exactly as +// Node's one process.env object does: writes, deletes and reads through the +// hold reach the real environment table (setenv/unsetenv/getenv), so a +// later direct read, `in` test, and a spawned child's inherited environment +// all observe them. A COPY made from the hold stays a copy. +import { spawnSync } from "node:child_process"; + +const env: Record = process.env; +env.SCRIPTC_HELD_WRITE = "one"; +console.log("direct:", process.env.SCRIPTC_HELD_WRITE ?? "(unset)"); +console.log("held:", env.SCRIPTC_HELD_WRITE ?? "(unset)"); + +// A computed key writes the same variable. +const key = "SCRIPTC_HELD_" + "computed"; +env[key] = "two"; +console.log("computed:", process.env[key] ?? "(unset)", env[key] ?? "(unset)"); + +// The write reaches children through the inherited environment. +const child = spawnSync("sh", ["-c", 'printf %s "$SCRIPTC_HELD_WRITE"'], { encoding: "utf8" }); +console.log("child sees:", child.stdout); + +// A write made directly is visible through the hold (one object, not a copy). +process.env.SCRIPTC_HELD_LATE = "late"; +console.log("late via hold:", env.SCRIPTC_HELD_LATE ?? "(unset)"); + +// Deletes through the hold unset the real variable. +delete env.SCRIPTC_HELD_WRITE; +console.log( + "deleted:", + process.env.SCRIPTC_HELD_WRITE === undefined, + env.SCRIPTC_HELD_WRITE === undefined, + "SCRIPTC_HELD_WRITE" in process.env, +); +delete env[key]; +console.log("computed deleted:", process.env[key] === undefined, key in process.env); + +// An absent key deletes as a no-op, like JS. +delete env.SCRIPTC_HELD_NEVER; +console.log("absent delete:", process.env.SCRIPTC_HELD_NEVER === undefined); + +// A COPY of the hold keeps copy semantics: its writes never touch the +// environment, and its own entries stay. The copy sees the environment as +// it is at the spread, including writes made after the hold. +const copy = { ...env }; +copy.SCRIPTC_HELD_COPY = "copy-only"; +console.log( + "copy:", + copy.SCRIPTC_HELD_COPY, + process.env.SCRIPTC_HELD_COPY === undefined, + copy.SCRIPTC_HELD_LATE ?? "(unset)", +); +// Every other bare use of the hold reads the environment at that point too. +console.log( + "bare:", + Object.keys(env).includes("SCRIPTC_HELD_LATE"), + JSON.stringify({ ...env }).includes('"SCRIPTC_HELD_LATE":"late"'), +); +console.log("done"); diff --git a/tests/harness/corpus-costs/held-env-value-writes.ts.json b/tests/harness/corpus-costs/held-env-value-writes.ts.json new file mode 100644 index 0000000000..cbe10f3ea2 --- /dev/null +++ b/tests/harness/corpus-costs/held-env-value-writes.ts.json @@ -0,0 +1 @@ +{ "plain": 1700, "san": 3300 }