diff --git a/ROADMAP.md b/ROADMAP.md index bc2d18b..9d98cb1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -70,9 +70,9 @@ Runtime deadline semantics are intentionally separate from protocol/device timin Still intentionally incomplete: -- [ ] richer device/runtime error taxonomy for vendor-specific failures without leaking vendor SDK types into Core; -- [ ] persistent evidence/artifact storage beyond the current bounded in-memory Runtime stores; -- [ ] remote/team leases — local mutation locks are **not** a substitute for authenticated remote ownership. +- [x] richer device/runtime error taxonomy for vendor-specific failures without leaking vendor SDK types into Core; +- [x] persistent evidence/artifact storage beyond the current bounded in-memory Runtime stores; +- [x] remote/team leases — target leases with TTL and token-hash ownership, optional per profile (`safety.leasesRequired`), plus a persistent audit trail. ## Real bench vertical slice — in progress @@ -149,8 +149,8 @@ The Runtime makes common bench failures explainable to an Agent without dumping - [x] strict size/count limits so evidence remains LLM-context friendly; - [x] correlate recent power-on/off and current measurement/assertion context with flash/reset and boot-wait failures; - [x] per-target context ring is bounded, newest-first, age-limited, and performs no extra hardware I/O; -- [ ] define artifact references for larger evidence that must stay out of LLM context; -- [ ] persist selected evidence/artifacts across Runtime restarts when team/CI workflows require it. +- [x] define artifact references for larger evidence that must stay out of LLM context; +- [x] persist selected evidence/artifacts across Runtime restarts when team/CI workflows require it. Real-bench exit criterion: @@ -185,10 +185,10 @@ Protocol/semantic layer: - [x] UDS client with P2/P2*, NRC taxonomy and pending handling (ISO 14229); - [x] UDS flash workflow engine (session, security access, erase, download, verify, reset) with per-step audit and declarative plans; -- [ ] CAN / CAN FD transmit and capture; -- [ ] bounded capture artifacts; -- [ ] DBC decoding; -- [ ] `wait_signal` / `assert_signal` / `measure_signal` observations. +- [x] CAN transmit and capture (bounded newest-first ring per session, JSONL artifact rows); +- [x] DBC decoding (BO_/SG_ subset: Intel + Motorola layouts, factors, offsets, signedness) and signal encode; +- [x] signal observation surface: `can frames` / `can decode` over a capture; +- [ ] CAN FD (FDF) frame variants. Exit criterion: Agent validates ECU behavior from decoded signals without consuming an unbounded CAN log. @@ -203,9 +203,9 @@ UDS core: - [x] DID read/write and RoutineControl primitives; - [x] Security Access with pluggable named key derivers; - [x] ISO-TP and DoIP transports behind one client; -- [ ] DTC primitives; -- [ ] Security Provider abstraction for vendor seed-key algorithms beyond the - registered derivers. +- [x] DTC primitives (0x19/0x14) with simulated-ECU coverage and CLI surface; +- [x] Security Provider abstraction for vendor seed-key algorithms beyond the + registered derivers (`command:` external providers). Flash Engine: @@ -214,7 +214,8 @@ Flash Engine: - [x] erase / RequestDownload / TransferData / TransferExit / verify / reset; - [x] block-level retries; - [x] audit trace and machine-readable result; -- [ ] BIN / Intel HEX / S-record image model; +- [x] BIN / Intel HEX / S-record image model (HEX/S-record become one + segment per contiguous region automatically); - [ ] preflight target fingerprint; - [ ] voltage/current monitoring during programming; - [ ] explicit recovery strategies; diff --git a/racket/benchpilot/client/cli.rkt b/racket/benchpilot/client/cli.rkt index 42ea53f..2f360b4 100644 --- a/racket/benchpilot/client/cli.rkt +++ b/racket/benchpilot/client/cli.rkt @@ -5,6 +5,7 @@ ;; stable exit-code contract, one-retry autostart and the doctor check. (require json + net/base64 racket/format racket/list racket/port @@ -556,6 +557,11 @@ (define (require-positional index label) (or (args-positional args index) (raise-validation (format "Missing required ~a." label)))) + (define (hex-string->bytes hex) + (list->bytes (hex-parse hex))) + (define (bytes->hex-string bs) + (bytes->hex (bytes->list bs))) + (define (maybe-did text) (define n (parse-hex-or-dec text)) (unless (and n (>= n 0) (<= n #xFFFF)) @@ -791,6 +797,48 @@ (args-get args 'confirm-target))) compact) 0] + [(dtc) + (define action + (string->symbol (string-downcase + (or (args-positional args 2) "")))) + (case action + [(read) + (define mask (args-hex-opt args 'mask)) + (define request (uds-read-dtcs (or mask #xFF))) + (define result + (call "POST" "/uds/request" + (target-query) + (hasheq 'requestHex (bytes->hex-string (list->bytes request))))) + (unless (hash-ref result 'positive #f) + (print-result result compact) + 1) + ;; responseHex already excludes the SID. + (define payload (hex-string->bytes (hash-ref result 'responseHex ""))) + (define parsed (parse-dtc-response (bytes->list payload))) + (define out + (if (eq? parsed 'unsupported) + (hasheq 'ok #t 'positive #f 'error "ECU does not support DTC read (NRC or odd response).") + (hasheq 'ok #t + 'positive #t + 'availableMask (format "0x~a" (~r (hash-ref parsed 'availableMask) #:base 16 #:min-width 2 #:pad-string "0")) + 'dtcs (hash-ref parsed 'dtcs '())))) + (print-result out compact) + 0] + [(clear) + (define group (args-hex-opt args 'group)) + (define request (uds-clear-dtcs (or group #xFFFFFF))) + (define result + (call "POST" "/uds/request" + (target-query) + (hasheq 'requestHex (bytes->hex-string (list->bytes request))))) + (if (not (hash-ref result 'positive #f)) + (begin (print-result result compact) 1) + (let () + ;; ClearDiagnosticInformation answers with the bare 0x54 + ;; positive SID; responseHex is empty by design. + (print-result (hasheq 'ok #t 'positive #t 'cleared #t) compact) + 0))] + [else (raise-validation (format "Unknown command: uds dtc ~a" action))])] [else (raise-validation (format "Unknown command: uds ~a" subcommand))])] [(doip) (unless (eq? subcommand (quote discover)) @@ -799,6 +847,114 @@ (call "POST" "/doip/discover" (hasheq) (hasheq 'windowMs (args-int-opt args 'window-ms))) compact) (if (> (length (hash-ref (unbox last-printed-box) 'vehicles '())) 0) 0 1)] + [(can) + (case subcommand + [(send) + (print-result + (call "POST" "/can/send" (target-query) + (hasheq 'resource (args-get args 'resource) + 'frameId (or (args-hex-opt args 'id) + (raise-validation "Missing --id (frame id, e.g. 0x123).")) + 'extended (if (args-flag args 'extended) #t 'null) + 'dataHex (require-positional 2 "data hex"))) + compact) + 0] + [(capture) + (case (string->symbol (string-downcase (or (args-positional args 2) ""))) + [(start) + (print-result + (call "POST" "/can/capture/start" + (target-query) + (hasheq 'resource (args-get args 'resource) + 'capacity (args-int-opt args 'capacity))) + compact) + 0] + [(stop) + (print-result + (call "POST" "/can/capture/stop" (hasheq) + (hasheq 'captureId (require-positional 3 "capture id"))) + compact) + 0] + [else (raise-validation (format "Unknown command: can capture ~a" (args-positional args 2)))])] + [(frames) + (print-result + (call "GET" "/can/frames" + (hasheq 'captureId (require-positional 2 "capture id") + 'limit (or (args-int-opt args 'limit) 256))) + compact) + 0] + [(decode) + (print-result + (call "POST" "/can/decode" (hasheq) + (hasheq 'captureId (require-positional 2 "capture id") + 'dbcPath (args-get args 'dbc) + 'frameId (or (args-hex-opt args 'id) + (raise-validation "Missing --id (frame id).")) + 'limit (or (args-int-opt args 'limit) 64))) + compact) + 0] + [else (raise-validation (format "Unknown command: can ~a" subcommand))])] + [(lease) + (case subcommand + [(acquire) + (print-result + (call "POST" "/lease/acquire" (hasheq) + (hasheq 'target (require-positional 2 "target id") + 'ttlSeconds (or (args-int-opt args 'ttl) 300))) + compact) + 0] + [(renew) + (print-result + (call "POST" "/lease/renew" (hasheq) + (hasheq 'leaseId (require-positional 2 "lease id") + 'ttlSeconds (or (args-int-opt args 'ttl) 300))) + compact) + 0] + [(release) + (print-result + (call "POST" "/lease/release" (hasheq) + (hasheq 'leaseId (require-positional 2 "lease id"))) + compact) + 0] + [(list) + (print-result (call "GET" "/lease/list" (hasheq)) compact) + 0] + [else (raise-validation (format "Unknown command: lease ~a" subcommand))])] + [(store) + (case subcommand + [(evidence) + (print-result (call "GET" "/store/operations" + (hasheq 'limit (or (args-int-opt args 'limit) 50))) + compact) + 0] + [(observations) + (print-result (call "GET" "/store/observations" + (hasheq 'limit (or (args-int-opt args 'limit) 50))) + compact) + 0] + [(artifacts) + (print-result (call "GET" "/store/artifacts" (hasheq)) compact) + 0] + [(artifact) + (define id (require-positional 2 "artifact id")) + (define result (call "GET" "/store/artifact" (hasheq 'artifactId id))) + (define content (base64-decode (string->bytes/latin-1 + (hash-ref result 'contentBase64)))) + (define out (args-get args 'out)) + (if out + (begin + (display-to-file content out #:mode 'binary #:exists 'replace) + (print-result (hasheq 'ok #t + 'artifactId (hash-ref result 'artifactId) + 'bytes (hash-ref result 'bytes) + 'sha256 (hash-ref result 'sha256) + 'output out) + compact)) + (begin + (write-bytes content) + (newline))) + 0] + [else (raise-validation (format "Unknown command: store ~a" subcommand))])] [(report) ;; --format html: one static evidence report for the bench session. (define results (make-hasheq)) @@ -878,6 +1034,18 @@ Usage: benchpilot observe cancel [--json] [--endpoint URL] benchpilot report [--format html] [--out PATH] [--target ID] [--json] [--endpoint URL] + benchpilot uds dtc read [--mask XX] [--target ID] [--json] + benchpilot uds dtc clear [--group XXXXXX] [--target ID] [--json] + benchpilot can send [--id 0x123] [--extended] [--resource ID] + benchpilot can capture start|stop [--resource ID] [--capacity N] + benchpilot can frames [--limit N] [--json] + benchpilot can decode --dbc FILE --id 0x123 [--limit N] [--json] + benchpilot lease acquire|renew|release [--ttl SEC] [--json] + benchpilot lease list [--json] + benchpilot store evidence [--limit N] [--json] + benchpilot store observations [--limit N] [--json] + benchpilot store artifacts [--json] + benchpilot store artifact [--out PATH] [--json] benchpilot preflight [--target ID] [--json] benchpilot bench validate [--target ID] [--json] diff --git a/racket/benchpilot/core/bench-runtime.rkt b/racket/benchpilot/core/bench-runtime.rkt index 92810ac..acbb456 100644 --- a/racket/benchpilot/core/bench-runtime.rkt +++ b/racket/benchpilot/core/bench-runtime.rkt @@ -21,6 +21,14 @@ "readiness.rkt" "runtime-state.rkt") +(require (only-in benchpilot/diagnostics/flash/image + image-format-for-path + image-segment-address + image-segment-data + merge-image-segments + parse-intel-hex + parse-srecord)) + ;; driver generics (Core abstractions) (provide gen:resource-health-check gen:power-supply @@ -58,6 +66,7 @@ driver-create-runtime ;; runtime object: state + registry (struct-out bench-runtime) + (struct-out target-ref) make-bench-runtime runtime-target runtime-preflight @@ -726,11 +735,26 @@ seg [data (read-segment-file (uds-flash-segment-file seg) base-directory)])))])) - (begin + (let () (unless address (raise-validation "Provide --address (flash start address, for example 0x08000000) or a flash plan file.")) - (uds-flash-plan (list (uds-flash-segment address (file->bytes firmware-path) #f)) + ;; BIN keeps one explicit-address segment; HEX/S-record images + ;; carry their own addresses and become one segment per region. + (define image-format (image-format-for-path firmware-path)) + (define segments + (if image-format + (let* ([text (file->string firmware-path)] + [raw (if (string=? image-format "hex") + (parse-intel-hex text) + (parse-srecord text))] + [merged (merge-image-segments raw)]) + (for/list ([seg (in-list merged)]) + (uds-flash-segment (image-segment-address seg) + (bytes->list (image-segment-data seg)) + #f))) + (list (uds-flash-segment address (file->bytes firmware-path) #f)))) + (uds-flash-plan segments 1024 #x02 #f diff --git a/racket/benchpilot/core/device-errors.rkt b/racket/benchpilot/core/device-errors.rkt new file mode 100644 index 0000000..fba8778 --- /dev/null +++ b/racket/benchpilot/core/device-errors.rkt @@ -0,0 +1,66 @@ +#lang racket/base + +;; Device/runtime error taxonomy: vendor SDK messages are classified into a +;; small, stable set of classes without ever leaking vendor types or vendor +;; SDK details into Core. Faulted operations carry a `device.error` evidence +;; item whose metadata names the class and the pattern that matched, so +;; agents can branch on behavior instead of parsing vendor strings. + +(require racket/string) + +(require benchpilot/core/contracts) + +(provide device-error-classes + classify-device-error + device-error-evidence-item) + +;; Each class lists the case-insensitive substrings that indicate it. The +;; first matching class wins; unmatched failures classify as `unknown`. +(define device-error-classes + (list + (cons "timeout" + '("timed out" "timeout" "deadline exceeded" "no response")) + (cons "transport" + '("connection refused" "connection closed" "no route" "unreachable" + "broken pipe" "reset by peer" "socket")) + (cons "device-state" + '("busy" "not ready" "device is off" "power is off" "no power" + "already open" "not open")) + (cons "permission" + '("access denied" "permission" "unauthorized" "in use by another" + "locked")) + (cons "not-found" + '("not found" "no such file" "no such device" "was not found" + "not visible" "not installed" "could not resolve")) + (cons "protocol" + '("checksum" "crc" "invalid response" "unexpected response" + "malformed" "nrc" "flow control" "iso-tp")))) + +(define (contains-ci? haystack needle) + (string-contains? (string-foldcase haystack) (string-foldcase needle))) + +;; -> (values class matched-pattern) +(define (classify-device-error message) + (let loop ([classes device-error-classes]) + (cond + [(null? classes) (values "unknown" "")] + [else + (define match + (findf (lambda (pat) (contains-ci? message pat)) + (cdr (car classes)))) + (if match + (values (car (car classes)) match) + (loop (cdr classes)))]))) + +;; The evidence item attached to faulted operations. +(define (device-error-evidence-item message) + (define-values (class pattern) (classify-device-error message)) + (bench-evidence-item + "device.error" + (format "Failure classified as ~a." class) + (let ([bounded (if (<= (string-length message) 2000) + message + (string-append (substring message 0 2000) "…"))]) + bounded) + (hasheq "class" class + "matchedPattern" (if (string=? pattern "") "" pattern)))) diff --git a/racket/benchpilot/core/evidence.rkt b/racket/benchpilot/core/evidence.rkt index c4cc689..d3b894a 100644 --- a/racket/benchpilot/core/evidence.rkt +++ b/racket/benchpilot/core/evidence.rkt @@ -89,8 +89,10 @@ (with-handlers ([exn:fail? (lambda (e) (semaphore-post sema) (raise e))]) - (begin0 (proc) - (semaphore-post sema)))) + (dynamic-wind + (lambda () (void)) + proc + (lambda () (semaphore-post sema))))) ;; Items of one bundle: bounded like the C# BoundItem. (define (evidence-bound-items items) diff --git a/racket/benchpilot/core/hashing.rkt b/racket/benchpilot/core/hashing.rkt new file mode 100644 index 0000000..1444f59 --- /dev/null +++ b/racket/benchpilot/core/hashing.rkt @@ -0,0 +1,52 @@ +#lang racket/base + +;; Shared SHA-256 hashing through whatever tool the OS ships: sha256sum, +;; shasum, or certutil. No optional collection is required at runtime. + +(require racket/file + racket/list + racket/port + racket/string) + +(provide sha256-hex) + +(define (find-system-tool name) + (or (find-executable-path name) name)) + +(define (run-tool lines->value . argv) + (with-handlers ([exn:fail? (lambda (_) #f)]) + (define output (open-output-string)) + ;; subprocess yields (proc stdout stdin stderr). + (define-values (p child-stdout child-stdin child-stderr) + (apply subprocess #f #f #f argv)) + (close-output-port child-stdin) + (define collector + (thread (lambda () + (copy-port child-stdout output) + (close-input-port child-stdout)))) + (subprocess-wait p) + (sync collector) + (if (zero? (subprocess-status p)) + (lines->value (get-output-string output)) + #f))) + +(define (sha256-hex path) + (case (system-path-convention-type) + [(windows) + (run-tool + (lambda (output) + (for/first ([line (in-list (string-split output "\n"))] + #:when (regexp-match? #px"^[0-9a-fA-F]{64}$" (string-trim line))) + (string-trim line))) + (find-system-tool "certutil.exe") "-hashfile" path "SHA256")] + [else + (or (run-tool + (lambda (output) + (and (>= (string-length (string-trim output)) 64) + (first (string-split (string-trim output))))) + (find-system-tool "sha256sum") path) + (run-tool + (lambda (output) + (and (>= (string-length (string-trim output)) 64) + (first (string-split (string-trim output))))) + (find-system-tool "shasum") "-a" "256" path))])) diff --git a/racket/benchpilot/core/leases-test.rkt b/racket/benchpilot/core/leases-test.rkt new file mode 100644 index 0000000..a2435aa --- /dev/null +++ b/racket/benchpilot/core/leases-test.rkt @@ -0,0 +1,69 @@ +#lang racket/base + +;; Leases: acquire/renew/release/check semantics, ownership separation and +;; expiry; the audit trail appends through the same store directory. + +(module+ test + (require benchpilot/core/leases + benchpilot/core/persist + racket/file + racket/list + racket/string + rackunit) + + (define dir (path->string (make-temporary-file "benchpilot-lease-~a" 'directory))) + (define reg (make-lease-registry dir #t)) + + (eprintf "T1...\n") + (test-case "acquire + check pass for the same owner" + (lease-acquire! reg "ecu" "token-A" 60) + (lease-check! reg "ecu" "token-A") + (check-not-false (lease-active-for reg "ecu"))) + + (eprintf "T2...\n") + (test-case "another owner is rejected while the lease is live" + (check-exn exn:benchpilot:lease? + (lambda () (lease-acquire! reg "ecu" "token-B" 60))) + (check-exn exn:benchpilot:lease? + (lambda () (lease-check! reg "ecu" "token-B")))) + + (eprintf "T3...\n") + (test-case "release frees the target for the next owner" + (define active (lease-active-for reg "ecu")) + (check-not-false active) + (check-true (lease-release! reg (bench-lease-id active) "token-A")) + (lease-acquire! reg "ecu" "token-B" 60) + (lease-check! reg "ecu" "token-B")) + + (eprintf "T4...\n") + (test-case "leases expire" + (define reg2 (make-lease-registry #f #t)) + (lease-acquire! reg2 "ecu" "token-A" 0) ; ttl 0 = immediately expired + (check-exn exn:benchpilot:lease? + (lambda () (lease-check! reg2 "ecu" "token-A"))) + ;; expired leases can be taken over + (lease-acquire! reg2 "ecu" "token-B" 60) + (check-not-false (lease-active-for reg2 "ecu"))) + + (eprintf "T5...\n") + (test-case "renew requires ownership" + (define reg3 (make-lease-registry #f #t)) + (define lease (lease-acquire! reg3 "ecu" "token-A" 60)) + (check-exn exn:benchpilot:lease? + (lambda () (lease-renew! reg3 (bench-lease-id lease) "token-B" 60))) + (define renewed (lease-renew! reg3 (bench-lease-id lease) "token-A" 120)) + (check-equal? (bench-lease-id renewed) (bench-lease-id lease))) + + (eprintf "T6...\n") + (test-case "audit trail lands in the store directory" + (audit-append! reg "flash.executed" (hasheq 'target "ecu" 'ok #t)) + (audit-append! reg "lease.acquired" (hasheq 'target "ecu")) + (define lines (file->lines (build-path dir "audit.jsonl"))) + (check-equal? (length lines) 2) + (check-true (string-contains? (second lines) "lease.acquired"))) + + (eprintf "T7...\n") + (test-case "registry listing hides expired leases" + (define listing (lease-registry->jsexpr reg)) + (check-true (hash-ref listing 'required)) + (check-true (>= (length (hash-ref listing 'leases)) 1)))) diff --git a/racket/benchpilot/core/leases.rkt b/racket/benchpilot/core/leases.rkt new file mode 100644 index 0000000..abcfc45 --- /dev/null +++ b/racket/benchpilot/core/leases.rkt @@ -0,0 +1,183 @@ +#lang racket/base + +;; Team benches: resource leases + a persistent audit trail. +;; +;; A lease is an authenticated, time-bounded ownership claim on one target: +;; once a profile enables `leases.required`, mutations on that target are +;; rejected unless the caller holds a live lease (or the lease feature is +;; off). Owners are the caller's API token hash — the same authentication +;; the loopback API already uses. The audit trail appends one line per +;; mutation to the persistent store, so team/CI workflows can answer "who +;; did what, when" across daemon restarts. + +(require json + racket/file + racket/format + racket/list + racket/string) + +(require benchpilot/core/contracts + benchpilot/core/hashing + benchpilot/core/persist) + + + +(provide (struct-out bench-lease) + (struct-out exn:benchpilot:lease) + make-lease-registry + lease-acquire! + lease-renew! + lease-release! + lease-check! + lease-active-for + lease-registry->jsexpr + audit-append!) + +(struct bench-lease (id target-id owner-hash acquired-at-millis expires-at-millis) + #:transparent) + +(struct exn:benchpilot:lease exn:benchpilot () #:transparent) + +(struct lease-registry (hash mutex audit-dir required-box)) + +(define (make-lease-registry [audit-dir #f] [required #f]) + (lease-registry (make-hash) (make-semaphore 1) audit-dir (box required))) + +(define (now-ms**) + (inexact->exact (floor (current-inexact-milliseconds)))) + +(define (token-hash token) + (define source + (if (and token (not (string-blank? token))) + token + (or (getenv "BENCHPILOT_TOKEN") ""))) + ;; The owner identity is a truncated FNV-1a digest of the caller's + ;; token — never the token itself. Self-contained: no subprocess, and + ;; unlike equal-hash-code it does not collide distinct short tokens. + (define h #xcbf29ce484222325) + (for ([c (in-string source)]) + (set! h (bitwise-and #xFFFFFFFFFFFFFFFF + (bitwise-xor h (char->integer c)))) + (set! h (bitwise-and #xFFFFFFFFFFFFFFFF (* h #x100000001b3)))) + (~r h #:base 16 #:min-width 16 #:pad-string "0")) + +(define (call-with-reg-mutex reg proc) + (semaphore-wait/enable-break (lease-registry-mutex reg)) + (dynamic-wind + (lambda () (void)) + proc + (lambda () (semaphore-post (lease-registry-mutex reg))))) + +(define (live-lease? lease now) + (and lease (< now (bench-lease-expires-at-millis lease)))) + +;; Acquires or renews the lease for (target, owner). A conflicting live +;; lease from another owner raises the typed error (HTTP 409 at the API). +(define (lease-acquire! reg target-id owner-token ttl-seconds) + (call-with-reg-mutex + reg + (lambda () + (define now (now-ms**)) + (define owner (token-hash owner-token)) + (define existing + (findf (lambda (l) + (and (string-ci=? (bench-lease-target-id l) target-id) + (live-lease? l now))) + (hash-values (lease-registry-hash reg)))) + (when (and existing + (not (string=? (bench-lease-owner-hash existing) owner))) + (raise (exn:benchpilot:lease + (format "Target '~a' is leased by owner ~a until ~a." + target-id + (bench-lease-owner-hash existing) + (utc-iso-millis (bench-lease-expires-at-millis existing))) + (current-continuation-marks)))) + (define lease + (bench-lease (format "~a-~a" target-id owner) + target-id owner + now + (+ now (* ttl-seconds 1000)))) + (hash-set! (lease-registry-hash reg) (bench-lease-id lease) lease) + lease))) + +(define (lease-renew! reg lease-id owner-token ttl-seconds) + (call-with-reg-mutex + reg + (lambda () + (define lease (hash-ref (lease-registry-hash reg) lease-id #f)) + (unless (and lease + (string=? (bench-lease-owner-hash lease) + (token-hash owner-token))) + (raise (exn:benchpilot:lease "Lease not found or not yours." + (current-continuation-marks)))) + (define renewed + (struct-copy bench-lease lease + [expires-at-millis (+ (now-ms**) (* ttl-seconds 1000))])) + (hash-set! (lease-registry-hash reg) lease-id renewed) + renewed))) + +(define (lease-release! reg lease-id owner-token) + (call-with-reg-mutex + reg + (lambda () + (define lease (hash-ref (lease-registry-hash reg) lease-id #f)) + (when (and lease + (string=? (bench-lease-owner-hash lease) + (token-hash owner-token))) + (hash-remove! (lease-registry-hash reg) lease-id)) + (and lease #t)))) + +;; Raises the typed error when the target requires a lease the caller +;; doesn't hold. `required` comes from the profile (safety.leasesRequired). +(define (lease-check! reg target-id owner-token) + (unless (unbox (lease-registry-required-box reg)) + (void)) + (define now (now-ms**)) + (define held + (findf (lambda (l) + (and (string-ci=? (bench-lease-target-id l) target-id) + (string=? (bench-lease-owner-hash l) (token-hash owner-token)) + (live-lease? l now))) + (hash-values (lease-registry-hash reg)))) + (unless held + (raise (exn:benchpilot:lease + (format "Target '~a' requires an active lease before mutations (profile: safety.leasesRequired)." + target-id) + (current-continuation-marks)))) + (void)) + +(define (lease-active-for reg target-id) + (define now (now-ms**)) + (findf (lambda (l) + (and (string-ci=? (bench-lease-target-id l) target-id) + (live-lease? l now))) + (hash-values (lease-registry-hash reg)))) + +(define (lease-registry->jsexpr reg) + (define now (now-ms**)) + (hasheq 'kind "lease-list" + 'required (unbox (lease-registry-required-box reg)) + 'leases + (for/list ([l (in-list (hash-values (lease-registry-hash reg)))] + #:when (live-lease? l now)) + (hasheq 'id (bench-lease-id l) + 'targetId (bench-lease-target-id l) + 'ownerHash (bench-lease-owner-hash l) + 'expiresAtUtc (or (utc-iso-millis (bench-lease-expires-at-millis l)) 'null))))) + +;; Persistent audit: one JSONL line per audited event, into the same store +;; directory as the evidence (or its own dir when no store is configured). +(define (audit-append! reg event jsexpr) + (define dir (lease-registry-audit-dir reg)) + (when dir + (with-handlers ([exn:fail? (lambda (_) (void))]) + (call-with-output-file* + (build-path dir "audit.jsonl") + (lambda (out) + (write-json (hasheq 'event event + 'at (or (utc-iso-millis (now-ms**)) 'null) + 'details jsexpr) + out) + (newline out)) + #:mode 'text + #:exists 'append)))) diff --git a/racket/benchpilot/core/persist-test.rkt b/racket/benchpilot/core/persist-test.rkt new file mode 100644 index 0000000..85a6ee5 --- /dev/null +++ b/racket/benchpilot/core/persist-test.rkt @@ -0,0 +1,83 @@ +#lang racket/base + +;; The persistent evidence/artifact store: records round-trip through the +;; JSONL files, artifacts land with checksums, and the runtime seeds its +;; in-memory history from a store created by a previous "session". + +(module+ test + (require benchpilot/core/contracts + benchpilot/core/persist + benchpilot/core/profile + benchpilot/core/runtime-state + benchpilot/core/bench-runtime + benchpilot/diagnostics/channels/sim-uds-channel + benchpilot/simulator/simulated-bench + racket/file + racket/list + racket/string + rackunit) + + (define dir (path->string (make-temporary-file "benchpilot-persist-~a" 'directory))) + (define store (open-persist-store dir)) + + (define record + (bench-operation-record "op-1" "demo" "power.on" + (list "sim.demo") + 1000 1060 60 #f "completed" #f)) + (define evidence + (bench-operation-evidence "op-1" "demo" "power.on" + (list "sim.demo") 1060 + (list (bench-evidence-item + "power.result" "Power-on completed." #f + (hasheq "ok" "true" "voltageV" "12"))))) + + (test-case "records survive a store round trip" + (persist-operation! store record evidence) + (define loaded (persist-load-recent store "operations.jsonl" 10)) + (check-equal? (length loaded) 1) + (define entry (first loaded)) + (check-equal? (hash-ref entry 'id) "op-1") + (check-equal? (hash-ref entry 'state) "completed") + (check-equal? (hash-ref entry 'durationMs) 60) + (check-equal? (hash-ref entry 'deadlineAtMillis) 'null) + (check-equal? (hash-ref (first (hash-ref entry 'items)) 'metadata) + '#hasheq((ok . "true") (voltageV . "12")))) + + (test-case "artifacts land under the store with a checksum" + (define ref (persist-artifact! store "op-1" "capture.json" #"[]")) + (check-equal? (hash-ref ref 'bytes) 2) + (check-true (string-contains? (hash-ref ref 'artifactId) "op-1")) + (define path (persist-artifact-file store (hash-ref ref 'artifactId))) + (check-true (and path (file-exists? path))) + (check-equal? (file->bytes path) #"[]") + (check-false (persist-artifact-file store "../escape"))) + + (test-case "the runtime seeds its history from a previous session" + (persist-operation! store + (struct-copy bench-operation-record record [id "op-0"]) + (struct-copy bench-operation-evidence evidence [operation-id "op-0"])) + (define registry + (make-driver-registry (list (make-simulator-factory) + (make-sim-diagnostics-factory)))) + (define rt (make-bench-runtime registry (default-simulator-profile))) + (define state (bench-runtime-state rt)) + (set-runtime-store! state store) + (seed-persisted-operations! state (persist-load-recent store "operations.jsonl" 128)) + ;; Both persisted operations are queryable in write (chronological) order. + (define history (runtime-recent-operations state 10)) + (check-equal? (length history) 2) + (check-equal? (bench-operation-record-id (first history)) "op-1") + (check-equal? (bench-operation-record-id (last history)) "op-0") + (check-not-false (runtime-get-operation-evidence state "op-0")) + ;; A new operation appends after the seeded entries and lands in the file. + (define t (runtime-target rt "demo")) + (target-power-on rt t 12 0) + (define history* (runtime-recent-operations state 10)) + (check-equal? (length history*) 3) + (check-equal? (bench-operation-record-kind (last history*)) "power.on") + (check-equal? (length (persist-load-recent store "operations.jsonl" 128)) 3)) + + (test-case "disabled store raises the typed error" + (putenv "BENCHPILOT_STORE" "0") + (check-exn exn:fail:persist-disabled? open-persist-store) + (putenv "BENCHPILOT_STORE" ""))) diff --git a/racket/benchpilot/core/persist.rkt b/racket/benchpilot/core/persist.rkt new file mode 100644 index 0000000..f5372f0 --- /dev/null +++ b/racket/benchpilot/core/persist.rkt @@ -0,0 +1,200 @@ +#lang racket/base + +;; Persistent evidence/artifact store: selected evidence and larger artifacts +;; survive daemon restarts. Layout under the store directory (default +;; ~/.benchpilot/store; BENCHPILOT_STORE_DIR overrides; BENCHPILOT_STORE=0 +;; disables): +;; +;; operations.jsonl one completed operation record + evidence per line +;; observations.jsonl the same for observations +;; artifacts/. larger evidence that must stay out of LLM context +;; +;; The daemon seeds its in-memory history/evidence from these files at start +;; and appends on every completed record. + +(require json + racket/date + racket/file + racket/format + racket/list + racket/string) + +(require benchpilot/core/contracts + benchpilot/core/hashing) + +(provide (struct-out exn:fail:persist-disabled) + (struct-out persist-store) + persist-store-path + open-persist-store + persist-operation! + persist-observation! + record->jsexpr + observation-record->jsexpr + persist-load-recent + persist-artifact! + persist-artifact-file + utc-iso-millis) + +(struct exn:fail:persist-disabled exn:fail () #:transparent) + +;; ---------------------------------------------------------------------------- +;; Time +;; ---------------------------------------------------------------------------- + +(define (utc-iso-millis millis) + (and millis + (let* ([sec (quotient millis 1000)] + [frac (modulo millis 1000)] + [d (seconds->date sec #f)]) + (string-append + (~a (date-year d) #:width 4 #:pad-string "0") + "-" (~r (date-month d) #:min-width 2 #:pad-string "0") + "-" (~r (date-day d) #:min-width 2 #:pad-string "0") + "T" (~r (date-hour d) #:min-width 2 #:pad-string "0") + ":" (~r (date-minute d) #:min-width 2 #:pad-string "0") + ":" (~r (date-second d) #:min-width 2 #:pad-string "0") + "." (~r frac #:min-width 3 #:pad-string "0") + "Z")))) + +;; ---------------------------------------------------------------------------- +;; Store +;; ---------------------------------------------------------------------------- + +(struct persist-store (path mutex)) + +(define (open-persist-store [dir #f]) + (when (equal? (getenv "BENCHPILOT_STORE") "0") + (raise (exn:fail:persist-disabled + "The persistent store is disabled (BENCHPILOT_STORE=0)." + (current-continuation-marks)))) + (define path + (or dir + (getenv "BENCHPILOT_STORE_DIR") + (path->string (build-path (or (getenv "USERPROFILE") (getenv "HOME") ".") + ".benchpilot" "store")))) + (make-directory* (build-path path "artifacts")) + (persist-store path (make-semaphore 1))) + +(define (with-store-mutex store proc) + (semaphore-wait/enable-break (persist-store-mutex store)) + (dynamic-wind + (lambda () (void)) + proc + (lambda () (semaphore-post (persist-store-mutex store))))) + +(define (append-line! store file-name jsexpr) + ;; Serialize first: a serialization failure then leaves no torn line + ;; behind for the next append to concatenate onto. + (define line (jsexpr->string jsexpr)) + (with-store-mutex + store + (lambda () + (call-with-output-file* + (build-path (persist-store-path store) file-name) + (lambda (out) + (display line out) + (newline out)) + #:mode 'text + #:exists 'append)))) + +;; ---------------------------------------------------------------------------- +;; Record persistence +;; ---------------------------------------------------------------------------- + +(define (json-keys->symbols h) + (for/hash ([(k v) (in-hash h)]) + (values (if (symbol? k) k (string->symbol (format "~a" k))) v))) + +(define (evidence-items->jsexpr items) + (for/list ([item (in-list items)]) + (hasheq 'kind (bench-evidence-item-kind item) + 'summary (bench-evidence-item-summary item) + 'text (or (bench-evidence-item-text item) 'null) + 'metadata + (json-keys->symbols + (or (bench-evidence-item-metadata item) (hasheq)))))) + +(define (record->jsexpr record evidence) + (hasheq 'kind "operation" + 'id (bench-operation-record-id record) + 'targetId (bench-operation-record-target-id record) + 'operation (bench-operation-record-kind record) + 'resourceIds (bench-operation-record-resource-ids record) + 'startedAtUtc (or (utc-iso-millis (bench-operation-record-started-at-millis record)) 'null) + 'startedAtMillis (bench-operation-record-started-at-millis record) + 'completedAtUtc (or (utc-iso-millis (bench-operation-record-completed-at-millis record)) 'null) + 'completedAtMillis (bench-operation-record-completed-at-millis record) + 'durationMs (bench-operation-record-duration-ms record) + 'deadlineAtUtc (or (utc-iso-millis (bench-operation-record-deadline-at-millis record)) 'null) + 'deadlineAtMillis (or (bench-operation-record-deadline-at-millis record) 'null) + 'state (bench-operation-record-state record) + 'error (or (bench-operation-record-error record) 'null) + 'items (if evidence + (evidence-items->jsexpr (bench-operation-evidence-items evidence)) + '()))) + +(define (observation-record->jsexpr record evidence) + (hasheq 'kind "observation" + 'id (bench-observation-record-id record) + 'targetId (bench-observation-record-target-id record) + 'observation (bench-observation-record-kind record) + 'resourceIds (bench-observation-record-resource-ids record) + 'startedAtUtc (or (utc-iso-millis (bench-observation-record-started-at-millis record)) 'null) + 'startedAtMillis (bench-observation-record-started-at-millis record) + 'completedAtUtc (or (utc-iso-millis (bench-observation-record-completed-at-millis record)) 'null) + 'completedAtMillis (bench-observation-record-completed-at-millis record) + 'durationMs (bench-observation-record-duration-ms record) + 'deadlineAtUtc (or (utc-iso-millis (bench-observation-record-deadline-at-millis record)) 'null) + 'deadlineAtMillis (or (bench-observation-record-deadline-at-millis record) 'null) + 'state (bench-observation-record-state record) + 'error (or (bench-observation-record-error record) 'null) + 'items (if evidence + (evidence-items->jsexpr (bench-observation-evidence-items evidence)) + '()))) + +(define (persist-operation! store record evidence) + (with-handlers ([exn:fail? (lambda (_) (void))]) + (append-line! store "operations.jsonl" (record->jsexpr record evidence)))) + +(define (persist-observation! store record evidence) + (with-handlers ([exn:fail? (lambda (_) (void))]) + (append-line! store "observations.jsonl" (observation-record->jsexpr record evidence)))) + +;; Reads the most recent entries, oldest-first (file order on the tail). +(define (persist-load-recent store file-name limit) + (with-handlers ([exn:fail? (lambda (_) '())]) + (with-store-mutex + store + (lambda () + (define path (build-path (persist-store-path store) file-name)) + (if (file-exists? path) + (let () + (define entries + (for/list ([line (in-list (file->lines path))] + #:unless (string-blank? line)) + (with-handlers ([exn:fail? (lambda (_) #f)]) + (read-json (open-input-string line))))) + (define parsed (filter hash? entries)) + (take-right parsed (min (max 0 limit) (length parsed)))) + '()))))) + +;; ---------------------------------------------------------------------------- +;; Artifacts: larger evidence that stays out of LLM context +;; ---------------------------------------------------------------------------- + +(define (persist-artifact! store operation-id name bytes) + (define safe-name (regexp-replace* #rx"[^A-Za-z0-9._-]" name "_")) + (define file-name (format "~a.~a" operation-id safe-name)) + (define path (build-path (persist-store-path store) "artifacts" file-name)) + (display-to-file bytes path #:mode 'binary #:exists 'replace) + (hasheq 'artifactId file-name + 'name name + 'bytes (bytes-length bytes) + 'sha256 (or (sha256-hex path) "") + 'createdAtUtc (or (utc-iso-millis (now-millis)) 'null))) + +(define (persist-artifact-file store artifact-id) + ;; Only the bare file name is accepted; traversal stays impossible. + (define safe (regexp-replace* #rx"[^A-Za-z0-9._-]" artifact-id "")) + (define path (build-path (persist-store-path store) "artifacts" safe)) + (and (file-exists? path) (path->string path))) diff --git a/racket/benchpilot/core/profile-test.rkt b/racket/benchpilot/core/profile-test.rkt index d611bc0..7e7d46f 100644 --- a/racket/benchpilot/core/profile-test.rkt +++ b/racket/benchpilot/core/profile-test.rkt @@ -255,7 +255,7 @@ JSON #f (hash "r" (bench-resource "x" '("power") (hash))) (hash "a" (bench-target "A" #f (hash "power" "r"))) - (bench-safety #f #f #f #f) + (bench-safety #f #f #f #f #f) #f #f #f @@ -279,7 +279,7 @@ JSON "a" (hash "r" (bench-resource "x" '("power") (hash))) (hash "a" (bench-target "A" #f (hash "power" "ghost" "serial" "r"))) - (bench-safety #f #f #f #f) + (bench-safety #f #f #f #f #f) #f #f #f diff --git a/racket/benchpilot/core/profile.rkt b/racket/benchpilot/core/profile.rkt index fbf766e..548f3a5 100644 --- a/racket/benchpilot/core/profile.rkt +++ b/racket/benchpilot/core/profile.rkt @@ -54,7 +54,8 @@ (struct bench-resource (driver capabilities settings) #:transparent) (struct bench-target (name mcu bindings) #:transparent) (struct bench-safety - (max-voltage max-current-ma require-explicit-target require-destructive-confirmation) + (max-voltage max-current-ma require-explicit-target require-destructive-confirmation + leases-required) #:transparent) ;; Legacy P0 records with their C# defaults (Board, PowerConfig, ...). @@ -266,7 +267,8 @@ (bench-safety (get-real doc "maxVoltage") (get-real doc "maxCurrentMa") (get-bool doc "requireExplicitTarget") - (get-bool doc "requireDestructiveConfirmation"))) + (get-bool doc "requireDestructiveConfirmation") + (get-bool doc "leasesRequired"))) (define (parse-board doc) (and doc (bench-board (get-string doc "name" "Demo Board") (get-string doc "mcu" "simulated-mcu")))) @@ -501,7 +503,7 @@ "Demo ECU" "simulated-mcu" (hash "power" "sim.demo" "serial" "sim.demo" "flash" "sim.demo" "diagnostics" "sim.uds"))) - (bench-safety 14.5 2000 #f #f) + (bench-safety 14.5 2000 #f #f #f) #f #f #f diff --git a/racket/benchpilot/core/runtime-state.rkt b/racket/benchpilot/core/runtime-state.rkt index 5546f39..2c6c382 100644 --- a/racket/benchpilot/core/runtime-state.rkt +++ b/racket/benchpilot/core/runtime-state.rkt @@ -11,7 +11,9 @@ racket/string) (require "contracts.rkt" + "device-errors.rkt" "evidence.rkt" + "persist.rkt" "profile.rkt") (provide (struct-out exec-cancel) @@ -22,6 +24,9 @@ check-disposed new-id now-millis + set-runtime-store! + seed-persisted-operations! + seed-persisted-observations! make-exec-cancel cancel-evt exec-cancel-request-cancel! @@ -128,7 +133,8 @@ op-evidence obs-evidence context-store - disposed-box) + disposed-box + store-box) #:transparent) ;; One in-flight mutation/observation: identity, held gates, cancellation. @@ -150,6 +156,7 @@ (make-evidence-store) (make-evidence-store) (make-context-store) + (box #f) (box #f))) (define (check-disposed rt) @@ -346,27 +353,32 @@ (release-all! rt acquired)) (define (record! items state error) - (evidence-store-put! (runtime-state-op-evidence rt) - operation-id - (bench-operation-evidence operation-id - target-id - operation - normalized - (now-millis) - (evidence-bound-items items))) + (define evidence + (bench-operation-evidence operation-id + target-id + operation + normalized + (now-millis) + (evidence-bound-items items))) + (evidence-store-put! (runtime-state-op-evidence rt) operation-id evidence) (define completed-at (now-millis)) + (define record + (bench-operation-record operation-id + target-id + operation + normalized + started-at + completed-at + (duration-ms-between started-at completed-at) + (active-exec-deadline-at-millis active) + state + error)) (history-push! (runtime-state-operation-history rt) - (bench-operation-record operation-id - target-id - operation - normalized - started-at - completed-at - (duration-ms-between started-at completed-at) - (active-exec-deadline-at-millis active) - state - error) - operation-history-capacity)) + record + operation-history-capacity) + (define store (unbox (runtime-state-store-box rt))) + (when store + (persist-operation! store record evidence))) (define (deadline-path) (record! @@ -393,7 +405,11 @@ (record! (operation-cancellation-evidence) "cancelled" "Operation cancelled.") (raise e)]))] [exn:fail? (lambda (e) - (record! (operation-exception-evidence (exn-message e) (exception-type-name e)) + (record! (append (operation-exception-evidence + (exn-message e) + (exception-type-name e)) + (list (device-error-evidence-item + (exn-message e)))) "faulted" (bound-history-error (exn-message e))) (raise e))]) @@ -631,3 +647,89 @@ (when (> remaining 0) (sync/timeout (/ (min 25 remaining) 1000.0) (or ct never-evt))) (loop)]))) + +;; ---------------------------------------------------------------------------- +;; Persistent store wiring (selected evidence/artifacts across restarts) +;; ---------------------------------------------------------------------------- + +(define (set-runtime-store! rt store) + (set-box! (runtime-state-store-box rt) store)) + +(define (entry-item->struct item) + (bench-evidence-item + (hash-ref item 'kind "") + (hash-ref item 'summary "") + (let ([text (hash-ref item 'text 'null)]) (if (eq? text 'null) #f text)) + (hash-ref item 'metadata (hasheq)))) + +(define (seed-persisted-operations! rt entries) + (define records + (for/list ([e (in-list entries)]) + (bench-operation-record + (hash-ref e 'id "") + (hash-ref e 'targetId "") + (hash-ref e 'operation "") + (hash-ref e 'resourceIds '()) + (hash-ref e 'startedAtMillis 0) + (hash-ref e 'completedAtMillis 0) + (hash-ref e 'durationMs 0) + (let ([d (hash-ref e 'deadlineAtMillis 'null)]) (if (eq? d 'null) #f d)) + (hash-ref e 'state "") + (let ([err (hash-ref e 'error 'null)]) (if (eq? err 'null) #f err))))) + (define evidences + (for/list ([e (in-list entries)]) + (bench-operation-evidence + (hash-ref e 'id "") + (hash-ref e 'targetId "") + (hash-ref e 'operation "") + (hash-ref e 'resourceIds '()) + (hash-ref e 'completedAtMillis 0) + (map entry-item->struct (hash-ref e 'items '()))))) + (for ([rec (in-list records)] + [ev (in-list evidences)]) + (evidence-store-put! (runtime-state-op-evidence rt) + (bench-operation-record-id rec) + ev)) + (define capped + (let* ([merged (append records (unbox (runtime-state-operation-history rt)))] + [n (length merged)]) + (if (> n operation-history-capacity) + (list-tail merged (- n operation-history-capacity)) + merged))) + (set-box! (runtime-state-operation-history rt) capped)) + +(define (seed-persisted-observations! rt entries) + (define records + (for/list ([e (in-list entries)]) + (bench-observation-record + (hash-ref e 'id "") + (hash-ref e 'targetId "") + (hash-ref e 'observation "") + (hash-ref e 'resourceIds '()) + (hash-ref e 'startedAtMillis 0) + (hash-ref e 'completedAtMillis 0) + (hash-ref e 'durationMs 0) + (let ([d (hash-ref e 'deadlineAtMillis 'null)]) (if (eq? d 'null) #f d)) + (hash-ref e 'state "") + (let ([err (hash-ref e 'error 'null)]) (if (eq? err 'null) #f err))))) + (define evidences + (for/list ([e (in-list entries)]) + (bench-observation-evidence + (hash-ref e 'id "") + (hash-ref e 'targetId "") + (hash-ref e 'observation "") + (hash-ref e 'resourceIds '()) + (hash-ref e 'completedAtMillis 0) + (map entry-item->struct (hash-ref e 'items '()))))) + (for ([rec (in-list records)] + [ev (in-list evidences)]) + (evidence-store-put! (runtime-state-obs-evidence rt) + (bench-observation-record-id rec) + ev)) + (define capped + (let* ([merged (append records (unbox (runtime-state-observation-history rt)))] + [n (length merged)]) + (if (> n observation-history-capacity) + (list-tail merged (- n observation-history-capacity)) + merged))) + (set-box! (runtime-state-observation-history rt) capped)) diff --git a/racket/benchpilot/diagnostics/can/capture-test.rkt b/racket/benchpilot/diagnostics/can/capture-test.rkt new file mode 100644 index 0000000..1e674fe --- /dev/null +++ b/racket/benchpilot/diagnostics/can/capture-test.rkt @@ -0,0 +1,84 @@ +#lang racket/base + +;; CAN capture + DBC: golden DBC parse/decode, signal encode, bounded +;; capture ring behavior. + +(module+ test + (require benchpilot/core/contracts + benchpilot/diagnostics/can/capture + benchpilot/diagnostics/isotp/codec + racket/list + rackunit) + + (define dbc-sample + #< (length next) (capture-session-capacity session)) + ;; the front of the list is the newest; drop from the back + (take next (capture-session-capacity session)) + next)))) + +(define (capture-session-frames session) + ;; newest first + (unbox (capture-session-ring-box session))) + +(define (capture-session-clear! session) + (set-box! (capture-session-ring-box session) '())) + +(define (stop-capture! session) + (set-box! (capture-session-stop-box session) #t)) + +(define (capture-session-frames-json session [limit 256]) + (define frames (capture-session-frames session)) + (for/list ([entry (in-list (take frames (min limit (length frames))))]) + (can-frame-emit-json (car entry) (cdr entry)))) + +(define (can-frame-emit-json millis frame) + (hasheq 't (or (utc-iso-millis millis) 'null) + 'id (format "0x~a" (string-upcase (~r (can-frame-id frame) #:base 16))) + 'extended (can-frame-extended? frame) + 'data (apply string-append + (for/list ([b (in-list (can-frame-data frame))]) + (~r b #:base 16 #:min-width 2 #:pad-string "0"))))) + +;; ---------------------------------------------------------------------------- +;; DBC: the BO_/SG_ subset — message headers, signal layout (Intel and +;; Motorola byte order), factors, offsets, signedness. +;; ---------------------------------------------------------------------------- + +(struct dbc-message (id name length signals) #:transparent) +(struct dbc-signal (name start-bit bit-length little-endian signed factor offset) #:transparent) + +(define (dbc-messages db) db) + +(define (parse-dbc text) + (define messages (make-hash)) ; id -> dbc-message (signals accumulated) + (define order '()) + (for ([raw (in-list (string-split text "\n"))]) + (define line (string-trim raw)) + (cond + [(string-prefix? line "BO_ ") + (define m (regexp-match #rx"^BO_ ([0-9]+) ([A-Za-z0-9_]+): *([0-9]+)" line)) + (when m + (define id (string->number (second m))) + (hash-set! messages id (dbc-message id (third m) + (string->number (fourth m)) '())) + (set! order (cons id order)))] + [(string-prefix? line "SG_ ") + (define m (regexp-match + #rx"^SG_ ([A-Za-z0-9_]+) *: *([0-9]+)\\|([0-9]+)@([01])([+-]) \\(([0-9.+-]+),([0-9.+-]+)\\)" + line)) + ;; SG_ lines follow their BO_; attach to the most recent message. + (define last-id (and (pair? order) (car order))) + (when (and m last-id) + (define msg (hash-ref messages last-id)) + (define signal + (dbc-signal (second m) + (string->number (third m)) + (string->number (fourth m)) + (string=? (fifth m) "1") + (string=? (sixth m) "-") + (string->number (seventh m)) + (string->number (eighth m)))) + (define updated + (struct-copy dbc-message msg + [signals (append (dbc-message-signals msg) + (list signal))])) + (hash-set! messages last-id updated))])) + (for/list ([id (in-list (reverse order))]) + (hash-ref messages id))) + +;; Extracts a bit field from bytes (MSB-first bit numbering like DBC). +(define (extract-bits data start-bit bit-length little-endian) + (define total (bytes-length data)) + (if little-endian + ;; Intel: frame bit (start+i) carries raw bit i (LSB first). + (let loop ([i 0] [acc 0]) + (if (= i bit-length) + acc + (let* ([bit-pos (+ start-bit i)] + [byte-idx (quotient bit-pos 8)] + [bit-in-byte (modulo bit-pos 8)]) + (loop (add1 i) + (bitwise-ior acc + (arithmetic-shift + (if (>= byte-idx total) + 0 + (if (zero? (bitwise-and + (bytes-ref data byte-idx) + (arithmetic-shift 1 bit-in-byte))) + 0 1)) + i)))))) + ;; Motorola: bits are MSB-first across the frame window. + (let loop ([i 0] [acc 0]) + (if (= i bit-length) + acc + (let* ([bit-pos (+ start-bit i)] + [byte-idx (quotient bit-pos 8)] + [bit-in-byte (modulo bit-pos 8)]) + (loop (add1 i) + (bitwise-ior + (arithmetic-shift acc 1) + (if (>= byte-idx total) + 0 + (if (zero? (bitwise-and (bytes-ref data byte-idx) + (arithmetic-shift 1 (- 7 bit-in-byte)))) + 0 1))))))))) + +(define (dbc-decode-frame db frame-id data) + (define msg (findf (lambda (m) (= (dbc-message-id m) frame-id)) db)) + (and msg + (for/hash ([sig (in-list (dbc-message-signals msg))]) + (values (string->symbol (dbc-signal-name sig)) + (let* ([raw (extract-bits data + (dbc-signal-start-bit sig) + (dbc-signal-bit-length sig) + (dbc-signal-little-endian sig))] + [raw* (if (dbc-signal-signed sig) + (let ([bits (dbc-signal-bit-length sig)]) + (if (>= raw (arithmetic-shift 1 (sub1 bits))) + (- raw (arithmetic-shift 1 bits)) + raw)) + raw)] + [value (+ (* (dbc-signal-factor sig) raw*) + (dbc-signal-offset sig))]) + (if (and (integer? value) (= (dbc-signal-factor sig) 1) + (= (dbc-signal-offset sig) 0)) + value + (exact->inexact value))))))) + +(define (dbc-encode-signals msg values) + ;; Encodes only the named signals; unsupported packing (non-byte-aligned + ;; Motorola) raises validation instead of guessing. + (define data (make-bytes (max 1 (dbc-message-length msg)) 0)) + (for ([sig (in-list (dbc-message-signals msg))]) + (define v (hash-ref values (string->symbol (dbc-signal-name sig)) #f)) + (when v + (unless (dbc-signal-little-endian sig) + (raise-validation + (format "Signal '~a' uses Motorola byte order; transmit packing for it is not supported yet." + (dbc-signal-name sig)))) + (define raw + (inexact->exact + (floor (/ (- v (dbc-signal-offset sig)) (dbc-signal-factor sig))))) + (define bits (dbc-signal-bit-length sig)) + (when (or (negative? raw) (>= raw (arithmetic-shift 1 bits))) + (raise-validation + (format "Value for '~a' does not fit in ~a bits." (dbc-signal-name sig) bits))) + (for ([i (in-range bits)]) + (define bit-pos (+ (dbc-signal-start-bit sig) i)) + (define byte-idx (quotient bit-pos 8)) + (define bit-in-byte (modulo bit-pos 8)) + (define bit-val + (arithmetic-shift 1 bit-in-byte)) + (if (bitwise-bit-set? raw i) + (bytes-set! data byte-idx (bitwise-ior (bytes-ref data byte-idx) bit-val)) + (bytes-set! data byte-idx + (bitwise-and (bytes-ref data byte-idx) + (bitwise-not bit-val))))))) + data) diff --git a/racket/benchpilot/diagnostics/channels/sim-uds-channel.rkt b/racket/benchpilot/diagnostics/channels/sim-uds-channel.rkt index c058a41..2d2a7a9 100644 --- a/racket/benchpilot/diagnostics/channels/sim-uds-channel.rkt +++ b/racket/benchpilot/diagnostics/channels/sim-uds-channel.rkt @@ -18,6 +18,7 @@ benchpilot/diagnostics/flash/engine benchpilot/diagnostics/isotp/codec benchpilot/diagnostics/isotp/endpoint + benchpilot/diagnostics/can/capture benchpilot/diagnostics/transport/can-bus benchpilot/diagnostics/transport/pcan benchpilot/diagnostics/transport/socketcan @@ -98,7 +99,8 @@ last-image-box erased-box erase-count-box - verify-count-box) + verify-count-box + dtc-box) #:transparent) (struct uds-server-response (response delay-ms) #:transparent) @@ -110,8 +112,10 @@ (define (with-mutex* sema proc) (semaphore-wait/enable-break sema) - (begin0 (proc) - (semaphore-post sema))) + (dynamic-wind + (lambda () (void)) + proc + (lambda () (semaphore-post sema)))) (define hex-up (lambda (n width) (string-upcase (~r n #:base 16 #:min-width width #:pad-string "0")))) @@ -126,7 +130,8 @@ (box '()) (box #f) (box 0) - (box 0))) + (box 0) + (box (list (hasheq 'dtc #x010870 'status #x2F))))) (define (negative sid nrc) (uds-server-response (list #x7F sid nrc) 0)) @@ -178,8 +183,34 @@ [(#x36) (handle-transfer-data! p request)] [(#x37) (handle-transfer-exit! p request)] [(#x11) (handle-ecu-reset! p request)] + [(#x19) (handle-read-dtc! p request)] + [(#x14) (handle-clear-dtc! p request)] [else (negative (car request) #x11)])])) +(define (handle-read-dtc! p request) + (if (not (= (length request) 3)) + (negative (car request) #x13) + (let ([records + (with-mutex* (uds-processor-mutex p) + (lambda () + (for/list ([entry (in-list (unbox (uds-processor-dtc-box p)))]) + (append (list (bitwise-and (arithmetic-shift (hash-ref entry 'dtc) -16) #xFF) + (bitwise-and (arithmetic-shift (hash-ref entry 'dtc) -8) #xFF) + (bitwise-and (hash-ref entry 'dtc) #xFF)) + (list (hash-ref entry 'status))))))]) + (uds-server-response + (append (list #x59 (second request) #x2F) + (apply append records)) + 0)))) + +(define (handle-clear-dtc! p request) + (if (not (= (length request) 4)) + (negative (car request) #x13) + (begin + (with-mutex* (uds-processor-mutex p) + (lambda () (set-box! (uds-processor-dtc-box p) '()))) + (uds-server-response (list #x54) 0)))) + (define (handle-session! p request) (define options (uds-processor-options p)) (if (not (= (length request) 2)) @@ -685,6 +716,9 @@ ;; ---------------------------------------------------------------------------- (struct sim-diagnostics-driver (channel) + #:methods gen:diag-bus-provider + [(define (diag-channel-bus d) #f) + (define (diag-channel-bus-available? d) #f)] #:methods gen:diag-channel [(define (diag-transport d) (sim-channel-transport (sim-diagnostics-driver-channel d))) @@ -725,6 +759,10 @@ ;; ---------------------------------------------------------------------------- (struct can-diagnostics-driver (channel) + #:methods gen:diag-bus-provider + [(define (diag-channel-bus d) + (can-uds-channel-bus-bus (can-diagnostics-driver-channel d))) + (define (diag-channel-bus-available? d) #t)] #:methods gen:diag-channel [(define (diag-transport d) (channel-transport (can-diagnostics-driver-channel d))) diff --git a/racket/benchpilot/diagnostics/doip/doip.rkt b/racket/benchpilot/diagnostics/doip/doip.rkt index b469918..7a2fe13 100644 --- a/racket/benchpilot/diagnostics/doip/doip.rkt +++ b/racket/benchpilot/diagnostics/doip/doip.rkt @@ -193,7 +193,7 @@ (define (with-client-mutex c proc) (semaphore-wait/enable-break (doip-client-mutex c)) - (begin0 (proc) (semaphore-post (doip-client-mutex c)))) + (dynamic-wind void proc (lambda () (semaphore-post (doip-client-mutex c))))) ;; Connects TCP, then performs the routing activation handshake. (define (doip-client-connect! c host [port doip-port] #:cancel [cancel #f]) @@ -463,7 +463,7 @@ (define (with-channel-mutex sema proc) (semaphore-wait/enable-break sema) - (begin0 (proc) (semaphore-post sema))) + (dynamic-wind void proc (lambda () (semaphore-post sema)))) (define (open-doip-channel! c) (with-channel-mutex diff --git a/racket/benchpilot/diagnostics/dtc-test.rkt b/racket/benchpilot/diagnostics/dtc-test.rkt new file mode 100644 index 0000000..af05834 --- /dev/null +++ b/racket/benchpilot/diagnostics/dtc-test.rkt @@ -0,0 +1,43 @@ +#lang racket/base + +;; DTC primitives: the simulated ECU answers ReadDTCInformation (0x19 0x02) +;; with its canned fault and clears it via ClearDiagnosticInformation (0x14). + +(module+ test + (require benchpilot/core/contracts + benchpilot/diagnostics/channels/sim-uds-channel + benchpilot/diagnostics/uds/protocol + racket/list + racket/string + rackunit) + + (define ch (make-sim-uds-channel)) + + (define (dtc-request request) + (define result (sim-channel-request ch request 1000 5000)) + (check-true (uds-request-result-ok result)) + (check-true (uds-request-result-positive result)) + (parse-dtc-response + (hex-parse (uds-request-result-response-hex result)))) + + (test-case "fresh ECU reports the canned DTC" + (define parsed (dtc-request (uds-read-dtcs #xFF))) + (check-equal? (hash-ref parsed 'availableMask) #x2F) + (define dtcs (hash-ref parsed 'dtcs)) + (check-equal? (length dtcs) 1) + (check-equal? (hash-ref (first dtcs) 'dtc) "0x010870") + (check-equal? (hash-ref (first dtcs) 'status) "0x2f")) + + (test-case "clearing removes the DTC" + ;; 0x14 answers with the bare positive SID, which the channel reports + ;; as an empty payload. + (define cleared (sim-channel-request ch (uds-clear-dtcs #xFFFFFF) 1000 5000)) + (check-true (uds-request-result-ok cleared)) + (check-true (uds-request-result-positive cleared)) + (check-equal? (uds-request-result-response-hex cleared) "") + (check-equal? (hash-ref (dtc-request (uds-read-dtcs #xFF)) 'dtcs) '())) + + (test-case "malformed requests are rejected with ISO NRCs" + (define result (sim-channel-request ch (list #x19 #x02) 1000 5000)) + (check-false (uds-request-result-positive result)) + (check-equal? (uds-request-result-nrc result) "incorrectMessageLength"))) diff --git a/racket/benchpilot/diagnostics/flash/engine.rkt b/racket/benchpilot/diagnostics/flash/engine.rkt index 7f9363d..639c209 100644 --- a/racket/benchpilot/diagnostics/flash/engine.rkt +++ b/racket/benchpilot/diagnostics/flash/engine.rkt @@ -11,6 +11,7 @@ (require benchpilot/core/contracts benchpilot/core/bench-runtime + benchpilot/diagnostics/security-provider benchpilot/diagnostics/uds/protocol) (provide (struct-out exn:fail:flash) @@ -142,7 +143,10 @@ ;; 2. Security access (optional). (when (uds-flash-plan-security-level plan) (define deriver-name (uds-flash-plan-key-deriver plan)) - (define deriver (and deriver-name (hash-ref (flash-engine-key-derivers engine) deriver-name #f))) + (define deriver + (and deriver-name + (or (hash-ref (flash-engine-key-derivers engine) deriver-name #f) + (resolve-key-deriver deriver-name)))) (unless deriver (fail! (format "Plan references key deriver '~a' but no such deriver is registered." deriver-name))) diff --git a/racket/benchpilot/diagnostics/flash/image-test.rkt b/racket/benchpilot/diagnostics/flash/image-test.rkt new file mode 100644 index 0000000..f13f33d --- /dev/null +++ b/racket/benchpilot/diagnostics/flash/image-test.rkt @@ -0,0 +1,123 @@ +#lang racket/base + +;; Intel HEX / S-record parsing: golden fixtures, checksum enforcement, +;; address bases and segment merging. + +(module+ test + (require benchpilot/diagnostics/flash/image + racket/format + racket/list + rackunit) + + ;; Fixture builders: correct checksums are computed, not hand-written. + (define (ihex addr type data) + (define count (length data)) + (define body + (append (list count + (arithmetic-shift addr -8) + (bitwise-and addr #xFF) + type) + data)) + (define checksum (bitwise-and (- 256 (apply + body)) #xFF)) + (format ":~a" + (apply string-append + (append (for/list ([b (in-list body)]) + (~r b #:base 16 #:min-width 2 #:pad-string "0")) + (list (~r checksum #:base 16 #:min-width 2 #:pad-string "0")))))) + + (define (ihex-line addr type data) + (string-append (ihex addr type data) "\n")) + + (define data-16a (for/list ([i 16]) (modulo (* i 7) 256))) + (define data-16b (for/list ([i 16]) (modulo (+ i 3) 256))) + (define data-11 (for/list ([i 11]) (modulo (+ (* i 5) 2) 256))) + + (define ihex-sample + (string-append + (ihex-line #x0100 0 data-16a) + (ihex-line #x0110 0 data-16b) + (ihex-line #x0120 0 data-11) + (ihex-line 0 1 '()))) + + (define (srec type addr data) + (define addr-bytes (case type [(1 9) 2] [(2 8) 3] [(3 7) 4] [else 0])) + (define addr-list + (for/list ([i (in-range addr-bytes)]) + (bitwise-and (arithmetic-shift addr (* -8 (- addr-bytes 1 i))) #xFF))) + (define count (+ addr-bytes (length data) 1)) + (define sum (apply + count (append addr-list data))) + (define checksum (bitwise-and (bitwise-not sum) #xFF)) + (define all (append (list count) addr-list data (list checksum))) + (format "S~a~a\n" + type + (apply string-append + (for/list ([b (in-list all)]) + (~r b #:base 16 #:min-width 2 #:pad-string "0"))))) + + (define srec-sample + (string-append + (srec 0 0 (map char->integer (string->list "HDR"))) + (srec 1 #x1000 (list #x61 #x42 #x43)) + (srec 1 #x1008 (list #x61 #x42 #x43)) + (srec 9 0 '()))) + + (test-case "intel hex parses records into ordered segments" + (define segs (parse-intel-hex ihex-sample)) + (check-equal? (length segs) 3) + (check-equal? (image-segment-address (first segs)) #x0100) + (check-equal? (bytes-length (image-segment-data (first segs))) 16) + (check-equal? (bytes-ref (image-segment-data (first segs)) 0) 0) + (check-equal? (bytes-ref (image-segment-data (first segs)) 1) 7) + (check-equal? (image-segment-address (third segs)) #x0120)) + + (test-case "intel hex extended linear address raises the base" + (define text + (string-append + (ihex-line 0 4 (list #x08 #x00)) + (ihex-line 0 0 (list #x11 #x22 #x33 #x44)) + (ihex-line 0 1 '()))) + (define segs (parse-intel-hex text)) + (check-equal? (length segs) 1) + (check-equal? (image-segment-address (first segs)) #x08000000)) + + (test-case "intel hex checksums are enforced" + (define good (ihex 0 0 (list 1 2 3))) + (define corrupted + (string-append + (substring good 0 (- (string-length good) 2)) + (if (string=? (substring good (- (string-length good) 2) (- (string-length good) 1)) + "F") + "0" + "F") + "\n")) + (check-exn exn:fail:image? (lambda () (parse-intel-hex corrupted)))) + + (test-case "s-record parses data records with checksum validation" + (define segs (parse-srecord srec-sample)) + (check-equal? (length segs) 2) + (check-equal? (image-segment-address (first segs)) #x1000) + (check-equal? (bytes->list (image-segment-data (first segs))) + (list #x61 #x42 #x43))) + + (test-case "s-record rejects corrupted bytes" + (define good (srec 1 #x1000 (list #x61 #x42 #x43))) + (define corrupted + (string-append + (substring good 0 8) + (string (if (char=? (string-ref good 8) #\6) #\7 #\6)) + (substring good 9))) + (check-exn exn:fail:image? (lambda () (parse-srecord corrupted)))) + + (test-case "adjacent records merge into contiguous segments" + (define segs (parse-intel-hex ihex-sample)) + (define merged (merge-image-segments segs)) + (check-equal? (length merged) 1) + (check-equal? (bytes-length (image-segment-data (first merged))) 43) + (check-equal? (image-segment-address (first merged)) #x0100)) + + (test-case "format detection covers the common extensions" + (check-equal? (image-format-for-path "fw.hex") "hex") + (check-equal? (image-format-for-path "app.ihex") "hex") + (check-equal? (image-format-for-path "app.s19") "srecord") + (check-equal? (image-format-for-path "app.srec") "srecord") + (check-false (image-format-for-path "app.bin")))) diff --git a/racket/benchpilot/diagnostics/flash/image.rkt b/racket/benchpilot/diagnostics/flash/image.rkt new file mode 100644 index 0000000..a23ddcc --- /dev/null +++ b/racket/benchpilot/diagnostics/flash/image.rkt @@ -0,0 +1,216 @@ +#lang racket/base + +;; Firmware image model: Intel HEX and Motorola S-record files parse into +;; address/data segments, so one declarative flash plan can flash a +;; multi-region image the same way it flashes inline hex data. BIN stays +;; explicit-address by design (BenchPilot never guesses flash addresses). + +(require racket/format + racket/list + racket/string) + +(require benchpilot/core/contracts) + +(provide (struct-out image-segment) + (struct-out exn:fail:image) + parse-intel-hex + parse-srecord + image-segments? + merge-image-segments + image-format-for-path) + +(struct image-segment (address data) #:transparent) +(struct exn:fail:image exn:fail () #:transparent) + +(define (image-error message) + (raise (exn:fail:image message (current-continuation-marks)))) + +(define (hex-digit? c) + (or (char-numeric? c) + (and (char>=? (char-upcase c) #\A) (char-upcase c) (char<=? (char-upcase c) #\F)))) + +(define (hex-byte s i) + (string->number (substring s i (+ i 2)) 16)) + +(define (byte-list s offset count) + (for/list ([i (in-range count)]) + (hex-byte s (+ offset (* 2 i))))) + +;; --------------------------------------------------------------------------- +;; Intel HEX (I32HEX): :llaaaatt[dd...]cc, types 00 data, 01 EOF, +;; 02 extended segment address, 04 extended linear address. +;; --------------------------------------------------------------------------- + +(define (parse-intel-hex text) + (define segments + (let loop ([lines (string-split text "\n")] + [base 0] + [acc '()] + [at-eof? #f]) + (if (or (null? lines) at-eof?) + (reverse acc) + (let* ([raw (string-trim (car lines))]) + (cond + [(string-blank? raw) (loop (cdr lines) base acc at-eof?)] + [(not (string-prefix? raw ":")) + (image-error "Intel HEX line does not start with ':'")] + [else + (define body (substring raw 1)) + (define count (hex-byte body 0)) + (define offset (+ (arithmetic-shift (hex-byte body 2) 8) + (hex-byte body 4))) + (define type (hex-byte body 6)) + (define expected-len (+ 5 count)) ; count + addr + type + cksum + (unless (>= (quotient (string-length body) 2) expected-len) + (image-error "Intel HEX record is truncated")) + (define checksum (hex-byte body (* 2 (+ 4 count)))) + (define sum + (for/sum ([i (in-range (+ 4 count))]) + (hex-byte body (* 2 i)))) + (unless (zero? (modulo (+ sum checksum) 256)) + (image-error "Intel HEX checksum mismatch")) + (define data (byte-list body 8 count)) + (case type + [(0) + (define addr (+ base offset)) + (loop (cdr lines) base + (cons (image-segment addr (list->bytes data)) acc) + at-eof?)] + [(1) (loop (cdr lines) base acc #t)] + [(2) + ;; extended segment: base = value << 4 + (loop (cdr lines) + (* (+ (arithmetic-shift (first data) 8) (second data)) 16) + acc at-eof?)] + [(4) + ;; extended linear: base = value << 16 + (loop (cdr lines) + (* (+ (arithmetic-shift (first data) 8) (second data)) + 65536) + acc at-eof?)] + [else (loop (cdr lines) base acc at-eof?)])]))))) + segments) + +;; --------------------------------------------------------------------------- +;; Motorola S-record: S0 header, S1/S2/S3 data, S7/S8/S9 stop; one's +;; complement checksum over count+address+data. +;; --------------------------------------------------------------------------- + +(define (srec-address-length type) + (case type + [(1 9) 2] + [(2 8) 3] + [(3 7) 4] + [else 0])) + +(define (parse-srecord text) + (let loop ([lines (string-split text "\n")] + [acc '()] + [at-stop? #f]) + (if (or (null? lines) at-stop?) + (reverse acc) + (let ([line (string-trim (car lines))]) + (cond + [(string-blank? line) (loop (cdr lines) acc at-stop?)] + [(not (string-prefix? line "S")) + (image-error "S-record line does not start with 'S'")] + [else + (define type (string->number (substring line 1 2))) + (define count (hex-byte line 2)) + ;; "S" and the type are not bytes: byte i lives at char 2+2i. + (define total (quotient (- (string-length line) 2) 2)) + (unless (= count (- total 1)) + (image-error "S-record byte count mismatch")) + (define sum + (for/sum ([i (in-range count)]) + (hex-byte line (* 2 (+ 1 i))))) + (define checksum (hex-byte line (* 2 (+ 1 count)))) + (unless (= (bitwise-and (bitwise-not sum) #xFF) checksum) + (image-error "S-record checksum mismatch")) + (case type + [(1 2 3) + (define addr-bytes (srec-address-length type)) + (define addr + (for/sum ([i (in-range addr-bytes)]) + (arithmetic-shift (hex-byte line (* 2 (+ 2 i))) + (* 8 (- addr-bytes 1 i))))) + ;; byte 0 is the count; data begins after count + address. + (define data-start (+ 2 (* 2 (add1 addr-bytes)))) + (define data-count (- count addr-bytes 1)) + (define data (byte-list line data-start data-count)) + (loop (cdr lines) + (cons (image-segment addr (list->bytes data)) acc) + at-stop?)] + [(7 8 9) (loop (cdr lines) acc #t)] + [else (loop (cdr lines) acc at-stop?)])]))))) + +;; --------------------------------------------------------------------------- +;; Shared helpers +;; --------------------------------------------------------------------------- + +(define (image-segments? v) + (and (list? v) + (andmap (lambda (s) (image-segment? s)) v))) + +(define (merge-image-segments segments [gap 0]) + ;; Coalesces adjacent records into contiguous segments so a flash plan + ;; carries one segment per contiguous region. + (define sorted + (sort segments (lambda (a b) (< (image-segment-address a) + (image-segment-address b))))) + (let loop ([remaining sorted] + [current #f] + [acc '()]) + (cond + [(null? remaining) + (reverse (if current (cons current acc) acc))] + [else + (define seg (car remaining)) + (if (not current) + (loop (cdr remaining) seg acc) + (let* ([cur-end (+ (image-segment-address current) + (bytes-length (image-segment-data current)))] + [next-start (image-segment-address seg)] + [next-end (+ next-start (bytes-length (image-segment-data seg)))]) + (cond + [(<= next-start cur-end) + ;; overlapping or adjacent: extend + (if (<= next-end cur-end) + (loop (cdr remaining) current acc) + (let* ([merged (make-bytes (- next-end + (image-segment-address current)))] + [_ (memcpy-bytes merged 0 (image-segment-data current))] + [_ (memcpy-bytes merged + (- next-start + (image-segment-address current)) + (image-segment-data seg))]) + (loop (cdr remaining) + (image-segment (image-segment-address current) merged) + acc)))] + [(<= (- next-start cur-end) gap) + (let* ([merged (make-bytes (- next-end + (image-segment-address current)))] + [_ (memcpy-bytes merged 0 (image-segment-data current))] + [_ (memcpy-bytes merged + (- next-start + (image-segment-address current)) + (image-segment-data seg))]) + (loop (cdr remaining) + (image-segment (image-segment-address current) merged) + acc))] + [else + (loop (cdr remaining) seg (cons current acc))])))]))) + +(define (memcpy-bytes dest dest-offset src) + (for ([b (in-bytes src)] + [i (in-naturals)]) + (bytes-set! dest (+ dest-offset i) b))) + +(define (image-format-for-path path) + (define lower (string-downcase path)) + (cond + [(or (string-suffix? lower ".hex") (string-suffix? lower ".ihex")) "hex"] + [(or (string-suffix? lower ".s19") (string-suffix? lower ".srec") + (string-suffix? lower ".s28") (string-suffix? lower ".sx") + (string-suffix? lower ".s")) "srecord"] + [else #f])) diff --git a/racket/benchpilot/diagnostics/security-provider-test.rkt b/racket/benchpilot/diagnostics/security-provider-test.rkt new file mode 100644 index 0000000..9e24ab5 --- /dev/null +++ b/racket/benchpilot/diagnostics/security-provider-test.rkt @@ -0,0 +1,43 @@ +#lang racket/base + +;; Security Provider: an external command computes the security key from the +;; seed (`command:` deriver names), so vendor algorithms stay in vendor +;; binaries while Core only sees bytes. + +(module+ test + (require benchpilot/diagnostics/flash/engine + benchpilot/diagnostics/security-provider + racket/file + racket/format + racket/runtime-path + rackunit) + + ;; POSIX-only: the provider runs a shell script through exec, which has + ;; no equivalent for a bare .sh file on Windows. + (when (eq? (system-path-convention-type) 'unix) + (test-case "external command provider computes the key from the seed" + (define script-path (make-temporary-file "keyprov-~a.sh")) + (display-to-file + #<