Skip to content

Commit ad8507d

Browse files
proggeramlugRalph Küpper
andauthored
fix(hir): keep the null-guard for an optional method call on a process.env read (#6058)
`process.env?.[k]?.method()` (env read accessed inline) silently dropped the per-receiver null-guard on the `?.` before the method, so the method was invoked on the `undefined` an unset variable reads as. The result was the STRING "undefined" (from stringifying the missing value) instead of a short-circuit to `undefined`. Cause: `process.env[k]` lowers to `IndexGet { object: ProcessEnv, .. }`, and the `a?.b?.method()` lowering in `arm_optchain` only re-adds the receiver null-guard when the receiver is `opt_call_receiver_repeatable`. That predicate did not list `ProcessEnv` / env reads, so the receiver was deemed unsafe to evaluate twice and the guard was skipped — leaving `process.env[k].method()` to dereference the unguarded `undefined`. (Hoisting the env read into a local first — `const e = process.env; e?.[k]?.method()` — worked, because a `LocalGet` receiver is repeatable and kept the guard.) Env reads are pure, side-effect-free, and stable within an expression, so they are safe to evaluate more than once (guard + call). Add `ProcessEnv`, `EnvGet`, and `EnvGetDynamic` (repeatable iff its key is) to `opt_call_receiver_repeatable`. This shape is ubiquitous: SDKs read config via `readEnv(k)?.trim()`, and a common HTTP-client base-URL default is `process.env.BASE_URL?.trim() ?? "…"`, which silently became the string "undefined" and produced `new URL("undefined/…")` failures. Adds an e2e test covering computed/static keys, a set var flowing through, and a raw missing read being JS `undefined` (not the string). Co-authored-by: Ralph Küpper <ralph@skelpo.com>
1 parent 5fe1303 commit ad8507d

2 files changed

Lines changed: 103 additions & 0 deletions

File tree

crates/perry-hir/src/lower/lower_expr/helpers.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,16 @@ pub(crate) fn opt_call_receiver_repeatable(expr: &Expr) -> bool {
270270
| Expr::Number(_)
271271
| Expr::String(_)
272272
| Expr::Bool(_) => true,
273+
// `process.env` and env-var reads are pure, side-effect-free, and
274+
// stable within an expression, so they are safe to evaluate more than
275+
// once (the guard AND the call). Without this, `process.env?.[k]?.m()`
276+
// has a NON-repeatable receiver `IndexGet { object: ProcessEnv, .. }`,
277+
// so the optional-method null-guard is dropped and `.m()` is called on
278+
// the unguarded `undefined` an unset var reads as — e.g.
279+
// `process.env.ANTHROPIC_BASE_URL?.trim()` returned the string
280+
// "undefined" instead of short-circuiting to `undefined`.
281+
Expr::ProcessEnv | Expr::EnvGet(_) => true,
282+
Expr::EnvGetDynamic(key) => opt_call_receiver_repeatable(key),
273283
// `a.b` / `a[const]` chains over repeatable receivers stay repeatable
274284
// (property reads are not side-effecting in this codebase's model).
275285
Expr::PropertyGet { object, .. } => opt_call_receiver_repeatable(object),
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
//! An optional method call whose receiver is an inline `process.env` read —
2+
//! `process.env?.[key]?.trim()` / `process.env.KEY?.trim()` — dropped its
3+
//! per-receiver null-guard and called the method on the `undefined` an unset
4+
//! variable reads as. It returned the STRING `"undefined"` (from stringifying
5+
//! the missing value) instead of short-circuiting to `undefined`.
6+
//!
7+
//! Root cause: `process.env[k]` lowers to `IndexGet { object: ProcessEnv, .. }`,
8+
//! and `opt_call_receiver_repeatable` did not treat `ProcessEnv`/env reads as
9+
//! repeatable, so the `a?.b?.method()` lowering took its "receiver not safe to
10+
//! duplicate → skip the guard" path. Env reads are pure and idempotent, so
11+
//! they are safe to evaluate twice (guard + call); classifying them repeatable
12+
//! restores the guard.
13+
//!
14+
//! This shape is ubiquitous — SDKs read config via `readEnv(k)?.trim()`; a
15+
//! popular client's base-URL default is
16+
//! `process.env.BASE_URL?.trim() ?? "https://…"`, which silently became the
17+
//! string `"undefined"` and produced `new URL("undefined/…")` failures.
18+
19+
use std::path::PathBuf;
20+
use std::process::Command;
21+
22+
fn perry_bin() -> PathBuf {
23+
PathBuf::from(env!("CARGO_BIN_EXE_perry"))
24+
}
25+
26+
/// Compile+run `source` with `env` cleared of the probe var names, return trimmed stdout.
27+
fn compile_and_run(dir: &std::path::Path, source: &str) -> String {
28+
let entry = dir.join("main.ts");
29+
let output = dir.join("main_bin");
30+
std::fs::write(&entry, source).expect("write entry");
31+
32+
let compile = Command::new(perry_bin())
33+
.current_dir(dir)
34+
.arg("compile")
35+
.arg(&entry)
36+
.arg("-o")
37+
.arg(&output)
38+
.output()
39+
.expect("run perry compile");
40+
assert!(
41+
compile.status.success(),
42+
"perry compile failed\nstdout:\n{}\nstderr:\n{}",
43+
String::from_utf8_lossy(&compile.stdout),
44+
String::from_utf8_lossy(&compile.stderr)
45+
);
46+
47+
let run = Command::new(&output)
48+
// Ensure the probed vars are unset so the reads short-circuit.
49+
.env_remove("PERRY_TEST_UNSET_A")
50+
.env_remove("PERRY_TEST_UNSET_B")
51+
.env_remove("PERRY_TEST_SET_C")
52+
.env("PERRY_TEST_SET_C", " hello ")
53+
.output()
54+
.expect("run compiled binary");
55+
assert!(
56+
run.status.success(),
57+
"compiled binary exited non-zero: {:?}\nstdout:\n{}\nstderr:\n{}",
58+
run.status,
59+
String::from_utf8_lossy(&run.stdout),
60+
String::from_utf8_lossy(&run.stderr)
61+
);
62+
String::from_utf8_lossy(&run.stdout).trim().to_string()
63+
}
64+
65+
#[test]
66+
fn optchain_method_on_inline_process_env_short_circuits() {
67+
let dir = tempfile::tempdir().expect("tempdir");
68+
let out = compile_and_run(
69+
dir.path(),
70+
r#"
71+
// Unset var, computed key, chained method (the base-URL default shape).
72+
const dyn = (k: string) => process.env?.[k]?.trim() ?? "DEFAULT";
73+
// Unset var, static key, chained method.
74+
const stat = process.env.PERRY_TEST_UNSET_B?.trim() ?? "DEFAULT";
75+
// SET var still flows through (proves we didn't just null everything).
76+
const setVal = process.env.PERRY_TEST_SET_C?.trim() ?? "DEFAULT";
77+
// A missing read must be JS `undefined`, not the string "undefined".
78+
const raw = process.env?.["PERRY_TEST_UNSET_A"]?.trim();
79+
80+
process.stdout.write(
81+
"dyn=" + dyn("PERRY_TEST_UNSET_A") +
82+
" stat=" + stat +
83+
" set=" + setVal +
84+
" rawIsUndef=" + (raw === undefined) +
85+
" rawType=" + typeof raw + "\n"
86+
);
87+
"#,
88+
);
89+
assert_eq!(
90+
out, "dyn=DEFAULT stat=DEFAULT set=hello rawIsUndef=true rawType=undefined",
91+
"optional method call on inline process.env read"
92+
);
93+
}

0 commit comments

Comments
 (0)