mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 01:13:39 +00:00
fix(javascript): emit no fs acquisition for a literal non-flag string (#8125)
* fix(javascript): emit no fs acquisition for a literal non-flag string
`fs.open` and `fs.openSync` validate a literal flag before touching the
filesystem:
if (!isFd(flags) && !FLAGS_SET.has(flags))
throw new ERR_INVALID_ARG_VALUE('flags', flags, ...);
The throw is synchronous, before a descriptor exists, but the analysis reported
an `acquire` operation for those calls.
Measured on Node 24.22.1 against a file that does not exist, so each valid flag
is exercised without an EEXIST masking it:
ACCEPTED as a flags string:
a a+ as as+ ax ax+ r r+ rs rs+ sa sa+ sr sr+ w w+ wx wx+ xa xa+ xw xw+
REJECTED with ERR_INVALID_ARG_VALUE:
"" A R W rb wb s s+ x r,x
A Python-style `"rb"`/`"wb"` is the common case: those are valid in Python and
fatal at the Node call site.
`FS_OPEN_FLAGS` in `javascriptStaticAnalysisHelpers.ts` already holds exactly
those 22 accepted flags. It is now exported and reused so the accepted set has
one owner. A numeric flag is a platform bitmask and a computed value may still be
valid, so only an exactly readable non-flag string is dropped.
`fs.createReadStream` keeps its acquisition: it does not validate the flag
synchronously, it returns a stream and reports the failure on the error event.
* chore: retrigger title and readiness checks after the title was corrected
* fix(javascript): reject every fs flag literal Node rejects
The first version only decided string flags. Node's check covers every flag
shape, and each one below throws `ERR_INVALID_ARG_VALUE` or `ERR_OUT_OF_RANGE`
synchronously, before a descriptor exists:
openSync(p, 1.5) -> THREW ERR_OUT_OF_RANGE
openSync(p, -1) -> THREW EINVAL
openSync(p, -1.5) -> THREW ERR_OUT_OF_RANGE
openSync(p, 1e400) -> THREW ERR_OUT_OF_RANGE
openSync(p, NaN) -> THREW ERR_OUT_OF_RANGE
openSync(p, Infinity) -> THREW ERR_OUT_OF_RANGE
openSync(p, 0n) -> THREW ERR_INVALID_ARG_VALUE
openSync(p, true) -> THREW ERR_INVALID_ARG_VALUE
openSync(p, false) -> THREW ERR_INVALID_ARG_VALUE
openSync(p, {}) -> THREW ERR_INVALID_ARG_VALUE
openSync(p, []) -> THREW ERR_INVALID_ARG_VALUE
--- accepted ---
openSync(p, 0) / 1 / 65536 / +5 -> ok
openSync(p) / undefined / null -> ok
openSync(p, "r") / "wx" / "sr" -> ok
`NaN` and `Infinity` are identifiers rather than literals, so they are decided
only through `isUnmodifiedGlobalReference`; a shadowed binding keeps the
acquisition.
* fix(javascript): judge an fs flag by its value, and ignore a shifting spread
Two P1 findings on the first version.
**A unary operator was judged by its operand.** `openSync(p, void 1.5)` passes
`undefined`, so Node applies the default read flag, but the branch saw a
fractional operand and dropped a real acquisition. `openSync(p, -0)` is the valid
zero bitmask and was also dropped. `staticFlagBits` now evaluates the operator:
openSync(p, void 1.5) -> ok (keeps the acquisition)
openSync(p, void 0) -> ok
openSync(p, -0) -> ok
openSync(p, -0.0) -> ok
openSync(p, +0) -> ok
openSync(p, ~-1) -> ok
openSync(p, -1) -> THREW EINVAL
openSync(p, -1.5) -> THREW ERR_OUT_OF_RANGE
openSync(p, +1.5) -> THREW ERR_OUT_OF_RANGE
openSync(p, !0) -> THREW ERR_INVALID_ARG_VALUE
openSync(p, ~0) -> THREW EINVAL
openSync(p, typeof 1) -> THREW ERR_INVALID_ARG_VALUE
**A spread before the flag slot shifts the runtime position.** With
`args = [path, "w"]`, `openSync(...args, "0600")` opens with mode `"0600"`, but
argument 1 is then the mode rather than the flag. The check now keeps the
acquisition whenever argument 0 is a spread, because the flag it inspects is not
provably the flag at runtime.
* fix(javascript): leave a signed fs bitmask undecided
greptile caught that the flag check rejected every negative integer, but Node
accepts some signed bitmasks. Measured on Node 24.22.1 against an existing file:
openSync(p, -2147483648) -> ok
openSync(p, -1073741824) -> ok
openSync(p, -1) -> THREW EINVAL
openSync(p, -2) -> THREW EINVAL
openSync(p, 2147483647) -> THREW EINVAL
openSync(p, 2147483648) -> THREW ERR_OUT_OF_RANGE
openSync(p, -2147483649) -> THREW ERR_OUT_OF_RANGE
So the sign does not decide: some negative values open a descriptor and others do
not, and which is which is a platform bitmask question this analysis cannot
settle. The check now rejects only a non-integer or a value outside the signed
32-bit range, and leaves an in-range negative undecided — the same choice
`greptile` asked for, on the principle that a call which may succeed must keep
its acquisition.
`!`, `typeof` and `delete` are still rejected, because those yield a boolean or a
string regardless of the operand.
* fix(javascript): read a no-expression template literal flag
Review finding. A no-expression template literal is exactly readable, so it is the
same flag string Node receives, but the guard only read `StringLiteral`:
fs.openSync(p, ` + "`rb`" + `) -> THREW ERR_INVALID_ARG_VALUE
fs.openSync(p, ` + "`r`" + `) -> ok
The flag slot now accepts an interpolation-free template; an interpolated template
stays undecided.
Four regressions cover both directions; two fail without the change.
This commit is contained in:
1 parent
65311d4992
commit
b668cea2af
3 files changed
+226
-2
No files matched your search
@@ -26,7 +26,7 @@ import {
|
||||
traverseDataEffects,
|
||||
type DataEffectTraversalContext,
|
||||
} from "./javascriptSemanticDataEffectHelpers.js";
|
||||
import { range } from "./javascriptStaticAnalysisHelpers.js";
|
||||
import { FS_OPEN_FLAGS, range } from "./javascriptStaticAnalysisHelpers.js";
|
||||
|
||||
const RELEASE_METHODS = ["close", "destroy", "end"] as const;
|
||||
|
||||
@@ -75,6 +75,90 @@ export const collectJavaScriptSemanticResources = (
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* `fs.open`/`fs.openSync` validate the flag before touching the filesystem, so
|
||||
* a flag Node rejects at the call site throws synchronously and no descriptor
|
||||
* exists. Node accepts only a known flag string, a nullish value (which defaults
|
||||
* to `"r"`), or a bitmask integer; everything else throws. A computed value may
|
||||
* still be valid, so only an exactly readable literal is decided.
|
||||
*/
|
||||
const rejectsOpenFlagLiteral = (
|
||||
node: t.CallExpression | t.OptionalCallExpression,
|
||||
context: DataEffectTraversalContext,
|
||||
): boolean => {
|
||||
// A spread before the flag slot shifts the runtime position, so argument 1 is
|
||||
// not provably the flag. `openSync(...args, "0600")` with
|
||||
// `args = [path, "w"]` opens with mode `"0600"`, not with an invalid flag.
|
||||
if (t.isSpreadElement(node.arguments[0])) return false;
|
||||
const flag = node.arguments[1];
|
||||
if (flag === undefined || t.isSpreadElement(flag)) return false;
|
||||
const value = unwrapJavaScriptExpression(flag).node;
|
||||
// A no-expression template literal is exactly readable, so it is the same flag
|
||||
// string Node receives.
|
||||
const flagText = t.isStringLiteral(value)
|
||||
? value.value
|
||||
: t.isTemplateLiteral(value) && value.expressions.length === 0
|
||||
? value.quasis[0]?.value.cooked
|
||||
: undefined;
|
||||
if (flagText !== undefined && flagText !== null)
|
||||
return !FS_OPEN_FLAGS.has(flagText);
|
||||
// A non-integer or out-of-int32 value is not a usable bitmask. The sign alone
|
||||
// does not decide: Node accepts some signed bitmasks such as `-2147483648` and
|
||||
// rejects others such as `-1`, so an in-range negative is left undecided.
|
||||
const bits = staticFlagBits(value);
|
||||
if (bits !== undefined)
|
||||
return (
|
||||
bits !== null &&
|
||||
(!Number.isInteger(bits) || bits > 2_147_483_647 || bits < -2_147_483_648)
|
||||
);
|
||||
// `NaN` and `Infinity` are unshadowed globals, and neither is a bitmask.
|
||||
if (
|
||||
t.isIdentifier(value) &&
|
||||
(value.name === "NaN" || value.name === "Infinity") &&
|
||||
isUnmodifiedGlobalReference(value, context.state, value.name)
|
||||
)
|
||||
return true;
|
||||
// BigInt, boolean, object and array literals all fail Node's flag check.
|
||||
return (
|
||||
t.isBigIntLiteral(value) ||
|
||||
t.isBooleanLiteral(value) ||
|
||||
t.isObjectExpression(value) ||
|
||||
t.isArrayExpression(value)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The number a literal flag argument evaluates to, or `null` when it evaluates to
|
||||
* `undefined`. Returns `undefined` when the value is not exactly readable.
|
||||
*
|
||||
* `void <expr>` is `undefined`, which Node accepts as the default read flag, so
|
||||
* it must never be judged by its operand. `!`, `typeof` and `delete` yield a
|
||||
* boolean or a string rather than a bitmask.
|
||||
*/
|
||||
const staticFlagBits = (value: t.Node): number | null | undefined => {
|
||||
if (t.isNumericLiteral(value)) return value.value;
|
||||
if (!t.isUnaryExpression(value)) return undefined;
|
||||
if (value.operator === "void") return null;
|
||||
if (
|
||||
value.operator === "!" ||
|
||||
value.operator === "typeof" ||
|
||||
value.operator === "delete"
|
||||
)
|
||||
return Number.NaN;
|
||||
const operand = unwrapJavaScriptExpression(value.argument).node;
|
||||
if (!t.isNumericLiteral(operand)) return undefined;
|
||||
if (value.operator === "-") return -operand.value;
|
||||
if (value.operator === "+") return operand.value;
|
||||
if (value.operator === "~") return ~operand.value;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** `fs.open`, `fs.openSync` and `fs/promises.open` take the flag in slot 1. */
|
||||
const openFlagInFirstArgument = (reference: AcquisitionReference): boolean =>
|
||||
reference.method === "open" || reference.method === "openSync"
|
||||
? reference.specifier === "fs" || reference.specifier === "fs/promises"
|
||||
: false;
|
||||
|
||||
const collectAcquisitions = (
|
||||
program: t.Program,
|
||||
context: DataEffectTraversalContext,
|
||||
@@ -84,6 +168,11 @@ const collectAcquisitions = (
|
||||
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
|
||||
const reference = acquisitionMethod(node.callee, context.state);
|
||||
if (reference === null) return;
|
||||
if (
|
||||
openFlagInFirstArgument(reference) &&
|
||||
rejectsOpenFlagLiteral(node, context)
|
||||
)
|
||||
return;
|
||||
output.push({
|
||||
node,
|
||||
specifier: reference.specifier,
|
||||
|
||||
Reference in new issue
Block a user