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 }