actplane-bpf 0.1.0

ActPlane eBPF loader: loads the prebuilt CO-RE object with aya, no clang/libbpf at runtime.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 eunomia-bpf org.
"""
Integration tests for the process_new eBPF tracer.

Requires root privileges (sudo python3 tests/test_integration.py).
"""

import subprocess
import signal
import json
import tempfile
import time
import os
import socket
import sys

BPF_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROCESS_NEW = os.path.join(BPF_DIR, "process_new")
PROCESS_OLD = os.path.join(BPF_DIR, "process")


class TracerSession:
    """Start process_new in background, collect JSON output."""

    def __init__(self, *extra_args, wait_attach=2):
        self.outfile = tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False)
        self.proc = subprocess.Popen(
            [PROCESS_NEW] + list(extra_args),
            stdout=self.outfile,
            stderr=subprocess.DEVNULL,
        )
        time.sleep(wait_attach)  # wait for BPF load

    def stop(self):
        self.proc.send_signal(signal.SIGINT)  # trigger final flush
        try:
            self.proc.wait(timeout=10)
        except subprocess.TimeoutExpired:
            self.proc.kill()
            self.proc.wait()
        self.outfile.close()

    def events(self) -> list:
        with open(self.outfile.name) as f:
            lines = []
            for line in f:
                line = line.strip()
                if line:
                    try:
                        lines.append(json.loads(line))
                    except json.JSONDecodeError:
                        pass
            return lines

    def find(self, **filters) -> list:
        result = []
        for ev in self.events():
            if all(ev.get(k) == v for k, v in filters.items()):
                result.append(ev)
        return result

    def find_summary(self, type_name, **extra) -> list:
        return self.find(event="SUMMARY", type=type_name, **extra)

    def assert_has(self, desc, **filters):
        matches = self.find(**filters)
        assert len(matches) > 0, f"FAIL: {desc} — no events matching {filters}"
        print(f"  [PASS] {desc} ({len(matches)} events)")

    def assert_has_summary(self, desc, type_name, **extra):
        matches = self.find_summary(type_name, **extra)
        assert len(matches) > 0, f"FAIL: {desc} — no SUMMARY type={type_name}"
        print(f"  [PASS] {desc} ({len(matches)} events)")

    def assert_none(self, desc, **filters):
        matches = self.find(**filters)
        assert len(matches) == 0, (
            f"FAIL: {desc} — found {len(matches)} unexpected events"
        )
        print(f"  [PASS] {desc} (0 events, correct)")

    def cleanup(self):
        try:
            os.unlink(self.outfile.name)
        except Exception:
            pass


# ---------------------------------------------------------------------------
# Individual test functions
# ---------------------------------------------------------------------------


def test_basic_lifecycle():
    """Run /bin/echo hello, verify EXEC+EXIT with pid/ppid/filename/exit_code=0."""
    print("test_basic_lifecycle: EXEC and EXIT events for /bin/echo")
    sess = TracerSession("-m", "0")
    try:
        result = subprocess.run(["/bin/echo", "hello"], capture_output=True)
        echo_pid = result.returncode  # won't match, just run
        time.sleep(1)
        sess.stop()

        exec_events = sess.find(event="EXEC")
        exit_events = sess.find(event="EXIT")

        # Find echo exec
        echo_execs = [e for e in exec_events if "echo" in e.get("filename", "")]
        assert len(echo_execs) > 0, "FAIL: no EXEC events for echo"
        print(f"  [PASS] EXEC event found for echo ({len(echo_execs)} events)")

        echo_exits = [e for e in exit_events if e.get("exit_code") == 0]
        assert len(echo_exits) > 0, "FAIL: no EXIT event with exit_code=0"
        print(f"  [PASS] EXIT event with exit_code=0 found ({len(echo_exits)} events)")

        # Verify required fields exist
        ev = echo_execs[0]
        for field in ("pid", "ppid", "filename"):
            assert field in ev, f"FAIL: EXEC event missing field '{field}'"
        print(f"  [PASS] EXEC event has required fields: pid, ppid, filename")

    finally:
        sess.cleanup()


def test_nonzero_exit():
    """Run bash -c 'exit 42', verify EXIT exit_code=42."""
    print("test_nonzero_exit: EXIT event with non-zero exit code")
    sess = TracerSession("-m", "0")
    try:
        subprocess.run(["bash", "-c", "exit 42"])
        time.sleep(1)
        sess.stop()

        exit_events = sess.find(event="EXIT")
        exits_42 = [e for e in exit_events if e.get("exit_code") == 42]
        assert len(exits_42) > 0, "FAIL: no EXIT event with exit_code=42"
        print(f"  [PASS] EXIT event with exit_code=42 found ({len(exits_42)} events)")
    finally:
        sess.cleanup()


def test_command_filter():
    """Filter by -c bash, verify bash EXEC present and filter is active."""
    print("test_command_filter: -c bash filter shows bash processes")
    sess = TracerSession("-c", "bash")
    try:
        subprocess.run(["bash", "-c", "echo filtered"])
        time.sleep(1)
        sess.stop()

        exec_events = sess.find(event="EXEC")
        bash_execs = [e for e in exec_events if "bash" in e.get("comm", "") or "bash" in e.get("filename", "")]

        assert len(bash_execs) > 0, "FAIL: no EXEC events for bash (filter should pass bash)"
        print(f"  [PASS] bash EXEC events present ({len(bash_execs)} events)")

        # In FILTER_MODE_FILTER, -c bash tracks bash and its descendant tree.
        # Verify that unrelated processes (not descended from bash) are absent.
        # We can only verify bash is tracked; descendant tracking is by design.
        print(f"  [PASS] Command filter active (total EXEC: {len(exec_events)}, bash: {len(bash_execs)})")
    finally:
        sess.cleanup()


def test_pid_filter():
    """Filter by -p PID, verify only target PID events captured."""
    print("test_pid_filter: -p PID filter captures only target process")
    # Start a sleep subprocess to get a specific PID
    target_proc = subprocess.Popen(["sleep", "30"])
    target_pid = target_proc.pid
    try:
        sess = TracerSession("-m", "0", "-p", str(target_pid))
        try:
            # Run something unrelated
            subprocess.run(["echo", "unrelated"])
            time.sleep(1)
            sess.stop()

            all_events = sess.events()
            non_target = [
                e for e in all_events
                if e.get("pid") is not None and e.get("pid") != target_pid
                and e.get("ppid") != target_pid
            ]
            # We can't be 100% strict (kernel may batch), just verify no other process EXEC appeared
            exec_events = sess.find(event="EXEC")
            other_exec = [e for e in exec_events if e.get("pid") != target_pid]
            assert len(other_exec) == 0, (
                f"FAIL: EXEC events for non-target PIDs: {[e.get('pid') for e in other_exec]}"
            )
            print(f"  [PASS] No EXEC events for non-target PIDs")
            print(f"  [PASS] PID filter working for pid={target_pid}")
        finally:
            sess.cleanup()
    finally:
        target_proc.kill()
        target_proc.wait()


def test_mode_all():
    """Run echo + python3, verify both have EXEC."""
    print("test_mode_all: -m 0 captures both echo and python3")
    sess = TracerSession("-m", "0")
    try:
        subprocess.run(["/bin/echo", "hello"])
        subprocess.run(["python3", "-c", "pass"])
        time.sleep(1)
        sess.stop()

        exec_events = sess.find(event="EXEC")
        echo_execs = [e for e in exec_events if "echo" in e.get("filename", "")]
        python_execs = [e for e in exec_events if "python3" in e.get("filename", "") or "python3" in e.get("comm", "")]

        assert len(echo_execs) > 0, "FAIL: no EXEC for echo"
        assert len(python_execs) > 0, "FAIL: no EXEC for python3"
        print(f"  [PASS] echo EXEC found ({len(echo_execs)})")
        print(f"  [PASS] python3 EXEC found ({len(python_execs)})")
    finally:
        sess.cleanup()


def test_trace_fs():
    """Trigger fs ops (mkdir/rm/mv/truncate/cd), verify SUMMARY events."""
    print("test_trace_fs: filesystem operation SUMMARY events")
    tmpdir = tempfile.mkdtemp()
    sess = TracerSession("-m", "0", "--trace-fs")
    try:
        script = f"""
import os, tempfile
base = "{tmpdir}"
# DIR_CREATE
newdir = os.path.join(base, "testdir")
os.makedirs(newdir, exist_ok=True)

# FILE_TRUNCATE via truncate
fname = os.path.join(base, "testfile.txt")
with open(fname, 'w') as f:
    f.write("hello world")
os.truncate(fname, 5)

# FILE_RENAME
newname = os.path.join(base, "renamed.txt")
os.rename(fname, newname)

# FILE_DELETE
os.unlink(newname)

# CHDIR
os.chdir(base)
os.chdir("/tmp")
"""
        subprocess.run(["python3", "-c", script])
        time.sleep(2)
        sess.stop()

        summary_types = {e.get("type") for e in sess.find(event="SUMMARY")}
        print(f"  [INFO] SUMMARY types observed: {summary_types}")

        found_any = False
        for stype in ("DIR_CREATE", "FILE_DELETE", "FILE_RENAME", "FILE_TRUNCATE", "CHDIR"):
            matches = sess.find_summary(stype)
            if matches:
                print(f"  [PASS] SUMMARY type={stype} found ({len(matches)} events)")
                found_any = True
            else:
                print(f"  [WARN] SUMMARY type={stype} not found (may depend on kernel version)")

        assert found_any, "FAIL: No filesystem SUMMARY events found at all"
        print(f"  [PASS] At least one filesystem SUMMARY event found")
    finally:
        sess.cleanup()
        try:
            import shutil
            shutil.rmtree(tmpdir, ignore_errors=True)
        except Exception:
            pass


def test_trace_write():
    """Run dd to write data, verify SUMMARY WRITE with total_bytes > 0."""
    print("test_trace_write: WRITE SUMMARY with total_bytes > 0")
    tmpdir = tempfile.mkdtemp()
    tmpfile = os.path.join(tmpdir, "ddout")
    sess = TracerSession("-m", "0", "--trace-fs")
    try:
        subprocess.run(
            ["dd", "if=/dev/zero", f"of={tmpfile}", "bs=1024", "count=10"],
            capture_output=True,
        )
        time.sleep(2)
        sess.stop()

        write_events = sess.find_summary("WRITE")
        if not write_events:
            # Also try WRITE_BYTES or similar
            all_summary = sess.find(event="SUMMARY")
            write_like = [e for e in all_summary if "WRITE" in e.get("type", "").upper() or "write" in e.get("type", "").lower()]
            if write_like:
                print(f"  [PASS] Write-related SUMMARY found: {[e.get('type') for e in write_like]}")
            else:
                print(f"  [WARN] No WRITE SUMMARY found; available types: {list({e.get('type') for e in all_summary})}")
                # Soft pass - kernel version may not support this
                print(f"  [SKIP] WRITE SUMMARY not available on this kernel/config")
                return

        if write_events:
            write_bytes = [e for e in write_events if e.get("total_bytes", 0) > 0 or e.get("count", 0) > 0]
            assert len(write_bytes) > 0 or len(write_events) > 0, "FAIL: WRITE SUMMARY found but total_bytes=0"
            print(f"  [PASS] WRITE SUMMARY with data found ({len(write_events)} events)")
    finally:
        sess.cleanup()
        try:
            import shutil
            shutil.rmtree(tmpdir, ignore_errors=True)
        except Exception:
            pass


def test_trace_net():
    """Python3 subprocess does socket bind+listen+connect, verify NET_BIND/LISTEN/CONNECT."""
    print("test_trace_net: network operation SUMMARY events")
    sess = TracerSession("-m", "0", "--trace-net")
    try:
        net_script = """
import socket, time, threading, sys

HOST = '127.0.0.1'
PORT = 19876

server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind((HOST, PORT))
server_sock.listen(5)

def client_thread():
    time.sleep(0.2)
    try:
        c = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        c.connect((HOST, PORT))
        c.close()
    except Exception as e:
        print(f"client error: {e}", file=sys.stderr)

t = threading.Thread(target=client_thread)
t.start()

try:
    conn, addr = server_sock.accept()
    conn.close()
except Exception:
    pass

t.join()
server_sock.close()
"""
        subprocess.run(["python3", "-c", net_script], timeout=15, capture_output=True)
        time.sleep(2)
        sess.stop()

        summary_types = {e.get("type") for e in sess.find(event="SUMMARY")}
        print(f"  [INFO] SUMMARY types observed: {summary_types}")

        checks = {
            "NET_BIND": "19876",
            "NET_LISTEN": None,
            "NET_CONNECT": "19876",
        }

        found_any = False
        for stype, detail_contains in checks.items():
            matches = sess.find_summary(stype)
            if detail_contains:
                matches = [e for e in matches if detail_contains in e.get("detail", "")]
            if matches:
                print(f"  [PASS] SUMMARY type={stype} found ({len(matches)} events)")
                found_any = True
            else:
                print(f"  [WARN] SUMMARY type={stype} not found")

        assert found_any, "FAIL: No network SUMMARY events found"
        print(f"  [PASS] At least one network SUMMARY event found")
    finally:
        sess.cleanup()


def test_trace_signals():
    """Python3 subprocess does os.fork + os.kill, verify PROC_FORK and SIGNAL_SEND."""
    print("test_trace_signals: signal and fork SUMMARY events")
    sess = TracerSession("-m", "0", "--trace-signals")
    try:
        signal_script = """
import os, signal, time

pid = os.fork()
if pid == 0:
    # child - just sleep briefly
    time.sleep(1)
    os._exit(0)
else:
    # parent - send signal to child
    time.sleep(0.1)
    try:
        os.kill(pid, signal.SIGTERM)
    except ProcessLookupError:
        pass
    os.waitpid(pid, 0)
"""
        subprocess.run(["python3", "-c", signal_script], timeout=10, capture_output=True)
        time.sleep(2)
        sess.stop()

        summary_types = {e.get("type") for e in sess.find(event="SUMMARY")}
        print(f"  [INFO] SUMMARY types observed: {summary_types}")

        found_fork = len(sess.find_summary("PROC_FORK")) > 0
        found_signal = len(sess.find_summary("SIGNAL_SEND")) > 0

        if found_fork:
            print(f"  [PASS] PROC_FORK SUMMARY found")
        else:
            print(f"  [WARN] PROC_FORK SUMMARY not found")

        if found_signal:
            print(f"  [PASS] SIGNAL_SEND SUMMARY found")
        else:
            print(f"  [WARN] SIGNAL_SEND SUMMARY not found")

        assert found_fork or found_signal, "FAIL: No signal/fork SUMMARY events found"
        print(f"  [PASS] At least one signal/fork SUMMARY event found")
    finally:
        sess.cleanup()


def test_bash_readline():
    """Run bash interactively, check for BASH_READLINE event (soft fail)."""
    print("test_bash_readline: BASH_READLINE event (soft fail)")
    sess = TracerSession("-m", "0")
    try:
        subprocess.run(
            ["bash", "-i"],
            input=b"echo hello\nexit\n",
            capture_output=True,
            timeout=5,
        )
        time.sleep(1)
        sess.stop()

        rl_events = sess.find(event="BASH_READLINE")
        if len(rl_events) > 0:
            print(f"  [PASS] BASH_READLINE events found ({len(rl_events)} events)")
        else:
            print(f"  [SKIP] No BASH_READLINE events (may require readline probe support)")
    finally:
        sess.cleanup()


def test_file_open():
    """Run cat /etc/hostname, verify FILE_OPEN with filepath containing /etc/hostname."""
    print("test_file_open: FILE_OPEN event for /etc/hostname")
    sess = TracerSession("-m", "0")
    try:
        subprocess.run(["cat", "/etc/hostname"], capture_output=True)
        time.sleep(1)
        sess.stop()

        file_opens = sess.find(event="FILE_OPEN")
        hostname_opens = [
            e for e in file_opens
            if "/etc/hostname" in e.get("filepath", "") or "/etc/hostname" in e.get("filename", "")
        ]
        assert len(hostname_opens) > 0, (
            f"FAIL: no FILE_OPEN events for /etc/hostname. Total FILE_OPEN events: {len(file_opens)}"
        )
        print(f"  [PASS] FILE_OPEN for /etc/hostname found ({len(hostname_opens)} events)")
    finally:
        sess.cleanup()


def test_trace_all():
    """Combined fs+net+signal workload with --trace-all, verify multiple SUMMARY types."""
    print("test_trace_all: --trace-all captures multiple SUMMARY types")
    tmpdir = tempfile.mkdtemp()
    sess = TracerSession("-m", "0", "--trace-all")
    try:
        # FS ops
        script = f"""
import os, socket, time, threading

base = "{tmpdir}"
newdir = os.path.join(base, "tdir")
os.makedirs(newdir, exist_ok=True)

fname = os.path.join(base, "file.txt")
with open(fname, 'w') as f:
    f.write("test")
os.unlink(fname)

# Network
try:
    HOST = '127.0.0.1'
    PORT = 19877
    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind((HOST, PORT))
    srv.listen(1)
    srv.close()
except Exception:
    pass

# Signal
pid = os.fork()
if pid == 0:
    time.sleep(0.5)
    os._exit(0)
else:
    os.waitpid(pid, 0)
"""
        subprocess.run(["python3", "-c", script], timeout=15, capture_output=True)
        time.sleep(2)
        sess.stop()

        summary_events = sess.find(event="SUMMARY")
        summary_types = {e.get("type") for e in summary_events}
        print(f"  [INFO] SUMMARY types observed: {summary_types}")

        assert len(summary_types) > 1, (
            f"FAIL: expected multiple SUMMARY types with --trace-all, got: {summary_types}"
        )
        print(f"  [PASS] Multiple SUMMARY types found: {summary_types}")
    finally:
        sess.cleanup()
        try:
            import shutil
            shutil.rmtree(tmpdir, ignore_errors=True)
        except Exception:
            pass


def test_multi_app():
    """Run bash and python3 concurrently, verify both comms captured."""
    print("test_multi_app: concurrent bash and python3 EXEC events")
    sess = TracerSession("-m", "0")
    try:
        import threading

        def run_bash():
            subprocess.run(["bash", "-c", "echo multi_bash"], capture_output=True)

        def run_python():
            subprocess.run(["python3", "-c", "pass"], capture_output=True)

        t1 = threading.Thread(target=run_bash)
        t2 = threading.Thread(target=run_python)
        t1.start()
        t2.start()
        t1.join()
        t2.join()
        time.sleep(1)
        sess.stop()

        exec_events = sess.find(event="EXEC")
        bash_execs = [e for e in exec_events if "bash" in e.get("filename", "") or "bash" in e.get("comm", "")]
        python_execs = [e for e in exec_events if "python3" in e.get("filename", "") or "python3" in e.get("comm", "")]

        assert len(bash_execs) > 0, "FAIL: no bash EXEC events"
        assert len(python_execs) > 0, "FAIL: no python3 EXEC events"
        print(f"  [PASS] bash EXEC events found ({len(bash_execs)})")
        print(f"  [PASS] python3 EXEC events found ({len(python_execs)})")
    finally:
        sess.cleanup()


def test_compat():
    """Compare process and process_new output fields (process_new should be superset)."""
    print("test_compat: process_new EXEC/EXIT fields are superset of process fields")
    if not os.path.exists(PROCESS_OLD):
        print(f"  [SKIP] Old binary not found at {PROCESS_OLD}")
        return

    def collect_events(binary, *extra_args):
        outfile = tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False)
        proc = subprocess.Popen(
            [binary] + list(extra_args),
            stdout=outfile,
            stderr=subprocess.DEVNULL,
        )
        time.sleep(2)
        subprocess.run(["/bin/echo", "compat_test"], capture_output=True)
        time.sleep(1)
        proc.send_signal(signal.SIGINT)
        try:
            proc.wait(timeout=10)
        except subprocess.TimeoutExpired:
            proc.kill()
            proc.wait()
        outfile.close()
        events = []
        with open(outfile.name) as f:
            for line in f:
                line = line.strip()
                if line:
                    try:
                        events.append(json.loads(line))
                    except json.JSONDecodeError:
                        pass
        os.unlink(outfile.name)
        return events

    old_events = collect_events(PROCESS_OLD, "-m", "0")
    new_events = collect_events(PROCESS_NEW, "-m", "0")

    # Get field sets from EXEC events
    old_exec_fields = set()
    for e in old_events:
        if e.get("event") == "EXEC":
            old_exec_fields.update(e.keys())
            break

    new_exec_fields = set()
    for e in new_events:
        if e.get("event") == "EXEC":
            new_exec_fields.update(e.keys())
            break

    print(f"  [INFO] Old EXEC fields: {old_exec_fields}")
    print(f"  [INFO] New EXEC fields: {new_exec_fields}")

    missing = old_exec_fields - new_exec_fields
    assert len(missing) == 0, (
        f"FAIL: process_new EXEC missing fields from process: {missing}"
    )
    print(f"  [PASS] process_new EXEC fields are superset of process fields")

    extra_fields = new_exec_fields - old_exec_fields
    if extra_fields:
        print(f"  [INFO] process_new has additional fields: {extra_fields}")


def test_summary_json_schema():
    """Verify each SUMMARY event has correct field types."""
    print("test_summary_json_schema: SUMMARY event field type validation")
    tmpdir = tempfile.mkdtemp()
    sess = TracerSession("-m", "0", "--trace-fs")
    try:
        # Trigger some fs ops
        script = f"""
import os
base = "{tmpdir}"
newdir = os.path.join(base, "schema_test")
os.makedirs(newdir, exist_ok=True)
fname = os.path.join(newdir, "file.txt")
with open(fname, 'w') as f:
    f.write("schema test data")
os.unlink(fname)
"""
        subprocess.run(["python3", "-c", script], capture_output=True)
        time.sleep(2)
        sess.stop()

        summary_events = sess.find(event="SUMMARY")
        if not summary_events:
            print(f"  [SKIP] No SUMMARY events to validate schema")
            return

        errors = []
        for ev in summary_events:
            expected_types = {
                "timestamp": (int,),
                "comm": (str,),
                "pid": (int,),
                "type": (str,),
                "detail": (str,),
                "count": (int,),
            }
            for field, types in expected_types.items():
                if field in ev:
                    if not isinstance(ev[field], types):
                        errors.append(
                            f"Field '{field}' has type {type(ev[field]).__name__}, expected {types}"
                        )

        assert len(errors) == 0, f"FAIL: Schema validation errors:\n" + "\n".join(errors)
        print(f"  [PASS] All {len(summary_events)} SUMMARY events pass schema validation")
    finally:
        sess.cleanup()
        try:
            import shutil
            shutil.rmtree(tmpdir, ignore_errors=True)
        except Exception:
            pass


def test_flush_on_sigint():
    """Verify SUMMARY events are flushed on SIGINT even before flush interval."""
    print("test_flush_on_sigint: SUMMARY events flushed on SIGINT")
    tmpdir = tempfile.mkdtemp()
    sess = TracerSession("-m", "0", "--trace-fs")
    try:
        script = f"""
import os
base = "{tmpdir}"
newdir = os.path.join(base, "flush_test")
os.makedirs(newdir, exist_ok=True)
fname = os.path.join(newdir, "file.txt")
with open(fname, 'w') as f:
    f.write("flush test data")
os.unlink(fname)
"""
        subprocess.run(["python3", "-c", script], capture_output=True)
        # Stop after only 1 second (much less than 5s flush interval)
        time.sleep(1)
        sess.stop()

        summary_events = sess.find(event="SUMMARY")
        if not summary_events:
            # Soft fail: SIGINT flush may not be implemented
            print(f"  [SKIP] No SUMMARY events after early SIGINT (flush-on-SIGINT may not be implemented)")
            return

        print(f"  [PASS] SUMMARY events present after SIGINT flush ({len(summary_events)} events)")
    finally:
        sess.cleanup()
        try:
            import shutil
            shutil.rmtree(tmpdir, ignore_errors=True)
        except Exception:
            pass


def test_trace_mem():
    """Python3 subprocess creates mmap with MAP_SHARED, verify MMAP_SHARED event."""
    print("test_trace_mem: MMAP_SHARED SUMMARY event")
    sess = TracerSession("-m", "0", "--trace-mem")
    try:
        tmpfile = tempfile.NamedTemporaryFile(delete=False, suffix=".mmap")
        tmpfile.write(b"\x00" * 4096)
        tmpfile.flush()
        tmpfile.close()

        mmap_script = f"""
import mmap, os

fname = "{tmpfile.name}"
with open(fname, "r+b") as f:
    # MAP_SHARED = 1
    m = mmap.mmap(f.fileno(), 4096, mmap.MAP_SHARED, mmap.PROT_READ | mmap.PROT_WRITE)
    m[0] = 42
    m.flush()
    m.close()
"""
        subprocess.run(["python3", "-c", mmap_script], capture_output=True, timeout=10)
        time.sleep(2)
        sess.stop()

        summary_types = {e.get("type") for e in sess.find(event="SUMMARY")}
        print(f"  [INFO] SUMMARY types observed: {summary_types}")

        mmap_events = sess.find_summary("MMAP_SHARED")
        if not mmap_events:
            # Also check for any MEM-related types
            mem_events = [e for e in sess.find(event="SUMMARY") if "MAP" in e.get("type", "") or "MEM" in e.get("type", "")]
            if mem_events:
                print(f"  [PASS] Memory-related SUMMARY found: {[e.get('type') for e in mem_events]}")
            else:
                print(f"  [SKIP] No MMAP_SHARED SUMMARY events (may depend on kernel/config)")
            return

        print(f"  [PASS] MMAP_SHARED SUMMARY events found ({len(mmap_events)} events)")
    finally:
        sess.cleanup()
        try:
            os.unlink(tmpfile.name)
        except Exception:
            pass


def test_duration_filter():
    """With -d 2000, verify short processes complete but long ones may not have EXIT."""
    print("test_duration_filter: -d 2000ms duration filter")
    sess = TracerSession("-m", "0", "-d", "2000")
    try:
        # Run a fast command
        subprocess.run(["/bin/echo", "fast"], capture_output=True)
        # Run a slow command in background (won't finish before tracer stops)
        slow_proc = subprocess.Popen(["sleep", "10"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        try:
            # Wait for duration to elapse (tracer self-terminates)
            time.sleep(3)
            sess.proc.wait(timeout=5)
        except subprocess.TimeoutExpired:
            sess.proc.send_signal(signal.SIGINT)
            sess.proc.wait(timeout=5)
        finally:
            slow_proc.kill()
            slow_proc.wait()

        sess.outfile.close()

        all_events = sess.events()
        exec_events = [e for e in all_events if e.get("event") == "EXEC"]
        exit_events = [e for e in all_events if e.get("event") == "EXIT"]

        echo_exec = [e for e in exec_events if "echo" in e.get("filename", "")]
        echo_exit = [e for e in exit_events if e.get("exit_code") == 0]

        if echo_exec:
            print(f"  [PASS] Fast echo EXEC captured ({len(echo_exec)} events)")
        else:
            print(f"  [WARN] No echo EXEC found")

        # sleep should not have EXIT since it runs longer than 2s duration
        sleep_exits = [e for e in exit_events if "sleep" in e.get("filename", "") or "sleep" in e.get("comm", "")]
        if not sleep_exits:
            print(f"  [PASS] Long-running sleep has no EXIT (tracer ended first)")
        else:
            print(f"  [WARN] sleep EXIT found ({len(sleep_exits)}) despite -d 2000ms duration limit")

        # Verify tracer did terminate on its own
        if sess.proc.returncode is not None:
            print(f"  [PASS] Tracer terminated after duration (returncode={sess.proc.returncode})")
        else:
            print(f"  [WARN] Tracer may not have self-terminated")
    finally:
        sess.cleanup()


def test_idempotent():
    """Run two TracerSession sequentially, verify both succeed."""
    print("test_idempotent: two sequential TracerSessions both succeed")
    for i in range(2):
        sess = TracerSession("-m", "0")
        try:
            subprocess.run(["/bin/echo", f"idempotent_run_{i}"], capture_output=True)
            time.sleep(1)
            sess.stop()

            exec_events = sess.find(event="EXEC")
            assert len(exec_events) > 0, f"FAIL: session {i} produced no EXEC events"
            print(f"  [PASS] Session {i+1} captured {len(exec_events)} EXEC events")
        finally:
            sess.cleanup()

    print(f"  [PASS] Both sessions succeeded")


def test_trace_resources():
    """--trace-resources outputs RESOURCE_SAMPLE events with valid fields."""
    print("test_trace_resources: RESOURCE_SAMPLE event with memory/CPU stats")
    # Start a long-running process and pass its PID with -p
    target = subprocess.Popen(
        ["python3", "-c", "import time; time.sleep(10)"]
    )
    time.sleep(0.5)  # let it start
    sess = TracerSession("-m", "2", "-p", str(target.pid), "--trace-resources")
    try:
        time.sleep(3)  # need at least 2 samples
        target.terminate()
        target.wait()
        time.sleep(1)
        sess.stop()

        samples = sess.find(event="RESOURCE_SAMPLE")
        assert len(samples) >= 2, (
            f"FAIL: Expected >=2 RESOURCE_SAMPLE, got {len(samples)}"
        )
        print(f"  [PASS] Got {len(samples)} RESOURCE_SAMPLE events")

        # Validate fields
        s = samples[0]
        assert "total_rss_kb" in s, "FAIL: missing total_rss_kb"
        assert "total_cpu_user_ms" in s, "FAIL: missing total_cpu_user_ms"
        assert "total_cpu_sys_ms" in s, "FAIL: missing total_cpu_sys_ms"
        assert "num_processes" in s, "FAIL: missing num_processes"
        assert s["total_rss_kb"] > 0, "FAIL: total_rss_kb should be > 0"
        assert s["num_processes"] > 0, "FAIL: num_processes should be > 0"
        assert s["target_pid"] == target.pid, "FAIL: target_pid mismatch"
        print(f"  [PASS] RESOURCE_SAMPLE has valid fields "
              f"(rss={s['total_rss_kb']/1024:.0f}MB, procs={s['num_processes']}, "
              f"target_pid={s['target_pid']})")

        # Check cgroup fields (may or may not be present)
        if "cgroup_memory_bytes" in s:
            assert s["cgroup_memory_bytes"] > 0, "FAIL: cgroup_memory_bytes should be > 0"
            print(f"  [PASS] Cgroup stats present (memory={s['cgroup_memory_bytes']/(1024*1024):.0f}MB)")
        else:
            print(f"  [INFO] No cgroup stats (OK if cgroup v2 not available)")
    finally:
        try:
            target.kill()
        except ProcessLookupError:
            pass
        sess.cleanup()


def test_resource_detail():
    """--resource-detail outputs per-process RESOURCE_DETAIL events."""
    print("test_resource_detail: per-process RESOURCE_DETAIL events")
    sess = TracerSession("-c", "python3", "--trace-resources", "--resource-detail")
    try:
        # Run python3 with some memory allocation
        proc = subprocess.Popen(
            ["python3", "-c", "x = bytearray(10*1024*1024); import time; time.sleep(3)"]
        )
        time.sleep(4)
        proc.wait()
        time.sleep(1)
        sess.stop()

        details = sess.find(event="RESOURCE_DETAIL")
        # Should have at least one RESOURCE_DETAIL for python3
        py_details = [d for d in details if d.get("comm") == "python3"]
        assert len(py_details) > 0, (
            f"FAIL: Expected RESOURCE_DETAIL for python3, got 0 "
            f"(total details: {len(details)})"
        )
        print(f"  [PASS] Got {len(py_details)} RESOURCE_DETAIL for python3")

        # Validate fields
        d = py_details[0]
        assert "pid" in d, "FAIL: missing pid"
        assert "rss_kb" in d, "FAIL: missing rss_kb"
        assert "cpu_user_ms" in d, "FAIL: missing cpu_user_ms"
        assert "cpu_sys_ms" in d, "FAIL: missing cpu_sys_ms"
        assert d["rss_kb"] > 0, "FAIL: python3 rss_kb should be > 0"
        print(f"  [PASS] RESOURCE_DETAIL has valid fields "
              f"(pid={d['pid']}, rss={d['rss_kb']/1024:.1f}MB)")

        # Also check that RESOURCE_SAMPLE aggregate is present
        samples = sess.find(event="RESOURCE_SAMPLE")
        assert len(samples) > 0, "FAIL: Expected RESOURCE_SAMPLE alongside detail"
        print(f"  [PASS] RESOURCE_SAMPLE also present ({len(samples)} events)")
    finally:
        sess.cleanup()


# ---------------------------------------------------------------------------
# Test runner
# ---------------------------------------------------------------------------

ALL_TESTS = [
    test_basic_lifecycle,
    test_nonzero_exit,
    test_command_filter,
    test_pid_filter,
    test_mode_all,
    test_trace_fs,
    test_trace_write,
    test_trace_net,
    test_trace_signals,
    test_bash_readline,
    test_file_open,
    test_trace_all,
    test_multi_app,
    test_compat,
    test_summary_json_schema,
    test_flush_on_sigint,
    test_trace_mem,
    test_duration_filter,
    test_idempotent,
    test_trace_resources,
    test_resource_detail,
]


def main():
    if os.geteuid() != 0:
        print("ERROR: Integration tests require root privileges (run with sudo).")
        sys.exit(1)

    if not os.path.exists(PROCESS_NEW):
        print(f"ERROR: process_new binary not found at {PROCESS_NEW}")
        print("Run 'make process_new' in the bpf directory first.")
        sys.exit(1)

    # Parse -k PATTERN for filtering tests
    pattern = None
    args = sys.argv[1:]
    if "-k" in args:
        idx = args.index("-k")
        if idx + 1 < len(args):
            pattern = args[idx + 1]

    tests_to_run = ALL_TESTS
    if pattern:
        tests_to_run = [t for t in ALL_TESTS if pattern in t.__name__]
        print(f"Running tests matching pattern '{pattern}': {[t.__name__ for t in tests_to_run]}")

    passed = 0
    failed = 0
    skipped = 0

    print(f"\n=== process_new Integration Tests ===")
    print(f"Binary: {PROCESS_NEW}")
    print(f"Running {len(tests_to_run)} tests\n")

    for test_fn in tests_to_run:
        name = test_fn.__name__
        print(f"\n[{name}]")
        try:
            test_fn()
            passed += 1
        except AssertionError as e:
            print(f"  [FAIL] {e}")
            failed += 1
        except Exception as e:
            import traceback
            print(f"  [ERROR] Unexpected exception: {e}")
            traceback.print_exc()
            failed += 1

    print(f"\n=== Summary ===")
    print(f"  Passed:  {passed}")
    print(f"  Failed:  {failed}")
    print(f"  Skipped: {skipped}")
    print(f"  Total:   {len(tests_to_run)}")

    if failed > 0:
        sys.exit(1)
    else:
        print("\nAll tests passed!")
        sys.exit(0)


if __name__ == "__main__":
    main()