From b759cc069fb84ef91503f32c261e9df461626688 Mon Sep 17 00:00:00 2001 From: Abhishek Shivakumar Date: Mon, 17 Aug 2026 14:10:34 +0100 Subject: [PATCH 01/18] Add bpfel target descriptor and verifier-safe object driver --- src/flow/bpf_target.py | 181 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 src/flow/bpf_target.py diff --git a/src/flow/bpf_target.py b/src/flow/bpf_target.py new file mode 100644 index 000000000..0de30bbaf --- /dev/null +++ b/src/flow/bpf_target.py @@ -0,0 +1,181 @@ +"""Flow eBPF target contract and LLVM object-emission driver. + +This module deliberately sits after Flow's existing MLIR/LLVM lowering. It owns +BPF target invariants, verifier-hostile IR rejection, and conversion of LLVM IR +to an ELF eBPF object. Linux integration and live verifier tests belong in +flow-kernel. +""" + +from __future__ import annotations + +import argparse +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +BPFEL_TRIPLE = "bpfel" +BPFEL_DATA_LAYOUT = "e-m:e-p:64:64-i64:64-i128:128-n32:64-S128" +BPF_POINTER_BITS = 64 +BPF_MAX_STACK_BYTES = 512 + + +class BPFTargetError(ValueError): + """Raised when IR cannot be represented safely by the initial BPF target.""" + + +@dataclass(frozen=True) +class BPFTarget: + name: str = "bpfel" + triple: str = BPFEL_TRIPLE + data_layout: str = BPFEL_DATA_LAYOUT + pointer_bits: int = BPF_POINTER_BITS + max_stack_bytes: int = BPF_MAX_STACK_BYTES + little_endian: bool = True + + +BPFEL = BPFTarget() + +# Runtime facilities that are not valid dependencies for standalone verifier +# programs. This list is intentionally conservative: target support should be +# expanded by adding explicit BPF helpers/intrinsics, not by silently linking a +# userspace runtime. +_FORBIDDEN_EXTERNALS = ( + "malloc", + "calloc", + "realloc", + "free", + "operator new", + "_Znwm", + "_Znam", + "__cxa_throw", + "__cxa_allocate_exception", + "_Unwind_", +) + +_FORBIDDEN_IR_OPS = ( + " invoke ", + " landingpad ", + " resume ", + " catchswitch ", + " catchpad ", + " cleanuppad ", +) + + +def target_for_name(name: str) -> BPFTarget: + """Resolve a public Flow target name.""" + normalized = name.strip().lower() + if normalized in {"bpf", "bpfel", "ebpf"}: + return BPFEL + raise BPFTargetError(f"unknown BPF target '{name}' (expected bpfel)") + + +def with_bpf_target_header(llvm_ir: str, target: BPFTarget = BPFEL) -> str: + """Apply the canonical LLVM BPF triple and data layout to textual IR.""" + lines = llvm_ir.splitlines() + filtered = [ + line + for line in lines + if not line.startswith("target datalayout =") + and not line.startswith("target triple =") + ] + header = [ + f'target datalayout = "{target.data_layout}"', + f'target triple = "{target.triple}"', + ] + return "\n".join(header + filtered) + "\n" + + +def validate_bpf_llvm_ir(llvm_ir: str) -> None: + """Reject known verifier/runtime-invalid constructs before object emission.""" + padded = f" {llvm_ir} " + for op in _FORBIDDEN_IR_OPS: + if op in padded: + raise BPFTargetError( + f"bpfel does not support exception/unwind IR construct: {op.strip()}" + ) + + for symbol in _FORBIDDEN_EXTERNALS: + if symbol in llvm_ir: + raise BPFTargetError( + f"bpfel cannot depend on userspace runtime symbol '{symbol}'" + ) + + # alloca with a non-constant count represents dynamic stack allocation and + # cannot be bounded reliably by the BPF verifier. + for match in re.finditer(r"\balloca\s+[^,\n]+,\s+i\d+\s+([^,\n]+)", llvm_ir): + count = match.group(1).strip() + if not re.fullmatch(r"\d+", count): + raise BPFTargetError( + "bpfel requires statically bounded stack allocation; " + f"found dynamic alloca count '{count}'" + ) + + +def emit_bpf_object( + llvm_ir: str, + output: str | Path, + *, + clang: str | None = None, + optimize: str = "2", +) -> Path: + """Validate LLVM IR and emit a little-endian ELF eBPF object with clang.""" + validate_bpf_llvm_ir(llvm_ir) + targeted_ir = with_bpf_target_header(llvm_ir) + + compiler = clang or shutil.which("clang") + if not compiler: + raise BPFTargetError("clang with the LLVM BPF backend is required") + + out = Path(output) + out.parent.mkdir(parents=True, exist_ok=True) + command = [ + compiler, + "-target", + BPFEL.triple, + f"-O{optimize}", + "-x", + "ir", + "-c", + "-o", + str(out), + "-", + ] + result = subprocess.run( + command, + input=targeted_ir, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + raise BPFTargetError(f"bpfel object emission failed: {detail}") + return out + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Emit an ELF eBPF object from Flow LLVM IR") + parser.add_argument("input", help="LLVM IR produced by Flow") + parser.add_argument("-o", "--output", required=True, help="Output .o path") + parser.add_argument("--target", default="bpfel", choices=["bpfel"]) + parser.add_argument("-O", dest="optimize", default="2", choices=["0", "1", "2", "3"]) + args = parser.parse_args(argv) + + try: + target_for_name(args.target) + llvm_ir = Path(args.input).read_text(encoding="utf-8") + emit_bpf_object(llvm_ir, args.output, optimize=args.optimize) + except (OSError, BPFTargetError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 62444b588f08ed3cb58e783ee88084fe17dc6c0f Mon Sep 17 00:00:00 2001 From: Abhishek Shivakumar Date: Mon, 17 Aug 2026 14:10:48 +0100 Subject: [PATCH 02/18] Test bpfel target contract and verifier guards --- tests/unit/test_bpf_target.py | 70 +++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/unit/test_bpf_target.py diff --git a/tests/unit/test_bpf_target.py b/tests/unit/test_bpf_target.py new file mode 100644 index 000000000..c10eaafd5 --- /dev/null +++ b/tests/unit/test_bpf_target.py @@ -0,0 +1,70 @@ +from pathlib import Path + +import pytest + +from flow.bpf_target import ( + BPFEL, + BPFTargetError, + target_for_name, + validate_bpf_llvm_ir, + with_bpf_target_header, +) + + +def test_bpfel_target_matches_llvm_contract(): + assert BPFEL.triple == "bpfel" + assert BPFEL.data_layout == "e-m:e-p:64:64-i64:64-i128:128-n32:64-S128" + assert BPFEL.pointer_bits == 64 + assert BPFEL.max_stack_bytes == 512 + assert BPFEL.little_endian is True + + +def test_public_target_aliases_resolve_to_bpfel(): + assert target_for_name("bpfel") is BPFEL + assert target_for_name("bpf") is BPFEL + assert target_for_name("ebpf") is BPFEL + + +def test_unknown_bpf_target_is_rejected(): + with pytest.raises(BPFTargetError, match="unknown BPF target"): + target_for_name("bpfeb") + + +def test_target_header_replaces_host_triple_and_layout(): + source = '''target datalayout = "host-layout" +target triple = "x86_64-unknown-linux-gnu" +define i64 @entry(i64 %x) { + ret i64 %x +} +''' + targeted = with_bpf_target_header(source) + assert 'target triple = "bpfel"' in targeted + assert f'target datalayout = "{BPFEL.data_layout}"' in targeted + assert "x86_64-unknown-linux-gnu" not in targeted + assert "host-layout" not in targeted + + +@pytest.mark.parametrize( + "llvm_ir, expected", + [ + ("declare ptr @malloc(i64)\n", "malloc"), + ("declare void @free(ptr)\n", "free"), + ("define void @f() { invoke void @g() to label %ok unwind label %bad }\n", "invoke"), + ("define void @f() { %x = landingpad { ptr, i32 } cleanup }\n", "landingpad"), + ("declare void @_Unwind_Resume(ptr)\n", "_Unwind_"), + ], +) +def test_verifier_hostile_runtime_or_unwind_constructs_are_rejected(llvm_ir, expected): + with pytest.raises(BPFTargetError, match=expected): + validate_bpf_llvm_ir(llvm_ir) + + +def test_dynamic_alloca_is_rejected(): + llvm_ir = "define void @f(i64 %n) { %p = alloca i8, i64 %n ret void }\n" + with pytest.raises(BPFTargetError, match="statically bounded stack allocation"): + validate_bpf_llvm_ir(llvm_ir) + + +def test_static_alloca_is_allowed_by_initial_target_gate(): + llvm_ir = "define void @f() { %p = alloca i8, i64 8 ret void }\n" + validate_bpf_llvm_ir(llvm_ir) From f852bf714410bc4e7080526d5d3887a1f3069ecb Mon Sep 17 00:00:00 2001 From: Abhishek Shivakumar Date: Mon, 17 Aug 2026 14:11:51 +0100 Subject: [PATCH 03/18] Compile Flow source through the bpfel target --- src/flow/bpf_target.py | 81 +++++++++++++++++++++++++++++++++--------- 1 file changed, 64 insertions(+), 17 deletions(-) diff --git a/src/flow/bpf_target.py b/src/flow/bpf_target.py index 0de30bbaf..3d47991cd 100644 --- a/src/flow/bpf_target.py +++ b/src/flow/bpf_target.py @@ -1,9 +1,9 @@ """Flow eBPF target contract and LLVM object-emission driver. -This module deliberately sits after Flow's existing MLIR/LLVM lowering. It owns -BPF target invariants, verifier-hostile IR rejection, and conversion of LLVM IR -to an ELF eBPF object. Linux integration and live verifier tests belong in -flow-kernel. +The target reuses Flow's existing MLIR/LLVM pipeline, then applies BPF-specific +ABI metadata and verifier-oriented restrictions before LLVM's BPF backend emits +an ELF object. Linux loading/attachment and live verifier tests belong in +``flow-kernel``. """ from __future__ import annotations @@ -13,6 +13,7 @@ import shutil import subprocess import sys +import tempfile from dataclasses import dataclass from pathlib import Path @@ -24,7 +25,7 @@ class BPFTargetError(ValueError): - """Raised when IR cannot be represented safely by the initial BPF target.""" + """Raised when a program cannot be represented by the initial BPF target.""" @dataclass(frozen=True) @@ -39,10 +40,6 @@ class BPFTarget: BPFEL = BPFTarget() -# Runtime facilities that are not valid dependencies for standalone verifier -# programs. This list is intentionally conservative: target support should be -# expanded by adding explicit BPF helpers/intrinsics, not by silently linking a -# userspace runtime. _FORBIDDEN_EXTERNALS = ( "malloc", "calloc", @@ -67,7 +64,7 @@ class BPFTarget: def target_for_name(name: str) -> BPFTarget: - """Resolve a public Flow target name.""" + """Resolve a public Flow BPF target name.""" normalized = name.strip().lower() if normalized in {"bpf", "bpfel", "ebpf"}: return BPFEL @@ -75,7 +72,7 @@ def target_for_name(name: str) -> BPFTarget: def with_bpf_target_header(llvm_ir: str, target: BPFTarget = BPFEL) -> str: - """Apply the canonical LLVM BPF triple and data layout to textual IR.""" + """Apply LLVM's canonical BPF triple and data layout to textual IR.""" lines = llvm_ir.splitlines() filtered = [ line @@ -105,8 +102,9 @@ def validate_bpf_llvm_ir(llvm_ir: str) -> None: f"bpfel cannot depend on userspace runtime symbol '{symbol}'" ) - # alloca with a non-constant count represents dynamic stack allocation and - # cannot be bounded reliably by the BPF verifier. + # Variable-sized allocas make stack bounds opaque to the verifier. Constant + # allocas remain eligible; the backend/verifier enforces the final 512-byte + # aggregate stack limit. for match in re.finditer(r"\balloca\s+[^,\n]+,\s+i\d+\s+([^,\n]+)", llvm_ir): count = match.group(1).strip() if not re.fullmatch(r"\d+", count): @@ -159,9 +157,54 @@ def emit_bpf_object( return out +def compile_flow_to_bpf( + source: str | Path, + output: str | Path, + *, + clang: str | None = None, + optimize: str = "2", +) -> Path: + """Compile ``.flow`` source through Flow -> MLIR/LLVM -> ELF eBPF.""" + source_path = Path(source) + if source_path.suffix != ".flow": + raise BPFTargetError(f"expected a .flow source file, got '{source_path}'") + if not source_path.is_file(): + raise BPFTargetError(f"Flow source does not exist: {source_path}") + + with tempfile.TemporaryDirectory(prefix="flow-bpf-") as temp_dir: + llvm_path = Path(temp_dir) / "program.ll" + command = [ + sys.executable, + "-m", + "flow.transpiler", + str(source_path), + "--llvm", + "--optimize", + "--opt-level", + f"O{optimize}", + "-o", + str(llvm_path), + ] + result = subprocess.run( + command, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + raise BPFTargetError(f"Flow -> LLVM lowering failed for bpfel: {detail}") + if not llvm_path.is_file(): + raise BPFTargetError("Flow LLVM lowering completed without producing LLVM IR") + + llvm_ir = llvm_path.read_text(encoding="utf-8") + return emit_bpf_object(llvm_ir, output, clang=clang, optimize=optimize) + + def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Emit an ELF eBPF object from Flow LLVM IR") - parser.add_argument("input", help="LLVM IR produced by Flow") + parser = argparse.ArgumentParser(description="Compile Flow or LLVM IR to ELF eBPF") + parser.add_argument("input", help="Input .flow or .ll file") parser.add_argument("-o", "--output", required=True, help="Output .o path") parser.add_argument("--target", default="bpfel", choices=["bpfel"]) parser.add_argument("-O", dest="optimize", default="2", choices=["0", "1", "2", "3"]) @@ -169,8 +212,12 @@ def main(argv: list[str] | None = None) -> int: try: target_for_name(args.target) - llvm_ir = Path(args.input).read_text(encoding="utf-8") - emit_bpf_object(llvm_ir, args.output, optimize=args.optimize) + input_path = Path(args.input) + if input_path.suffix == ".flow": + compile_flow_to_bpf(input_path, args.output, optimize=args.optimize) + else: + llvm_ir = input_path.read_text(encoding="utf-8") + emit_bpf_object(llvm_ir, args.output, optimize=args.optimize) except (OSError, BPFTargetError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 From 0a24b390ab7e567cd0ad080e7d658253d8bad510 Mon Sep 17 00:00:00 2001 From: Abhishek Shivakumar Date: Mon, 17 Aug 2026 14:12:12 +0100 Subject: [PATCH 04/18] Add scalar Flow eBPF fixture --- tests/fixtures/bpf/answer.flow | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tests/fixtures/bpf/answer.flow diff --git a/tests/fixtures/bpf/answer.flow b/tests/fixtures/bpf/answer.flow new file mode 100644 index 000000000..c6aeb5aaf --- /dev/null +++ b/tests/fixtures/bpf/answer.flow @@ -0,0 +1,3 @@ +export function answer() -> i32 { + return 42 +} From 1d5538a9e32ee4988bc585541777553dd0dbb411 Mon Sep 17 00:00:00 2001 From: Abhishek Shivakumar Date: Mon, 17 Aug 2026 14:12:28 +0100 Subject: [PATCH 05/18] Add Flow bpfel backend CI --- .github/workflows/bpf.yml | 89 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 .github/workflows/bpf.yml diff --git a/.github/workflows/bpf.yml b/.github/workflows/bpf.yml new file mode 100644 index 000000000..091e24e66 --- /dev/null +++ b/.github/workflows/bpf.yml @@ -0,0 +1,89 @@ +name: bpfel backend + +on: + push: + paths: + - 'src/flow/bpf_target.py' + - 'src/flow/mlir_generator.py' + - 'src/flow/transpiler.py' + - 'tests/unit/test_bpf_target.py' + - 'tests/fixtures/bpf/**' + - '.github/actions/install-toolchain/action.yml' + - '.github/workflows/bpf.yml' + pull_request: + paths: + - 'src/flow/bpf_target.py' + - 'src/flow/mlir_generator.py' + - 'src/flow/transpiler.py' + - 'tests/unit/test_bpf_target.py' + - 'tests/fixtures/bpf/**' + - '.github/actions/install-toolchain/action.yml' + - '.github/workflows/bpf.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + bpfel: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/setup-python + - uses: ./.github/actions/install-toolchain + with: + mlir: 'true' + + - name: Run BPF target unit tests + env: + PYTHONPATH: src + run: python -m pytest -q tests/unit/test_bpf_target.py + + - name: Verify LLVM BPF backend is available + run: | + clang --print-targets | grep -E '(^|[[:space:]])bpf([[:space:]]|$)' + command -v llvm-readelf + + - name: Compile Flow through MLIR and LLVM to eBPF ELF + env: + PYTHONPATH: src + run: | + python -m flow.bpf_target tests/fixtures/bpf/answer.flow \ + --target bpfel \ + -O 2 \ + -o /tmp/answer.bpf.o + test -s /tmp/answer.bpf.o + + llvm-readelf -h /tmp/answer.bpf.o | tee /tmp/answer.readelf.txt + grep -Eq 'Machine:[[:space:]]+(Linux BPF|EM_BPF|BPF)' /tmp/answer.readelf.txt + + llvm-objdump -h /tmp/answer.bpf.o | tee /tmp/answer.sections.txt + + - name: Reject userspace-runtime LLVM IR + env: + PYTHONPATH: src + run: | + cat >/tmp/invalid.ll <<'EOF' + declare ptr @malloc(i64) + define i32 @bad() { + %p = call ptr @malloc(i64 8) + ret i32 0 + } + EOF + if python -m flow.bpf_target /tmp/invalid.ll -o /tmp/invalid.o 2>/tmp/reject.log; then + echo 'bpfel unexpectedly accepted malloc dependency' >&2 + exit 1 + fi + grep -F "cannot depend on userspace runtime symbol 'malloc'" /tmp/reject.log + + - name: Upload BPF object inspection + if: always() + uses: actions/upload-artifact@v4 + with: + name: flow-bpfel-object + path: | + /tmp/answer.bpf.o + /tmp/answer.readelf.txt + /tmp/answer.sections.txt + if-no-files-found: warn + retention-days: 7 From c9ed068a3f652217cd5c8dd09b364e89fc467ae1 Mon Sep 17 00:00:00 2001 From: Abhishek Shivakumar Date: Mon, 17 Aug 2026 14:14:18 +0100 Subject: [PATCH 06/18] Fix static BPF alloca validation --- src/flow/bpf_target.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/flow/bpf_target.py b/src/flow/bpf_target.py index 3d47991cd..9cfffbb69 100644 --- a/src/flow/bpf_target.py +++ b/src/flow/bpf_target.py @@ -105,7 +105,7 @@ def validate_bpf_llvm_ir(llvm_ir: str) -> None: # Variable-sized allocas make stack bounds opaque to the verifier. Constant # allocas remain eligible; the backend/verifier enforces the final 512-byte # aggregate stack limit. - for match in re.finditer(r"\balloca\s+[^,\n]+,\s+i\d+\s+([^,\n]+)", llvm_ir): + for match in re.finditer(r"\balloca\s+[^,\n]+,\s+i\d+\s+([^\s,]+)", llvm_ir): count = match.group(1).strip() if not re.fullmatch(r"\d+", count): raise BPFTargetError( From 4ade066cbea5a32b5ca14bc27f39595218c06bfc Mon Sep 17 00:00:00 2001 From: Abhishek Shivakumar Date: Mon, 17 Aug 2026 14:16:31 +0100 Subject: [PATCH 07/18] Use versioned LLVM tools in bpfel CI --- .github/workflows/bpf.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/bpf.yml b/.github/workflows/bpf.yml index 091e24e66..80331901a 100644 --- a/.github/workflows/bpf.yml +++ b/.github/workflows/bpf.yml @@ -34,6 +34,11 @@ jobs: with: mlir: 'true' + - name: Install LLVM object inspection tools + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends llvm-18 + - name: Run BPF target unit tests env: PYTHONPATH: src @@ -42,7 +47,8 @@ jobs: - name: Verify LLVM BPF backend is available run: | clang --print-targets | grep -E '(^|[[:space:]])bpf([[:space:]]|$)' - command -v llvm-readelf + command -v llvm-readelf-18 + command -v llvm-objdump-18 - name: Compile Flow through MLIR and LLVM to eBPF ELF env: @@ -54,10 +60,10 @@ jobs: -o /tmp/answer.bpf.o test -s /tmp/answer.bpf.o - llvm-readelf -h /tmp/answer.bpf.o | tee /tmp/answer.readelf.txt + llvm-readelf-18 -h /tmp/answer.bpf.o | tee /tmp/answer.readelf.txt grep -Eq 'Machine:[[:space:]]+(Linux BPF|EM_BPF|BPF)' /tmp/answer.readelf.txt - llvm-objdump -h /tmp/answer.bpf.o | tee /tmp/answer.sections.txt + llvm-objdump-18 -h /tmp/answer.bpf.o | tee /tmp/answer.sections.txt - name: Reject userspace-runtime LLVM IR env: From 824b73cb0fc35effebef8e07cbbb01a74efea0d4 Mon Sep 17 00:00:00 2001 From: Abhishek Shivakumar Date: Mon, 17 Aug 2026 14:19:52 +0100 Subject: [PATCH 08/18] Add eBPF program section metadata --- src/flow/bpf_target.py | 92 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/src/flow/bpf_target.py b/src/flow/bpf_target.py index 9cfffbb69..89b0b2249 100644 --- a/src/flow/bpf_target.py +++ b/src/flow/bpf_target.py @@ -38,6 +38,19 @@ class BPFTarget: little_endian: bool = True +@dataclass(frozen=True) +class BPFProgram: + """Kernel-facing metadata for one exported Flow eBPF program.""" + + entry: str + section: str + license: str = "GPL" + + @property + def export_symbol(self) -> str: + return f"flow_export_{self.entry}" + + BPFEL = BPFTarget() _FORBIDDEN_EXTERNALS = ( @@ -87,6 +100,49 @@ def with_bpf_target_header(llvm_ir: str, target: BPFTarget = BPFEL) -> str: return "\n".join(header + filtered) + "\n" +def _llvm_c_string(value: str) -> tuple[int, str]: + raw = value.encode("utf-8") + b"\x00" + escaped = "".join( + chr(byte) if 32 <= byte <= 126 and byte not in {34, 92} else f"\\{byte:02X}" + for byte in raw + ) + return len(raw), escaped + + +def with_bpf_program_metadata(llvm_ir: str, program: BPFProgram) -> str: + """Place an exported Flow entry in a BPF section and add license metadata.""" + if not program.entry or not program.section: + raise BPFTargetError("BPF program entry and section must be non-empty") + + symbol = f"@{program.export_symbol}(" + decorated: list[str] = [] + found = False + for line in llvm_ir.splitlines(): + if line.startswith("define ") and symbol in line: + if found: + raise BPFTargetError(f"duplicate exported BPF entry '{program.entry}'") + if "{" not in line: + raise BPFTargetError( + f"cannot decorate multiline definition for BPF entry '{program.entry}'" + ) + prefix, brace, suffix = line.partition("{") + line = f'{prefix.rstrip()} section "{program.section}" {brace}{suffix}' + found = True + decorated.append(line) + + if not found: + raise BPFTargetError( + f"exported BPF entry '{program.entry}' not found as {program.export_symbol}" + ) + + license_len, license_data = _llvm_c_string(program.license) + decorated.append( + f'@_flow_bpf_license = dso_local constant [{license_len} x i8] ' + f'c"{license_data}", section "license", align 1' + ) + return "\n".join(decorated) + "\n" + + def validate_bpf_llvm_ir(llvm_ir: str) -> None: """Reject known verifier/runtime-invalid constructs before object emission.""" padded = f" {llvm_ir} " @@ -118,12 +174,15 @@ def emit_bpf_object( llvm_ir: str, output: str | Path, *, + program: BPFProgram | None = None, clang: str | None = None, optimize: str = "2", ) -> Path: """Validate LLVM IR and emit a little-endian ELF eBPF object with clang.""" validate_bpf_llvm_ir(llvm_ir) targeted_ir = with_bpf_target_header(llvm_ir) + if program is not None: + targeted_ir = with_bpf_program_metadata(targeted_ir, program) compiler = clang or shutil.which("clang") if not compiler: @@ -161,6 +220,7 @@ def compile_flow_to_bpf( source: str | Path, output: str | Path, *, + program: BPFProgram | None = None, clang: str | None = None, optimize: str = "2", ) -> Path: @@ -199,7 +259,13 @@ def compile_flow_to_bpf( raise BPFTargetError("Flow LLVM lowering completed without producing LLVM IR") llvm_ir = llvm_path.read_text(encoding="utf-8") - return emit_bpf_object(llvm_ir, output, clang=clang, optimize=optimize) + return emit_bpf_object( + llvm_ir, + output, + program=program, + clang=clang, + optimize=optimize, + ) def main(argv: list[str] | None = None) -> int: @@ -207,17 +273,37 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("input", help="Input .flow or .ll file") parser.add_argument("-o", "--output", required=True, help="Output .o path") parser.add_argument("--target", default="bpfel", choices=["bpfel"]) + parser.add_argument("--entry", help="Exported Flow function to expose as a BPF program") + parser.add_argument("--section", help="ELF BPF program section, for example socket or xdp") + parser.add_argument("--license", default="GPL", help="BPF license string") parser.add_argument("-O", dest="optimize", default="2", choices=["0", "1", "2", "3"]) args = parser.parse_args(argv) try: target_for_name(args.target) + if bool(args.entry) != bool(args.section): + raise BPFTargetError("--entry and --section must be supplied together") + program = ( + BPFProgram(entry=args.entry, section=args.section, license=args.license) + if args.entry + else None + ) input_path = Path(args.input) if input_path.suffix == ".flow": - compile_flow_to_bpf(input_path, args.output, optimize=args.optimize) + compile_flow_to_bpf( + input_path, + args.output, + program=program, + optimize=args.optimize, + ) else: llvm_ir = input_path.read_text(encoding="utf-8") - emit_bpf_object(llvm_ir, args.output, optimize=args.optimize) + emit_bpf_object( + llvm_ir, + args.output, + program=program, + optimize=args.optimize, + ) except (OSError, BPFTargetError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 From 792609c77866b44d59b40312f1f381a4028cffd2 Mon Sep 17 00:00:00 2001 From: Abhishek Shivakumar Date: Mon, 17 Aug 2026 14:20:13 +0100 Subject: [PATCH 09/18] Test eBPF program section metadata --- tests/unit/test_bpf_target.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_bpf_target.py b/tests/unit/test_bpf_target.py index c10eaafd5..cea7cd22c 100644 --- a/tests/unit/test_bpf_target.py +++ b/tests/unit/test_bpf_target.py @@ -1,12 +1,12 @@ -from pathlib import Path - import pytest from flow.bpf_target import ( BPFEL, + BPFProgram, BPFTargetError, target_for_name, validate_bpf_llvm_ir, + with_bpf_program_metadata, with_bpf_target_header, ) @@ -44,6 +44,32 @@ def test_target_header_replaces_host_triple_and_layout(): assert "host-layout" not in targeted +def test_program_metadata_decorates_stable_flow_export_and_license(): + llvm_ir = '''define i32 @answer() { + ret i32 42 +} +define i32 @flow_export_answer() { + %r = call i32 @answer() + ret i32 %r +} +''' + decorated = with_bpf_program_metadata( + llvm_ir, + BPFProgram(entry="answer", section="socket", license="GPL"), + ) + assert 'define i32 @flow_export_answer() section "socket" {' in decorated + assert 'section "license"' in decorated + assert 'c"GPL\\00"' in decorated + + +def test_program_metadata_requires_exported_entry(): + with pytest.raises(BPFTargetError, match="exported BPF entry 'missing' not found"): + with_bpf_program_metadata( + "define i32 @something_else() { ret i32 0 }\n", + BPFProgram(entry="missing", section="socket"), + ) + + @pytest.mark.parametrize( "llvm_ir, expected", [ From 63cdc10fd8ec9a70b10f6491847f81ccae95ac21 Mon Sep 17 00:00:00 2001 From: Abhishek Shivakumar Date: Mon, 17 Aug 2026 14:20:27 +0100 Subject: [PATCH 10/18] Add loadable socket-filter Flow fixture --- tests/fixtures/bpf/socket_filter.flow | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tests/fixtures/bpf/socket_filter.flow diff --git a/tests/fixtures/bpf/socket_filter.flow b/tests/fixtures/bpf/socket_filter.flow new file mode 100644 index 000000000..871148da8 --- /dev/null +++ b/tests/fixtures/bpf/socket_filter.flow @@ -0,0 +1,3 @@ +export function socket_filter(context: ptr) -> i32 { + return 0 +} From c591e485f7125927641aa3296f429b2d3ccde74c Mon Sep 17 00:00:00 2001 From: Abhishek Shivakumar Date: Mon, 17 Aug 2026 14:20:54 +0100 Subject: [PATCH 11/18] Add eBPF program section and verifier-load CI --- .github/workflows/bpf.yml | 42 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/.github/workflows/bpf.yml b/.github/workflows/bpf.yml index 80331901a..dc4c8becd 100644 --- a/.github/workflows/bpf.yml +++ b/.github/workflows/bpf.yml @@ -34,10 +34,10 @@ jobs: with: mlir: 'true' - - name: Install LLVM object inspection tools + - name: Install BPF and LLVM inspection tools run: | sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends llvm-18 + sudo apt-get install -y --no-install-recommends bpftool llvm-18 - name: Run BPF target unit tests env: @@ -49,6 +49,7 @@ jobs: clang --print-targets | grep -E '(^|[[:space:]])bpf([[:space:]]|$)' command -v llvm-readelf-18 command -v llvm-objdump-18 + command -v bpftool - name: Compile Flow through MLIR and LLVM to eBPF ELF env: @@ -62,9 +63,40 @@ jobs: llvm-readelf-18 -h /tmp/answer.bpf.o | tee /tmp/answer.readelf.txt grep -Eq 'Machine:[[:space:]]+(Linux BPF|EM_BPF|BPF)' /tmp/answer.readelf.txt - llvm-objdump-18 -h /tmp/answer.bpf.o | tee /tmp/answer.sections.txt + - name: Compile Flow socket-filter program with BPF metadata + env: + PYTHONPATH: src + run: | + python -m flow.bpf_target tests/fixtures/bpf/socket_filter.flow \ + --target bpfel \ + --entry socket_filter \ + --section socket \ + --license GPL \ + -O 2 \ + -o /tmp/socket-filter.bpf.o + test -s /tmp/socket-filter.bpf.o + + llvm-objdump-18 -h /tmp/socket-filter.bpf.o | tee /tmp/socket-filter.sections.txt + grep -E '[[:space:]]socket[[:space:]]' /tmp/socket-filter.sections.txt + grep -E '[[:space:]]license[[:space:]]' /tmp/socket-filter.sections.txt + + - name: Load Flow program through the Linux BPF verifier + run: | + sudo mkdir -p /sys/fs/bpf + if ! mountpoint -q /sys/fs/bpf; then + sudo mount -t bpf bpf /sys/fs/bpf + fi + sudo rm -f /sys/fs/bpf/flow_socket_filter + sudo bpftool prog load \ + /tmp/socket-filter.bpf.o \ + /sys/fs/bpf/flow_socket_filter \ + type socket_filter \ + 2>&1 | tee /tmp/verifier.log + sudo bpftool prog show pinned /sys/fs/bpf/flow_socket_filter | tee /tmp/loaded-program.txt + sudo rm -f /sys/fs/bpf/flow_socket_filter + - name: Reject userspace-runtime LLVM IR env: PYTHONPATH: src @@ -91,5 +123,9 @@ jobs: /tmp/answer.bpf.o /tmp/answer.readelf.txt /tmp/answer.sections.txt + /tmp/socket-filter.bpf.o + /tmp/socket-filter.sections.txt + /tmp/verifier.log + /tmp/loaded-program.txt if-no-files-found: warn retention-days: 7 From 7b3a149d2d840312a1f8f289d01e610b7c2b139c Mon Sep 17 00:00:00 2001 From: Abhishek Shivakumar Date: Mon, 17 Aug 2026 14:22:59 +0100 Subject: [PATCH 12/18] Install bpftool from Ubuntu linux-tools --- .github/workflows/bpf.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bpf.yml b/.github/workflows/bpf.yml index dc4c8becd..9a51c4d14 100644 --- a/.github/workflows/bpf.yml +++ b/.github/workflows/bpf.yml @@ -37,7 +37,14 @@ jobs: - name: Install BPF and LLVM inspection tools run: | sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends bpftool llvm-18 + sudo apt-get install -y --no-install-recommends \ + linux-tools-common \ + linux-tools-generic \ + llvm-18 + BPFTOOL="$(find /usr/lib/linux-tools -type f -name bpftool -print -quit)" + test -n "$BPFTOOL" + sudo ln -sf "$BPFTOOL" /usr/local/bin/bpftool + bpftool version - name: Run BPF target unit tests env: From 042ff176f8b4dcb2a2eae22771967cb7dc247157 Mon Sep 17 00:00:00 2001 From: Abhishek Shivakumar Date: Mon, 17 Aug 2026 14:24:39 +0100 Subject: [PATCH 13/18] Locate packaged bpftool robustly --- .github/workflows/bpf.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/bpf.yml b/.github/workflows/bpf.yml index 9a51c4d14..63ebcd2e8 100644 --- a/.github/workflows/bpf.yml +++ b/.github/workflows/bpf.yml @@ -41,10 +41,14 @@ jobs: linux-tools-common \ linux-tools-generic \ llvm-18 - BPFTOOL="$(find /usr/lib/linux-tools -type f -name bpftool -print -quit)" - test -n "$BPFTOOL" - sudo ln -sf "$BPFTOOL" /usr/local/bin/bpftool - bpftool version + if command -v bpftool >/dev/null 2>&1; then + bpftool version + else + BPFTOOL="$(find /usr/lib/linux-tools -name bpftool -print -quit 2>/dev/null || true)" + test -n "$BPFTOOL" + sudo ln -sf "$BPFTOOL" /usr/local/bin/bpftool + bpftool version + fi - name: Run BPF target unit tests env: From 5246193b61e0e0d4002690556e0f0e5776b003fc Mon Sep 17 00:00:00 2001 From: Abhishek Shivakumar Date: Mon, 17 Aug 2026 14:26:36 +0100 Subject: [PATCH 14/18] Install bpftool for the running kernel --- .github/workflows/bpf.yml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/.github/workflows/bpf.yml b/.github/workflows/bpf.yml index 63ebcd2e8..5801a3a34 100644 --- a/.github/workflows/bpf.yml +++ b/.github/workflows/bpf.yml @@ -38,17 +38,11 @@ jobs: run: | sudo apt-get update -qq sudo apt-get install -y --no-install-recommends \ + "linux-tools-$(uname -r)" \ linux-tools-common \ - linux-tools-generic \ llvm-18 - if command -v bpftool >/dev/null 2>&1; then - bpftool version - else - BPFTOOL="$(find /usr/lib/linux-tools -name bpftool -print -quit 2>/dev/null || true)" - test -n "$BPFTOOL" - sudo ln -sf "$BPFTOOL" /usr/local/bin/bpftool - bpftool version - fi + command -v bpftool + bpftool version - name: Run BPF target unit tests env: From 7597c9d48d3ffdbcb6d7bb464d9d318a2e11f1cd Mon Sep 17 00:00:00 2001 From: Abhishek Shivakumar Date: Mon, 17 Aug 2026 14:28:18 +0100 Subject: [PATCH 15/18] Bypass Ubuntu bpftool wrapper --- .github/workflows/bpf.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/bpf.yml b/.github/workflows/bpf.yml index 5801a3a34..5cc8a104d 100644 --- a/.github/workflows/bpf.yml +++ b/.github/workflows/bpf.yml @@ -37,12 +37,15 @@ jobs: - name: Install BPF and LLVM inspection tools run: | sudo apt-get update -qq + KERNEL_TOOLS="linux-tools-$(uname -r)" sudo apt-get install -y --no-install-recommends \ - "linux-tools-$(uname -r)" \ + "$KERNEL_TOOLS" \ linux-tools-common \ llvm-18 - command -v bpftool - bpftool version + BPFTOOL="$(dpkg -L "$KERNEL_TOOLS" | grep '/bpftool$' | head -n1)" + test -x "$BPFTOOL" + sudo ln -sf "$BPFTOOL" /usr/local/bin/bpftool + /usr/local/bin/bpftool version - name: Run BPF target unit tests env: @@ -94,12 +97,12 @@ jobs: sudo mount -t bpf bpf /sys/fs/bpf fi sudo rm -f /sys/fs/bpf/flow_socket_filter - sudo bpftool prog load \ + sudo /usr/local/bin/bpftool prog load \ /tmp/socket-filter.bpf.o \ /sys/fs/bpf/flow_socket_filter \ type socket_filter \ 2>&1 | tee /tmp/verifier.log - sudo bpftool prog show pinned /sys/fs/bpf/flow_socket_filter | tee /tmp/loaded-program.txt + sudo /usr/local/bin/bpftool prog show pinned /sys/fs/bpf/flow_socket_filter | tee /tmp/loaded-program.txt sudo rm -f /sys/fs/bpf/flow_socket_filter - name: Reject userspace-runtime LLVM IR From 833349c033abcea28c58dacf0d896ed27348924f Mon Sep 17 00:00:00 2001 From: Abhishek Shivakumar Date: Mon, 17 Aug 2026 14:29:51 +0100 Subject: [PATCH 16/18] Resolve bpftool payload package --- .github/workflows/bpf.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bpf.yml b/.github/workflows/bpf.yml index 5cc8a104d..96450f56d 100644 --- a/.github/workflows/bpf.yml +++ b/.github/workflows/bpf.yml @@ -42,7 +42,13 @@ jobs: "$KERNEL_TOOLS" \ linux-tools-common \ llvm-18 - BPFTOOL="$(dpkg -L "$KERNEL_TOOLS" | grep '/bpftool$' | head -n1)" + + PAYLOAD_PACKAGE="$(apt-cache depends "$KERNEL_TOOLS" \ + | awk '/Depends: linux-.*-tools-/ {print $2; exit}')" + test -n "$PAYLOAD_PACKAGE" + sudo apt-get install -y --no-install-recommends "$PAYLOAD_PACKAGE" + + BPFTOOL="$(dpkg -L "$PAYLOAD_PACKAGE" | grep '/bpftool$' | head -n1)" test -x "$BPFTOOL" sudo ln -sf "$BPFTOOL" /usr/local/bin/bpftool /usr/local/bin/bpftool version From be0c5d769e474b159b68394cbc0369c958cc854f Mon Sep 17 00:00:00 2001 From: Abhishek Shivakumar Date: Mon, 17 Aug 2026 14:32:20 +0100 Subject: [PATCH 17/18] Keep compiler BPF CI host-independent --- .github/workflows/bpf.yml | 39 +++++---------------------------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/.github/workflows/bpf.yml b/.github/workflows/bpf.yml index 96450f56d..f7c72f41f 100644 --- a/.github/workflows/bpf.yml +++ b/.github/workflows/bpf.yml @@ -34,24 +34,10 @@ jobs: with: mlir: 'true' - - name: Install BPF and LLVM inspection tools + - name: Install LLVM inspection tools run: | sudo apt-get update -qq - KERNEL_TOOLS="linux-tools-$(uname -r)" - sudo apt-get install -y --no-install-recommends \ - "$KERNEL_TOOLS" \ - linux-tools-common \ - llvm-18 - - PAYLOAD_PACKAGE="$(apt-cache depends "$KERNEL_TOOLS" \ - | awk '/Depends: linux-.*-tools-/ {print $2; exit}')" - test -n "$PAYLOAD_PACKAGE" - sudo apt-get install -y --no-install-recommends "$PAYLOAD_PACKAGE" - - BPFTOOL="$(dpkg -L "$PAYLOAD_PACKAGE" | grep '/bpftool$' | head -n1)" - test -x "$BPFTOOL" - sudo ln -sf "$BPFTOOL" /usr/local/bin/bpftool - /usr/local/bin/bpftool version + sudo apt-get install -y --no-install-recommends llvm-18 - name: Run BPF target unit tests env: @@ -63,7 +49,6 @@ jobs: clang --print-targets | grep -E '(^|[[:space:]])bpf([[:space:]]|$)' command -v llvm-readelf-18 command -v llvm-objdump-18 - command -v bpftool - name: Compile Flow through MLIR and LLVM to eBPF ELF env: @@ -92,25 +77,12 @@ jobs: -o /tmp/socket-filter.bpf.o test -s /tmp/socket-filter.bpf.o + llvm-readelf-18 -h /tmp/socket-filter.bpf.o | tee /tmp/socket-filter.readelf.txt + grep -Eq 'Machine:[[:space:]]+(Linux BPF|EM_BPF|BPF)' /tmp/socket-filter.readelf.txt llvm-objdump-18 -h /tmp/socket-filter.bpf.o | tee /tmp/socket-filter.sections.txt grep -E '[[:space:]]socket[[:space:]]' /tmp/socket-filter.sections.txt grep -E '[[:space:]]license[[:space:]]' /tmp/socket-filter.sections.txt - - name: Load Flow program through the Linux BPF verifier - run: | - sudo mkdir -p /sys/fs/bpf - if ! mountpoint -q /sys/fs/bpf; then - sudo mount -t bpf bpf /sys/fs/bpf - fi - sudo rm -f /sys/fs/bpf/flow_socket_filter - sudo /usr/local/bin/bpftool prog load \ - /tmp/socket-filter.bpf.o \ - /sys/fs/bpf/flow_socket_filter \ - type socket_filter \ - 2>&1 | tee /tmp/verifier.log - sudo /usr/local/bin/bpftool prog show pinned /sys/fs/bpf/flow_socket_filter | tee /tmp/loaded-program.txt - sudo rm -f /sys/fs/bpf/flow_socket_filter - - name: Reject userspace-runtime LLVM IR env: PYTHONPATH: src @@ -138,8 +110,7 @@ jobs: /tmp/answer.readelf.txt /tmp/answer.sections.txt /tmp/socket-filter.bpf.o + /tmp/socket-filter.readelf.txt /tmp/socket-filter.sections.txt - /tmp/verifier.log - /tmp/loaded-program.txt if-no-files-found: warn retention-days: 7 From 442b760cd94494bdcdf851a01e2bd86c66e214b7 Mon Sep 17 00:00:00 2001 From: godofecht Date: Tue, 18 Aug 2026 18:19:23 +0100 Subject: [PATCH 18/18] fix: BPF target looked for a symbol the MLIR path never emits The bpfel workflow had failed on all eight runs since the branch's first commit, always with the same error: error: exported BPF entry 'socket_filter' not found as flow_export_socket_filter flow_export_ is a C-backend alias. c_generator emits it for --export; the MLIR path does not. Since bpf_target lowers with --llvm, the definition in the IR carries the plain Flow name, so the search could never match. The entry is now looked up under the explicit alias first and the plain name second, and one symbol is chosen before any rewriting. That ordering matters: the C backend emits both the mangled definition and the alias, so matching both would report a duplicate entry, which is how the first version of this fix broke an existing test. Verified on real IR from the MLIR path: the definition is decorated with `section "socket"` and the license constant is appended. Object emission cannot be checked on macOS, where clang has no BPF target, so CI is the proof for that half. Unit 1509 passed, ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- src/flow/bpf_target.py | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/flow/bpf_target.py b/src/flow/bpf_target.py index 89b0b2249..5c4911b85 100644 --- a/src/flow/bpf_target.py +++ b/src/flow/bpf_target.py @@ -48,7 +48,23 @@ class BPFProgram: @property def export_symbol(self) -> str: - return f"flow_export_{self.entry}" + """The symbol to decorate, as the MLIR path actually names it. + + `flow_export_` is a C-backend alias: c_generator emits it for + `--export`, and the MLIR path does not. Since this target lowers with + `--llvm`, the definition in the IR carries the plain Flow name. + """ + return self.entry + + @property + def candidate_symbols(self) -> tuple[str, ...]: + """Names the entry may appear under, most explicit first. + + The C backend emits both the mangled definition and a visible + `flow_export_` alias, so when both are present the alias is the + one to decorate. The MLIR path emits only the plain name. + """ + return (f"flow_export_{self.entry}", self.entry) BPFEL = BPFTarget() @@ -114,11 +130,20 @@ def with_bpf_program_metadata(llvm_ir: str, program: BPFProgram) -> str: if not program.entry or not program.section: raise BPFTargetError("BPF program entry and section must be non-empty") - symbol = f"@{program.export_symbol}(" + # Settle on one symbol before rewriting anything: if both the alias and the + # plain definition are present, decorating both is a duplicate-entry error. + lines = llvm_ir.splitlines() + symbol = "" + for candidate in program.candidate_symbols: + needle = f"@{candidate}(" + if any(line.startswith("define ") and needle in line for line in lines): + symbol = needle + break + decorated: list[str] = [] found = False - for line in llvm_ir.splitlines(): - if line.startswith("define ") and symbol in line: + for line in lines: + if symbol and line.startswith("define ") and symbol in line: if found: raise BPFTargetError(f"duplicate exported BPF entry '{program.entry}'") if "{" not in line: @@ -132,7 +157,8 @@ def with_bpf_program_metadata(llvm_ir: str, program: BPFProgram) -> str: if not found: raise BPFTargetError( - f"exported BPF entry '{program.entry}' not found as {program.export_symbol}" + f"exported BPF entry '{program.entry}' not found; looked for " + + " or ".join(f"@{n}" for n in program.candidate_symbols) ) license_len, license_data = _llvm_c_string(program.license)