Skip to content

Commit 32cd773

Browse files
author
Douglas Mun
committed
Give and/or Python value semantics and deref map lookups
`prev = map.lookup(k)` returns a pointer into the map (NULL when the key is absent), and the boolean-operand path converted that pointer straight to i1 via convert_to_bool(). Two things went wrong in `map.update(k, (prev or 0) + 1)`: 1. the pointer was tested for NULL rather than dereferenced, so the stored count never took part in the expression; and 2. `or` returned i1 regardless of its operands, so the result was sign-extended and the addition operated on a 0/1 flag. The counter therefore stuck at 1 instead of accumulating -- silently, as the program compiles and loads cleanly. Fix both halves. `_prepare_bool_operand()` auto-dereferences pointer operands, mirroring what `get_operand_value()` already does on the binary -operator path; `deref_to_depth()` emits a null-checked load, so an absent key yields a zero-valued pointee, which is the fallback `prev or 0` asks for. The phi in `_handle_and_op`/`_handle_or_op` now carries the operand values instead of a bool, matching Python, where `a or b` evaluates to an operand and not to True/False. Operands that cannot yield an integer keep their previous truth-value behaviour. Truthiness contexts are unaffected: `if prev:` goes through functions_pass.py, which calls convert_to_bool() directly, so pointer NULL-ness still means "is the key present?" there. Regression tests: the existing IR and llc tiers only assert that compilation succeeds, so a wrong-value miscompile passes them both, which is how this survived. tests/test_boolop_semantics.py asserts on the emitted IR instead -- that or.result is not i1 and that the lookup is actually loaded. Both fail before this change and pass after. Refs #89
1 parent 926ce3f commit 32cd773

3 files changed

Lines changed: 142 additions & 24 deletions

File tree

pythonbpf/expr/expr_pass.py

Lines changed: 69 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -401,14 +401,52 @@ def _handle_unary_op(
401401
# ============================================================================
402402

403403

404+
def _widen_to(builder, val, result_type, val_bool):
405+
"""Widen an operand to the phi's result type.
406+
407+
Anything that is not an integer (a pointer that did not dereference to
408+
one) cannot carry a meaningful value, so its truth value is used
409+
instead -- preserving the old behaviour for those operands.
410+
"""
411+
if val.type == result_type:
412+
return val
413+
if isinstance(val.type, ir.IntType):
414+
return builder.zext(val, result_type)
415+
return builder.zext(val_bool, result_type)
416+
417+
418+
def _prepare_bool_operand(func, builder, val):
419+
"""Dereference a pointer operand so its *value* takes part in the expression.
420+
421+
`map.lookup()` returns a pointer into the map (NULL when the key is
422+
absent). A bare truthiness test on that pointer asks "is the key
423+
present?", which is what `if prev:` wants, but not what `prev or 0`
424+
means: there the operand's stored value is the result of the expression.
425+
The binary-operator path already auto-dereferences pointer results (see
426+
`get_operand_value`); this mirrors it for boolean operands.
427+
428+
`deref_to_depth` emits a null-checked load, so an absent key yields a
429+
zero-valued pointee rather than faulting -- exactly the fallback that
430+
`prev or 0` asks for.
431+
"""
432+
base_type, depth = get_base_type_and_depth(val.type)
433+
if depth > 0:
434+
deref = deref_to_depth(func, builder, val, depth)
435+
if deref is not None:
436+
return deref
437+
return val
438+
439+
404440
def _handle_and_op(func, builder, expr, local_sym_tab, compilation_context):
405441
"""Handle `and` boolean operations."""
406442

407443
logger.debug(f"Handling 'and' operator with {len(expr.values)} operands")
408444

409445
merge_block = func.append_basic_block(name="and.merge")
410-
false_block = func.append_basic_block(name="and.false")
411446

447+
# Python's `and` evaluates to the operand itself, not to a bool (see the
448+
# note in _handle_or_op).
449+
result_type = ir.IntType(64)
412450
incoming_values = []
413451

414452
for i, value in enumerate(expr.values):
@@ -423,35 +461,37 @@ def _handle_and_op(func, builder, expr, local_sym_tab, compilation_context):
423461
return None
424462

425463
operand_val, operand_type = operand_result
464+
operand_val = _prepare_bool_operand(func, builder, operand_val)
426465

427466
# Convert to boolean if needed
428467
operand_bool = convert_to_bool(builder, operand_val)
468+
operand_val = _widen_to(builder, operand_val, result_type, operand_bool)
429469
current_block = builder.block
430470

431471
if is_last:
432472
# Last operand: result is this value
433473
builder.branch(merge_block)
434-
incoming_values.append((operand_bool, current_block))
474+
incoming_values.append((operand_val, current_block))
435475
else:
436-
# Not last: check if true, continue or short-circuit
476+
# Not last: short-circuit with this operand's value if it is falsy
437477
next_check = func.append_basic_block(name=f"and.check_{i + 1}")
438-
builder.cbranch(operand_bool, next_check, false_block)
439-
builder.position_at_end(next_check)
478+
short_circuit = func.append_basic_block(name=f"and.value_{i}")
479+
builder.cbranch(operand_bool, next_check, short_circuit)
440480

441-
# False block: short-circuit with false
442-
builder.position_at_end(false_block)
443-
builder.branch(merge_block)
444-
false_value = ir.Constant(ir.IntType(1), 0)
445-
incoming_values.append((false_value, false_block))
481+
builder.position_at_end(short_circuit)
482+
builder.branch(merge_block)
483+
incoming_values.append((operand_val, short_circuit))
484+
485+
builder.position_at_end(next_check)
446486

447487
# Merge block: phi node
448488
builder.position_at_end(merge_block)
449-
phi = builder.phi(ir.IntType(1), name="and.result")
489+
phi = builder.phi(result_type, name="and.result")
450490
for val, block in incoming_values:
451491
phi.add_incoming(val, block)
452492

453493
logger.debug(f"Generated 'and' with {len(incoming_values)} incoming values")
454-
return phi, ir.IntType(1)
494+
return phi, result_type
455495

456496

457497
def _handle_or_op(func, builder, expr, local_sym_tab, compilation_context):
@@ -460,8 +500,11 @@ def _handle_or_op(func, builder, expr, local_sym_tab, compilation_context):
460500
logger.debug(f"Handling 'or' operator with {len(expr.values)} operands")
461501

462502
merge_block = func.append_basic_block(name="or.merge")
463-
true_block = func.append_basic_block(name="or.true")
464503

504+
# Python's `or` evaluates to the operand itself, not to a bool, so the phi
505+
# carries values. i64 covers every integer the frontend produces and keeps
506+
# a map-lookup result (i64) exact.
507+
result_type = ir.IntType(64)
465508
incoming_values = []
466509

467510
for i, value in enumerate(expr.values):
@@ -476,35 +519,37 @@ def _handle_or_op(func, builder, expr, local_sym_tab, compilation_context):
476519
return None
477520

478521
operand_val, operand_type = operand_result
522+
operand_val = _prepare_bool_operand(func, builder, operand_val)
479523

480524
# Convert to boolean if needed
481525
operand_bool = convert_to_bool(builder, operand_val)
526+
operand_val = _widen_to(builder, operand_val, result_type, operand_bool)
482527
current_block = builder.block
483528

484529
if is_last:
485530
# Last operand: result is this value
486531
builder.branch(merge_block)
487-
incoming_values.append((operand_bool, current_block))
532+
incoming_values.append((operand_val, current_block))
488533
else:
489-
# Not last: check if false, continue or short-circuit
534+
# Not last: short-circuit with this operand's value if it is truthy
490535
next_check = func.append_basic_block(name=f"or.check_{i + 1}")
491-
builder.cbranch(operand_bool, true_block, next_check)
492-
builder.position_at_end(next_check)
536+
short_circuit = func.append_basic_block(name=f"or.value_{i}")
537+
builder.cbranch(operand_bool, short_circuit, next_check)
538+
539+
builder.position_at_end(short_circuit)
540+
builder.branch(merge_block)
541+
incoming_values.append((operand_val, short_circuit))
493542

494-
# True block: short-circuit with true
495-
builder.position_at_end(true_block)
496-
builder.branch(merge_block)
497-
true_value = ir.Constant(ir.IntType(1), 1)
498-
incoming_values.append((true_value, true_block))
543+
builder.position_at_end(next_check)
499544

500545
# Merge block: phi node
501546
builder.position_at_end(merge_block)
502-
phi = builder.phi(ir.IntType(1), name="or.result")
547+
phi = builder.phi(result_type, name="or.result")
503548
for val, block in incoming_values:
504549
phi.add_incoming(val, block)
505550

506551
logger.debug(f"Generated 'or' with {len(incoming_values)} incoming values")
507-
return phi, ir.IntType(1)
552+
return phi, result_type
508553

509554

510555
def _handle_boolean_op(
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# A map lookup used as a value in `or`, not just as a nullness test.
2+
#
3+
# `last.lookup(0)` returns a pointer into the map (NULL when the key is
4+
# absent), so `prev or 0` must evaluate to the *stored value* when the key
5+
# is present and to 0 when it is not. Compiling it as a truthiness test on
6+
# the pointer makes the counter add a 0/1 flag instead of the stored count,
7+
# so it sticks at 1 (or 2) forever.
8+
from pythonbpf import bpf, map, section, bpfglobal, compile
9+
from ctypes import c_void_p, c_int64, c_uint64
10+
from pythonbpf.maps import HashMap
11+
12+
13+
@bpf
14+
@map
15+
def last() -> HashMap:
16+
return HashMap(key=c_uint64, value=c_uint64, max_entries=3)
17+
18+
19+
@bpf
20+
@section("tracepoint/syscalls/sys_enter_execve")
21+
def hello_world(ctx: c_void_p) -> c_int64:
22+
prev = last.lookup(0)
23+
last.update(0, (prev or 0) + 1)
24+
return c_int64(0)
25+
26+
27+
@bpf
28+
@bpfglobal
29+
def LICENSE() -> str:
30+
return "GPL"
31+
32+
33+
compile()

tests/test_boolop_semantics.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Semantic tests for `and` / `or` result values.
2+
3+
The generic IR-generation and llc tiers only check that compilation
4+
succeeds, so a boolean operator that compiles cleanly but yields the wrong
5+
*value* passes them both. `(prev or 0) + 1` did exactly that: the map
6+
pointer was converted to i1 and sign-extended, so the expression added a
7+
0/1 flag instead of the stored count.
8+
"""
9+
10+
import re
11+
from pathlib import Path
12+
13+
from tests.framework.compiler import run_ir_generation
14+
15+
SOURCE = Path(__file__).parent / "passing_tests" / "conditionals" / "map_or_default.py"
16+
17+
18+
def _compile(tmp_path):
19+
ll_path = tmp_path / "output.ll"
20+
run_ir_generation(SOURCE, ll_path)
21+
return ll_path.read_text()
22+
23+
24+
def test_or_result_is_not_a_truncated_bool(tmp_path):
25+
"""`prev or 0` must not collapse to i1 before the addition."""
26+
ir = _compile(tmp_path)
27+
phi = re.search(r'%"or\.result" = phi\s+(\S+)', ir)
28+
assert phi, "no or.result phi in emitted IR"
29+
assert phi.group(1) != "i1", (
30+
"`or` produced an i1: the operand value is lost, so arithmetic on it "
31+
"adds a 0/1 flag instead of the stored value"
32+
)
33+
34+
35+
def test_or_dereferences_the_map_lookup(tmp_path):
36+
"""The stored value must be loaded, not just tested for NULL."""
37+
ir = _compile(tmp_path)
38+
assert re.search(r"load i64, i64\*", ir) or re.search(r"load i64, ptr", ir), (
39+
"map lookup result was never dereferenced; only its NULL-ness was used"
40+
)

0 commit comments

Comments
 (0)