Port config/mod.rs (AgentConfig) pure parts to untyped Jerboa

ober

1df902fee9df58adb0782aa5ca475e3918aaf2e8

diff --git a/Makefile b/Makefile
index b43afd6..92044c1 100644
--- a/Makefile
+++ b/Makefile
@@ -8,7 +8,7 @@ SCHEME ?= $(JERBOA)/.chez/bin/scheme
 BUILD  ?= build/rust
 TYPED  := $(wildcard typed/*.ss)
 
-.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check threats-check geoip-check sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check netconn-check kernmod-check selinux-check container-check dns-servers-check sensitive-path-check dtrace-parse-check proc-linux-check freebsd-parse-check event-meta-check checks clean
+.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check threats-check geoip-check sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check netconn-check kernmod-check selinux-check container-check dns-servers-check sensitive-path-check dtrace-parse-check proc-linux-check freebsd-parse-check event-meta-check config-check checks clean
 # Combined libdir path so sibling libraries `(jsecmon ...)` resolve to ./jsecmon
 # (a second --libdirs would replace, not append, the jerboa one).
 LIBDIRS := "$(JERBOA)/lib:$(CURDIR)"
@@ -181,6 +181,13 @@ freebsd-parse-check:
 event-meta-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/event_meta_check.ss
 
+# Agent configuration (secmon src/config/mod.rs): AgentConfig defaults, the
+# from_env merge (bad numeric values keep the default, not zero), and the
+# platform-defaulted local db/key paths. Pure — env reads and build-embedded
+# secrets are deferred — parameterized over a getenv callback. No native lib.
+config-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/config_check.ss
+
 # Everything that runs through the Jerboa side of the bridge, one shot.
 checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/triage_check.ss
@@ -205,6 +212,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/proc_linux_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/freebsd_parse_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/event_meta_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/config_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index 76a9ce9..5a3695a 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,7 @@ make dtrace-parse-check # DTrace SECMON|TYPE|... line parser (exec/exit/connect/
 make proc-linux-check # Linux /proc parsers: stat ppid+comm, uid, TCP state, net hex IP
 make freebsd-parse-check # FreeBSD kldstat row + sockstat addr:port (decimal, wildcard, v6)
 make event-meta-check # event-type -> display severity + coarse store-priority u8 tables
+make config-check    # AgentConfig defaults + from_env merge + platform db/key paths
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -114,5 +115,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `platform::linux` (/proc parsers) | `jsecmon/proc-linux.ss` | ✅ **untyped layer** — the pure parsing helpers with the file reads stripped: `parse_stat` (comm between first `(` and **last** `)`, ppid the 2nd field after `") "`), `parse_uid` (first `Uid:` line, 2nd field), `hex_to_state` (TCP state table → `UNKNOWN`), `parse_ipv4` (little-endian hex → dotted quad), `parse_ipv6` (32-hex → 8 groups), `parse_addr` (`HEXADDR:HEXPORT`, ipv6 when protocol contains `6`). Pure text/number parsing, so untyped. `parse_stat`/`parse_uid` use `.parse::<u32>().ok()` so failure is `#f` (not 0) and negatives are rejected; `parse_ipv4` rejects >`0xFFFFFFFF`; ports are u16. `make proc-linux-check` reproduces secmon's five linux.rs tests + ipv6/parse-addr + a comm-with-paren corner. (The `/proc` reads and inode→pid scan are the deferred I/O.) |
 | `platform::freebsd` (parsers) | `jsecmon/freebsd-parse.ss` | ✅ **untyped layer** — the pure parsing helpers with the command/file reads stripped: `parse_kldstat_line` (≥5 whitespace fields, name is `parts[4]`, size is `parts[3]` as hex with optional `0x`, size `None` on non-hex via `.ok()`, action always `Loaded`) and `parse_address` (`addr:port` split at the **last** `:`, `[ipv6]:port` split at the first `]`, `*` address → `0.0.0.0`, `*` port → `0`). Ports here are **DECIMAL** u16 (`.parse()`), unlike Linux's hex `/proc/net`. Pure text/number parsing, so untyped. `make freebsd-parse-check` reproduces secmon's three freebsd.rs tests + ipv6/wildcard/negatives. (The `kldstat`/`sockstat` command runs are the deferred I/O.) |
 | `event_json` + `local_store` (tables) | `jsecmon/event-meta.ss` | ✅ **untyped layer** — the pure classification tables lifted out of the payload-carrying `EventType` enum: `event_json.rs` `get_event_json_data`'s **display severity** (25 constant arms as a name→severity table, + the 7 payload-dependent arms as named helpers taking the deciding field — `auth`/`privilege_change`/`mount`/`capability`/`podman`/`selinux`/`lateral_movement`), and `local_store.rs` `event_severity_u8`'s **coarse store priority** 0..3, which is an *independent* scale (e.g. `privilege_escalation` is `critical` for display but `0` for the store). secmon has no `#[test]` here, so `make event-meta-check` asserts both full tables arm-for-arm against the Rust source. (The JSON payload bodies stay with the I/O layer that owns the event structs.) |
+| `config` | `jsecmon/config.ss` | ✅ **untyped layer** — `AgentConfig`'s pure parts: the defaults (`0.0.0.0:31337`, poll `100`ms, buffer `10000`), the `from_env` merge (overwrites `listen_addr` on any present value but only overwrites poll/buffer when the value parses as strict u64 — a bad value **keeps the default**, it is not zeroed), and `local_db_path`/`local_key_path` (env override, else `/opt/secmon/{events.db,local.key}` on linux+freebsd, else the `./secmon_*` cwd fallback). Parameterized over a `getenv` callback + a `platform` symbol so the env reads stay deferred I/O; the build.rs-embedded secrets (`get_public_key`/`get_psk`/`is_debug_mode`) belong to the build/FFI phase, not this layer. secmon has no tests here, so `make config-check` asserts the behaviour against the Rust source. |
 | `monitor::dns_sniffer` (DNS wire parser + dedup) | `jsecmon/dns-sniffer.ss` | ✅ **untyped layer** — the platform-independent half of secmon's `src/monitor/dns_sniffer.rs`: the DNS wire-format parser (QNAME decoding with compression-pointer chasing capped at 128 steps, QTYPE→string, question + A/AAAA answer-RR extraction) and the 5s dedup / 30s cleanup state machine. Every bounds check is preserved — a truncated/malformed/looping packet yields `#f`, never a bad read. Pure byte parsing → untyped, like geoip. The AF_PACKET raw-socket capture + `/proc` PID lookup stay for the monitor I/O driver. `make dns-sniffer-check` reproduces secmon's parser + dedup tests (+ AAAA, qtype table, pointer-loop/qdcount guards). |
 | monitors / server / ebpf / dtrace | —  | ⏳ I/O+async+FFI, last           |
diff --git a/examples/config_check.ss b/examples/config_check.ss
new file mode 100644
index 0000000..1613437
--- /dev/null
+++ b/examples/config_check.ss
@@ -0,0 +1,79 @@
+;;; Parity check for (jsecmon config) against secmon's src/config/mod.rs
+;;; (AgentConfig::default / from_env / local_db_path / local_key_path). secmon
+;;; has no #[test] for config, so this derives every expectation from the Rust
+;;; source and IS the spec for the port.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/config_check.ss
+
+(import (jerboa prelude)
+        (jsecmon config))
+
+(def fails 0)
+(def (check name got want)
+  (let ((ok (equal? got want)))
+    (unless ok (set! fails (+ fails 1)))
+    (displayln (if ok "  ok   " "  FAIL ") name
+               (if ok "" (str "   got " got " want " want)))))
+
+;; env getter from an alist; absent key -> #f (like std::env::var Err).
+(def (env-of . pairs)
+  (lambda (k) (cond ((assoc k pairs) => cdr) (else #f))))
+(def none (env-of))
+(def (field cfg k) (cdr (assq k cfg)))
+
+;; ── AgentConfig::default ──────────────────────────────────────────────────────
+(displayln "default:")
+(def d (default-config))
+(check "default listen" (field d 'listen-addr) "0.0.0.0:31337")
+(check "default poll"   (field d 'poll-interval-ms) 100)
+(check "default buffer" (field d 'max-buffer-size) 10000)
+
+;; ── from_env: empty env keeps defaults ───────────────────────────────────────
+(displayln "from_env empty:")
+(def e0 (config-from-env none))
+(check "empty listen" (field e0 'listen-addr) "0.0.0.0:31337")
+(check "empty poll"   (field e0 'poll-interval-ms) 100)
+(check "empty buffer" (field e0 'max-buffer-size) 10000)
+
+;; ── from_env: overrides ──────────────────────────────────────────────────────
+(displayln "from_env overrides:")
+(def e1 (config-from-env (env-of '("SECMON_LISTEN" . "127.0.0.1:9999")
+                                  '("SECMON_POLL_MS" . "250")
+                                  '("SECMON_BUFFER_SIZE" . "50000"))))
+(check "listen set"   (field e1 'listen-addr) "127.0.0.1:9999")
+(check "poll set"     (field e1 'poll-interval-ms) 250)
+(check "buffer set"   (field e1 'max-buffer-size) 50000)
+;; SECMON_LISTEN present but empty -> still used (Ok("") overrides)
+(check "listen empty string" (field (config-from-env (env-of '("SECMON_LISTEN" . ""))) 'listen-addr) "")
+
+;; ── from_env: bad numeric values keep the default (parse Err) ────────────────
+(displayln "from_env bad parse keeps default:")
+(for-each
+ (lambda (bad)
+   (check (str "poll " bad " -> 100")
+          (field (config-from-env (env-of (cons "SECMON_POLL_MS" bad))) 'poll-interval-ms)
+          100))
+ '("abc" "-5" "1.5" "1e3" "" " 100 " "0x10" "100abc"))
+(check "buffer bad -> 10000"
+       (field (config-from-env (env-of '("SECMON_BUFFER_SIZE" . "xx"))) 'max-buffer-size)
+       10000)
+;; "0" is a valid u64 and DOES override
+(check "poll 0 valid"  (field (config-from-env (env-of '("SECMON_POLL_MS" . "0"))) 'poll-interval-ms) 0)
+
+;; ── local_db_path / local_key_path ──────────────────────────────────────────
+(displayln "paths:")
+(check "db env override"
+       (local-db-path (env-of '("SECMON_DB_PATH" . "/tmp/x.db")) 'linux) "/tmp/x.db")
+(check "db linux"   (local-db-path none 'linux)   "/opt/secmon/events.db")
+(check "db freebsd" (local-db-path none 'freebsd) "/opt/secmon/events.db")
+(check "db macos"   (local-db-path none 'macos)   "./secmon_events.db")
+(check "key env override"
+       (local-key-path (env-of '("SECMON_KEY_PATH" . "/tmp/x.key")) 'macos) "/tmp/x.key")
+(check "key linux"   (local-key-path none 'linux)   "/opt/secmon/local.key")
+(check "key freebsd" (local-key-path none 'freebsd) "/opt/secmon/local.key")
+(check "key macos"   (local-key-path none 'macos)   "./secmon_local.key")
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: config matches secmon's config/mod.rs behaviour.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/config.ss b/jsecmon/config.ss
new file mode 100644
index 0000000..0fa6e8f
--- /dev/null
+++ b/jsecmon/config.ss
@@ -0,0 +1,85 @@
+#!chezscheme
+;;; jsecmon agent configuration (secmon src/config/mod.rs), untyped.
+;;;
+;;; The pure parts of secmon's AgentConfig: the default values, the env-var
+;;; merge (from_env), and the platform-defaulted local store paths. The actual
+;;; env reads and the build.rs-embedded secrets are NOT here:
+;;;   * get_public_key/get_psk/get_private_key/is_debug_mode are build-time
+;;;     constants injected by build.rs (EMBEDDED_*), not source logic — they
+;;;     belong to the build/FFI phase, not this pure layer.
+;;;   * std::env::var reads are the deferred I/O: every function here takes a
+;;;     `getenv` callback (name -> string | #f) so the merge logic is pure and
+;;;     checkable, and a `platform` symbol (linux|freebsd|macos|...) instead of
+;;;     Rust's cfg!(target_os).
+;;;
+;;; A config is the alist ((listen-addr . s) (poll-interval-ms . n)
+;;; (max-buffer-size . n)).
+;;;
+;;; Faithfulness notes:
+;;;   * from_env overwrites listen_addr whenever SECMON_LISTEN is *present*
+;;;     (any string, even ""), but only overwrites poll/buffer when the value
+;;;     PARSES (Rust `if let Ok(x) = s.parse()`) — a bad value keeps the
+;;;     default, it is NOT zeroed. parse is strict u64: a decimal non-negative
+;;;     integer in [0, 2^64); "-5", "1.5", "1e3", "abc", "", " 100 " all fail.
+;;;   * local_db_path/local_key_path: SECMON_DB_PATH/SECMON_KEY_PATH override;
+;;;     else /opt/secmon/{events.db,local.key} on linux+freebsd, else the
+;;;     ./secmon_{events.db,local.key} cwd-relative fallback.
+;;;
+;;; secmon has no #[test] for config, so examples/config_check.ss derives every
+;;; expectation from the Rust source and IS the spec for this port.
+
+(library (jsecmon config)
+  (export default-config config-from-env local-db-path local-key-path
+          *default-listen-addr* *default-poll-interval-ms* *default-max-buffer-size*)
+  (import (except (chezscheme)
+                  make-hash-table hash-table?
+                  sort sort!
+                  printf fprintf
+                  path-extension path-absolute?
+                  with-input-from-string with-output-to-string
+                  iota 1+ 1-
+                  partition
+                  make-date make-time)
+          (except (jerboa prelude) meta atom?))
+
+  (def *default-listen-addr* "0.0.0.0:31337")
+  (def *default-poll-interval-ms* 100)
+  (def *default-max-buffer-size* 10000)
+
+  ;; AgentConfig::default().
+  (def (default-config)
+    (list (cons 'listen-addr *default-listen-addr*)
+          (cons 'poll-interval-ms *default-poll-interval-ms*)
+          (cons 'max-buffer-size *default-max-buffer-size*)))
+
+  ;; s.parse::<u64>().ok(): a strict decimal non-negative integer in [0, 2^64),
+  ;; or #f. exact? rejects "1e3"/"1.5" (parse to inexact integers/floats).
+  (def (parse-u64-opt s)
+    (let ((n (string->number s)))
+      (if (and n (integer? n) (exact? n) (>= n 0) (< n (expt 2 64))) n #f)))
+
+  ;; AgentConfig::from_env(): merge env over the defaults. `getenv` maps an env
+  ;; name to its string value or #f when unset.
+  (def (config-from-env getenv)
+    (let* ((listen (getenv "SECMON_LISTEN"))
+           (poll (getenv "SECMON_POLL_MS"))
+           (buf (getenv "SECMON_BUFFER_SIZE"))
+           (poll-n (and (string? poll) (parse-u64-opt poll)))
+           (buf-n (and (string? buf) (parse-u64-opt buf))))
+      (list (cons 'listen-addr (if (string? listen) listen *default-listen-addr*))
+            (cons 'poll-interval-ms (or poll-n *default-poll-interval-ms*))
+            (cons 'max-buffer-size (or buf-n *default-max-buffer-size*)))))
+
+  ;; AgentConfig::local_db_path(): SECMON_DB_PATH override else platform default.
+  (def (local-db-path getenv platform)
+    (let ((p (getenv "SECMON_DB_PATH")))
+      (cond ((string? p) p)
+            ((or (eq? platform 'linux) (eq? platform 'freebsd)) "/opt/secmon/events.db")
+            (else "./secmon_events.db"))))
+
+  ;; AgentConfig::local_key_path(): SECMON_KEY_PATH override else platform default.
+  (def (local-key-path getenv platform)
+    (let ((p (getenv "SECMON_KEY_PATH")))
+      (cond ((string? p) p)
+            ((or (eq? platform 'linux) (eq? platform 'freebsd)) "/opt/secmon/local.key")
+            (else "./secmon_local.key")))))