fix: gate implicit optional unwrap on flow narrowing (issue 0179)
Optional (?T) operands were implicitly unwrapped without proof of
presence, silently miscompiling a NULL ?T to garbage. Unwraps in
binary ops and other expression positions are now gated on flow
narrowing: a ?T value is only auto-unwrapped where control flow has
established it is non-null (the narrowed_refs set). Outside a narrowed
region, an implicit unwrap is rejected rather than producing garbage.
Touches the lowering pipeline (lower.zig + lower/{call,closure,coerce,
comptime,control_flow,expr,ffi,generic,pack,stmt}.zig). Adds optionals
examples 0919-0923 and closures example 0312 covering flow narrowing,
binop narrowing, no-implicit-unwrap rejection, and no closure leak of
narrowed state. Updates specs.md and readme.md.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
// Calling a closure VALUE whose parameter is `?T` coerces the argument to the
|
||||
// param type, just like a call to a top-level function does: a concrete arg
|
||||
// wraps to a present optional, and `null` becomes an absent optional.
|
||||
//
|
||||
// Regression (issue 0186): the closure-value call path lowered args without
|
||||
// coercing to the closure's declared param types, so a concrete `7` arrived as
|
||||
// a bare payload (read ABSENT) and `null` reached a `{T,i1}` slot as a bare
|
||||
// pointer (LLVM verifier failure). Fixed by typing args against the closure's
|
||||
// params (`resolveCallParamTypes`) AND coercing them at the call site
|
||||
// (`coerceClosureCallArgs`).
|
||||
#import "modules/std.sx";
|
||||
|
||||
main :: () {
|
||||
pick := (p: ?i64) -> i64 => {
|
||||
if p == null { return -1; }
|
||||
return p; // narrowed inside the lambda body
|
||||
};
|
||||
print("pick 7: {}\n", pick(7)); // 7 (concrete arg wraps present)
|
||||
print("pick null: {}\n", pick(null)); // -1 (null arg → absent)
|
||||
|
||||
// also via a closure stored in a struct field
|
||||
Holder :: struct { f: Closure(?i64) -> i64; }
|
||||
h := Holder.{ f = pick };
|
||||
print("h 5: {}\n", h.f(5)); // 5
|
||||
print("h null: {}\n", h.f(null)); // -1
|
||||
|
||||
// and via a plain function-pointer VALUE (same coercion contract)
|
||||
fp : (?i64) -> i64 = target;
|
||||
print("fp 8: {}\n", fp(8)); // 8
|
||||
print("fp null: {}\n", fp(null)); // -1
|
||||
}
|
||||
|
||||
target :: (p: ?i64) -> i64 { if p == null { return -1; } return p; }
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
pick 7: 7
|
||||
pick null: -1
|
||||
h 5: 5
|
||||
h null: -1
|
||||
fp 8: 8
|
||||
fp null: -1
|
||||
54
examples/optionals/0919-optionals-flow-narrowing.sx
Normal file
54
examples/optionals/0919-optionals-flow-narrowing.sx
Normal file
@@ -0,0 +1,54 @@
|
||||
// Flow-sensitive narrowing: a `?T` proven present by a `!= null` guard
|
||||
// converts to its concrete payload `T` in a value position (call arg,
|
||||
// `return`, arithmetic). Covers the if-then form, the divergent `== null`
|
||||
// guard form, compound `and` / `or`, the `else` arm, and reassignment killing
|
||||
// narrowing (the value must be re-narrowed before reuse).
|
||||
//
|
||||
// Regression (issue 0179): an un-narrowed `?T → concrete` used to unwrap
|
||||
// unconditionally — yielding the zero payload of a null optional with no
|
||||
// diagnostic. Narrowing is now the ONLY implicit path; everything else is a
|
||||
// compile error (see 0920).
|
||||
#import "modules/std.sx";
|
||||
|
||||
takes_i32 :: (x: i32) { print("i32 {}\n", x); }
|
||||
add :: (a: i64, b: i64) -> i64 { return a + b; }
|
||||
|
||||
// Divergent `== null` guard narrows the rest of the body.
|
||||
guard :: (v: ?i64) -> i64 {
|
||||
if v == null { return -1; }
|
||||
return v; // v narrowed to i64
|
||||
}
|
||||
|
||||
// Compound `or` guard narrows both names afterward.
|
||||
guard2 :: (a: ?i64, b: ?i64) -> i64 {
|
||||
if a == null or b == null { return 0; }
|
||||
return a + b; // both narrowed
|
||||
}
|
||||
|
||||
main :: () {
|
||||
// if-then narrowing
|
||||
n : ?i64 = 41;
|
||||
if n != null { takes_i32(n); } // i32 41
|
||||
|
||||
// else-branch narrowing (false ⇒ present)
|
||||
m : ?i64 = 7;
|
||||
if m == null { print("none\n"); } else { takes_i32(m); } // i32 7
|
||||
|
||||
// compound `and` narrows both inside the then-branch
|
||||
a : ?i64 = 3;
|
||||
b : ?i64 = 4;
|
||||
if a != null and b != null { print("sum {}\n", add(a, b)); } // sum 7
|
||||
|
||||
print("guard 9: {}\n", guard(9)); // 9
|
||||
print("guard null: {}\n", guard(null)); // -1
|
||||
print("guard2: {}\n", guard2(5, 6)); // 11
|
||||
print("guard2 null: {}\n", guard2(5, null)); // 0
|
||||
|
||||
// reassignment kills narrowing; re-narrow before reuse
|
||||
k : ?i64 = 100;
|
||||
if k != null {
|
||||
takes_i32(k); // i32 100
|
||||
k = 200; // narrowing killed here
|
||||
if k != null { takes_i32(k); } // i32 200 (re-narrowed)
|
||||
}
|
||||
}
|
||||
16
examples/optionals/0920-optionals-no-implicit-unwrap.sx
Normal file
16
examples/optionals/0920-optionals-no-implicit-unwrap.sx
Normal file
@@ -0,0 +1,16 @@
|
||||
// An un-narrowed `?T` does NOT implicitly unwrap to its concrete payload in a
|
||||
// value position — it is a compile error pointing at the explicit forms.
|
||||
//
|
||||
// Regression (issue 0179): passing a `?i64` where `i32` is expected used to
|
||||
// compile and unconditionally extract the payload, so a NULL optional yielded
|
||||
// `0` with no diagnostic (silent miscompile across the whole `?T → concrete`
|
||||
// family, incl. `?bool → bool`). The legal extractions are `!` / `??` /
|
||||
// binding / `case` / a `!= null` guard (see 0919); everything else is rejected.
|
||||
#import "modules/std.sx";
|
||||
|
||||
takes_i32 :: (x: i32) { print("got {}\n", x); }
|
||||
|
||||
main :: () {
|
||||
n : ?i64 = null;
|
||||
takes_i32(n); // error: optional does not implicitly unwrap
|
||||
}
|
||||
29
examples/optionals/0921-optionals-binop-narrowing.sx
Normal file
29
examples/optionals/0921-optionals-binop-narrowing.sx
Normal file
@@ -0,0 +1,29 @@
|
||||
// Flow narrowing extends to binary-op OPERANDS: a `?T` proven present by a
|
||||
// `!= null` guard reads as its concrete payload in arithmetic / comparison.
|
||||
//
|
||||
// Regression (issue 0185): an un-narrowed `?T` operand used to unwrap
|
||||
// unconditionally, so `null + 10` silently yielded `10` (the zero payload).
|
||||
// Narrowing is now the only implicit operand unwrap; everything else is a
|
||||
// compile error (see 0922).
|
||||
#import "modules/std.sx";
|
||||
|
||||
sum :: (a: ?i64, b: ?i64) -> i64 {
|
||||
if a == null or b == null { return -1; }
|
||||
return a + b; // both operands narrowed
|
||||
}
|
||||
|
||||
main :: () {
|
||||
x : ?i64 = 30;
|
||||
y : ?i64 = 12;
|
||||
if x != null and y != null {
|
||||
print("add {}\n", x + y); // 42
|
||||
print("lt {}\n", x < y); // false
|
||||
}
|
||||
print("sum 11: {}\n", sum(5, 6)); // 11
|
||||
print("sum null: {}\n", sum(5, null)); // -1
|
||||
|
||||
// guard form narrows for the rest of the scope
|
||||
n : ?i64 = 9;
|
||||
if n == null { return; }
|
||||
print("times {}\n", n * 2); // 18
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// An un-narrowed `?T` used directly as a binary-op operand is a compile error
|
||||
// — it does not implicitly unwrap.
|
||||
//
|
||||
// Regression (issue 0185): `null + 10` used to compile and unconditionally
|
||||
// extract the payload, silently yielding `10` (0 + 10) with no diagnostic.
|
||||
// The legal forms are `!` / `??` / a `!= null` guard (see 0921).
|
||||
#import "modules/std.sx";
|
||||
|
||||
main :: () {
|
||||
a : ?i64 = null;
|
||||
b : i64 = 10;
|
||||
c := a + b; // error: optional operand does not implicitly unwrap
|
||||
print("c = {}\n", c);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Flow narrowing (issue 0179) does NOT cross into a nested function body. A
|
||||
// closure capturing a local that is narrowed in the ENCLOSING scope still sees
|
||||
// it as `?T` inside the closure — using it unguarded is a compile error.
|
||||
//
|
||||
// This guards the soundness fix from the adversarial review of 0179/0185: the
|
||||
// narrowing gate is keyed by per-function SSA `Ref`, and a closure body's `Ref`
|
||||
// space overlaps the enclosing function's, so without isolation an outer
|
||||
// narrowed `Ref` would falsely permit unwrapping a not-proven-present optional
|
||||
// inside the closure (`Lowering.NarrowGuard`). The closure could run later when
|
||||
// the captured value is null, so the reject is mandatory — write `n!` to assert.
|
||||
#import "modules/std.sx";
|
||||
|
||||
takes_i64 :: (x: i64) { print("{}\n", x); }
|
||||
|
||||
main :: () {
|
||||
n : ?i64 = 7;
|
||||
if n != null {
|
||||
// `n` is narrowed HERE, but the closure body is a separate function:
|
||||
// `n` is `?i64` inside it, so this implicit unwrap must be rejected.
|
||||
g := () => { takes_i64(n); };
|
||||
g();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
i32 41
|
||||
i32 7
|
||||
sum 7
|
||||
guard 9: 9
|
||||
guard null: -1
|
||||
guard2: 11
|
||||
guard2 null: 0
|
||||
i32 100
|
||||
i32 200
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1,5 @@
|
||||
error: cannot use a value of type '?i64' where 'i32' is expected: an optional does not implicitly unwrap; force-unwrap with '!', supply a fallback with '?? <default>', bind it (`if v := ...`), or guard with '!= null'
|
||||
--> examples/optionals/0920-optionals-no-implicit-unwrap.sx:15:5
|
||||
|
|
||||
15 | takes_i32(n); // error: optional does not implicitly unwrap
|
||||
| ^^^^^^^^^^^^
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
add 42
|
||||
lt false
|
||||
sum 11: 11
|
||||
sum null: -1
|
||||
times 18
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1,5 @@
|
||||
error: cannot use a value of type '?i64' as an operand: an optional does not implicitly unwrap; force-unwrap with '!', supply a fallback with '?? <default>', or guard with '!= null'
|
||||
--> examples/optionals/0922-optionals-binop-no-implicit-unwrap.sx:12:10
|
||||
|
|
||||
12 | c := a + b; // error: optional operand does not implicitly unwrap
|
||||
| ^
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1,5 @@
|
||||
error: cannot use a value of type '?i64' where 'i64' is expected: an optional does not implicitly unwrap; force-unwrap with '!', supply a fallback with '?? <default>', bind it (`if v := ...`), or guard with '!= null'
|
||||
--> examples/optionals/0923-optionals-narrowing-no-closure-leak.sx:20:22
|
||||
|
|
||||
20 | g := () => { takes_i64(n); };
|
||||
| ^^^^^^^^^^^^
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user