diff --git a/.cargo/audit.toml b/.cargo/audit.toml index b655899..a46a141 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -6,21 +6,21 @@ [advisories] ignore = [ - # rsa 0.9.x: timing-sidechannel ("Marvin Attack") in RSA-PKCS#1v1.5 decrypt. - # Reaches us via sqlx-mysql, which is pulled in by sqlx's `macros` feature - # (the proc-macro support needs to know every backend's SQL dialect at - # compile time, even when only the sqlite backend is enabled at runtime). - # Our daemon never opens a MySQL connection -> the vulnerable code path is - # dead at runtime. Track upstream sqlx for a backend-scoped macros feature. - "RUSTSEC-2023-0071", + # rsa 0.9.x: timing-sidechannel ("Marvin Attack") in RSA-PKCS#1v1.5 decrypt. + # Reaches us via sqlx-mysql, which is pulled in by sqlx's `macros` feature + # (the proc-macro support needs to know every backend's SQL dialect at + # compile time, even when only the sqlite backend is enabled at runtime). + # Our daemon never opens a MySQL connection -> the vulnerable code path is + # dead at runtime. Track upstream sqlx for a backend-scoped macros feature. + "RUSTSEC-2023-0071", - # paste 1.0.15: unmaintained warning. Transitive via ratatui 0.29 for - # internal macro-by-example helpers. Not a CVE, not a soundness issue. - # Drop once ratatui releases a version that removes the paste dep. - "RUSTSEC-2024-0436", + # paste 1.0.15: unmaintained warning. Transitive via ratatui 0.29 for + # internal macro-by-example helpers. Not a CVE, not a soundness issue. + # Drop once ratatui releases a version that removes the paste dep. + "RUSTSEC-2024-0436", - # lru 0.12.5: unsoundness in `IterMut`. ratatui uses lru as a cell cache - # but does not invoke `IterMut`; the unsound code path is not reachable - # through our usage. Drop once ratatui depends on lru >= 0.13. - "RUSTSEC-2026-0002", + # lru 0.12.5: unsoundness in `IterMut`. ratatui uses lru as a cell cache + # but does not invoke `IterMut`; the unsound code path is not reachable + # through our usage. Drop once ratatui depends on lru >= 0.13. + "RUSTSEC-2026-0002", ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c7cec3..738210e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,11 +7,9 @@ on: - master tags: - v?[0-9]+.[0-9]+.[0-9]+* - concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true - jobs: DeterminateCI: uses: DeterminateSystems/ci/.github/workflows/workflow.yml@main @@ -20,3 +18,4 @@ jobs: contents: read with: visibility: public + fail-fast: false diff --git a/.gitignore b/.gitignore index 44ee6a1..e96f7a7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ result-* __pycache__ *.pyc + +objects diff --git a/Cargo.lock b/Cargo.lock index 74aeaf8..ea25f33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,12 +117,36 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "append-only-vec" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2114736faba96bcd79595c700d03183f61357b9fbce14852515e59f3bee4ed4a" + [[package]] name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "askama" version = "0.12.1" @@ -164,7 +188,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" dependencies = [ - "nom", + "nom 7.1.3", +] + +[[package]] +name = "async-scoped" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4042078ea593edffc452eef14e99fdb2b120caa4ad9618bcdeabc4a023b98740" +dependencies = [ + "futures", + "pin-project", + "tokio", ] [[package]] @@ -227,7 +262,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", + "sha1 0.10.6", "sync_wrapper", "tokio", "tokio-tungstenite", @@ -284,12 +319,28 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base-encode" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a17bd29f7c70f32e9387f4d4acfa5ea7b7749ef784fb78cf382df97069337b8c" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -305,6 +356,21 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -320,6 +386,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -329,6 +409,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -375,6 +464,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -394,8 +494,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -439,6 +541,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "color-eyre" version = "0.6.5" @@ -472,6 +583,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "commondir" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab552acb7c0a751c75c3dd4f9b95d31ed85c985ce5c70232a2952ffbe7ecfda5" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "compact_str" version = "0.8.1" @@ -486,6 +606,21 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -495,12 +630,55 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "concurrent_lru" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feb5cb312f774e8a24540e27206db4e890f7d488563671d24a16389cf4c2e4e" +dependencies = [ + "once_cell", +] + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.0", + "windows-sys 0.59.0", +] + [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -527,6 +705,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -536,6 +720,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "cranelift-assembler-x64" version = "0.132.0" @@ -676,6 +869,34 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -727,6 +948,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "darling" version = "0.23.0" @@ -761,6 +991,20 @@ dependencies = [ "syn", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -773,23 +1017,57 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + [[package]] name = "directories" version = "5.0.1" @@ -849,6 +1127,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dragonbox_ecma" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd8e701084c37e7ef62d3f9e453b618130cbc0ef3573847785952a3ac3f746bf" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -864,6 +1148,36 @@ dependencies = [ "serde", ] +[[package]] +name = "elf" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4445909572dbd556c457c849c4ca58623d84b27c8fff1e74b0b4227d8b90d17b" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "endian-type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" + [[package]] name = "equivalent" version = "1.0.2" @@ -877,7 +1191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -912,6 +1226,26 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fast-glob" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b9e81515b0279bf618200fd15d132e7195d2048fb46eed6f0f3c10cbc068266" +dependencies = [ + "arrayvec", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -934,6 +1268,32 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "flume" version = "0.11.1" @@ -1162,13 +1522,28 @@ dependencies = [ ] [[package]] -name = "hashbrown" -version = "0.15.5" +name = "halfbrown" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "0c7ed2f2edad8a14c8186b847909a41fbb9c3eafa44f88bd891114ed5019da09" dependencies = [ - "allocator-api2", - "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", ] @@ -1189,6 +1564,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", "foldhash 0.2.0", ] @@ -1217,6 +1593,7 @@ dependencies = [ "heimdall-driver", "heimdall-fuzzer", "heimdall-golden", + "heimdall-i18n", "heimdall-test", "heimdall-tools", "heimdall-transport", @@ -1224,6 +1601,21 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "heimdall-api" +version = "0.1.0" +dependencies = [ + "bytes", + "chrono", + "heimdall-core", + "hex", + "serde", + "serde_json", + "sha2", + "ts-rs", + "uuid", +] + [[package]] name = "heimdall-config" version = "0.1.0" @@ -1245,6 +1637,7 @@ dependencies = [ "serde_json", "sha2", "thiserror 1.0.69", + "ts-rs", "uuid", ] @@ -1252,45 +1645,63 @@ dependencies = [ name = "heimdall-daemon" version = "0.1.0" dependencies = [ - "askama", "async-trait", "axum", "base64", "bytes", "chrono", "futures", + "heimdall-api", "heimdall-config", "heimdall-core", + "heimdall-disasm", "heimdall-driver", + "heimdall-fuzzer", "heimdall-golden", "heimdall-i18n", "heimdall-test", + "heimdall-tools", "heimdall-transport", - "hex", - "mime_guess", + "heimdall-web", "reqwest", - "rust-embed", "serde", "serde_json", - "sha2", "sqlx", "tar", "tempfile", "thiserror 1.0.69", "tokio", + "tokio-tungstenite", + "tokio-util", "tower-http", "tracing", "uuid", ] +[[package]] +name = "heimdall-disasm" +version = "0.1.0" +dependencies = [ + "async-trait", + "heimdall-core", + "serde", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", + "ts-rs", +] + [[package]] name = "heimdall-driver" version = "0.1.0" dependencies = [ "aegis-ip", "async-trait", + "elf", "heimdall-core", "heimdall-golden", + "heimdall-mock-openocd", "heimdall-test", "heimdall-tools", "heimdall-transport", @@ -1313,6 +1724,7 @@ dependencies = [ "heimdall", "heimdall-daemon", "heimdall-i18n", + "indicatif", "reqwest", "serde", "serde_json", @@ -1328,13 +1740,17 @@ dependencies = [ "aegis-ip", "async-trait", "bytes", + "clap", "cranelift-codegen", "cranelift-frontend", "heimdall-core", "heimdall-driver", "heimdall-golden", "heimdall-test", + "heimdall-tools", + "heimdall-transport", "rand 0.8.6", + "serde", "serde_json", "target-lexicon", "thiserror 1.0.69", @@ -1350,6 +1766,7 @@ dependencies = [ "aegis-ip", "aegis-sim", "async-trait", + "elf", "heimdall-core", "serde_json", "tempfile", @@ -1365,11 +1782,19 @@ dependencies = [ "toml", ] +[[package]] +name = "heimdall-mock-openocd" +version = "0.1.0" +dependencies = [ + "tokio", +] + [[package]] name = "heimdall-test" version = "0.1.0" dependencies = [ "async-trait", + "heimdall-api", "heimdall-core", "heimdall-driver", "heimdall-golden", @@ -1413,12 +1838,14 @@ dependencies = [ name = "heimdall-tui" version = "0.1.0" dependencies = [ + "chrono", "crossterm", "futures", "heimdall-config", "heimdall-core", "heimdall-daemon", "heimdall-i18n", + "heimdall-test", "ratatui", "reqwest", "serde", @@ -1430,6 +1857,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "heimdall-web" +version = "0.1.0" +dependencies = [ + "askama", + "async-trait", + "axum", + "heimdall-api", + "heimdall-core", + "heimdall-disasm", + "heimdall-i18n", + "lucide-icons", + "mime_guess", + "rolldown", + "rust-embed", + "serde", + "thiserror 1.0.69", + "tokio", + "ts-rs", +] + [[package]] name = "hex" version = "0.4.3" @@ -1451,7 +1899,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1517,6 +1965,15 @@ dependencies = [ "libm", ] +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1734,6 +2191,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.0", + "web-time", +] + [[package]] name = "indoc" version = "2.0.7" @@ -1743,6 +2213,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + [[package]] name = "instability" version = "0.3.12" @@ -1793,6 +2272,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1811,6 +2299,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-escape-simd" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e770254dd7802184595b1d30da2a15cb72569e2aca2b177aef8d22eac8a693" + +[[package]] +name = "json-strip-comments" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9301b34ecbe81051a62001a2dfa56d906628efdfbc68153e0a4d5eba58181ece" +dependencies = [ + "memchr", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1909,6 +2412,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lucide-icons" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ada83468e5be8c9b17564fc0c0732435311cde019d83aa4d8815c865186ef7d" + [[package]] name = "mach2" version = "0.4.3" @@ -1940,7 +2449,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -1978,6 +2487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -2005,6 +2515,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + [[package]] name = "nix" version = "0.26.4" @@ -2028,6 +2547,12 @@ dependencies = [ "libc", ] +[[package]] +name = "nodejs-built-in-modules" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5eb86a92577833b75522336f210c49d9ebd7dd55a44d80a92e68c668a75f27c" + [[package]] name = "nom" version = "7.1.3" @@ -2038,6 +2563,21 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2047,6 +2587,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -2093,6 +2643,12 @@ dependencies = [ "libm", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "nusb" version = "0.1.14" @@ -2139,6 +2695,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "owo-colors" version = "4.3.0" @@ -2146,133 +2708,776 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] -name = "parking" -version = "2.2.1" +name = "oxc" +version = "0.133.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +checksum = "cd840e7bf69e7db0148361251349bc85939dc81506b22584245ec539bff53e66" +dependencies = [ + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_cfg", + "oxc_codegen", + "oxc_diagnostics", + "oxc_isolated_declarations", + "oxc_mangler", + "oxc_minifier", + "oxc_parser", + "oxc_regular_expression", + "oxc_semantic", + "oxc_span", + "oxc_syntax", + "oxc_transformer", + "oxc_transformer_plugins", +] [[package]] -name = "parking_lot" -version = "0.12.5" +name = "oxc-browserslist" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "ed057e45c57a87f33e7942215417089e3c038ba6c5dd5e4a6b3ecaa09bbd58d8" dependencies = [ - "lock_api", - "parking_lot_core", + "flate2", + "postcard", + "rustc-hash", + "serde", + "thiserror 2.0.18", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "oxc-miette" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "4356a61f2ed4c9b3610245215fbf48970eb277126919f87db9d0efa93a74245c" dependencies = [ "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", + "owo-colors", + "oxc-miette-derive", + "textwrap", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-width 0.2.0", ] [[package]] -name = "paste" -version = "1.0.15" +name = "oxc-miette-derive" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "b237422b014f8f8fff75bb9379e697d13f8d57551a22c88bebb39f073c1bf696" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "pem-rfc7468" -version = "0.7.0" +name = "oxc_allocator" +version = "0.133.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +checksum = "5e900ccc598726485709ccee5caf11687db0fdce7a7f6ab5ca67ab99036347fd" dependencies = [ - "base64ct", + "allocator-api2", + "hashbrown 0.17.1", + "oxc_data_structures", + "oxc_estree", + "rustc-hash", + "serde", ] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "oxc_ast" +version = "0.133.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "e85f2b08a659b819c31ae798a4d8ed43d5d3e4b5d3c24fe244599ed602401c16" +dependencies = [ + "bitflags 2.11.1", + "oxc_allocator", + "oxc_ast_macros", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_estree", + "oxc_regular_expression", + "oxc_span", + "oxc_str", + "oxc_syntax", +] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "oxc_ast_macros" +version = "0.133.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "b3baa8c4432cc17e36cb2aff37fc9e57e7defa5d0cf8fd4127d68ca474dd5edd" +dependencies = [ + "phf", + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "pkcs1" -version = "0.7.5" +name = "oxc_ast_visit" +version = "0.133.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +checksum = "976e4c8e1874fd007c4e9a729ac0975e15cbc6b715b620cbb9616b73a30828be" dependencies = [ - "der", - "pkcs8", - "spki", + "oxc_allocator", + "oxc_ast", + "oxc_span", + "oxc_syntax", ] [[package]] -name = "pkcs8" -version = "0.10.2" +name = "oxc_cfg" +version = "0.133.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +checksum = "656308108c2be80cddf6fdf36f2296138f3c0061845149d4f0b405ea6ffb6855" dependencies = [ - "der", - "spki", + "bitflags 2.11.1", + "itertools 0.14.0", + "oxc_index", + "oxc_syntax", + "petgraph", + "rustc-hash", ] [[package]] -name = "pkg-config" -version = "0.3.33" +name = "oxc_codegen" +version = "0.133.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "9f4d0ed54011c9647ec07e0445cbbc5113c0000b2994ac738321ea41a3a85644" +dependencies = [ + "bitflags 2.11.1", + "cow-utils", + "dragonbox_ecma", + "itoa", + "oxc_allocator", + "oxc_ast", + "oxc_data_structures", + "oxc_index", + "oxc_semantic", + "oxc_sourcemap", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", +] [[package]] -name = "plain" -version = "0.2.3" +name = "oxc_compat" +version = "0.133.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +checksum = "004a1b0e2bbba4afe2a68914d595346d2ea6705d6c467fcdd9256c16984cf71c" +dependencies = [ + "cow-utils", + "oxc-browserslist", + "oxc_syntax", + "rustc-hash", + "serde", +] [[package]] -name = "potential_utf" -version = "0.1.5" +name = "oxc_data_structures" +version = "0.133.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "9315b3a6c1560d176796921441fb91dc45ef3810fb2b98fedc040162f3a3a0d8" dependencies = [ - "zerovec", + "ropey", ] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "oxc_diagnostics" +version = "0.133.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "a729e6dbb517aec94346685e769f960dbdd335523cf9c844ecca7731beb15e2c" dependencies = [ - "zerocopy", + "cow-utils", + "oxc-miette", + "percent-encoding", ] [[package]] -name = "prettyplease" -version = "0.2.37" +name = "oxc_ecmascript" +version = "0.133.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "6f803b86d86fd830075a6a7f7b84c59e93131367738c55b4e7526669eeb0cc70" dependencies = [ - "proc-macro2", - "syn", + "cow-utils", + "num-bigint", + "num-traits", + "oxc_allocator", + "oxc_ast", + "oxc_regular_expression", + "oxc_span", + "oxc_syntax", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "oxc_estree" +version = "0.133.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "ad19053743d90699386a7783562185cc88a4a240a02406e005225a9ea07e4f3a" dependencies = [ - "unicode-ident", + "dragonbox_ecma", + "itoa", + "oxc_data_structures", ] [[package]] -name = "quinn" +name = "oxc_index" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3e6120999627ec9703025eab7c9f410ebb7e95557632a8902ca48210416c2b" +dependencies = [ + "nonmax", + "rayon", + "serde", +] + +[[package]] +name = "oxc_isolated_declarations" +version = "0.133.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd64ce6511afb4f982f4234c6f5f3de9c60dfab309d55c9353c949b38f0ac591" +dependencies = [ + "bitflags 2.11.1", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", +] + +[[package]] +name = "oxc_mangler" +version = "0.133.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31a183e801bfaeb743a40867c2cbd2568e063f04da204e7b66a9707fb4b060d" +dependencies = [ + "itertools 0.14.0", + "oxc_allocator", + "oxc_ast", + "oxc_data_structures", + "oxc_index", + "oxc_semantic", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", +] + +[[package]] +name = "oxc_minifier" +version = "0.133.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb678b4f814f97cf8c4553580663bfca3dd26cabba2ff234532ec441ea81b28" +dependencies = [ + "cow-utils", + "itoa", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_compat", + "oxc_data_structures", + "oxc_ecmascript", + "oxc_index", + "oxc_mangler", + "oxc_parser", + "oxc_regular_expression", + "oxc_semantic", + "oxc_span", + "oxc_str", + "oxc_syntax", + "oxc_traverse", + "rustc-hash", +] + +[[package]] +name = "oxc_parser" +version = "0.133.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5983baba9d73d319214c3d0492987f4b47cb75b916b0e428adb3b3695a728ae" +dependencies = [ + "bitflags 2.11.1", + "cow-utils", + "memchr", + "num-bigint", + "num-traits", + "oxc_allocator", + "oxc_ast", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_regular_expression", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", + "seq-macro", +] + +[[package]] +name = "oxc_regular_expression" +version = "0.133.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bc4b6c29740a9de457f498b4e07c60af2c3acdc93cdc83a87b51f9c18b34b5" +dependencies = [ + "bitflags 2.11.1", + "oxc_allocator", + "oxc_ast_macros", + "oxc_diagnostics", + "oxc_span", + "oxc_str", + "phf", + "rustc-hash", + "unicode-id-start", +] + +[[package]] +name = "oxc_resolver" +version = "11.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c64950816ae082c23c9e43e59cf75343f3087a37ae9888c0a174fde95e5f7de" +dependencies = [ + "cfg-if", + "compact_str 0.9.1", + "fast-glob", + "indexmap", + "json-strip-comments", + "nodejs-built-in-modules", + "once_cell", + "papaya", + "percent-encoding", + "pnp", + "rustc-hash", + "rustix 1.1.4", + "self_cell", + "serde", + "serde_json", + "simd-json", + "simdutf8", + "thiserror 2.0.18", + "tracing", + "windows", +] + +[[package]] +name = "oxc_semantic" +version = "0.133.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa288d48d10f2bbf470870b76280f754048fde578ce6051987f40c2dec84495" +dependencies = [ + "itertools 0.14.0", + "memchr", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_cfg", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_index", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", + "self_cell", + "smallvec", +] + +[[package]] +name = "oxc_sourcemap" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d378eb8bad20e89d66276aebab51f6a5408571092cac94abdd3eabb773713d6" +dependencies = [ + "base64-simd", + "json-escape-simd", + "rustc-hash", + "serde", + "serde_json", +] + +[[package]] +name = "oxc_span" +version = "0.133.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92507cc30d1b8abd38fc1368ef90a33d573e746a048adefaae7434ad1be91ff" +dependencies = [ + "compact_str 0.9.1", + "oxc-miette", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_str", + "serde", +] + +[[package]] +name = "oxc_str" +version = "0.133.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c8413fd0d68722180b2a3568a73920330ae2674975336b35a9cceb691b6f3f2" +dependencies = [ + "compact_str 0.9.1", + "hashbrown 0.17.1", + "oxc_allocator", + "oxc_estree", + "serde", +] + +[[package]] +name = "oxc_syntax" +version = "0.133.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8ca91f5e39d6db686f3a75bdf01e2a44c05d24a554d22470073dd79462877b" +dependencies = [ + "bitflags 2.11.1", + "cow-utils", + "dragonbox_ecma", + "nonmax", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_index", + "oxc_span", + "oxc_str", + "phf", + "serde", + "unicode-id-start", +] + +[[package]] +name = "oxc_transformer" +version = "0.133.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de8df164fbaa1b09d98b6f24ef537635e01a72d623c005c52ec411b5f9d11b80" +dependencies = [ + "base64", + "compact_str 0.9.1", + "indexmap", + "itoa", + "memchr", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_compat", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_regular_expression", + "oxc_semantic", + "oxc_span", + "oxc_str", + "oxc_syntax", + "oxc_traverse", + "rustc-hash", + "serde", + "serde_json", + "sha1 0.11.0", +] + +[[package]] +name = "oxc_transformer_plugins" +version = "0.133.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c055d27fff3322072b91bbca7b6bc9f1dcbec7e381e1bdbaedd0f6a1b1333c" +dependencies = [ + "cow-utils", + "itoa", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_parser", + "oxc_semantic", + "oxc_span", + "oxc_str", + "oxc_syntax", + "oxc_transformer", + "oxc_traverse", + "rustc-hash", +] + +[[package]] +name = "oxc_traverse" +version = "0.133.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3faf0ba83a4aa1af760f887b628afa08c1da08d1e0f916e3580116ad2abdc955" +dependencies = [ + "itoa", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_data_structures", + "oxc_ecmascript", + "oxc_semantic", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", +] + +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", + "serde", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "pnp" +version = "0.12.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d021b5b4a2ea34bf137831fbbc5856e9c7ca70861f95a0f8dc51323b6e821faa" +dependencies = [ + "byteorder", + "concurrent_lru", + "fancy-regex", + "flate2", + "indexmap", + "nodejs-built-in-modules", + "pathdiff", + "radix_trie", + "rustc-hash", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" @@ -2323,7 +3528,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2347,6 +3552,16 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radix_trie" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.8.6" @@ -2414,11 +3629,11 @@ checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ "bitflags 2.11.1", "cassowary", - "compact_str", + "compact_str 0.8.1", "crossterm", "indoc", "instability", - "itertools", + "itertools 0.13.0", "lru", "paste", "strum", @@ -2427,6 +3642,26 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2467,6 +3702,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regalloc2" version = "0.15.1" @@ -2481,6 +3736,18 @@ dependencies = [ "smallvec", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -2508,6 +3775,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "regress" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158a764437582235e3501f683b93a0a6f8d825d04a789dbe5ed30b8799b8908a" +dependencies = [ + "hashbrown 0.16.1", + "memchr", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -2532,32 +3809,456 @@ dependencies = [ "rustls-pki-types", "serde", "serde_json", - "serde_urlencoded", - "sync_wrapper", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rolldown" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c723ffaf89da647ca5d724e2ea69ea8c9ee0dc03005cb7f8e6ba7a0d1fada9c" +dependencies = [ + "anyhow", + "append-only-vec", + "arcstr", + "bitflags 2.11.1", + "commondir", + "dashmap", + "futures", + "indexmap", + "itertools 0.14.0", + "itoa", + "json-escape-simd", + "memchr", + "oxc", + "oxc_allocator", + "oxc_ecmascript", + "oxc_index", + "oxc_str", + "oxc_traverse", + "petgraph", + "rayon", + "rolldown_common", + "rolldown_dev_common", + "rolldown_devtools", + "rolldown_ecmascript", + "rolldown_ecmascript_utils", + "rolldown_error", + "rolldown_fs", + "rolldown_plugin", + "rolldown_plugin_asset_module", + "rolldown_plugin_chunk_import_map", + "rolldown_plugin_copy_module", + "rolldown_plugin_data_url", + "rolldown_plugin_hmr", + "rolldown_plugin_lazy_compilation", + "rolldown_plugin_oxc_runtime", + "rolldown_resolver", + "rolldown_sourcemap", + "rolldown_std_utils", + "rolldown_tracing", + "rolldown_utils", + "rustc-hash", + "serde", + "serde_json", + "string_wizard", + "sugar_path", + "tokio", + "tracing", + "url", + "xxhash-rust", +] + +[[package]] +name = "rolldown-ariadne" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee296fe9ac0007f2c0eff04d55c980de537aa3d736e873d70c9c47d4c737ea44" +dependencies = [ + "unicode-width 0.2.0", + "yansi", +] + +[[package]] +name = "rolldown_common" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eca464f2402f767a494a29810120f61359ca807158a8b1297f528adb8f59925" +dependencies = [ + "anyhow", + "arcstr", + "bitflags 2.11.1", + "dashmap", + "derive_more", + "fast-glob", + "itertools 0.14.0", + "num-bigint", + "oxc", + "oxc_ecmascript", + "oxc_index", + "oxc_resolver", + "oxc_str", + "rolldown_ecmascript", + "rolldown_error", + "rolldown_sourcemap", + "rolldown_std_utils", + "rolldown_utils", + "rustc-hash", + "serde", + "serde_json", + "simdutf8", + "string_wizard", + "sugar_path", + "tokio", +] + +[[package]] +name = "rolldown_dev_common" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0a36ba5fb63303e3a3455b34736a08804662c627752c7784dcaea46667a4c2" +dependencies = [ + "derive_more", + "rolldown_common", + "rolldown_error", + "rolldown_utils", +] + +[[package]] +name = "rolldown_devtools" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afec505c7cab83fd5cecb31c24f8e6676de3ec4b358f80dcdd48aa5dd21a2c42" +dependencies = [ + "blake3", + "rolldown_devtools_action", + "rustc-hash", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "rolldown_devtools_action" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b133ed280e2243e729fc14ac7e2fac80f93dc2f7e3b58e4d26a1be49bee49d7" +dependencies = [ + "serde", + "ts-rs", +] + +[[package]] +name = "rolldown_ecmascript" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4208eba5ac08b442cc8d9f88afaad0ca9d7140bfa10b5ae0e2c789b9b7df7a" +dependencies = [ + "arcstr", + "oxc", + "oxc_sourcemap", + "oxc_str", + "rolldown_error", + "self_cell", +] + +[[package]] +name = "rolldown_ecmascript_utils" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ea09628a023db38306b0d4cf595db36562a3f9465a2aef6fe88085072f7db4" +dependencies = [ + "memchr", + "oxc", + "rolldown_common", + "rolldown_utils", +] + +[[package]] +name = "rolldown_error" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e51eec9a9dca9a24258e5ec9dffeb549e56830036d72ffe8f86366bdf70618" +dependencies = [ + "anyhow", + "arcstr", + "bitflags 2.11.1", + "derive_more", + "heck", + "oxc", + "oxc_resolver", + "rolldown-ariadne", + "ropey", + "rustc-hash", + "sugar_path", +] + +[[package]] +name = "rolldown_fs" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1aa67d6a32f03fe25e8923048225160a16c013016374c279b74fb7488edfe3d" +dependencies = [ + "oxc_resolver", + "vfs", +] + +[[package]] +name = "rolldown_plugin" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ee4fa03628392496179c1e1d03a47d1115ebaefe83d4ff5616b4ed959cc149" +dependencies = [ + "anyhow", + "arcstr", + "async-trait", + "bitflags 2.11.1", + "dashmap", + "derive_more", + "nodejs-built-in-modules", + "oxc_index", + "rolldown_common", + "rolldown_devtools", + "rolldown_ecmascript", + "rolldown_error", + "rolldown_fs", + "rolldown_resolver", + "rolldown_sourcemap", + "rolldown_utils", + "rustc-hash", + "serde", + "serde_json", + "string_wizard", + "sugar_path", + "tokio", + "tracing", + "typedmap", +] + +[[package]] +name = "rolldown_plugin_asset_module" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a35d44b109a65cd8a56379b856830a88e9cd2a78a722825f5100e4d12513e698" +dependencies = [ + "anyhow", + "memchr", + "rolldown_common", + "rolldown_plugin", + "rolldown_utils", + "rustc-hash", + "string_wizard", + "sugar_path", + "tokio", +] + +[[package]] +name = "rolldown_plugin_chunk_import_map" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a18e27746045b562b16ab567420b3a4b2d453a7743d6e75209469443eb0e677" +dependencies = [ + "arcstr", + "rolldown_common", + "rolldown_plugin", + "rolldown_utils", + "rustc-hash", + "serde_json", + "xxhash-rust", +] + +[[package]] +name = "rolldown_plugin_copy_module" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8abd0764a5f7d7defcd2dbc1959e9123e3a0aaba19ae2226e312d449b42168d6" +dependencies = [ + "anyhow", + "arcstr", + "memchr", + "rolldown_common", + "rolldown_plugin", + "rolldown_utils", + "rustc-hash", + "string_wizard", + "sugar_path", + "tokio", +] + +[[package]] +name = "rolldown_plugin_data_url" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78dd5185eda1085e1cedd4345cf3d9cc570ccaf78197a16b559ee558d9a035c" +dependencies = [ + "arcstr", + "base64-simd", + "rolldown_common", + "rolldown_plugin", + "rolldown_utils", + "simdutf8", + "urlencoding", +] + +[[package]] +name = "rolldown_plugin_hmr" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9adc2e03fd50373c1567ec8b10408aec4998074534af908d0ce5f79a0f099d10" +dependencies = [ + "rolldown_common", + "rolldown_plugin", +] + +[[package]] +name = "rolldown_plugin_lazy_compilation" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e526b8248625a591682c11d00d9fcf2dfd7d764b3cf9f19d03d2aa38adf2466" +dependencies = [ + "anyhow", + "arcstr", + "oxc", + "rolldown_common", + "rolldown_plugin", + "rolldown_utils", + "serde_json", +] + +[[package]] +name = "rolldown_plugin_oxc_runtime" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16c3d7a364cbe7175dad8e3a02c30e40c8e8df724342041392ce316c30f08c7e" +dependencies = [ + "arcstr", + "phf", + "rolldown_common", + "rolldown_plugin", + "rolldown_utils", +] + +[[package]] +name = "rolldown_resolver" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39b095c30226bd069b804b1101e46d2bca034569825907ddf1662b90dd3e2aee" +dependencies = [ + "anyhow", + "arcstr", + "dashmap", + "itertools 0.14.0", + "oxc_resolver", + "rolldown_common", + "rolldown_fs", + "rolldown_utils", + "sugar_path", +] + +[[package]] +name = "rolldown_sourcemap" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "354a856ffa5620b63d644ddadb49730a82bf761365289574a686dfc83ced88ac" +dependencies = [ + "memchr", + "oxc", + "oxc_sourcemap", +] + +[[package]] +name = "rolldown_std_utils" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3ef2a1fb1f90ee56b59144d8933ad47a345105fbd6922496e71bdb4935fc486" +dependencies = [ + "regex", +] + +[[package]] +name = "rolldown_tracing" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58bce845240b32a2e5e02f0301fb422de3ef0e2ffe62293a904af7fdc5e86f05" +dependencies = [ + "tracing", + "tracing-chrome", + "tracing-subscriber", +] + +[[package]] +name = "rolldown_utils" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c15db8664071d618e453a7b5d3b77919ed641f6da571b408521c46ee55cf21f" +dependencies = [ + "anyhow", + "arcstr", + "async-scoped", + "base-encode", + "base64-simd", + "cow-utils", + "dashmap", + "fast-glob", + "form_urlencoded", + "futures", + "indexmap", + "infer", + "itoa", + "memchr", + "mime", + "nom 8.0.0", + "oxc", + "oxc_index", + "oxc_str", + "rayon", + "regex", + "regress 0.11.1", + "rolldown_error", + "rolldown_std_utils", + "rustc-hash", + "serde_json", + "simdutf8", + "sugar_path", "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", + "uuid", + "xxhash-rust", ] [[package]] -name = "ring" -version = "0.17.14" +name = "ropey" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", + "smallvec", + "str_indices", ] [[package]] @@ -2566,8 +4267,8 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -2627,6 +4328,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -2637,7 +4347,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2650,7 +4360,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2739,6 +4449,22 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.28" @@ -2749,6 +4475,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.228" @@ -2796,6 +4528,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -2872,8 +4605,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -2883,8 +4627,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -2948,10 +4692,40 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd-json" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4255126f310d2ba20048db6321c81ab376f6a6735608bf11f0785c41f01f64e3" +dependencies = [ + "halfbrown", + "ref-cast", + "simdutf8", + "value-trait", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -2967,6 +4741,12 @@ dependencies = [ "serde", ] +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.6.3" @@ -3095,7 +4875,7 @@ dependencies = [ "bytes", "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -3115,7 +4895,7 @@ dependencies = [ "rand 0.8.6", "rsa", "serde", - "sha1", + "sha1 0.10.6", "sha2", "smallvec", "sqlx-core", @@ -3200,6 +4980,26 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[package]] +name = "string_wizard" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a81357c56c7281652864c8927548a133c0fafe1ed8fabdbe687e01f89b08c4" +dependencies = [ + "memchr", + "oxc_index", + "oxc_sourcemap", + "regex", + "rustc-hash", + "serde", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -3245,6 +5045,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "sugar_path" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe837e881ad5c3b60fadeb8e9b0bc5c907c4b7d84b4415a7f0bbc3f9073631" +dependencies = [ + "memchr", + "smallvec", +] + [[package]] name = "syn" version = "2.0.117" @@ -3303,7 +5113,27 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width 0.2.0", ] [[package]] @@ -3454,6 +5284,19 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -3565,6 +5408,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tracing-chrome" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0a738ed5d6450a9fb96e86a23ad808de2b727fd1394585da5cdd6788ffe724" +dependencies = [ + "serde_json", + "tracing-core", + "tracing-subscriber", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3633,6 +5487,30 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "chrono", + "thiserror 2.0.18", + "ts-rs-macros", + "uuid", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "termcolor", +] + [[package]] name = "tungstenite" version = "0.24.0" @@ -3646,11 +5524,17 @@ dependencies = [ "httparse", "log", "rand 0.8.6", - "sha1", + "sha1 0.10.6", "thiserror 1.0.69", "utf-8", ] +[[package]] +name = "typedmap" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63278e72ed4f207eb3216c944cbafb35bdb656d2eab97ef73c0c165a1cd3e319" + [[package]] name = "typenum" version = "1.20.0" @@ -3677,7 +5561,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "regress", + "regress 0.10.5", "schemars", "semver", "serde", @@ -3725,12 +5609,24 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-id-start" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" version = "0.1.25" @@ -3758,7 +5654,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "itertools", + "itertools 0.13.0", "unicode-segmentation", "unicode-width 0.1.14", ] @@ -3799,6 +5695,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -3835,6 +5737,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-trait" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e80f0c733af0720a501b3905d22e2f97662d8eacfe082a75ed7ffb5ab08cb59" +dependencies = [ + "float-cmp", + "halfbrown", + "itoa", + "ryu", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -3847,6 +5761,21 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vfs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d33cbe2ac48f2a446f04c33aef4906078ffd224888df5ae60b7ed401ded771" +dependencies = [ + "filetime", +] + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -4056,7 +5985,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -4065,6 +5994,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -4078,6 +6028,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -4106,6 +6067,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -4142,6 +6113,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4182,6 +6162,15 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -4391,6 +6380,18 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.2" @@ -4494,6 +6495,12 @@ dependencies = [ "syn", ] +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 018776d..39d643d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,13 @@ categories = ["development-tools::testing", "embedded", "hardware-support"] [workspace.dependencies] # core serde = { version = "1", features = ["derive"] } +tokio-util = "0.7" +# Build-time icon set: heimdall-web build.rs reads LUCIDE_FONT_BYTES +# and the Icon enum to stage `lucide.ttf` + a generated CSS class +# table into the embedded assets bundle. Default features only; the +# `iced` feature would pull in the whole iced runtime. +lucide-icons = { version = "1", default-features = false } +ts-rs = { version = "12", features = ["serde-compat"] } base64 = "0.22" serde_json = "1" toml = "0.8" @@ -25,29 +32,49 @@ bytes = "1" uuid = { version = "1", features = ["v4", "serde"] } sha2 = "0.10" hex = "0.4" -chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } +chrono = { version = "0.4", default-features = false, features = [ + "clock", + "serde", +] } # async -tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "process", "fs", "time", "sync", "net"] } +tokio = { version = "1", features = [ + "rt-multi-thread", + "macros", + "io-util", + "process", + "fs", + "time", + "sync", + "net", +] } async-trait = "0.1" futures = "0.3" # errors + logging thiserror = "1" tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } +tracing-subscriber = { version = "0.3", features = [ + "env-filter", + "fmt", + "json", +] } # binary-only clap = { version = "4", features = ["derive"] } color-eyre = "0.6" eyre = "0.6" directories = "5" +indicatif = "0.17" # tui ratatui = "0.29" crossterm = { version = "0.28", features = ["event-stream"] } tokio-tungstenite = "0.24" +# ELF parsing for River boot-elf loads. Pure-Rust, no_std-friendly, focused. +elf = "0.7" + # transports (feature-gated where appropriate) tokio-serial = "5" gpiocdev = "0.7" @@ -56,8 +83,17 @@ nusb = "0.1" # daemon axum = { version = "0.7", features = ["ws", "macros"] } tower-http = { version = "0.6", features = ["trace", "cors"] } -sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate", "chrono"] } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +sqlx = { version = "0.8", default-features = false, features = [ + "runtime-tokio", + "sqlite", + "macros", + "migrate", + "chrono", +] } +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "rustls-tls", +] } # tests tempfile = "3" @@ -80,15 +116,19 @@ aegis-ip = { git = "https://github.com/Midstall/aegis.git", rev = "dce7edc5706d6 aegis-sim = { git = "https://github.com/Midstall/aegis.git", rev = "dce7edc5706d6c2539fa2e17bdb7e3528c987e09", version = "0.1.0" } # intra-workspace -heimdall-i18n = { path = "crates/heimdall-i18n", version = "0.1.0" } -heimdall-core = { path = "crates/heimdall-core", version = "0.1.0" } -heimdall-config = { path = "crates/heimdall-config", version = "0.1.0" } +heimdall-i18n = { path = "crates/heimdall-i18n", version = "0.1.0" } +heimdall-core = { path = "crates/heimdall-core", version = "0.1.0" } +heimdall-api = { path = "crates/heimdall-api", version = "0.1.0" } +heimdall-mock-openocd = { path = "crates/heimdall-mock-openocd", version = "0.1.0" } +heimdall-config = { path = "crates/heimdall-config", version = "0.1.0" } heimdall-transport = { path = "crates/heimdall-transport", version = "0.1.0" } -heimdall-tools = { path = "crates/heimdall-tools", version = "0.1.0" } -heimdall-golden = { path = "crates/heimdall-golden", version = "0.1.0" } -heimdall-driver = { path = "crates/heimdall-driver", version = "0.1.0" } -heimdall-test = { path = "crates/heimdall-test", version = "0.1.0" } -heimdall-fuzzer = { path = "crates/heimdall-fuzzer", version = "0.1.0" } -heimdall-daemon = { path = "crates/heimdall-daemon", version = "0.1.0" } -heimdall-tui = { path = "crates/heimdall-tui", version = "0.1.0" } -heimdall = { path = "crates/heimdall", version = "0.1.0" } +heimdall-tools = { path = "crates/heimdall-tools", version = "0.1.0" } +heimdall-disasm = { path = "crates/heimdall-disasm", version = "0.1.0" } +heimdall-golden = { path = "crates/heimdall-golden", version = "0.1.0" } +heimdall-driver = { path = "crates/heimdall-driver", version = "0.1.0" } +heimdall-test = { path = "crates/heimdall-test", version = "0.1.0" } +heimdall-fuzzer = { path = "crates/heimdall-fuzzer", version = "0.1.0" } +heimdall-daemon = { path = "crates/heimdall-daemon", version = "0.1.0" } +heimdall-web = { path = "crates/heimdall-web", version = "0.1.0" } +heimdall-tui = { path = "crates/heimdall-tui", version = "0.1.0" } +heimdall = { path = "crates/heimdall", version = "0.1.0" } diff --git a/README.md b/README.md index 4f1f35b..fff77a1 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ Every crate in `crates/` is publishable on its own. Build only what you need. ## Crates | Crate | Role | -|----------------------|---------------------------------------------------| +| -------------------- | ------------------------------------------------- | | `heimdall` | Umbrella re-export. | | `heimdall-core` | IDs, verdicts, observations, stimuli, artifacts. | | `heimdall-config` | TOML schema + validation. | diff --git a/crates/heimdall-api/Cargo.toml b/crates/heimdall-api/Cargo.toml new file mode 100644 index 0000000..50ebafd --- /dev/null +++ b/crates/heimdall-api/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "heimdall-api" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +description = "API-side types for the Heimdall daemon HTTP/WS surface. Pure data with serde derives so the same definitions feed every client binding generator (TypeScript today via ts-rs; Python/Go/etc. follow without re-stating the wire shapes)." + +[features] +default = [] +# Enables `#[derive(ts_rs::TS)]` on every wire enum/struct here, plus +# `heimdall-core/bindings` since several variants reference `State` +# from there. heimdall-web's build.rs turns this on as a build-dep +# and runs the export inline so the frontend's TypeScript bindings +# regenerate on every cargo build, with no need to commit them. +# Future per-language features (e.g. `python-bindings`, `go-bindings`) +# slot in alongside this one without touching the types themselves. +bindings = ["dep:ts-rs", "heimdall-core/bindings"] + +[dependencies] +heimdall-core.workspace = true +serde.workspace = true +serde_json.workspace = true +chrono.workspace = true +uuid.workspace = true +bytes.workspace = true +sha2.workspace = true +hex.workspace = true +ts-rs = { workspace = true, optional = true, features = [ + "chrono-impl", + "uuid-impl", +] } diff --git a/crates/heimdall-api/src/lib.rs b/crates/heimdall-api/src/lib.rs new file mode 100644 index 0000000..7df3605 --- /dev/null +++ b/crates/heimdall-api/src/lib.rs @@ -0,0 +1,692 @@ +//! Wire types for the Heimdall daemon's HTTP + WebSocket surface. +//! +//! Pure data crate: every public item is a `serde`-friendly enum or +//! struct describing the on-the-wire shape an external client sees. +//! No server logic, no I/O, no driver internals. That's what the +//! `heimdall-daemon` crate is for. +//! +//! Keeping the wire types in their own crate lets us: +//! +//! - Generate client bindings for any language (TypeScript today +//! via `ts-rs`, with Python, Go, etc. dropping in alongside as new +//! features on this crate) without dragging the daemon's runtime +//! deps into the codegen step. +//! - Sidestep the build-graph cycle the bindings used to have: +//! `heimdall-daemon -> heimdall-web` (for the embedded asset +//! bundle) meant `heimdall-web/build.rs` couldn't depend on +//! `heimdall-daemon` to read the types it needed to render. With +//! `heimdall-api` as a thin leaf both crates can depend on, the +//! web crate's build.rs reads the types directly and emits the +//! `.ts` files on every cargo build, no commit needed. +//! +//! Conversion impls that bridge into runtime-side types +//! (`heimdall_core::Verdict` -> [`VerdictSummary`], +//! `heimdall_test::SnapshotSource` -> [`SnapshotSourceTag`]) live in +//! `heimdall-daemon` so this crate stays a leaf. + +use bytes::Bytes; +use chrono::{DateTime, Utc}; +use heimdall_core::{DutId, DutKind}; +use serde::{Deserialize, Serialize}; +use std::fmt; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct JobId(pub Uuid); + +impl JobId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } +} + +impl Default for JobId { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for JobId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct LeaseId(pub Uuid); + +impl LeaseId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } +} + +impl Default for LeaseId { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct EventId(pub u64); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", tag = "kind")] +#[cfg_attr(feature = "bindings", derive(ts_rs::TS), ts(export))] +pub enum JobKind { + /// Runs the built-in "mock-hello" test against the DUT. + MockHello, + /// Loads an Aegis bitstream via JTAG. `descriptor_json` carries the + /// device layout (notably `total_bits`). `bitstream_b64` is the raw + /// bitstream, base64-encoded. + LoadAegisBitstream { + descriptor_json: String, + bitstream_b64: String, + }, + /// Load an Aegis bitstream, drive the configured input pads to the + /// supplied values, settle, then read output pads and diff against the + /// supplied expected_outputs. + RunAegisVector { + descriptor_json: String, + bitstream_b64: String, + #[serde(default)] + inputs: std::collections::BTreeMap, + #[serde(default)] + expected_outputs: std::collections::BTreeMap, + #[serde(default)] + settle_cycles: u64, + }, + /// Boots a precompiled River ELF via OpenOCD JTAG. Cycles is the + /// wait_halt budget in millisecond-equivalents (see RiverCpuDriver::run). + BootRiverElf { elf_b64: String, cycles: u64 }, + /// Coverage-guided fuzz session against a configured DUT. The factory + /// (`RiverFuzzFactory`, etc.) builds the concrete driver+golden out + /// of the DUT registry. The engine itself runs `iterations` rounds and + /// reports the aggregate verdict. + Fuzz { + /// Number of fuzz iterations to run. + iterations: u64, + /// RNG seed. Deterministic with the same seed + generator config. + seed: u64, + /// Instructions per generated program (for `RawAsmGen`-style + /// generators). + #[serde(default = "default_fuzz_insn_count")] + insn_count: usize, + /// Per-iteration wait_halt budget in cycle-equivalents. Passed + /// straight to the driver via `Stimulus { budget: Cycles { count } }`. + #[serde(default = "default_fuzz_cycles")] + cycles: u64, + /// If true, a sim-vs-silicon coverage divergence flips the verdict + /// to fail. Off by default so fuzz runs keep going even when the + /// silicon and golden disagree on coverage. + #[serde(default)] + strict_coverage: bool, + /// Which code generator drives this fuzz session. Default + /// `raw-asm` keeps the proven hand-rolled RV64 encoder. The + /// `cranelift` choice requires the daemon (and heimdall-fuzzer) + /// to be built with the `cranelift` feature. The worker + /// surfaces a clean error otherwise. + #[serde(default)] + generator: GeneratorKind, + }, + /// Generic. The daemon dispatches based on `name` and `payload`. + Named { + name: String, + // The TypeScript binding renders the payload as `unknown` so the + // generated `JobKind.ts` doesn't need to import a cross-crate + // `JsonValue.ts` (ts-rs's serde-json-impl writes that into + // each crate's local `./bindings/` directory, producing a + // brittle relative path). + #[cfg_attr(feature = "bindings", ts(type = "unknown"))] + payload: serde_json::Value, + }, +} + +fn default_fuzz_insn_count() -> usize { + 16 +} +fn default_fuzz_cycles() -> u64 { + 1_000 +} + +/// Which code generator a `JobKind::Fuzz` session uses. `RawAsm` is +/// the always-available hand-rolled RV64 encoder. `Cranelift` lowers +/// a randomised SSA IR through Cranelift's RV64 backend and only +/// works when the daemon was built with the `cranelift` cargo +/// feature (which transitively enables `heimdall-fuzzer/cranelift`). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[cfg_attr(feature = "bindings", derive(ts_rs::TS), ts(export))] +pub enum GeneratorKind { + #[default] + RawAsm, + Cranelift, +} + +impl JobKind { + /// Short identifier for logs and UI badges. Matches the serde tag, so the + /// same string the wire payload uses also names the variant in human- + /// readable contexts. + pub fn tag(&self) -> &'static str { + match self { + JobKind::MockHello => "mock-hello", + JobKind::LoadAegisBitstream { .. } => "load-aegis-bitstream", + JobKind::RunAegisVector { .. } => "run-aegis-vector", + JobKind::BootRiverElf { .. } => "boot-river-elf", + JobKind::Fuzz { .. } => "fuzz", + JobKind::Named { .. } => "named", + } + } + + /// One-line summary suitable for a `JobLog` message body. Includes the + /// variant tag plus only the small scalar parameters, never the bulky + /// base64 / JSON-blob fields. The bulky fields would otherwise wrap + /// across hundreds of columns in the web UI and TUI. + pub fn summary(&self) -> String { + match self { + JobKind::MockHello => "mock-hello".to_string(), + JobKind::LoadAegisBitstream { bitstream_b64, .. } => { + let bytes = b64_decoded_len(bitstream_b64); + format!("load-aegis-bitstream (bitstream={bytes}B)") + } + JobKind::RunAegisVector { + bitstream_b64, + inputs, + expected_outputs, + settle_cycles, + .. + } => { + let bytes = b64_decoded_len(bitstream_b64); + format!( + "run-aegis-vector (bitstream={bytes}B, inputs={}, expect={}, cycles={settle_cycles})", + inputs.len(), + expected_outputs.len(), + ) + } + JobKind::BootRiverElf { elf_b64, cycles } => { + let bytes = b64_decoded_len(elf_b64); + format!("boot-river-elf (elf={bytes}B, cycles={cycles})") + } + JobKind::Fuzz { + iterations, + seed, + insn_count, + cycles, + strict_coverage, + generator, + } => { + let strict = if *strict_coverage { ", strict" } else { "" }; + let gen_label = match generator { + GeneratorKind::RawAsm => "raw-asm", + GeneratorKind::Cranelift => "cranelift", + }; + format!( + "fuzz (iters={iterations}, seed=0x{seed:x}, insns={insn_count}, cycles={cycles}, gen={gen_label}{strict})" + ) + } + JobKind::Named { name, .. } => format!("named {name}"), + } + } +} + +/// Estimate the decoded length of a base64 string without actually decoding. +/// Off by up to 2 bytes when the trailing padding is missing or atypical, but +/// good enough for a log summary. +fn b64_decoded_len(s: &str) -> usize { + let raw = s.trim().len(); + if raw == 0 { + return 0; + } + let pad = s.bytes().rev().take_while(|&b| b == b'=').count(); + (raw * 3) / 4 - pad +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", tag = "state", content = "detail")] +#[cfg_attr(feature = "bindings", derive(ts_rs::TS), ts(export))] +pub enum JobState { + Queued, + Running, + Done(VerdictSummary), + Failed(String), + Cancelled, + /// Terminal state for jobs that were `Running` when the daemon + /// process exited (crash, SIGTERM, restart). The reconcile pass + /// in `runtime::start_inner` walks the store at boot and flips + /// any orphaned `Running` row to `Dead` so the UI and operators + /// can tell "the daemon went away under this job" apart from a + /// clean `Failed` or operator `Cancelled`. + Dead, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", tag = "kind")] +#[cfg_attr(feature = "bindings", derive(ts_rs::TS), ts(export))] +pub enum VerdictSummary { + Pass, + Fail { reason: String }, + Skip { reason: String }, + Error { message: String }, +} + +impl From<&heimdall_core::Verdict> for VerdictSummary { + fn from(v: &heimdall_core::Verdict) -> Self { + match v { + heimdall_core::Verdict::Pass => Self::Pass, + heimdall_core::Verdict::Fail { kind, .. } => Self::Fail { + reason: kind.to_string(), + }, + heimdall_core::Verdict::Skip { reason } => Self::Skip { + reason: format!("{reason:?}"), + }, + heimdall_core::Verdict::Error { message } => Self::Error { + message: message.clone(), + }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CampaignId(pub Uuid); + +impl CampaignId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } +} + +impl Default for CampaignId { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for CampaignId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", tag = "kind")] +#[cfg_attr(feature = "bindings", derive(ts_rs::TS), ts(export))] +pub enum CampaignTemplate { + BringUp, + Characterization, + Release, + Custom { name: String }, +} + +impl CampaignTemplate { + pub fn name(&self) -> &str { + match self { + Self::BringUp => "bring-up", + Self::Characterization => "characterization", + Self::Release => "release", + Self::Custom { name } => name, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", tag = "state")] +#[cfg_attr(feature = "bindings", derive(ts_rs::TS), ts(export))] +pub enum CampaignState { + Pending, + Running, + Pass, + Fail, + Mixed, + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Campaign { + pub id: CampaignId, + pub dut: DutId, + pub chip_serial: Option, + pub template: CampaignTemplate, + pub state: CampaignState, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewJob { + pub dut: DutId, + pub kind: JobKind, + #[serde(default)] + pub campaign: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Job { + pub id: JobId, + pub dut: DutId, + pub dut_kind: DutKind, + pub kind: JobKind, + pub campaign: Option, + pub state: JobState, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Lease { + pub id: LeaseId, + pub dut: DutId, + pub holder: JobId, + pub acquired_at: DateTime, + pub expires_at: DateTime, +} + +/// Wire-side tag mirroring `heimdall_test::SnapshotSource`. Lives here so +/// the `Event::DutStateSnapshot` variant has a Serialize/Deserialize-safe +/// kebab-case tag without the daemon needing a serde impl on the test +/// crate's enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[cfg_attr(feature = "bindings", derive(ts_rs::TS), ts(export))] +pub enum SnapshotSourceTag { + Dut, + Golden, +} + +/// Severity tag on a `JobLog`. Matches the tracing convention. Serialized +/// as lowercase strings ("info", "warn", ...) for the WebSocket wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "bindings", derive(ts_rs::TS), ts(export))] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +impl LogLevel { + pub fn as_str(self) -> &'static str { + match self { + LogLevel::Trace => "trace", + LogLevel::Debug => "debug", + LogLevel::Info => "info", + LogLevel::Warn => "warn", + LogLevel::Error => "error", + } + } + + /// i18n catalog key for the level's display label. + pub fn i18n_key(self) -> &'static str { + match self { + LogLevel::Trace => "log.level.trace", + LogLevel::Debug => "log.level.debug", + LogLevel::Info => "log.level.info", + LogLevel::Warn => "log.level.warn", + LogLevel::Error => "log.level.error", + } + } +} + +impl std::fmt::Display for LogLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Wire envelope wrapping an [`Event`] with the UTC timestamp the daemon +/// assigned at publish time. Backend always stores UTC. Clients localize on +/// display. Uses `#[serde(flatten)]` so the JSON shape stays +/// `{"ts": "...", "kind": "...", ...event-fields}` rather than nesting. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StampedEvent { + pub ts: DateTime, + #[serde(flatten)] + pub event: Event, +} + +impl StampedEvent { + pub fn new(ts: DateTime, event: Event) -> Self { + Self { ts, event } + } +} + +/// One row returned by `JobStore::list_events_since` and +/// `JobStore::list_job_logs`: the storage id, the assigned UTC timestamp, +/// and the event itself. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct EventRecord { + pub id: EventId, + pub ts: DateTime, + #[serde(flatten)] + pub event: Event, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", tag = "kind")] +pub enum Event { + JobCreated { + job: JobId, + dut: DutId, + }, + JobStateChanged { + job: JobId, + state: JobState, + }, + JobLog { + job: JobId, + level: LogLevel, + /// Server-rendered fallback. Whenever `i18n_key` is set, clients + /// should prefer localizing the key+args themselves. + message: String, + /// Optional stage tag (kebab-case Stage variant). `None` for + /// daemon-level logs that don't correspond to a Runner stage. + #[serde(default, skip_serializing_if = "Option::is_none")] + stage: Option, + /// Stable i18n catalog key. Together with `i18n_args`, lets each + /// client render the message in its own locale rather than the + /// daemon's. Absent on free-form ad-hoc logs. + #[serde(default, skip_serializing_if = "Option::is_none")] + i18n_key: Option, + /// Named-argument substitutions for `i18n_key`. Values are + /// pre-stringified so consumers never have to parse typed args. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + i18n_args: std::collections::BTreeMap, + }, + LeaseAcquired { + lease: LeaseId, + dut: DutId, + holder: JobId, + }, + LeaseReleased { + lease: LeaseId, + dut: DutId, + }, + /// Architectural snapshot captured during the runner's `observe()` + /// phase. The DUT card mirrors the latest snapshot per DUT so users + /// can peek at registers / PC without halting the CPU again. + DutStateSnapshot { + dut: DutId, + job: JobId, + source: SnapshotSourceTag, + state: heimdall_core::State, + }, + CampaignCreated { + campaign: CampaignId, + dut: DutId, + template: CampaignTemplate, + }, + CampaignStateChanged { + campaign: CampaignId, + state: CampaignState, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct BlobId(pub String); + +impl BlobId { + pub fn from_bytes(bytes: &[u8]) -> Self { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(bytes); + Self(hex::encode(h.finalize())) + } +} + +impl fmt::Display for BlobId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Debug, Clone, Default)] +pub struct JobFilter { + pub dut: Option, + pub state_in: Option>, + pub limit: Option, + /// Row offset for pagination. `0` (or `None`) returns the first + /// page. Combined with `limit` this gives the web UI's + /// Prev/Next pager what it needs without a cursor. + pub offset: Option, + /// Drop jobs created strictly older than this. Used by the + /// prune surface to scope a delete to "everything completed + /// before X." `None` = no cutoff. + pub created_before: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum JobStateTag { + Queued, + Running, + Done, + Failed, + Cancelled, + Dead, +} + +impl JobState { + pub fn tag(&self) -> JobStateTag { + match self { + Self::Queued => JobStateTag::Queued, + Self::Running => JobStateTag::Running, + Self::Done(_) => JobStateTag::Done, + Self::Failed(_) => JobStateTag::Failed, + Self::Cancelled => JobStateTag::Cancelled, + Self::Dead => JobStateTag::Dead, + } + } +} + +/// Runtime introspection payload exposed by `GET /about`. Used by +/// the web "About" tab and the TUI's about view to render the +/// running daemon's identity + which optional features it was built +/// with. Sourced from `cfg!(feature = "...")` at daemon compile +/// time, so reflects the actual binary on disk rather than whatever +/// the wire client thinks should be enabled. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(ts_rs::TS), ts(export))] +pub struct AboutInfo { + /// Crate version of `heimdall-core`. Matches the value baked + /// into other surfaces (`/health`, `/metrics`). + pub version: String, + /// Build profile of the daemon binary: `"debug"` or `"release"`. + pub build_profile: String, + /// Cargo feature flags compiled into the daemon. The set is + /// derived from `cfg!(feature = ...)` at build time. + pub features: AboutFeatures, +} + +/// Per-feature presence flags for [`AboutInfo`]. Each field maps +/// 1:1 to a Cargo feature in `crates/heimdall-daemon/Cargo.toml`. +/// `true` means the daemon binary was built with that feature on. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(ts_rs::TS), ts(export))] +pub struct AboutFeatures { + pub sqlite: bool, + pub aegis: bool, + pub river: bool, + pub fuzzer: bool, + pub cranelift: bool, +} + +/// A blob stored in the BlobStore. Convenience wrapper. +#[derive(Debug, Clone)] +pub struct Blob { + pub id: BlobId, + pub bytes: Bytes, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn summary_omits_bulky_base64_payloads() { + let big = "A".repeat(4096); // 4 KiB of base64, ~3 KiB decoded. + let k = JobKind::BootRiverElf { + elf_b64: big.clone(), + cycles: 5000, + }; + let s = k.summary(); + assert!( + !s.contains(&big), + "summary should not contain raw base64; got `{s}`" + ); + assert!(s.contains("boot-river-elf"), "got `{s}`"); + assert!(s.contains("cycles=5000"), "got `{s}`"); + assert!(s.contains("elf=3072B"), "decoded len wrong; got `{s}`"); + assert!(s.len() < 80, "summary should stay short; got {}B", s.len()); + } + + #[test] + fn summary_handles_each_variant() { + assert_eq!(JobKind::MockHello.summary(), "mock-hello"); + assert!( + JobKind::LoadAegisBitstream { + descriptor_json: "{}".into(), + bitstream_b64: "AAAA".into(), + } + .summary() + .starts_with("load-aegis-bitstream") + ); + assert!( + JobKind::RunAegisVector { + descriptor_json: "{}".into(), + bitstream_b64: "AAAA".into(), + inputs: std::collections::BTreeMap::new(), + expected_outputs: std::collections::BTreeMap::new(), + settle_cycles: 1, + } + .summary() + .contains("run-aegis-vector") + ); + assert_eq!( + JobKind::Named { + name: "n".into(), + payload: serde_json::Value::Null, + } + .summary(), + "named n" + ); + } + + #[test] + fn tag_matches_serde_tag() { + assert_eq!(JobKind::MockHello.tag(), "mock-hello"); + assert_eq!( + JobKind::BootRiverElf { + elf_b64: "".into(), + cycles: 0, + } + .tag(), + "boot-river-elf" + ); + } +} diff --git a/crates/heimdall-config/Cargo.toml b/crates/heimdall-config/Cargo.toml index cd207e0..a7b4dd6 100644 --- a/crates/heimdall-config/Cargo.toml +++ b/crates/heimdall-config/Cargo.toml @@ -18,4 +18,3 @@ serde.workspace = true toml.workspace = true thiserror.workspace = true tracing.workspace = true - diff --git a/crates/heimdall-config/src/lib.rs b/crates/heimdall-config/src/lib.rs index 5d58b6a..3422ac9 100644 --- a/crates/heimdall-config/src/lib.rs +++ b/crates/heimdall-config/src/lib.rs @@ -8,8 +8,8 @@ pub mod validate; pub use error::ConfigError; pub use load::load_from_path; pub use schema::{ - BringupSpec, ConfigFile, DutCfg, GoldenBackendCfg, GoldenCfg, GpioDriver, GpioTransportCfg, - HostCfg, JtagDriver, JtagTransportCfg, PadDirection, PadMapEntry, PsuTransportCfg, - SerialTransportCfg, SpiceWatchCfg, ToolsCfg, TransportRef, TransportSection, UartDriver, - UsbTransportCfg, + BringupSpec, ConfigFile, DutCfg, DutTimeouts, GoldenBackendCfg, GoldenCfg, GpioDriver, + GpioTransportCfg, HostCfg, JtagDriver, JtagTransportCfg, PadDirection, PadMapEntry, + PsuTransportCfg, SerialTransportCfg, SpiceWatchCfg, ToolsCfg, TransportRef, TransportSection, + UartDriver, UsbTransportCfg, }; diff --git a/crates/heimdall-config/src/schema.rs b/crates/heimdall-config/src/schema.rs index 0af09cf..830eb33 100644 --- a/crates/heimdall-config/src/schema.rs +++ b/crates/heimdall-config/src/schema.rs @@ -51,6 +51,65 @@ pub struct DutCfg { /// Drives the renderer's input/output highlighting. #[serde(default, rename = "spice_watch")] pub spice_watches: Vec, + /// Per-DUT timeout profile. Unset fields fall back to the + /// silicon-friendly defaults inside [`DutTimeouts`]. Slow simulation + /// rigs (River HDL via remote_bitbang) should bump these. + #[serde(default)] + pub timeouts: DutTimeouts, + /// Declared ISA (XLEN + extensions) for this DUT. Optional: when + /// omitted, the driver attempts a JTAG `misa` probe and falls + /// back to RV32I if even that fails. Per-DUT config wins over a + /// successful probe so operators can downscope what the fuzzer is + /// allowed to emit (e.g. silicon supports F but isn't verified + /// yet). + #[serde(default)] + pub isa: Option, +} + +/// Per-DUT ISA configuration block (TOML `[dut.isa]`). Either set a +/// canonical ISA string (e.g. `string = "rv64imac_zicsr"`) and let +/// the daemon parse it into `(xlen, extensions)`, or provide the +/// pair directly via `xlen = 64` and `extensions = ["i", "m", ...]`. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct DutIsaCfg { + pub xlen: u8, + /// Extension identifiers as canonical letters (`"i"`, `"m"`) or + /// Z-style names (`"zicsr"`, `"zifencei"`). Validated downstream + /// by `heimdall_fuzzer::parse_isa_string` semantics. + pub extensions: Vec, +} + +/// Per-DUT timeout knobs. All durations are in milliseconds except +/// `lease_secs` which is in whole seconds (TOML readability). Sensible +/// defaults are tuned for real silicon; ROHD/Dart sim DUTs need a profile +/// like `{ openocd_startup_ms = 180000, openocd_rpc_ms = 120000, +/// lease_secs = 600, wait_halt_max_ms = 180000 }`. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(default, deny_unknown_fields)] +pub struct DutTimeouts { + /// How long [`SpawnedOpenocdJtagTransport::open`] waits for OpenOCD's + /// Tcl port to become reachable after spawning. + pub openocd_startup_ms: u64, + /// How long [`OpenOcdJtagTransport::rpc`] waits for each command reply. + pub openocd_rpc_ms: u64, + /// DUT lease TTL. The daemon's worker thread acquires a lease for the + /// duration of one job; if the job runs longer than this without a + /// heartbeat, the lease releases and the job fails with `lease expired`. + pub lease_secs: u64, + /// Upper clamp on the RiverCpuDriver's `wait_halt` timeout per stimulus + /// budget. Below this the per-stimulus budget wins. + pub wait_halt_max_ms: u64, +} + +impl Default for DutTimeouts { + fn default() -> Self { + Self { + openocd_startup_ms: 10_000, + openocd_rpc_ms: 5_000, + lease_secs: 60, + wait_halt_max_ms: 30_000, + } + } } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] diff --git a/crates/heimdall-config/testdata/timeouts.toml b/crates/heimdall-config/testdata/timeouts.toml new file mode 100644 index 0000000..6a73a11 --- /dev/null +++ b/crates/heimdall-config/testdata/timeouts.toml @@ -0,0 +1,20 @@ +[host] +name = "rig-sim" +bind = "127.0.0.1:7777" + +[[dut]] +id = "river-sim-1" +kind = "river-rc1-nano" +transports = ["jtag.openocd-1"] + +[dut.timeouts] +openocd_startup_ms = 180000 +openocd_rpc_ms = 120000 +lease_secs = 600 +wait_halt_max_ms = 180000 + +[[transport.jtag]] +id = "jtag.openocd-1" +driver = "openocd" +openocd_endpoint = "127.0.0.1:6666" +freq_hz = 1000000 diff --git a/crates/heimdall-config/tests/validate.rs b/crates/heimdall-config/tests/validate.rs index be4f8e4..f1cdd1e 100644 --- a/crates/heimdall-config/tests/validate.rs +++ b/crates/heimdall-config/tests/validate.rs @@ -6,6 +6,25 @@ fn example_validates() { validate(&cfg).unwrap(); } +#[test] +fn dut_timeouts_block_parses_and_defaults_apply() { + let cfg = load_from_path("testdata/timeouts.toml").unwrap(); + let dut = &cfg.duts[0]; + assert_eq!(dut.timeouts.openocd_startup_ms, 180_000); + assert_eq!(dut.timeouts.openocd_rpc_ms, 120_000); + assert_eq!(dut.timeouts.lease_secs, 600); + assert_eq!(dut.timeouts.wait_halt_max_ms, 180_000); + + // The example.toml has no [dut.timeouts] block so we get the + // silicon-friendly defaults. + let plain = load_from_path("testdata/example.toml").unwrap(); + let pd = plain.duts[0].timeouts; + assert_eq!(pd.openocd_startup_ms, 10_000); + assert_eq!(pd.openocd_rpc_ms, 5_000); + assert_eq!(pd.lease_secs, 60); + assert_eq!(pd.wait_halt_max_ms, 30_000); +} + #[test] fn duplicate_transport_id_rejected() { let cfg = load_from_path("testdata/duplicate_transport.toml").unwrap(); @@ -37,6 +56,8 @@ fn base_cfg_with_dut() -> heimdall_config::ConfigFile { bringup: None, netlist: None, spice_watches: vec![], + timeouts: Default::default(), + isa: None, }], transport: heimdall_config::TransportSection { gpio: vec![GpioTransportCfg { diff --git a/crates/heimdall-core/Cargo.toml b/crates/heimdall-core/Cargo.toml index 7736073..bf38594 100644 --- a/crates/heimdall-core/Cargo.toml +++ b/crates/heimdall-core/Cargo.toml @@ -12,6 +12,13 @@ keywords.workspace = true categories.workspace = true description = "Core types for Heimdall: IDs, verdicts, observations, stimuli, artifacts, and DUT kinds." +[features] +default = [] +# Enables `#[derive(ts_rs::TS)]` on wire types so downstream crates +# can re-export TypeScript bindings into the web UI. Off by default +# so the production build doesn't pull in the ts-rs derive crate. +bindings = ["dep:ts-rs"] + [dependencies] serde.workspace = true serde_json.workspace = true @@ -20,3 +27,4 @@ uuid.workspace = true sha2.workspace = true hex.workspace = true thiserror.workspace = true +ts-rs = { workspace = true, optional = true } diff --git a/crates/heimdall-core/build.rs b/crates/heimdall-core/build.rs new file mode 100644 index 0000000..b357b9b --- /dev/null +++ b/crates/heimdall-core/build.rs @@ -0,0 +1,55 @@ +//! Compute the workspace's full version string. +//! +//! 1. Nix builds preset `HEIMDALL_FULL_VERSION` (see `flake.nix`'s +//! `commonArgs`) using flakever's +//! `pre--` shape. We pass +//! that through verbatim. +//! 2. Otherwise we synthesise the same shape from `git`. +//! 3. With no git history (e.g. a crates.io tarball), the var stays +//! unset and `heimdall_core::VERSION`'s `option_env!` falls +//! through to `CARGO_PKG_VERSION`. + +use std::process::Command; + +fn main() { + // HEAD changes on commit/checkout, index changes on stage/unstage. + // Together they cover everything that would flip the synthesised + // rev or `-dirty` suffix. + println!("cargo:rerun-if-env-changed=HEIMDALL_FULL_VERSION"); + println!("cargo:rerun-if-changed=../../.git/HEAD"); + println!("cargo:rerun-if-changed=../../.git/index"); + + if let Ok(forced) = std::env::var("HEIMDALL_FULL_VERSION") { + if !forced.trim().is_empty() { + println!("cargo:rustc-env=HEIMDALL_FULL_VERSION={forced}"); + return; + } + } + + let cargo_pkg = std::env::var("CARGO_PKG_VERSION").unwrap_or_default(); + if let Some(synth) = synth_from_git(&cargo_pkg) { + println!("cargo:rustc-env=HEIMDALL_FULL_VERSION={synth}"); + } +} + +/// Build `pre--[-dirty]` from +/// the surrounding git checkout. `None` means the caller should +/// fall back to the bare version. +fn synth_from_git(cargo_pkg: &str) -> Option { + let date = git_output(&["log", "-1", "--format=%cd", "--date=format:%Y%m%d"])?; + let rev = git_output(&["rev-parse", "--short", "HEAD"])?; + let dirty = git_output(&["status", "--porcelain"]) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + let suffix = if dirty { "-dirty" } else { "" }; + Some(format!("{cargo_pkg}pre-{date}-{rev}{suffix}")) +} + +fn git_output(args: &[&str]) -> Option { + let out = Command::new("git").args(args).output().ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8(out.stdout).ok()?.trim().to_string(); + if s.is_empty() { None } else { Some(s) } +} diff --git a/crates/heimdall-core/src/state.rs b/crates/heimdall-core/src/state.rs index 4fd5481..68db164 100644 --- a/crates/heimdall-core/src/state.rs +++ b/crates/heimdall-core/src/state.rs @@ -6,6 +6,7 @@ use std::time::Duration; /// A typed architectural-state value. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", content = "value", rename_all = "kebab-case")] +#[cfg_attr(feature = "bindings", derive(ts_rs::TS), ts(export))] pub enum ValueRepr { U64(u64), Bytes(Vec), @@ -24,8 +25,13 @@ impl fmt::Display for ValueRepr { /// A snapshot of architectural state. Ordered map so diffs are deterministic. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(ts_rs::TS), ts(export))] pub struct State { pub fields: BTreeMap, + #[cfg_attr( + feature = "bindings", + ts(type = "{ secs: number, nanos: number } | null") + )] pub captured_after: Option, } diff --git a/crates/heimdall-core/src/verdict.rs b/crates/heimdall-core/src/verdict.rs index cb3c66e..f3fe80a 100644 --- a/crates/heimdall-core/src/verdict.rs +++ b/crates/heimdall-core/src/verdict.rs @@ -27,6 +27,11 @@ pub enum FailureKind { got: ValueRepr, expected: ValueRepr, }, + /// Golden's `State` references a key the DUT's `State` doesn't carry. + /// Distinct from `DiffMismatch` so reports stop pretending the DUT + /// "returned Bool(false)" when the truth is the field never showed up. + #[error("field `{field}` missing on dut; expected {expected}")] + MissingField { field: String, expected: ValueRepr }, #[error("dut unresponsive for at least {millis} ms")] DutUnresponsive { millis: u64 }, #[error("timed out after {elapsed_ms} ms of {budget_ms} ms budget")] diff --git a/crates/heimdall-daemon/Cargo.toml b/crates/heimdall-daemon/Cargo.toml index 96bec4f..41e0224 100644 --- a/crates/heimdall-daemon/Cargo.toml +++ b/crates/heimdall-daemon/Cargo.toml @@ -16,46 +16,64 @@ description = "HTTP/WebSocket daemon for Heimdall: job queue, blob store, campai default = ["sqlite"] sqlite = ["dep:sqlx"] ftdi = ["heimdall-transport/ftdi"] +# Note: TypeScript binding generation moved to `heimdall-web/build.rs` +# (which depends on `heimdall-api`, `heimdall-core`, and +# `heimdall-disasm` with their respective bindings features). The +# daemon no longer needs a `bindings` feature of its own. aegis = [ - "heimdall-driver/aegis", - "heimdall-golden/aegis", - "heimdall-transport/bitbang-jtag", - "heimdall-transport/linux-cdev", - "heimdall-transport/openocd", + "heimdall-driver/aegis", + "heimdall-golden/aegis", + "heimdall-transport/bitbang-jtag", + "heimdall-transport/linux-cdev", + "heimdall-transport/openocd", ] river = [ - "heimdall-driver/river", - "heimdall-golden/spike", - "heimdall-transport/openocd", + "heimdall-driver/river", + "heimdall-golden/spike", + "heimdall-transport/openocd", ] +fuzzer = ["dep:heimdall-fuzzer"] +# Enables the Cranelift-backed code generator inside `heimdall-fuzzer`. +# Pure additive: the daemon still defaults to RawAsm; this only widens +# the set of acceptable `GeneratorKind` values for the worker. +cranelift = ["fuzzer", "heimdall-fuzzer/cranelift"] [dependencies] heimdall-config = { workspace = true } heimdall-core.workspace = true +heimdall-api.workspace = true heimdall-i18n.workspace = true heimdall-driver.workspace = true heimdall-golden = { workspace = true, features = ["spice"] } heimdall-test.workspace = true +heimdall-tools.workspace = true +heimdall-disasm.workspace = true +heimdall-web.workspace = true heimdall-transport.workspace = true +heimdall-fuzzer = { workspace = true, optional = true } base64 = { workspace = true } async-trait.workspace = true thiserror.workspace = true tracing.workspace = true -tokio = { workspace = true, features = ["sync", "fs", "macros", "rt-multi-thread", "time", "net", "signal"] } +tokio = { workspace = true, features = [ + "sync", + "fs", + "macros", + "rt-multi-thread", + "time", + "net", + "signal", +] } +tokio-util = { workspace = true } serde.workspace = true serde_json.workspace = true chrono.workspace = true uuid = { workspace = true, features = ["v4", "serde"] } bytes.workspace = true -sha2.workspace = true -hex.workspace = true axum.workspace = true tower-http.workspace = true -rust-embed = { workspace = true } -mime_guess = { workspace = true } -askama = { workspace = true } tar = { workspace = true } @@ -68,3 +86,4 @@ tempfile.workspace = true reqwest.workspace = true base64 = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio-tungstenite.workspace = true diff --git a/crates/heimdall-daemon/assets/app.css b/crates/heimdall-daemon/assets/app.css deleted file mode 100644 index ac4f315..0000000 --- a/crates/heimdall-daemon/assets/app.css +++ /dev/null @@ -1,233 +0,0 @@ -/* Tokyo Night palette (public; derived from enkia/tokyo-night-vscode-theme) */ -:root { - --bg-base: #1a1b26; - --bg-dark: #16161e; - --bg-medium: #1e202e; - --fg-primary: #a9b1d6; - --fg-bright: #c0caf5; - --fg-muted: #787c99; - --fg-dim: #545c7e; - --accent-blue: #7aa2f7; - --accent-blue-light: #7dcfff; - --accent-cyan: #2ac3de; - --accent-teal: #73daca; - --accent-green: #9ece6a; - --accent-purple: #bb9af7; - --accent-red: #f7768e; - --accent-red-dark: #db4b4b; - --accent-orange: #ff9e64; - --accent-yellow: #e0af68; - - --verdict-pass: var(--accent-green); - --verdict-fail: var(--accent-red); - --verdict-skip: var(--accent-yellow); - --verdict-error: var(--accent-red-dark); - --state-running: var(--accent-blue); - --state-queued: var(--fg-muted); - --state-cancelled: var(--fg-dim); -} - -* { box-sizing: border-box; } - -html, body { - margin: 0; - padding: 0; - background: var(--bg-base); - color: var(--fg-primary); - font-family: system-ui, -apple-system, "Segoe UI", sans-serif; - font-size: 14px; -} - -.topbar { - display: flex; - align-items: center; - gap: 1.5rem; - padding: 0.75rem 1rem; - background: var(--bg-dark); - border-bottom: 1px solid var(--bg-medium); -} - -.brand { - color: var(--fg-bright); - font-weight: 700; - letter-spacing: 0.18em; -} - -.status { - color: var(--fg-muted); - flex: 1; -} - -.banner { - color: var(--accent-orange); - font-size: 12px; - letter-spacing: 0.05em; -} - -.tabs { - display: flex; - gap: 0; - background: var(--bg-medium); - border-bottom: 1px solid var(--bg-dark); -} - -.tab { - background: transparent; - color: var(--fg-muted); - border: none; - padding: 0.5rem 1rem; - font: inherit; - cursor: pointer; - border-bottom: 2px solid transparent; -} - -.tab.active { - color: var(--fg-bright); - border-bottom-color: var(--accent-blue); -} - -.tab:hover { color: var(--fg-primary); } - -main { - padding: 1rem; -} - -.view.hidden { display: none; } - -table { - width: 100%; - border-collapse: collapse; - background: var(--bg-medium); -} - -th, td { - text-align: left; - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--bg-dark); -} - -th { - color: var(--fg-bright); - font-weight: 600; - letter-spacing: 0.05em; -} - -td.id { color: var(--fg-muted); font-family: ui-monospace, monospace; } - -.empty td { color: var(--fg-dim); text-align: center; padding: 1rem; } - -.state-queued { color: var(--state-queued); } -.state-running { color: var(--state-running); } -.state-done.verdict-pass { color: var(--verdict-pass); } -.state-done.verdict-fail { color: var(--verdict-fail); } -.state-done.verdict-skip { color: var(--verdict-skip); } -.state-done.verdict-error { color: var(--verdict-error); } -.state-failed { color: var(--verdict-error); } -.state-cancelled { color: var(--state-cancelled); } - -.campaign-state-pending { color: var(--state-queued); } -.campaign-state-running { color: var(--state-running); } -.campaign-state-pass { color: var(--verdict-pass); } -.campaign-state-fail { color: var(--verdict-fail); } -.campaign-state-mixed { color: var(--accent-orange); } -.campaign-state-cancelled { color: var(--state-cancelled); } - -.empty-block { - color: var(--fg-dim); - text-align: center; - padding: 2rem; -} - -.dut-card { - background: var(--bg-medium); - border: 1px solid var(--bg-dark); - border-radius: 4px; - margin-bottom: 0.75rem; - padding: 0.75rem 1rem; -} - -.dut-header { - display: flex; - gap: 1.5rem; - align-items: baseline; -} - -.dut-id { - color: var(--fg-bright); - font-weight: 600; - font-family: ui-monospace, monospace; -} - -.dut-kind { - color: var(--accent-blue); -} - -.dut-serial, -.dut-jtag { - color: var(--fg-muted); - font-size: 12px; -} - -.dut-status { - margin-left: auto; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.08em; - padding: 0.15rem 0.55rem; - border-radius: 3px; - border: 1px solid currentColor; - font-family: ui-monospace, monospace; -} - -.dut-status-connected { - color: var(--accent-green); -} - -.dut-status-disconnected { - color: var(--accent-red); -} - -.dut-status-idle { - color: var(--fg-dim); -} - -.netlist-panel { - margin-top: 0.5rem; -} - -.netlist-panel summary { - cursor: pointer; - color: var(--accent-blue-light); - padding: 0.25rem 0; - user-select: none; -} - -.netlist-panel summary:hover { - color: var(--fg-bright); -} - -.netlist-svg { - margin-top: 0.5rem; - padding: 0.5rem; - background: var(--bg-base); - border: 1px solid var(--bg-dark); - border-radius: 4px; - overflow: auto; -} - -.netlist-svg img { - display: block; - max-width: 100%; -} - -.helpbar { - position: fixed; - bottom: 0; left: 0; right: 0; - background: var(--bg-dark); - color: var(--fg-muted); - display: flex; - justify-content: space-between; - padding: 0.4rem 1rem; - font-size: 12px; - border-top: 1px solid var(--bg-medium); -} diff --git a/crates/heimdall-daemon/assets/app.js b/crates/heimdall-daemon/assets/app.js deleted file mode 100644 index 82d71bb..0000000 --- a/crates/heimdall-daemon/assets/app.js +++ /dev/null @@ -1,256 +0,0 @@ -(() => { - const statusEl = document.getElementById("status"); - const jobsTbody = document.getElementById("jobs-tbody"); - const campaignsTbody = document.getElementById("campaigns-tbody"); - const dutsList = document.getElementById("duts-list"); - const tabs = document.querySelectorAll(".tab"); - const views = { - jobs: document.getElementById("view-jobs"), - campaigns: document.getElementById("view-campaigns"), - duts: document.getElementById("view-duts"), - }; - - // ---------- i18n ---------- - // Catalog populated by initI18n() from /i18n.json. Until that resolves, - // t() returns the literal key (or the fallback) so first paint isn't blank. - let catalog = {}; - let activeLocale = "en"; - - function detectLocale() { - const params = new URLSearchParams(location.search); - const fromQuery = params.get("lang"); - if (fromQuery) return fromQuery; - try { - const stored = localStorage.getItem("heimdall.lang"); - if (stored) return stored; - } catch (_) { /* localStorage may be disabled */ } - return navigator.language || "en"; - } - - function t(key, fallback) { - return catalog[key] || fallback || key; - } - - // Substitute {name} placeholders with values from `args`. - function tr(key, args) { - let s = t(key); - if (args) { - for (const name of Object.keys(args)) { - s = s.split(`{${name}}`).join(String(args[name])); - } - } - return s; - } - - function applyStaticTranslations() { - document.querySelectorAll("[data-i18n]").forEach((el) => { - const key = el.getAttribute("data-i18n"); - const translated = catalog[key]; - if (translated) el.textContent = translated; - }); - document.documentElement.setAttribute("lang", activeLocale); - } - - async function initI18n() { - const lang = detectLocale(); - try { - const r = await fetch(`/i18n.json?lang=${encodeURIComponent(lang)}`); - if (r.ok) { - const body = await r.json(); - catalog = body; - activeLocale = body._locale || "en"; - try { localStorage.setItem("heimdall.lang", activeLocale); } catch (_) {} - } - } catch (_) { - // Network failure: leave catalog empty. Keys surface as fallback text. - } - applyStaticTranslations(); - } - - function shortId(s) { return (s || "").slice(0, 8); } - function fmtTime(iso) { return iso ? iso.replace("T", " ").slice(0, 19) : "-"; } - - function jobStateLabel(state) { - if (!state) return ["", "-"]; - const tag = state.state; - if (tag === "done") { - const v = state.detail && state.detail.kind; - return [`state-done verdict-${v}`, `done/${v}`]; - } - if (tag === "failed") { - return ["state-failed", `failed: ${state.detail || ""}`]; - } - return [`state-${tag}`, tag]; - } - - function renderJobs(jobs) { - if (!jobs.length) { - jobsTbody.innerHTML = `${escapeHtml(t("tui.empty.no_jobs", "no jobs"))}`; - return; - } - jobsTbody.innerHTML = jobs.map(j => { - const [cls, label] = jobStateLabel(j.state); - const kindKind = (j.kind && j.kind.kind) || "?"; - return ` - - ${shortId(j.id)} - ${j.dut} - ${kindKind} - ${label} - ${fmtTime(j.created_at)} - `; - }).join(""); - } - - function renderCampaigns(campaigns) { - if (!campaigns.length) { - campaignsTbody.innerHTML = `${escapeHtml(t("tui.empty.no_campaigns", "no campaigns"))}`; - return; - } - campaignsTbody.innerHTML = campaigns.map(c => { - const state = (c.state && c.state.state) || "?"; - const tpl = (c.template && c.template.kind) || "?"; - return ` - - ${shortId(c.id)} - ${c.dut} - ${tpl} - ${state} - ${c.chip_serial || "-"} - `; - }).join(""); - } - - async function refreshJobs() { - try { - const r = await fetch("/jobs"); - if (!r.ok) throw new Error(r.status); - const body = await r.json(); - renderJobs(body.jobs || []); - } catch (e) { - console.warn("refreshJobs", e); - } - } - - async function refreshCampaigns() { - try { - const r = await fetch("/campaigns"); - if (!r.ok) throw new Error(r.status); - const body = await r.json(); - renderCampaigns(body.campaigns || []); - } catch (e) { - console.warn("refreshCampaigns", e); - } - } - - function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, c => ({ - "&": "&", "<": "<", ">": ">", '"': """, "'": "'", - }[c])); - } - - function renderDuts(duts) { - if (!duts.length) { - dutsList.innerHTML = `

${escapeHtml(t("web.duts.no_duts", "no DUTs configured"))}

`; - return; - } - const jtagPrefix = t("web.duts.jtag_prefix", "jtag:"); - const showNetlist = t("web.duts.show_netlist", "Show netlist"); - // Status label is localized. CSS class stays in English (`connected`, - // `disconnected`, `idle`) so the color rules don't need to change. - const statusLabels = { - connected: t("common.status.connected", "connected"), - disconnected: t("common.status.disconnected", "disconnected"), - idle: t("common.status.idle", "idle"), - }; - dutsList.innerHTML = duts.map(d => { - const id = escapeHtml(d.id); - const kind = escapeHtml(d.kind); - const serial = d.chip_serial ? escapeHtml(d.chip_serial) : "-"; - const jtag = (d.jtag && d.jtag.driver) ? escapeHtml(d.jtag.driver) : "-"; - const rawStatus = d.connection_status || "unknown"; - const statusClass = rawStatus === "unknown" ? "idle" : rawStatus; - const statusLabel = statusLabels[statusClass]; - const hasNetlist = !!d.netlist; - const netlistBlock = hasNetlist ? ` -
- ${escapeHtml(showNetlist)} -
- netlist for ${id} -
-
` : ""; - return ` -
-
- ${id} - ${kind} - ${serial} - ${escapeHtml(jtagPrefix)} ${jtag} - ${escapeHtml(statusLabel)} -
- ${netlistBlock} -
`; - }).join(""); - } - - async function refreshDuts() { - try { - const r = await fetch("/duts"); - if (!r.ok) throw new Error(r.status); - const body = await r.json(); - renderDuts(body.duts || []); - } catch (e) { - console.warn("refreshDuts", e); - } - } - - function activate(view) { - tabs.forEach(t => t.classList.toggle("active", t.dataset.view === view)); - Object.entries(views).forEach(([k, el]) => el.classList.toggle("hidden", k !== view)); - } - - tabs.forEach(t => t.addEventListener("click", () => activate(t.dataset.view))); - document.addEventListener("keydown", (e) => { - if (e.key === "1") activate("jobs"); - if (e.key === "2") activate("campaigns"); - if (e.key === "3") activate("duts"); - }); - - function openSocket() { - const proto = location.protocol === "https:" ? "wss" : "ws"; - const url = `${proto}://${location.host}/events`; - let ws; - try { ws = new WebSocket(url); } catch (e) { - statusEl.textContent = t("common.status.disconnected", "disconnected"); - return; - } - ws.onopen = () => { statusEl.textContent = t("common.status.connected", "connected"); }; - ws.onclose = () => { - statusEl.textContent = t("tui.disconnected_short", "disconnected, retrying..."); - setTimeout(openSocket, 2000); - }; - ws.onerror = () => { statusEl.textContent = t("common.status.disconnected", "disconnected"); }; - ws.onmessage = (e) => { - try { - const ev = JSON.parse(e.data); - const kind = ev.kind || "?"; - statusEl.textContent = tr("tui.event_label", { kind }); - if (kind.startsWith("job-") || kind.startsWith("lease-")) refreshJobs(); - if (kind.startsWith("campaign-")) refreshCampaigns(); - if (kind.startsWith("lease-")) refreshDuts(); - } catch (_) { /* ignore */ } - }; - } - - // Wait for the catalog before painting, so first render isn't in English. - initI18n().then(() => { - refreshJobs(); - refreshCampaigns(); - refreshDuts(); - openSocket(); - setInterval(refreshJobs, 5000); - setInterval(refreshCampaigns, 5000); - // DUTs change only on daemon restart. Refresh less aggressively. - setInterval(refreshDuts, 30000); - }); -})(); diff --git a/crates/heimdall-daemon/build.rs b/crates/heimdall-daemon/build.rs deleted file mode 100644 index 3b4406d..0000000 --- a/crates/heimdall-daemon/build.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! Assembles the web-UI `assets/` directory under `$OUT_DIR` so `rust-embed` -//! can include both the hand-written static files (CSS, JS) and the SVGs -//! produced by the `heimdall-logo` Python package. -//! -//! Two paths for the SVGs: -//! -//! 1. `HEIMDALL_LOGO_SVGS` env var points at a directory of pre-rendered -//! SVGs (the Nix path: `pkgs/heimdall-logo` has a `passthru.svgs` -//! derivation that publishes them). Files are copied verbatim. -//! -//! 2. No env var set. Falls back to invoking `python3 -m heimdall_logo` with -//! `PYTHONPATH` pointing at `../../pkgs/heimdall-logo`. This is the -//! developer cargo-build path; requires `python3` on `PATH`. - -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn main() { - let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR set by cargo")); - let manifest_dir = - PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set by cargo")); - let assets_out = out_dir.join("assets"); - fs::create_dir_all(&assets_out).expect("create assets dir"); - - copy_static_assets(&manifest_dir.join("assets"), &assets_out); - render_logo_svgs(&manifest_dir, &assets_out); -} - -fn copy_static_assets(src: &Path, dest: &Path) { - println!("cargo:rerun-if-changed={}", src.display()); - for entry in fs::read_dir(src).expect("read assets dir") { - let entry = entry.expect("entry"); - let path = entry.path(); - if path.is_file() { - let name = path.file_name().expect("file name"); - fs::copy(&path, dest.join(name)) - .unwrap_or_else(|e| panic!("copy {}: {e}", path.display())); - } - } -} - -fn render_logo_svgs(manifest_dir: &Path, dest: &Path) { - println!("cargo:rerun-if-env-changed=HEIMDALL_LOGO_SVGS"); - if let Some(svgs_dir) = env::var_os("HEIMDALL_LOGO_SVGS") { - copy_from_render(Path::new(&svgs_dir), dest); - return; - } - invoke_python_generator(manifest_dir, dest); -} - -fn copy_from_render(src: &Path, dest: &Path) { - // The heimdall-logo Nix derivation writes these specific filenames. - let mapping = [ - ("heimdall-favicon.svg", "favicon.svg"), - ("heimdall-logomark-darkbg.svg", "heimdall-logomark.svg"), - ]; - for (input, output) in mapping { - let from = src.join(input); - let to = dest.join(output); - fs::copy(&from, &to) - .unwrap_or_else(|e| panic!("copy {} -> {}: {e}", from.display(), to.display())); - } -} - -fn invoke_python_generator(manifest_dir: &Path, dest: &Path) { - let logo_pkg = manifest_dir.join("../../pkgs/heimdall-logo"); - let logo_pkg = logo_pkg.canonicalize().unwrap_or(logo_pkg); - println!("cargo:rerun-if-changed={}", logo_pkg.display()); - - run_python( - &logo_pkg, - &[ - "-m", - "heimdall_logo", - "favicon", - "--output", - dest.join("favicon.svg").to_str().expect("utf-8 path"), - ], - ); - run_python( - &logo_pkg, - &[ - "-m", - "heimdall_logo", - "logomark", - "--background", - "#1a1b26", - "--output", - dest.join("heimdall-logomark.svg") - .to_str() - .expect("utf-8 path"), - ], - ); -} - -fn run_python(pythonpath: &Path, args: &[&str]) { - let status = Command::new("python3") - .env("PYTHONPATH", pythonpath) - .args(args) - .status() - .expect( - "failed to spawn `python3`. Either install python3 and the heimdall_logo \ - package, or set HEIMDALL_LOGO_SVGS to a directory of pre-rendered SVGs.", - ); - if !status.success() { - panic!("python3 {:?} exited with status {status:?}", args); - } -} diff --git a/crates/heimdall-daemon/migrations/20260602000001_job_programs.sql b/crates/heimdall-daemon/migrations/20260602000001_job_programs.sql new file mode 100644 index 0000000..e3036e3 --- /dev/null +++ b/crates/heimdall-daemon/migrations/20260602000001_job_programs.sql @@ -0,0 +1,22 @@ +-- Per-job mapping for "the latest program bytes loaded onto the DUT". +-- Populated by the fuzz worker's program observer; consumed by the +-- /jobs/:id/disasm route as a fallback when the in-memory +-- LoadedProgramCache is cold (e.g. after a daemon restart). +-- +-- One row per job; the worker upserts as iters advance, so the row +-- always reflects the most recent iter's program. The actual bytes +-- live in the BlobStore under `blob_id`; this table just tracks the +-- pointer plus enough metadata (kind, iter) for the disasm route to +-- assemble its response without round-tripping the JobKind. + +CREATE TABLE IF NOT EXISTS job_programs ( + job_id TEXT PRIMARY KEY NOT NULL, + blob_id TEXT NOT NULL, + -- ArtifactKind serialized as kebab-case JSON tag, mirroring the + -- on-the-wire representation (e.g. "raw-bytes", "elf-riscv"). + kind TEXT NOT NULL, + -- Iteration index for fuzz jobs (0-based). NULL for one-shot + -- jobs whose program doesn't have an iter concept. + iter INTEGER, + recorded_at TEXT NOT NULL +); diff --git a/crates/heimdall-daemon/src/campaign.rs b/crates/heimdall-daemon/src/campaign.rs index 7224a91..f9324b3 100644 --- a/crates/heimdall-daemon/src/campaign.rs +++ b/crates/heimdall-daemon/src/campaign.rs @@ -68,7 +68,7 @@ pub async fn submit_campaign( } for new in new_jobs { - queue.submit(new).await?; + queue.submit(new, dut_kind).await?; } // Move to Running once we've actually submitted jobs. @@ -125,7 +125,11 @@ pub fn compute_state(jobs: &[Job]) -> CampaignState { } } } - JobStateTag::Failed => { + JobStateTag::Failed | JobStateTag::Dead => { + // Dead == "daemon crashed mid-run." Rolls up the same + // as Failed: the campaign couldn't complete cleanly, + // operator must re-submit. Don't conflate with the + // operator-driven Cancelled path. any_fail = true; all_cancelled = false; } diff --git a/crates/heimdall-daemon/src/cancellations.rs b/crates/heimdall-daemon/src/cancellations.rs new file mode 100644 index 0000000..9daff8e --- /dev/null +++ b/crates/heimdall-daemon/src/cancellations.rs @@ -0,0 +1,135 @@ +//! Per-job cancellation handles for in-flight worker tasks. +//! +//! The cancel HTTP route looks up the token by [`JobId`] and calls +//! `.cancel()` on it; the worker races its run future against the +//! token via `tokio::select!`, propagating an early +//! [`crate::error::DaemonError::Cancelled`] when the operator +//! signals. Tokens are removed from the map once the worker +//! finishes handling the job (terminal transition or cancellation), +//! so the registry only ever holds tokens for jobs currently in +//! flight. + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use tokio_util::sync::CancellationToken; + +use crate::types::JobId; + +/// Map of currently-running jobs to their cancellation token. +/// `Clone` is cheap (Arc); the same handle lives in [`AppState`] +/// (for the cancel HTTP route) and the worker (for registration + +/// race). +#[derive(Clone, Default)] +pub struct CancellationRegistry { + inner: Arc>>, +} + +impl CancellationRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Insert a fresh token for `job` and return a handle. The + /// worker holds the returned token to await its `.cancelled()` + /// future inside `tokio::select!`; the registry retains a clone + /// that the cancel route can fire. + pub fn register(&self, job: JobId) -> CancellationToken { + let token = CancellationToken::new(); + let mut map = self.inner.write().expect("cancellation lock poisoned"); + map.insert(job, token.clone()); + token + } + + /// Drop the entry for `job`. Called by the worker after handling + /// completes (either normally or via cancellation) so stale + /// tokens don't pile up after long-running daemons see + /// thousands of jobs. + pub fn remove(&self, job: JobId) { + let mut map = self.inner.write().expect("cancellation lock poisoned"); + map.remove(&job); + } + + /// Fire the token for `job` if registered, returning `true` if + /// a token was found and signalled. The cancel HTTP route uses + /// the bool to distinguish "running job successfully signalled" + /// from "job no longer running, cancel via queue transition + /// instead." + pub fn cancel(&self, job: JobId) -> bool { + let map = self.inner.read().expect("cancellation lock poisoned"); + match map.get(&job) { + Some(token) => { + token.cancel(); + true + } + None => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + fn jid() -> JobId { + JobId(Uuid::new_v4()) + } + + #[test] + fn register_then_cancel_signals_the_token() { + let reg = CancellationRegistry::new(); + let id = jid(); + let token = reg.register(id); + assert!(!token.is_cancelled()); + assert!(reg.cancel(id)); + assert!(token.is_cancelled()); + } + + #[test] + fn cancel_unknown_job_reports_false() { + let reg = CancellationRegistry::new(); + assert!(!reg.cancel(jid())); + } + + #[test] + fn remove_drops_the_registry_entry() { + let reg = CancellationRegistry::new(); + let id = jid(); + let _t = reg.register(id); + reg.remove(id); + assert!(!reg.cancel(id)); + } + + /// Mirror of the `tokio::select!` race in `worker::handle_one`: + /// a long-running future loses to the cancellation token when + /// the route fires it. Guards against regressions where the + /// registry's token is dropped or re-created somewhere along + /// the path between registry and worker. + #[tokio::test] + async fn race_pattern_aborts_long_future_on_cancel() { + use std::time::Duration; + + let reg = CancellationRegistry::new(); + let id = jid(); + let token = reg.register(id); + let task = tokio::spawn(async move { + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(60)) => false, + _ = token.cancelled() => true, + } + }); + // Give the spawned task a chance to install both arms of the + // select! before we fire. Without the yield the cancel can + // race the task's first poll. + tokio::task::yield_now().await; + assert!(reg.cancel(id), "cancel must report a hit for a live job"); + let cancelled_arm_won = tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("task did not finish in time"); + assert!( + cancelled_arm_won.expect("task panicked"), + "select! should have resolved on the cancelled() arm", + ); + } +} diff --git a/crates/heimdall-daemon/src/dump.rs b/crates/heimdall-daemon/src/dump.rs index 88c8cb8..4bb408e 100644 --- a/crates/heimdall-daemon/src/dump.rs +++ b/crates/heimdall-daemon/src/dump.rs @@ -104,8 +104,10 @@ where write_entry(&mut tar, "campaigns.jsonl", &camp_jsonl)?; let mut ev_jsonl = Vec::new(); - for (id, ev) in &events { - let line = serde_json::to_string(&(id, ev))?; + for rec in &events { + // Persist as `[id, ts, event]` so restore can round-trip the + // timestamp the daemon assigned at original publish time. + let line = serde_json::to_string(&(rec.id, rec.ts, &rec.event))?; ev_jsonl.extend_from_slice(line.as_bytes()); ev_jsonl.push(b'\n'); } @@ -209,8 +211,9 @@ where if let Some(b) = ev_jsonl { for line in split_lines(&b) { - let (id, ev): (EventId, crate::types::Event) = serde_json::from_slice(line)?; - store.import_event(id, ev).await?; + let (id, ts, ev): (EventId, chrono::DateTime, crate::types::Event) = + serde_json::from_slice(line)?; + store.import_event(id, ts, ev).await?; stats.events_restored += 1; } } diff --git a/crates/heimdall-daemon/src/dut_registry.rs b/crates/heimdall-daemon/src/dut_registry.rs index a94bf7b..82f4c94 100644 --- a/crates/heimdall-daemon/src/dut_registry.rs +++ b/crates/heimdall-daemon/src/dut_registry.rs @@ -211,6 +211,87 @@ pub struct DutRecord { /// inputs/outputs in the SVG overlay. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub spice_watches: Vec, + pub timeouts: DutTimeouts, + /// Declared ISA for this DUT. `None` means "no operator-supplied + /// info; let the driver probe `misa` via JTAG at examine time, + /// falling back to RV32I if even that fails." Populated from a + /// `[dut.isa]` block in heimdall.toml. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub isa: Option, +} + +/// Declared ISA for a DUT. Mirrors what a `misa` probe over JTAG +/// would produce: XLEN (32 or 64) plus the set of supported +/// extensions. Strings like `"rv64imac"` parse into this struct via +/// `heimdall_fuzzer::parse_isa_string`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct IsaSpec { + pub xlen: u8, + /// Extensions named by their canonical letter (`i`, `m`, ...) or + /// Z-style identifier (`zicsr`, `zifencei`, ...). Serialized as + /// strings so the daemon can carry future extensions through + /// even if `heimdall_fuzzer::RvExtension` hasn't enumerated them + /// yet. + pub extensions: Vec, +} + +impl IsaSpec { + /// Default ISA when neither config nor probe supplied one: RV32I. + pub fn default_rv32i() -> Self { + Self { + xlen: 32, + extensions: vec!["i".into()], + } + } +} + +/// Forward-compat conversion: take the TOML-mirror `IsaSpec` and +/// produce a typed [`heimdall_fuzzer::ParsedIsa`] with the +/// extension strings resolved into [`heimdall_fuzzer::RvExtension`] +/// values. Unknown extension strings are silently dropped so the +/// per-DUT config layer keeps working when a future heimdall.toml +/// names an extension this build doesn't recognise. +/// [`heimdall_fuzzer::RvExtension::I`] is always included. +#[cfg(feature = "fuzzer")] +impl From<&IsaSpec> for heimdall_fuzzer::ParsedIsa { + fn from(spec: &IsaSpec) -> Self { + let xlen = if spec.xlen == 32 { 32 } else { 64 }; + heimdall_fuzzer::ParsedIsa::from((xlen, spec.extensions.iter().map(|s| s.as_str()))) + } +} + +/// Daemon-side mirror of [`heimdall_config::DutTimeouts`]. Carries the +/// resolved per-DUT timeout values into the worker, factory, and lease +/// manager. +#[derive(Debug, Clone, Copy, Serialize)] +pub struct DutTimeouts { + pub openocd_startup_ms: u64, + pub openocd_rpc_ms: u64, + pub lease_secs: u64, + pub wait_halt_max_ms: u64, +} + +impl Default for DutTimeouts { + fn default() -> Self { + // Matches heimdall_config::DutTimeouts::default(). + Self { + openocd_startup_ms: 10_000, + openocd_rpc_ms: 5_000, + lease_secs: 60, + wait_halt_max_ms: 30_000, + } + } +} + +impl From for DutTimeouts { + fn from(c: heimdall_config::DutTimeouts) -> Self { + Self { + openocd_startup_ms: c.openocd_startup_ms, + openocd_rpc_ms: c.openocd_rpc_ms, + lease_secs: c.lease_secs, + wait_halt_max_ms: c.wait_halt_max_ms, + } + } } /// Daemon-side mirror of [`heimdall_config::SpiceWatchCfg`]. Lives here so @@ -274,6 +355,26 @@ where seq.end() } +impl DutRecord { + /// Build a minimal `DutRecord` with the given id+kind and a `Mock` + /// transport. Intended for tests that need the daemon's HTTP /jobs route + /// to resolve a `DutId` without loading a full ConfigFile. + pub fn mock(id: impl Into, kind: DutKind) -> Self { + Self { + id: DutId::new(id), + kind, + chip_serial: None, + jtag: TransportSpec::Mock, + pad_map: IoPinmap::default(), + bringup: None, + netlist: None, + spice_watches: Vec::new(), + timeouts: DutTimeouts::default(), + isa: None, + } + } +} + impl DutRegistry { pub fn new() -> Self { Self::default() @@ -510,6 +611,11 @@ pub fn build_registry_with_root( bringup, netlist, spice_watches, + timeouts: d.timeouts.into(), + isa: d.isa.as_ref().map(|cfg| IsaSpec { + xlen: cfg.xlen, + extensions: cfg.extensions.clone(), + }), }); } @@ -676,6 +782,8 @@ mod tests { bringup: None, netlist: None, spice_watches: vec![], + timeouts: Default::default(), + isa: None, } } @@ -1055,6 +1163,8 @@ mod tests { }), netlist: None, spice_watches: vec![], + timeouts: Default::default(), + isa: None, }], transport: TransportSection { jtag: vec![jtag_mock("jtag.mock")], @@ -1101,6 +1211,8 @@ mod tests { }), netlist: None, spice_watches: vec![], + timeouts: Default::default(), + isa: None, }], transport: TransportSection { jtag: vec![jtag_mock("jtag.mock")], diff --git a/crates/heimdall-daemon/src/dut_state.rs b/crates/heimdall-daemon/src/dut_state.rs new file mode 100644 index 0000000..0646a36 --- /dev/null +++ b/crates/heimdall-daemon/src/dut_state.rs @@ -0,0 +1,176 @@ +//! Per-DUT latest architectural snapshot cache. +//! +//! A background task subscribes to the [`EventBus`] and stores the most +//! recent `DutStateSnapshot` for each DUT, keyed by `(DutId, source)`. The +//! `/duts` HTTP route reads from this cache so a freshly-opened web client +//! sees the last-known register state without waiting for the next observe() +//! cycle. + +use std::collections::HashMap; +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use heimdall_core::{DutId, State}; +use serde::Serialize; +use tokio::sync::RwLock; + +use crate::event_bus::EventBus; +use crate::types::{Event, JobId, SnapshotSourceTag}; + +/// One cached entry: the most recent snapshot we've seen for a given +/// `(DutId, source)` pair, plus the timestamp the bus stamped on the +/// event and the job that produced it. +#[derive(Debug, Clone, Serialize)] +pub struct DutStateLatest { + pub job: JobId, + pub source: SnapshotSourceTag, + pub ts: DateTime, + pub state: State, +} + +/// Thread-safe map of `(DutId, source) -> DutStateLatest`. Cloning is cheap +/// (Arc-shared). Mutating goes through [`Self::insert`]; reading goes +/// through [`Self::snapshot_for`] or [`Self::all`]. +#[derive(Clone, Default)] +pub struct DutStateCache { + inner: Arc>>, +} + +impl DutStateCache { + pub fn new() -> Self { + Self::default() + } + + /// Overwrite the entry for `(dut, source)`. + pub async fn insert(&self, dut: DutId, latest: DutStateLatest) { + let mut g = self.inner.write().await; + g.insert((dut, latest.source), latest); + } + + /// Return every entry for a single DUT (across all snapshot sources). + /// Order: dut snapshots first, then golden. + pub async fn snapshot_for(&self, dut: &DutId) -> Vec { + let g = self.inner.read().await; + let mut out: Vec = g + .iter() + .filter(|((d, _), _)| d == dut) + .map(|(_, v)| v.clone()) + .collect(); + out.sort_by_key(|v| match v.source { + SnapshotSourceTag::Dut => 0, + SnapshotSourceTag::Golden => 1, + }); + out + } + + /// Borrow the whole map keyed by DUT id. Mostly for diagnostics. + pub async fn all(&self) -> HashMap> { + let g = self.inner.read().await; + let mut out: HashMap> = HashMap::new(); + for ((dut, _), latest) in g.iter() { + out.entry(dut.clone()).or_default().push(latest.clone()); + } + out + } +} + +/// Spawn a background task that subscribes to `bus` and pipes every +/// `DutStateSnapshot` event into `cache`. Returns the join handle so the +/// caller can abort on shutdown. +pub fn spawn_snapshot_task(bus: &EventBus, cache: DutStateCache) -> tokio::task::JoinHandle<()> { + let mut rx = bus.subscribe(); + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(stamped) => { + if let Event::DutStateSnapshot { + dut, + job, + source, + state, + } = stamped.event + { + cache + .insert( + dut.clone(), + DutStateLatest { + job, + source, + ts: stamped.ts, + state, + }, + ) + .await; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => return, + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use heimdall_core::ValueRepr; + + #[tokio::test] + async fn insert_overwrites_per_source() { + let cache = DutStateCache::new(); + let dut = DutId::new("d1"); + let job = JobId::new(); + let ts = Utc::now(); + cache + .insert( + dut.clone(), + DutStateLatest { + job, + source: SnapshotSourceTag::Dut, + ts, + state: State::new().with("x10", ValueRepr::U64(0x10)), + }, + ) + .await; + cache + .insert( + dut.clone(), + DutStateLatest { + job, + source: SnapshotSourceTag::Dut, + ts, + state: State::new().with("x10", ValueRepr::U64(0x20)), + }, + ) + .await; + cache + .insert( + dut.clone(), + DutStateLatest { + job, + source: SnapshotSourceTag::Golden, + ts, + state: State::new().with("x10", ValueRepr::U64(0x30)), + }, + ) + .await; + let snaps = cache.snapshot_for(&dut).await; + assert_eq!( + snaps.len(), + 2, + "dut + golden survive, dut overwrites itself" + ); + assert_eq!(snaps[0].source, SnapshotSourceTag::Dut); + let dut_val = match snaps[0].state.fields.get("x10").unwrap() { + ValueRepr::U64(v) => *v, + other => panic!("wrong variant: {other:?}"), + }; + assert_eq!(dut_val, 0x20, "second dut insert should win"); + } + + #[tokio::test] + async fn snapshot_for_unknown_dut_returns_empty() { + let cache = DutStateCache::new(); + assert!(cache.snapshot_for(&DutId::new("missing")).await.is_empty()); + } +} diff --git a/crates/heimdall-daemon/src/error.rs b/crates/heimdall-daemon/src/error.rs index cce2c7a..46202cd 100644 --- a/crates/heimdall-daemon/src/error.rs +++ b/crates/heimdall-daemon/src/error.rs @@ -18,6 +18,9 @@ pub enum DaemonError { Golden(#[from] heimdall_golden::GoldenError), #[error("test: {0}")] Test(#[from] heimdall_test::TestError), + #[cfg(feature = "fuzzer")] + #[error("fuzzer: {0}")] + Fuzzer(#[from] heimdall_fuzzer::FuzzerError), #[error("unknown job id {0}")] UnknownJob(String), #[error("unknown dut id {0}")] @@ -30,6 +33,21 @@ pub enum DaemonError { Unsupported(&'static str), #[error("dump format: {0}")] DumpFormat(String), + /// The job was cancelled mid-flight (operator hit the cancel + /// button on a running job). Drives the terminal transition to + /// `JobState::Cancelled` instead of `Failed`. + #[error("job cancelled by operator")] + Cancelled, + #[error("daemon built without the `fuzzer` feature; rebuild with --features fuzzer")] + FuzzerFeatureDisabled, + #[error( + "fuzz dispatch requested generator=cranelift but the daemon was built \ + without the `cranelift` feature; rebuild with --features cranelift" + )] + CraneliftFeatureDisabled, + #[cfg(all(feature = "fuzzer", feature = "cranelift"))] + #[error("cranelift init: {0}")] + CraneliftInit(#[from] heimdall_fuzzer::CraneliftInitError), } pub type Result = std::result::Result; diff --git a/crates/heimdall-daemon/src/event_bus.rs b/crates/heimdall-daemon/src/event_bus.rs index de018d6..1d4e526 100644 --- a/crates/heimdall-daemon/src/event_bus.rs +++ b/crates/heimdall-daemon/src/event_bus.rs @@ -4,15 +4,16 @@ use std::sync::Arc; +use chrono::Utc; use tokio::sync::broadcast; use crate::error::Result; use crate::store::JobStore; -use crate::types::Event; +use crate::types::{Event, StampedEvent}; #[derive(Clone)] pub struct EventBus { - tx: broadcast::Sender, + tx: broadcast::Sender, store: Arc, } @@ -22,20 +23,23 @@ impl EventBus { Self { tx, store } } - /// Publish an event. Persists to the store and emits on the broadcast - /// channel. Broadcast failure (no subscribers) is fine. Only store - /// persistence failure is returned. + /// Publish an event. Stamps a single UTC timestamp, persists via + /// `append_event_at` so the stored row carries the same value the + /// broadcast frame reports, and emits on the broadcast channel. + /// Broadcast failure (no subscribers) is fine. Only store persistence + /// failure is returned. pub async fn publish(&self, ev: Event) -> Result<()> { - let _ = self.store.append_event(ev.clone()).await?; - // broadcast::Sender::send returns Err if there are no receivers. - if let Err(broadcast::error::SendError(_)) = self.tx.send(ev) { + let ts = Utc::now(); + let _ = self.store.append_event_at(ev.clone(), ts).await?; + let stamped = StampedEvent::new(ts, ev); + if let Err(broadcast::error::SendError(_)) = self.tx.send(stamped) { // No live subscribers. The persisted event remains readable // via JobStore::list_events_since. } Ok(()) } - pub fn subscribe(&self) -> broadcast::Receiver { + pub fn subscribe(&self) -> broadcast::Receiver { self.tx.subscribe() } @@ -64,7 +68,7 @@ mod tests { }; bus.publish(ev.clone()).await.unwrap(); let recv = rx.recv().await.unwrap(); - match recv { + match recv.event { Event::JobStateChanged { job: j, state } => { assert_eq!(j, job); assert!(matches!(state, JobState::Running)); @@ -73,5 +77,8 @@ mod tests { } let stored = store.list_events_since(EventId(0), 10).await.unwrap(); assert_eq!(stored.len(), 1); + // Bus and storage agree on the timestamp value: the broadcast frame + // and the persisted row see the same `ts`. + assert_eq!(recv.ts, stored[0].ts); } } diff --git a/crates/heimdall-daemon/src/factory.rs b/crates/heimdall-daemon/src/factory.rs index bbd4dec..c9a9fb0 100644 --- a/crates/heimdall-daemon/src/factory.rs +++ b/crates/heimdall-daemon/src/factory.rs @@ -32,14 +32,60 @@ impl std::fmt::Debug for DispatchBundle { } } +/// Parameters for a fuzz session resolved from a [`JobKind::Fuzz`] payload. +/// The factory layer copies fields straight from the job; the worker side +/// feeds them into [`heimdall_fuzzer::FuzzerEngine::builder`]. +#[derive(Debug, Clone)] +pub struct FuzzConfig { + pub iterations: u64, + pub seed: u64, + pub insn_count: usize, + pub cycles: u64, + pub strict_coverage: bool, + pub generator: crate::types::GeneratorKind, + /// Resolved ISA for the target DUT. Sourced from the DUT + /// registry's `[dut.isa]` block; the worker layers a JTAG + /// `misa` probe on top if the driver supports it. `None` means + /// "use the generator's library default (RV64 + I + M)" for + /// backwards compat with pre-isa configs. + pub isa: Option, +} + +/// Fuzz-session dispatch bundle. Factory returns this when the job is a +/// `JobKind::Fuzz` and the worker drives the engine to completion. +pub struct FuzzDispatch { + pub driver: Box, + pub golden: Box, + pub config: FuzzConfig, +} + +impl std::fmt::Debug for FuzzDispatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FuzzDispatch") + .field("driver", &self.driver.target()) + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +/// Discriminated dispatch result. Single-shot tests go through +/// `Dispatch::Test`; coverage-guided fuzz sessions go through +/// `Dispatch::Fuzz`. Same factory contract, different worker routing. +#[derive(Debug)] +pub enum Dispatch { + Test(DispatchBundle), + Fuzz(FuzzDispatch), +} + /// Constructs the per-job machinery. One implementation per JobKind family. #[async_trait] pub trait DriverFactory: Send + Sync { /// Whether this factory handles the given JobKind. fn handles(&self, kind: &JobKind) -> bool; - /// Build the dispatch bundle for the job. - async fn build(&self, job: &Job) -> Result; + /// Build the dispatch bundle for the job. Each factory returns the + /// `Dispatch` variant matching the JobKind it claims via `handles`. + async fn build(&self, job: &Job) -> Result; } /// Maps JobKinds to factories. The worker calls dispatch() per job and the @@ -94,12 +140,15 @@ impl DriverRegistry { r = r.with(Arc::new(RiverRealFactory { registry: dut_registry.clone(), })); + r = r.with(Arc::new(RiverFuzzFactory { + registry: dut_registry.clone(), + })); } let _ = &dut_registry; // silence unused warning if no features r } - pub async fn dispatch(&self, job: &Job) -> Result { + pub async fn dispatch(&self, job: &Job) -> Result { for f in &self.factories { if f.handles(&job.kind) { return f.build(job).await; @@ -125,18 +174,18 @@ impl DriverFactory for MockHelloFactory { } #[instrument(skip(self, _job))] - async fn build(&self, _job: &Job) -> Result { + async fn build(&self, _job: &Job) -> Result { let driver = MockDriver::new(DutKind::RiverRc1Nano) - .with_state(State::new().with("a0", ValueRepr::U64(0x42))); + .with_state(State::new().with("x10", ValueRepr::U64(0x42))); let golden = MockGoldenModel::new(DutKind::RiverRc1Nano); let test = MockHelloTest { target: DutKind::RiverRc1Nano, }; - Ok(DispatchBundle { + Ok(Dispatch::Test(DispatchBundle { driver: Box::new(driver), golden: Box::new(golden), test: Box::new(test), - }) + })) } } @@ -155,7 +204,7 @@ impl Test for MockHelloTest { async fn build(&self, _ctx: &mut BuildCtx<'_>) -> std::result::Result { Ok(Plan { input: Artifact::new(ArtifactKind::Asm, &b"li a0, 0x42"[..]), - expected: State::new().with("a0", ValueRepr::U64(0x42)), + expected: State::new().with("x10", ValueRepr::U64(0x42)), budget: StepBudget::cycles(1000), inputs: std::collections::BTreeMap::new(), }) @@ -176,19 +225,19 @@ impl DriverFactory for MockBootRiverElfFactory { } #[instrument(skip(self, job))] - async fn build(&self, job: &Job) -> Result { - let driver = - MockDriver::new(job.dut_kind).with_state(State::new().with("a0", ValueRepr::U64(0x42))); + async fn build(&self, job: &Job) -> Result { + let driver = MockDriver::new(job.dut_kind) + .with_state(State::new().with("x10", ValueRepr::U64(0x42))); let golden = MockGoldenModel::new(job.dut_kind) - .with_state(State::new().with("a0", ValueRepr::U64(0x42))); + .with_state(State::new().with("x10", ValueRepr::U64(0x42))); let test = MockBootRiverElfTest { target: job.dut_kind, }; - Ok(DispatchBundle { + Ok(Dispatch::Test(DispatchBundle { driver: Box::new(driver), golden: Box::new(golden), test: Box::new(test), - }) + })) } } @@ -207,7 +256,7 @@ impl Test for MockBootRiverElfTest { async fn build(&self, _ctx: &mut BuildCtx<'_>) -> std::result::Result { Ok(Plan { input: Artifact::new(ArtifactKind::ElfRiscv, &b"\x7fELF"[..]), - expected: State::new().with("a0", ValueRepr::U64(0x42)), + expected: State::new().with("x10", ValueRepr::U64(0x42)), budget: StepBudget::cycles(1000), inputs: std::collections::BTreeMap::new(), }) @@ -252,7 +301,7 @@ mod aegis_factory { matches!(kind, JobKind::LoadAegisBitstream { .. }) } - async fn build(&self, job: &Job) -> Result { + async fn build(&self, job: &Job) -> Result { let (descriptor_json, bitstream) = decode_load_bitstream(&job.kind)?; let mock = MockTransport::new(); let pins = BitbangPins { @@ -265,7 +314,7 @@ mod aegis_factory { .with_clock_delay(std::time::Duration::from_nanos(1)); let driver = AegisFpgaDriver::new(job.dut_kind, jtag); let golden = MockGoldenModel::new(job.dut_kind).with_state(State::new()); - Ok(DispatchBundle { + Ok(Dispatch::Test(DispatchBundle { driver: Box::new(driver), golden: Box::new(golden), test: Box::new(AegisLoadTest { @@ -273,7 +322,7 @@ mod aegis_factory { descriptor_json, bitstream, }), - }) + })) } } @@ -294,7 +343,7 @@ mod aegis_factory { ) } - async fn build(&self, job: &Job) -> Result { + async fn build(&self, job: &Job) -> Result { let golden = Box::new(MockGoldenModel::new(job.dut_kind).with_state(State::new())) as Box; @@ -372,7 +421,12 @@ mod aegis_factory { )); } TransportSpec::Openocd { endpoint } => { - let ocd = OpenOcdJtagTransport::new(endpoint).with_tap_name(AEGIS_TAP_NAME); + let timeouts = dut_record.as_ref().map(|r| r.timeouts).unwrap_or_default(); + let ocd = OpenOcdJtagTransport::new(endpoint) + .with_tap_name(AEGIS_TAP_NAME) + .with_rpc_timeout(std::time::Duration::from_millis( + timeouts.openocd_rpc_ms, + )); let mut d = AegisFpgaDriver::new(job.dut_kind, ocd); attach_pinmap(&mut d, dut_record.as_ref()); Box::new(d) @@ -383,6 +437,7 @@ mod aegis_factory { tcl_port, extra_args, } => { + let timeouts = dut_record.as_ref().map(|r| r.timeouts).unwrap_or_default(); let spawned = heimdall_transport::openocd::spawn::SpawnedOpenocdJtagTransport::new( binary.clone(), @@ -390,7 +445,13 @@ mod aegis_factory { tcl_port, ) .with_extra_args(extra_args.clone()) - .with_tap_name(AEGIS_TAP_NAME); + .with_tap_name(AEGIS_TAP_NAME) + .with_startup_timeout(std::time::Duration::from_millis( + timeouts.openocd_startup_ms, + )) + .with_rpc_timeout( + std::time::Duration::from_millis(timeouts.openocd_rpc_ms), + ); let mut d = AegisFpgaDriver::new(job.dut_kind, spawned); attach_pinmap(&mut d, dut_record.as_ref()); Box::new(d) @@ -402,11 +463,11 @@ mod aegis_factory { } }; - Ok(DispatchBundle { + Ok(Dispatch::Test(DispatchBundle { driver, golden, test, - }) + })) } } @@ -604,7 +665,7 @@ mod river_factory { matches!(kind, JobKind::BootRiverElf { .. }) } - async fn build(&self, job: &Job) -> Result { + async fn build(&self, job: &Job) -> Result { let (elf_bytes, cycles) = decode_river_kind(&job.kind)?; let dut = self.registry.lookup(&job.dut).ok_or_else(|| { @@ -614,12 +675,15 @@ mod river_factory { )) })?; - // Build the driver boxed as dyn TestDriver so both transport arms - // can unify behind a single type-erased pointer. + let t = dut.timeouts; + let rpc_to = std::time::Duration::from_millis(t.openocd_rpc_ms); + let startup_to = std::time::Duration::from_millis(t.openocd_startup_ms); + let wait_max = std::time::Duration::from_millis(t.wait_halt_max_ms); + let driver: Box = match &dut.jtag { TransportSpec::Openocd { endpoint } => { - let jtag = OpenOcdJtagTransport::new(*endpoint); - Box::new(RiverCpuDriver::new(job.dut_kind, jtag)) + let jtag = OpenOcdJtagTransport::new(*endpoint).with_rpc_timeout(rpc_to); + Box::new(RiverCpuDriver::new(job.dut_kind, jtag).with_wait_halt_max(wait_max)) } TransportSpec::OpenocdSpawned { binary, @@ -632,8 +696,12 @@ mod river_factory { config_file.clone(), *tcl_port, ) - .with_extra_args(extra_args.clone()); - Box::new(RiverCpuDriver::new(job.dut_kind, spawned)) + .with_extra_args(extra_args.clone()) + .with_startup_timeout(startup_to) + .with_rpc_timeout(rpc_to); + Box::new( + RiverCpuDriver::new(job.dut_kind, spawned).with_wait_halt_max(wait_max), + ) } other => { return Err(crate::error::DaemonError::Config(format!( @@ -666,11 +734,11 @@ mod river_factory { cycles, }); - Ok(DispatchBundle { + Ok(Dispatch::Test(DispatchBundle { driver, golden, test, - }) + })) } } @@ -713,10 +781,134 @@ mod river_factory { }) } } + + /// Factory for `JobKind::Fuzz` against a River DUT. Same driver+golden + /// construction logic as `RiverRealFactory` (lives behind it via shared + /// helpers); difference is the dispatch variant and the carried fuzz + /// config. Routed by `DriverFactory::handles` matching `JobKind::Fuzz`. + pub struct RiverFuzzFactory { + pub registry: Arc, + } + + #[async_trait] + impl DriverFactory for RiverFuzzFactory { + fn handles(&self, kind: &JobKind) -> bool { + // Only River DUTs are supported today. We match Fuzz here and + // re-check the DUT family in `build`. + matches!(kind, JobKind::Fuzz { .. }) + } + + async fn build(&self, job: &Job) -> Result { + use heimdall_core::kind::Family; + if job.dut_kind.family() != Family::Cpu { + return Err(crate::error::DaemonError::Config(format!( + "river fuzz factory: dut_kind `{:?}` is not a CPU; fuzz currently \ + only supports the River CPU family", + job.dut_kind + ))); + } + let (iterations, seed, insn_count, cycles, strict_coverage, generator) = match &job.kind + { + JobKind::Fuzz { + iterations, + seed, + insn_count, + cycles, + strict_coverage, + generator, + } => ( + *iterations, + *seed, + *insn_count, + *cycles, + *strict_coverage, + *generator, + ), + _ => { + return Err(crate::error::DaemonError::Config( + "river fuzz factory: wrong JobKind".into(), + )); + } + }; + + let dut = self.registry.lookup(&job.dut).ok_or_else(|| { + crate::error::DaemonError::Config(format!( + "river fuzz factory: dut `{}` not in registry", + job.dut.0 + )) + })?; + + let t = dut.timeouts; + let rpc_to = std::time::Duration::from_millis(t.openocd_rpc_ms); + let startup_to = std::time::Duration::from_millis(t.openocd_startup_ms); + let wait_max = std::time::Duration::from_millis(t.wait_halt_max_ms); + + let driver: Box = match &dut.jtag { + TransportSpec::Openocd { endpoint } => { + let jtag = OpenOcdJtagTransport::new(*endpoint).with_rpc_timeout(rpc_to); + Box::new(RiverCpuDriver::new(job.dut_kind, jtag).with_wait_halt_max(wait_max)) + } + TransportSpec::OpenocdSpawned { + binary, + config_file, + tcl_port, + extra_args, + } => { + let spawned = SpawnedOpenocdJtagTransport::new( + binary.clone(), + config_file.clone(), + *tcl_port, + ) + .with_extra_args(extra_args.clone()) + .with_startup_timeout(startup_to) + .with_rpc_timeout(rpc_to); + Box::new( + RiverCpuDriver::new(job.dut_kind, spawned).with_wait_halt_max(wait_max), + ) + } + other => { + return Err(crate::error::DaemonError::Config(format!( + "river fuzz factory only supports openocd or openocd-spawn transport; got {other:?}" + ))); + } + }; + + let golden_spec = self + .registry + .golden_for(job.dut_kind) + .cloned() + .unwrap_or(GoldenSpec::Mock); + let golden: Box = match golden_spec { + GoldenSpec::Mock => Box::new(MockGoldenModel::new(job.dut_kind)), + GoldenSpec::SpikeOneShot { binary, extra_args } => { + Box::new(SpikeOneShot::new(binary, job.dut_kind).with_extra_args(extra_args)) + } + GoldenSpec::DartRpc { .. } => { + return Err(crate::error::DaemonError::Config( + "river fuzz factory: DartRpc golden is not yet supported".into(), + )); + } + }; + + Ok(Dispatch::Fuzz(FuzzDispatch { + driver, + golden, + config: FuzzConfig { + iterations, + seed, + insn_count, + cycles, + strict_coverage, + generator, + isa: dut.isa.clone(), + }, + })) + } + } } #[cfg(feature = "river")] -pub use river_factory::{BootRiverElfTest, RiverRealFactory}; +pub use river_factory::{BootRiverElfTest, RiverFuzzFactory, RiverRealFactory}; #[cfg(all(test, feature = "river"))] mod river_factory_tests { @@ -742,6 +934,8 @@ mod river_factory_tests { bringup: None, netlist: None, spice_watches: vec![], + timeouts: Default::default(), + isa: None, }); let factory = RiverRealFactory { registry: Arc::new(registry), @@ -762,8 +956,13 @@ mod river_factory_tests { updated_at: Utc::now(), }; assert!(factory.handles(&job.kind)); - let bundle = factory.build(&job).await.expect("build"); - assert_eq!(bundle.driver.target(), DutKind::RiverRc1Nano); + let dispatch = factory.build(&job).await.expect("build"); + match dispatch { + Dispatch::Test(bundle) => { + assert_eq!(bundle.driver.target(), DutKind::RiverRc1Nano); + } + other => panic!("expected Dispatch::Test, got {other:?}"), + } } #[tokio::test] @@ -778,6 +977,8 @@ mod river_factory_tests { bringup: None, netlist: None, spice_watches: vec![], + timeouts: Default::default(), + isa: None, }); let factory = RiverRealFactory { registry: Arc::new(registry), @@ -799,4 +1000,93 @@ mod river_factory_tests { "got: {err}" ); } + + #[tokio::test] + async fn river_fuzz_factory_builds_for_openocd_dut() { + let mut registry = DutRegistry::new(); + registry.insert(DutRecord { + id: DutId::new("river-1"), + kind: DutKind::RiverRc1Nano, + chip_serial: None, + jtag: TransportSpec::Openocd { + endpoint: "127.0.0.1:6666".parse().unwrap(), + }, + pad_map: IoPinmap::default(), + bringup: None, + netlist: None, + spice_watches: vec![], + timeouts: Default::default(), + isa: None, + }); + let factory = RiverFuzzFactory { + registry: Arc::new(registry), + }; + let job = Job { + id: crate::types::JobId::new(), + dut: DutId::new("river-1"), + dut_kind: DutKind::RiverRc1Nano, + kind: JobKind::Fuzz { + iterations: 5, + seed: 0xdeadbeef, + insn_count: 8, + cycles: 500, + strict_coverage: false, + generator: crate::types::GeneratorKind::RawAsm, + }, + campaign: None, + state: JobState::Queued, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + assert!(factory.handles(&job.kind)); + let dispatch = factory.build(&job).await.expect("fuzz build"); + match dispatch { + Dispatch::Fuzz(fuzz) => { + assert_eq!(fuzz.driver.target(), DutKind::RiverRc1Nano); + assert_eq!(fuzz.config.iterations, 5); + assert_eq!(fuzz.config.seed, 0xdeadbeef); + assert_eq!(fuzz.config.insn_count, 8); + assert_eq!(fuzz.config.cycles, 500); + assert!(!fuzz.config.strict_coverage); + assert_eq!( + fuzz.config.generator, + crate::types::GeneratorKind::RawAsm, + "default generator must be raw-asm", + ); + } + other => panic!("expected Dispatch::Fuzz, got {other:?}"), + } + } + + #[cfg(feature = "aegis")] + #[tokio::test] + async fn river_fuzz_factory_rejects_non_cpu_dut_kind() { + let registry = Arc::new(DutRegistry::new()); + let factory = RiverFuzzFactory { registry }; + let bogus_kind = DutKind::AegisLuna1; + { + let job = Job { + id: crate::types::JobId::new(), + dut: DutId::new("not-a-cpu"), + dut_kind: bogus_kind, + kind: JobKind::Fuzz { + iterations: 1, + seed: 0, + insn_count: 1, + cycles: 1, + strict_coverage: false, + generator: crate::types::GeneratorKind::RawAsm, + }, + campaign: None, + state: JobState::Queued, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + let err = factory + .build(&job) + .await + .expect_err("non-CPU must be rejected"); + assert!(format!("{err}").contains("not a CPU"), "got: {err}"); + } + } } diff --git a/crates/heimdall-daemon/src/isa_probes.rs b/crates/heimdall-daemon/src/isa_probes.rs new file mode 100644 index 0000000..3d409fc --- /dev/null +++ b/crates/heimdall-daemon/src/isa_probes.rs @@ -0,0 +1,137 @@ +//! In-memory cache of per-DUT ISA probe results. +//! +//! The fuzz worker, on first dispatch against a given DUT, calls +//! `driver.probe_isa()` over JTAG (reads CSR `misa` via DMI abstract +//! command) and stores the result here keyed by [`DutId`]. Subsequent +//! fuzz jobs against the same DUT skip the probe and reuse the +//! cached value, so the once-per-daemon-lifetime probe cost stays +//! amortised. +//! +//! Precedence (resolved by the worker, not this module): +//! 1. Explicit `[dut.isa]` block in heimdall.toml. Operator-authoritative +//! so the fuzzer can be told to emit a subset of what the DUT +//! actually supports. +//! 2. Cached probe result from this cache. +//! 3. Default RV32I. +//! +//! In-memory only; a daemon restart drops it and the next fuzz +//! against each DUT re-probes. + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use heimdall_core::DutId; +use heimdall_driver::IsaProbe; + +use crate::dut_registry::IsaSpec; + +/// Probed ISA capabilities for a DUT, normalised to the same shape +/// the registry-side [`crate::dut_registry::IsaSpec`] uses so the +/// worker can treat both sources uniformly. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProbedIsa { + pub xlen: u8, + /// Canonical extension identifiers: misa letters (`"i"`, `"m"`, + /// ...) or Z-style names. Empty when no extensions are decoded + /// (e.g. misa returned all zeros). + pub extensions: Vec, +} + +/// A driver-returned probe maps 1:1 to a cache entry with the same +/// shape and semantics. The `From` impl lets the worker stash a probe +/// result with `cache.insert(dut, probe.into())`. +impl From for ProbedIsa { + fn from(probe: IsaProbe) -> Self { + Self { + xlen: probe.xlen, + extensions: probe.extensions, + } + } +} + +/// Same shape as the TOML-mirror [`IsaSpec`], differing only in +/// intent. Lets the worker layer cache results into the +/// `Option<&IsaSpec>` pipeline `resolve_isa` consumes. +impl From<&ProbedIsa> for IsaSpec { + fn from(probe: &ProbedIsa) -> Self { + Self { + xlen: probe.xlen, + extensions: probe.extensions.clone(), + } + } +} + +#[derive(Clone, Default)] +pub struct IsaProbeCache { + inner: Arc>>, +} + +impl IsaProbeCache { + pub fn new() -> Self { + Self::default() + } + + pub fn get(&self, dut: &DutId) -> Option { + let map = self.inner.read().expect("isa-probe lock poisoned"); + map.get(dut).cloned() + } + + pub fn insert(&self, dut: DutId, probe: ProbedIsa) { + let mut map = self.inner.write().expect("isa-probe lock poisoned"); + map.insert(dut, probe); + } + + /// Drop a cached entry. Useful when the operator re-flashes a + /// DUT and the misa shape may have changed. + pub fn forget(&self, dut: &DutId) { + let mut map = self.inner.write().expect("isa-probe lock poisoned"); + map.remove(dut); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_get_insert_forget() { + let cache = IsaProbeCache::new(); + let id = DutId::new("river-1"); + assert!(cache.get(&id).is_none()); + cache.insert( + id.clone(), + ProbedIsa { + xlen: 64, + extensions: vec!["i".into(), "m".into()], + }, + ); + let got = cache.get(&id).unwrap(); + assert_eq!(got.xlen, 64); + assert_eq!(got.extensions, vec!["i".to_string(), "m".to_string()]); + cache.forget(&id); + assert!(cache.get(&id).is_none()); + } + + #[test] + fn insert_replaces_prior() { + let cache = IsaProbeCache::new(); + let id = DutId::new("river-1"); + cache.insert( + id.clone(), + ProbedIsa { + xlen: 32, + extensions: vec!["i".into()], + }, + ); + cache.insert( + id.clone(), + ProbedIsa { + xlen: 64, + extensions: vec!["i".into(), "m".into(), "c".into()], + }, + ); + let got = cache.get(&id).unwrap(); + assert_eq!(got.xlen, 64); + assert_eq!(got.extensions.len(), 3); + } +} diff --git a/crates/heimdall-daemon/src/job_logger.rs b/crates/heimdall-daemon/src/job_logger.rs new file mode 100644 index 0000000..75f2a3f --- /dev/null +++ b/crates/heimdall-daemon/src/job_logger.rs @@ -0,0 +1,214 @@ +//! `JobLogger` wraps an [`EventBus`] and a [`JobId`] so the test runner can +//! emit per-stage events that get fanned out over the `/events` WebSocket. +//! Surfaces in the web UI as a live job timeline. + +use std::collections::BTreeMap; + +use async_trait::async_trait; +use heimdall_core::{DutId, State}; +use heimdall_i18n::Locale; +use heimdall_test::{SnapshotSource, Stage, StageMessage, StageObserver}; +use tracing::warn; + +use crate::event_bus::EventBus; +use crate::types::{Event, JobId, LogLevel, SnapshotSourceTag}; + +#[derive(Clone)] +pub struct JobLogger { + bus: EventBus, + job: JobId, +} + +impl JobLogger { + pub fn new(bus: EventBus, job: JobId) -> Self { + Self { bus, job } + } + + /// Publish a daemon-level `JobLog` keyed by an i18n catalog entry. + /// Each named arg is stringified once on the publish side. Used by the + /// worker for its own dispatch-summary line. + pub async fn log_keyed( + &self, + level: LogLevel, + key: &'static str, + args: &[(&'static str, String)], + ) { + let arg_refs: Vec<(&str, &str)> = args.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let message = heimdall_i18n::t_args_in(Locale::En, key, &arg_refs); + let i18n_args: BTreeMap = args + .iter() + .map(|(k, v)| ((*k).to_string(), v.clone())) + .collect(); + self.publish(level, message, None, Some(key.to_string()), i18n_args) + .await; + } + + /// Publish a daemon-level `JobLog` with a free-form English message + /// (no catalog key). Reserved for ad-hoc messages that don't yet have + /// a translation key, e.g. propagated driver error strings. + pub async fn log(&self, level: LogLevel, message: impl Into) { + self.publish(level, message.into(), None, None, BTreeMap::new()) + .await; + } + + async fn publish( + &self, + level: LogLevel, + message: String, + stage: Option, + i18n_key: Option, + i18n_args: BTreeMap, + ) { + if let Err(e) = self + .bus + .publish(Event::JobLog { + job: self.job, + level, + message, + stage: stage.map(|s| s.as_str().to_string()), + i18n_key, + i18n_args, + }) + .await + { + warn!(error = %e, job = %self.job, "job-log publish failed"); + } + } +} + +#[async_trait] +impl StageObserver for JobLogger { + async fn stage(&self, stage: Stage, message: StageMessage) { + let arg_refs: Vec<(&str, &str)> = + message.args.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let rendered = heimdall_i18n::t_args_in(Locale::En, message.key, &arg_refs); + let i18n_args: BTreeMap = message + .args + .iter() + .map(|(k, v)| ((*k).to_string(), v.clone())) + .collect(); + self.publish( + LogLevel::Info, + rendered, + Some(stage), + Some(message.key.to_string()), + i18n_args, + ) + .await; + } + + async fn state_snapshot(&self, source: SnapshotSource, dut: &DutId, state: &State) { + if let Err(e) = self + .bus + .publish(Event::DutStateSnapshot { + dut: dut.clone(), + job: self.job, + source: SnapshotSourceTag::from(source), + state: state.clone(), + }) + .await + { + warn!(error = %e, job = %self.job, "dut-state-snapshot publish failed"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[cfg(feature = "sqlite")] + #[tokio::test] + async fn stage_publishes_job_log_event_with_i18n_key_and_args() { + use crate::store::{JobStore, SqliteJobStore}; + let store = Arc::new(SqliteJobStore::open_in_memory().await.unwrap()) as Arc; + let bus = EventBus::new(store.clone(), 16); + let mut rx = bus.subscribe(); + let job = JobId::new(); + let logger = JobLogger::new(bus, job); + logger + .stage( + Stage::Load, + StageMessage::new("log.runner.load_start").arg("bytes", 30), + ) + .await; + let stamped = rx.recv().await.unwrap(); + match stamped.event { + Event::JobLog { + job: j, + level, + message, + stage, + i18n_key, + i18n_args, + } => { + assert_eq!(j, job); + assert_eq!(level, LogLevel::Info); + // Server-rendered English fallback uses the en catalog. + assert_eq!(message, "loading image into dut (30 bytes)"); + assert_eq!(stage.as_deref(), Some("load")); + assert_eq!(i18n_key.as_deref(), Some("log.runner.load_start")); + assert_eq!(i18n_args.get("bytes").map(String::as_str), Some("30")); + } + other => panic!("unexpected event {other:?}"), + } + } + + #[cfg(feature = "sqlite")] + #[tokio::test] + async fn log_publishes_without_stage_tag() { + use crate::store::{JobStore, SqliteJobStore}; + let store = Arc::new(SqliteJobStore::open_in_memory().await.unwrap()) as Arc; + let bus = EventBus::new(store.clone(), 16); + let mut rx = bus.subscribe(); + let job = JobId::new(); + let logger = JobLogger::new(bus, job); + logger.log(LogLevel::Info, "dispatching mock-hello").await; + let stamped = rx.recv().await.unwrap(); + match stamped.event { + Event::JobLog { + stage, i18n_key, .. + } => { + assert!(stage.is_none()); + assert!(i18n_key.is_none()); + } + other => panic!("unexpected event {other:?}"), + } + } + + #[cfg(feature = "sqlite")] + #[tokio::test] + async fn log_keyed_sets_i18n_fields() { + use crate::store::{JobStore, SqliteJobStore}; + let store = Arc::new(SqliteJobStore::open_in_memory().await.unwrap()) as Arc; + let bus = EventBus::new(store.clone(), 16); + let mut rx = bus.subscribe(); + let job = JobId::new(); + let logger = JobLogger::new(bus, job); + logger + .log_keyed( + LogLevel::Info, + "log.worker.dispatching", + &[("summary", "boot-river-elf (elf=1216B, cycles=2000)".into())], + ) + .await; + let stamped = rx.recv().await.unwrap(); + match stamped.event { + Event::JobLog { + message, + i18n_key, + i18n_args, + .. + } => { + assert_eq!(i18n_key.as_deref(), Some("log.worker.dispatching")); + assert_eq!( + i18n_args.get("summary").map(String::as_str), + Some("boot-river-elf (elf=1216B, cycles=2000)"), + ); + assert!(message.starts_with("dispatching ")); + } + other => panic!("unexpected event {other:?}"), + } + } +} diff --git a/crates/heimdall-daemon/src/lease.rs b/crates/heimdall-daemon/src/lease.rs index 33a89b4..673c130 100644 --- a/crates/heimdall-daemon/src/lease.rs +++ b/crates/heimdall-daemon/src/lease.rs @@ -42,10 +42,21 @@ impl LeaseManager { } } - /// Attempt to acquire an exclusive lease on the DUT for the given job. - /// Returns `Err(LeaseExpired)` if the DUT is already leased and not yet - /// expired. + /// Attempt to acquire an exclusive lease on the DUT for the given job + /// with the manager's default TTL. Returns `Err(LeaseExpired)` if the DUT + /// is already leased and not yet expired. pub async fn acquire(&self, dut: DutId, holder: JobId) -> Result { + self.acquire_with_ttl(dut, holder, self.ttl).await + } + + /// Acquire with a per-call TTL override. Used by the worker so each + /// job can take a TTL matched to its DUT's `dut.timeouts.lease_secs`. + pub async fn acquire_with_ttl( + &self, + dut: DutId, + holder: JobId, + ttl: LeaseTtl, + ) -> Result { let mut inner = self.inner.lock().await; self.gc_expired(&mut inner); if let Some((existing, _)) = inner.by_dut.get(&dut) { @@ -61,12 +72,12 @@ impl LeaseManager { holder, acquired_at: now, expires_at: now - + chrono::Duration::from_std(self.ttl.0) + + chrono::Duration::from_std(ttl.0) .unwrap_or_else(|_| chrono::Duration::seconds(60)), }; inner .by_dut - .insert(dut, (lease.clone(), Instant::now() + self.ttl.0)); + .insert(dut, (lease.clone(), Instant::now() + ttl.0)); Ok(lease) } @@ -164,6 +175,25 @@ mod tests { let _ = mgr.acquire(DutId::new("d1"), JobId::new()).await.unwrap(); } + #[tokio::test] + async fn acquire_with_ttl_overrides_default() { + let mgr = LeaseManager::new(LeaseTtl(Duration::from_millis(50))); + let _ = mgr + .acquire_with_ttl( + DutId::new("slow"), + JobId::new(), + LeaseTtl(Duration::from_secs(60)), + ) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(75)).await; + let err = mgr + .acquire(DutId::new("slow"), JobId::new()) + .await + .unwrap_err(); + assert!(matches!(err, DaemonError::LeaseExpired(_))); + } + #[tokio::test] async fn heartbeat_extends_lease() { let mgr = mgr(); diff --git a/crates/heimdall-daemon/src/lib.rs b/crates/heimdall-daemon/src/lib.rs index ec27b9e..007d725 100644 --- a/crates/heimdall-daemon/src/lib.rs +++ b/crates/heimdall-daemon/src/lib.rs @@ -5,12 +5,17 @@ //! through `heimdall_test::Runner`. pub mod campaign; +pub mod cancellations; pub mod dump; pub mod dut_registry; +pub mod dut_state; pub mod error; pub mod event_bus; pub mod factory; +pub mod isa_probes; +pub mod job_logger; pub mod lease; +pub mod loaded_programs; pub mod queue; pub mod routes; pub mod runtime; @@ -21,10 +26,13 @@ pub mod types; pub mod worker; pub use campaign::{compute_state, refresh_state, submit_campaign}; +pub use cancellations::CancellationRegistry; pub use dut_registry::{ - BringupPayload, ConnectionStatus, DutRecord, DutRegistry, GoldenSpec, GpioSpec, IoPinmap, - PadDirection, PadEntry, TransportSpec, build_registry, build_registry_with_root, + BringupPayload, ConnectionStatus, DutRecord, DutRegistry, DutTimeouts, GoldenSpec, GpioSpec, + IoPinmap, IsaSpec, PadDirection, PadEntry, TransportSpec, build_registry, + build_registry_with_root, }; +pub use dut_state::{DutStateCache, DutStateLatest}; pub use error::{DaemonError, Result}; pub use event_bus::EventBus; #[cfg(feature = "aegis")] @@ -32,16 +40,23 @@ pub use factory::{AegisLoadMockFactory, AegisRealFactory, AegisVectorTest}; #[cfg(feature = "river")] pub use factory::{BootRiverElfTest, RiverRealFactory}; pub use factory::{DispatchBundle, DriverFactory, DriverRegistry, MockHelloFactory}; +pub use isa_probes::{IsaProbeCache, ProbedIsa}; +pub use job_logger::JobLogger; pub use lease::{LeaseManager, LeaseTtl}; +pub use loaded_programs::{LoadedProgram, LoadedProgramCache}; pub use queue::{JobQueue, JobQueueReceiver}; -pub use runtime::{DaemonHandles, start, start_with_config, start_with_registry}; +pub use runtime::{ + DaemonHandles, start, start_binds, start_with_config, start_with_config_binds, + start_with_dut_registry, start_with_registry, +}; pub use server::{AppState, build_router}; -pub use store::{BlobStore, JobStore, LocalFsBlobStore}; +pub use store::{BlobStore, JobProgramRef, JobStore, LocalFsBlobStore}; pub use worker::Worker; #[cfg(feature = "sqlite")] pub use store::SqliteJobStore; pub use types::{ - Blob, BlobId, Campaign, CampaignId, CampaignState, CampaignTemplate, Event, EventId, Job, - JobFilter, JobId, JobKind, JobState, JobStateTag, Lease, LeaseId, NewJob, VerdictSummary, + AboutFeatures, AboutInfo, Blob, BlobId, Campaign, CampaignId, CampaignState, CampaignTemplate, + Event, EventId, EventRecord, GeneratorKind, Job, JobFilter, JobId, JobKind, JobState, + JobStateTag, Lease, LeaseId, LogLevel, NewJob, SnapshotSourceTag, StampedEvent, VerdictSummary, }; diff --git a/crates/heimdall-daemon/src/loaded_programs.rs b/crates/heimdall-daemon/src/loaded_programs.rs new file mode 100644 index 0000000..37014b8 --- /dev/null +++ b/crates/heimdall-daemon/src/loaded_programs.rs @@ -0,0 +1,140 @@ +//! Tiny in-memory cache of "the last program we loaded onto the DUT +//! for this job." Populated by the worker's Fuzz dispatch (the engine +//! fires a [`heimdall_fuzzer::ProgramCallback`] before each iter's +//! load), consumed by the `/jobs/:id/disasm` route. +//! +//! Kept in-memory because the cache is informational: a daemon restart +//! drops it and the next iter repopulates. Persisting per-iter program +//! bytes is wasteful for a UX feature; if we later want post-hoc replay +//! we can plumb a BlobStore write at the same observer call site. +//! +//! [`LoadedProgramCache`] is `Clone` (the inner `Arc` is shared) so +//! the daemon can hand the same handle to both [`crate::AppState`] +//! and the worker's per-job dispatch. + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use heimdall_core::ArtifactKind; + +use crate::types::JobId; + +/// One snapshot of "the program currently loaded on the DUT" for a +/// fuzz job. `iter` is the 0-based iteration index the engine was on +/// when it generated the artifact, surfaced in the UI so the operator +/// knows which iter they're looking at when the WS PC highlight +/// catches up. +#[derive(Debug, Clone)] +pub struct LoadedProgram { + pub iter: u64, + pub kind: ArtifactKind, + pub bytes: Vec, +} + +#[derive(Clone, Default)] +pub struct LoadedProgramCache { + inner: Arc>>, +} + +impl LoadedProgramCache { + pub fn new() -> Self { + Self::default() + } + + /// Replace any prior entry for `job` with `program`. Single-writer + /// per job in practice (the worker owns the engine), so a write + /// lock is fine. + pub fn record(&self, job: JobId, program: LoadedProgram) { + let mut map = self.inner.write().expect("loaded-program lock poisoned"); + map.insert(job, program); + } + + /// Fetch the most recently loaded program for `job`, or `None` if + /// no iter has been recorded yet (job hasn't started or wasn't a + /// Fuzz dispatch). + pub fn latest(&self, job: JobId) -> Option { + let map = self.inner.read().expect("loaded-program lock poisoned"); + map.get(&job).cloned() + } + + /// Drop the entry for `job`. Called from the worker on terminal + /// transitions so the cache doesn't grow unboundedly across long- + /// running daemons. Idempotent. + pub fn drop_job(&self, job: JobId) { + let mut map = self.inner.write().expect("loaded-program lock poisoned"); + map.remove(&job); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + fn jid() -> JobId { + JobId(Uuid::new_v4()) + } + + #[test] + fn record_then_latest_round_trips() { + let cache = LoadedProgramCache::new(); + let id = jid(); + let prog = LoadedProgram { + iter: 3, + kind: ArtifactKind::RawBytes, + bytes: vec![0x93, 0x00, 0x00, 0x00], + }; + cache.record(id, prog.clone()); + let got = cache.latest(id).expect("must round-trip"); + assert_eq!(got.iter, 3); + assert_eq!(got.bytes, prog.bytes); + assert_eq!(got.kind, ArtifactKind::RawBytes); + } + + #[test] + fn record_replaces_prior_entry() { + let cache = LoadedProgramCache::new(); + let id = jid(); + cache.record( + id, + LoadedProgram { + iter: 0, + kind: ArtifactKind::RawBytes, + bytes: vec![1], + }, + ); + cache.record( + id, + LoadedProgram { + iter: 5, + kind: ArtifactKind::RawBytes, + bytes: vec![2, 3], + }, + ); + let got = cache.latest(id).unwrap(); + assert_eq!(got.iter, 5); + assert_eq!(got.bytes, vec![2, 3]); + } + + #[test] + fn latest_returns_none_for_unknown_job() { + let cache = LoadedProgramCache::new(); + assert!(cache.latest(jid()).is_none()); + } + + #[test] + fn drop_job_removes_entry() { + let cache = LoadedProgramCache::new(); + let id = jid(); + cache.record( + id, + LoadedProgram { + iter: 0, + kind: ArtifactKind::RawBytes, + bytes: vec![0], + }, + ); + cache.drop_job(id); + assert!(cache.latest(id).is_none()); + } +} diff --git a/crates/heimdall-daemon/src/queue.rs b/crates/heimdall-daemon/src/queue.rs index 0e54024..3876c65 100644 --- a/crates/heimdall-daemon/src/queue.rs +++ b/crates/heimdall-daemon/src/queue.rs @@ -7,6 +7,8 @@ use std::sync::Arc; use tokio::sync::mpsc; use tracing::debug; +use heimdall_core::DutKind; + use crate::error::Result; use crate::event_bus::EventBus; use crate::store::JobStore; @@ -31,9 +33,10 @@ impl JobQueue { } /// Submit a NewJob: persist via the store, publish JobCreated, dispatch - /// to the worker channel. - pub async fn submit(&self, new: NewJob) -> Result { - let job = self.store.create_job(new).await?; + /// to the worker channel. `dut_kind` is resolved by the caller (HTTP + /// route from the DUT registry, campaign runtime from its own DutKind). + pub async fn submit(&self, new: NewJob, dut_kind: DutKind) -> Result { + let job = self.store.create_job(new, dut_kind).await?; self.bus .publish(Event::JobCreated { job: job.id, @@ -48,6 +51,18 @@ impl JobQueue { Ok(job) } + /// Re-dispatch an already-persisted job into the worker channel + /// without re-creating it in the store. Used by the boot-time + /// reconcile pass to recover `Queued` jobs that were stranded + /// when the previous daemon process exited: the DB row already + /// says `Queued`, we just need to push the id at the worker. + pub fn requeue(&self, id: JobId) -> Result<()> { + self.tx + .send(id) + .map_err(|_| crate::error::DaemonError::Config("worker channel closed".into()))?; + Ok(()) + } + /// Update the job state and publish a JobStateChanged event. pub async fn transition(&self, id: JobId, state: JobState) -> Result<()> { self.store.update_state(id, state.clone()).await?; @@ -71,7 +86,7 @@ mod tests { use super::*; use crate::store::SqliteJobStore; use crate::types::JobKind; - use heimdall_core::DutId; + use heimdall_core::{DutId, DutKind}; #[tokio::test] async fn submit_persists_emits_dispatches() { @@ -81,17 +96,21 @@ mod tests { let mut sub = bus.subscribe(); let job = queue - .submit(NewJob { - dut: DutId::new("d1"), - kind: JobKind::MockHello, - campaign: None, - }) + .submit( + NewJob { + dut: DutId::new("d1"), + kind: JobKind::MockHello, + campaign: None, + }, + DutKind::RiverRc1Small, + ) .await .unwrap(); + assert_eq!(job.dut_kind, DutKind::RiverRc1Small); // Event broadcast received. - let ev = sub.recv().await.unwrap(); - match ev { + let stamped = sub.recv().await.unwrap(); + match stamped.event { Event::JobCreated { job: j, .. } => assert_eq!(j, job.id), other => panic!("unexpected {other:?}"), } diff --git a/crates/heimdall-daemon/src/routes/about.rs b/crates/heimdall-daemon/src/routes/about.rs new file mode 100644 index 0000000..c94c2fa --- /dev/null +++ b/crates/heimdall-daemon/src/routes/about.rs @@ -0,0 +1,15 @@ +//! `GET /about` returns the running daemon's version + feature +//! flags. Powers the web "About" tab and the TUI's about view. + +use axum::{Json, Router, routing::get}; + +use crate::server::AppState; +use crate::types::{AboutInfo, current_about_info}; + +pub fn router() -> Router { + Router::new().route("/about", get(about)) +} + +async fn about() -> Json { + Json(current_about_info()) +} diff --git a/crates/heimdall-daemon/src/routes/campaigns.rs b/crates/heimdall-daemon/src/routes/campaigns.rs index 57c097c..405bfa2 100644 --- a/crates/heimdall-daemon/src/routes/campaigns.rs +++ b/crates/heimdall-daemon/src/routes/campaigns.rs @@ -4,7 +4,7 @@ use axum::{ http::StatusCode, routing::get, }; -use heimdall_core::{DutId, DutKind}; +use heimdall_core::DutId; use serde::{Deserialize, Serialize}; use crate::campaign::{refresh_state, submit_campaign}; @@ -44,12 +44,12 @@ async fn create( State(app): State, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { - // Look up the DUT record from the registry. If the DUT is not registered, - // fall back to RiverRc1Nano and no bringup payload so that integration - // tests that do not populate the registry continue to pass. - let dut_record = app.dut_registry.lookup(&body.dut); - let dut_kind = dut_record.map(|r| r.kind).unwrap_or(DutKind::RiverRc1Nano); - let bringup = dut_record.and_then(|r| r.bringup.as_ref()); + let dut_record = app + .dut_registry + .lookup(&body.dut) + .ok_or_else(|| ApiError::BadRequest(format!("unknown dut `{}`", body.dut.0)))?; + let dut_kind = dut_record.kind; + let bringup = dut_record.bringup.as_ref(); let campaign = submit_campaign( &app.queue, body.template, diff --git a/crates/heimdall-daemon/src/routes/disasm.rs b/crates/heimdall-daemon/src/routes/disasm.rs new file mode 100644 index 0000000..8da9e48 --- /dev/null +++ b/crates/heimdall-daemon/src/routes/disasm.rs @@ -0,0 +1,260 @@ +//! `GET /jobs/:id/disasm`. Render the program a job is running as a +//! structured disassembly listing. Today only `BootRiverElf` carries an +//! addressable program directly on the job. The route refuses +//! disassembly for kinds that don't (fuzz programs are generated +//! per-iteration inside the engine, not stored on the job). The +//! Aegis-bitstream variants get an explicit "not yet supported" so +//! the UI can render the right hint instead of an opaque 500. +//! +//! Error responses carry an `i18n_key` + `i18n_args` envelope (in +//! addition to the English `error` fallback) so the frontend can +//! localize them in the operator's language, matching the JobLog +//! event pattern. +//! +//! The disassembler is instantiated per request (it's effectively +//! stateless, just a binary path) and respects the +//! `HEIMDALL_LLVM_OBJDUMP_BIN` env override so a daemon running in a +//! sealed environment can pin a specific nix-store path without +//! relying on PATH lookups. + +use std::collections::BTreeMap; + +use axum::{ + Json, Router, + body::Body, + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, +}; +use base64::Engine as _; +use heimdall_core::{Artifact, ArtifactKind}; +use heimdall_disasm::{DisasmListing, DisasmOpts, Disassembler, LlvmObjdumpDisassembler}; +use serde::Serialize; +use thiserror::Error; + +use crate::server::AppState; +use crate::types::{JobId, JobKind}; + +pub fn router() -> Router { + Router::new().route("/jobs/:id/disasm", get(disasm)) +} + +/// Response shape for `/jobs/:id/disasm`. Carries the listing plus an +/// optional `iter` for Fuzz jobs (the engine sets this through the +/// program observer so the UI can show "showing iter N of M"). For +/// jobs with a single static program (e.g. BootRiverElf) `iter` is +/// `None`. +#[derive(Serialize)] +struct DisasmResponse { + #[serde(skip_serializing_if = "Option::is_none")] + iter: Option, + #[serde(flatten)] + listing: DisasmListing, +} + +/// Concrete error surface for the disasm route. Each variant maps +/// to a specific (status, i18n_key, args) triple, which +/// `IntoResponse` serialises into the envelope the frontend +/// localises. +#[derive(Debug, Error)] +enum DisasmRouteError { + #[error("job not found")] + NotFound, + #[error("internal error: {0}")] + Internal(String), + #[error( + "aegis bitstream rendering is not yet supported; \ + a netlist->Verilog renderer is the planned path" + )] + AegisUnsupported, + #[error( + "fuzz job has not loaded any iter yet; check back \ + once the first iter generates a program" + )] + NoIterYet, + #[error("job kind `{kind}` has no executable program to disassemble")] + NoProgram { kind: &'static str }, + #[error("elf_b64 decode: {0}")] + ElfDecode(String), + #[error("disasm: {0}")] + Disassembler(String), +} + +impl From for DisasmRouteError { + fn from(e: crate::error::DaemonError) -> Self { + Self::Internal(e.to_string()) + } +} + +impl DisasmRouteError { + fn status(&self) -> StatusCode { + match self { + Self::NotFound => StatusCode::NOT_FOUND, + Self::Internal(_) | Self::Disassembler(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::AegisUnsupported + | Self::NoIterYet + | Self::NoProgram { .. } + | Self::ElfDecode(_) => StatusCode::BAD_REQUEST, + } + } + + /// i18n catalog key for this error. `None` for errors the + /// daemon doesn't expect operators to want translated (`Internal` + /// surfaces a backend bug. The English text is most useful). + fn i18n_key(&self) -> Option<&'static str> { + match self { + Self::AegisUnsupported => Some("web.disasm.error.aegis_unsupported"), + Self::NoIterYet => Some("web.disasm.error.no_iter_yet"), + Self::NoProgram { .. } => Some("web.disasm.error.no_program"), + Self::ElfDecode(_) => Some("web.disasm.error.elf_decode"), + _ => None, + } + } + + fn i18n_args(&self) -> BTreeMap { + let mut args = BTreeMap::new(); + match self { + Self::NoProgram { kind } => { + args.insert("kind".into(), (*kind).to_string()); + } + Self::ElfDecode(detail) => { + args.insert("detail".into(), detail.clone()); + } + _ => {} + } + args + } +} + +#[derive(Serialize)] +struct DisasmErrorBody { + /// Server-rendered fallback. Clients that don't know the + /// `i18n_key` can render this verbatim. + error: String, + /// Stable i18n catalog key for the error. Absent for variants + /// the daemon doesn't expect operators to want translated. + #[serde(skip_serializing_if = "Option::is_none")] + i18n_key: Option<&'static str>, + /// Named-argument substitutions for `i18n_key`. Empty for + /// variants whose translation has no placeholders. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + i18n_args: BTreeMap, +} + +impl IntoResponse for DisasmRouteError { + fn into_response(self) -> Response { + let body = DisasmErrorBody { + error: self.to_string(), + i18n_key: self.i18n_key(), + i18n_args: self.i18n_args(), + }; + let json = serde_json::to_vec(&body) + .unwrap_or_else(|_| b"{\"error\":\"serialize failed\"}".to_vec()); + Response::builder() + .status(self.status()) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from(json)) + .unwrap() + } +} + +async fn disasm( + State(app): State, + Path(id): Path, +) -> Result, DisasmRouteError> { + let job_id = JobId(id); + let job = app + .store + .get_job(job_id) + .await? + .ok_or(DisasmRouteError::NotFound)?; + + let (bytes, kind, iter) = match &job.kind { + JobKind::BootRiverElf { elf_b64, .. } => { + let elf = base64::engine::general_purpose::STANDARD + .decode(elf_b64) + .map_err(|e| DisasmRouteError::ElfDecode(e.to_string()))?; + (elf, ArtifactKind::ElfRiscv, None) + } + // Aegis bitstreams need a netlist+Verilog renderer, not a CPU + // disassembler. Surface that distinction explicitly so the UI + // can show a precise hint and the operator doesn't waste time + // looking for an llvm-objdump misconfiguration. + JobKind::LoadAegisBitstream { .. } | JobKind::RunAegisVector { .. } => { + return Err(DisasmRouteError::AegisUnsupported); + } + // Fuzz programs are generated per-iter inside the engine + // and pushed into the loaded-program cache as they go. The + // route serves whichever iter is most recent so the UI + // tracks the running fuzz session live. Once the job is + // terminal the entry sticks around for post-mortem + // inspection. When the in-memory cache is cold (e.g. + // immediately after a daemon restart for a historical + // job), fall back to the durable JobStore/BlobStore + // mapping the worker writes at end-of-run. + JobKind::Fuzz { .. } => match app.loaded_programs.latest(job_id) { + Some(program) => (program.bytes, program.kind, Some(program.iter)), + None => { + let Some(reference) = app.store.get_job_program(job_id).await? else { + return Err(DisasmRouteError::NoIterYet); + }; + let Some(bytes) = app.blobs.get(&reference.blob_id).await? else { + return Err(DisasmRouteError::NoIterYet); + }; + (bytes.to_vec(), reference.kind, reference.iter) + } + }, + JobKind::MockHello | JobKind::Named { .. } => { + return Err(DisasmRouteError::NoProgram { + kind: job.kind.tag(), + }); + } + }; + + let disassembler = build_disassembler(); + let opts = DisasmOpts { + extensions: enabled_extensions_for_dut(&app, &job.dut), + ..DisasmOpts::default() + }; + let listing = disassembler + .disassemble(&Artifact::new(kind, bytes), &opts) + .await + .map_err(|e| DisasmRouteError::Disassembler(e.to_string()))?; + Ok(Json(DisasmResponse { iter, listing })) +} + +/// Resolve "what extensions does this DUT actually have enabled" +/// for the disassembler's `--mattr` flag. Precedence: +/// +/// 1. Live JTAG probe (most authoritative: it read `misa` off the +/// silicon) +/// 2. heimdall.toml `[dut.isa]` extensions list +/// 3. Empty (base ISA only) +/// +/// We deliberately do NOT widen this to a "supported" superset: +/// the strictness is the feature. If codegen accidentally emits a +/// `mul` for a DUT without M, the listing will show `` +/// and the bug surfaces in review instead of silently looking +/// legitimate. +fn enabled_extensions_for_dut(app: &AppState, dut: &heimdall_core::DutId) -> Vec { + if let Some(probed) = app.isa_probes.get(dut) { + return probed.extensions.clone(); + } + if let Some(rec) = app.dut_registry.lookup(dut) + && let Some(spec) = rec.isa.as_ref() + { + return spec.extensions.clone(); + } + Vec::new() +} + +/// Default-build the disassembler. Honours `HEIMDALL_LLVM_OBJDUMP_BIN` +/// (mirrors the spike-smoke env override) so deployments can pin a +/// specific build. +fn build_disassembler() -> LlvmObjdumpDisassembler { + match std::env::var("HEIMDALL_LLVM_OBJDUMP_BIN") { + Ok(p) if !p.is_empty() => LlvmObjdumpDisassembler::new(p), + _ => LlvmObjdumpDisassembler::from_path(), + } +} diff --git a/crates/heimdall-daemon/src/routes/duts.rs b/crates/heimdall-daemon/src/routes/duts.rs index 8cf2f6f..0db0641 100644 --- a/crates/heimdall-daemon/src/routes/duts.rs +++ b/crates/heimdall-daemon/src/routes/duts.rs @@ -10,7 +10,8 @@ use axum::{ }; use serde::Serialize; -use crate::dut_registry::{ConnectionStatus, DutRecord}; +use crate::dut_registry::{ConnectionStatus, DutRecord, IsaSpec}; +use crate::dut_state::DutStateLatest; use crate::server::AppState; use crate::types::Lease; @@ -20,14 +21,41 @@ struct DutsResponse { leases: Vec, } -/// A `DutRecord` augmented with a live `connection_status` probe result. -/// Serialized at request time so callers (web UI, TUI) see fresh state -/// without needing a separate endpoint. +/// Where the effective ISA came from. Lets the UI render a hint +/// next to the extension list so an operator can tell at a glance +/// whether the daemon is leaning on a `misa` read off the silicon +/// or on what heimdall.toml claims. +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +enum IsaSource { + Probe, + Config, +} + +#[derive(Serialize)] +struct EffectiveIsa { + #[serde(flatten)] + spec: IsaSpec, + source: IsaSource, +} + +/// A `DutRecord` augmented with a live `connection_status` probe result, +/// the last-known per-source register snapshots, and the resolved +/// effective ISA (probe-then-config precedence). Serialized at request +/// time so a freshly-loaded UI sees current state without waiting for the +/// next observe() cycle. #[derive(Serialize)] struct DutWithStatus { #[serde(flatten)] record: DutRecord, connection_status: ConnectionStatus, + #[serde(skip_serializing_if = "Vec::is_empty")] + snapshots: Vec, + /// Live ISA the disassembler / fuzzer will use against this DUT. + /// `None` when neither a probe nor a config spec exists; UI + /// can render "unknown" rather than implying base-I default. + #[serde(skip_serializing_if = "Option::is_none")] + effective_isa: Option, } pub fn router() -> Router { @@ -42,18 +70,39 @@ async fn list(State(app): State) -> Json { // serialize across the whole list. let probes = records.iter().map(|d| d.jtag.probe_connection()); let statuses = futures::future::join_all(probes).await; - let duts = records - .into_iter() - .zip(statuses) - .map(|(record, connection_status)| DutWithStatus { + let mut duts = Vec::with_capacity(records.len()); + for (record, connection_status) in records.into_iter().zip(statuses) { + let snapshots = app.dut_state_cache.snapshot_for(&record.id).await; + let effective_isa = resolve_effective_isa(&app, &record); + duts.push(DutWithStatus { record, connection_status, - }) - .collect(); + snapshots, + effective_isa, + }); + } let leases = app.leases.list().await; Json(DutsResponse { duts, leases }) } +/// Probe-first ISA resolution that mirrors what the disasm route +/// and worker use for `--mattr` and codegen. Keeps a single source +/// of truth for "what is the DUT actually running": JTAG `misa` if +/// we have a fresh probe, fall back to `[dut.isa]` in heimdall.toml, +/// and `None` when neither is present so the UI doesn't lie. +fn resolve_effective_isa(app: &AppState, record: &DutRecord) -> Option { + if let Some(probed) = app.isa_probes.get(&record.id) { + return Some(EffectiveIsa { + spec: IsaSpec::from(&probed), + source: IsaSource::Probe, + }); + } + record.isa.as_ref().map(|spec| EffectiveIsa { + spec: spec.clone(), + source: IsaSource::Config, + }) +} + /// Render the SPICE netlist for `id` as SVG. 404 if the DUT has no netlist /// configured. 500 if the file was removed after startup. async fn netlist_svg( diff --git a/crates/heimdall-daemon/src/routes/jobs.rs b/crates/heimdall-daemon/src/routes/jobs.rs index d0bf901..2db3347 100644 --- a/crates/heimdall-daemon/src/routes/jobs.rs +++ b/crates/heimdall-daemon/src/routes/jobs.rs @@ -8,13 +8,15 @@ use axum::{ use serde::{Deserialize, Serialize}; use crate::server::AppState; -use crate::types::{Job, JobFilter, JobId, JobState, JobStateTag, NewJob}; +use crate::types::{EventId, EventRecord, Job, JobFilter, JobId, JobState, JobStateTag, NewJob}; pub fn router() -> Router { Router::new() - .route("/jobs", get(list).post(create)) - .route("/jobs/:id", get(get_one)) + .route("/jobs", get(list).post(create).delete(prune)) + .route("/jobs/:id", get(get_one).delete(delete_one)) .route("/jobs/:id/cancel", post(cancel)) + .route("/jobs/:id/restart", post(restart)) + .route("/jobs/:id/logs", get(logs)) } #[derive(Deserialize)] @@ -22,31 +24,60 @@ struct ListQuery { dut: Option, state: Option, limit: Option, + /// Row offset for pagination. Combined with `limit`, the UI can + /// walk pages without holding a server-side cursor. `0` = + /// first page, which is also the response when omitted. + offset: Option, } #[derive(Serialize)] struct ListResponse { jobs: Vec, + /// Total rows matching `filter` ignoring `limit`/`offset`. The + /// UI reads this to render "page X of N" without firing a + /// second request for the count. + total: u64, } async fn list( State(app): State, Query(q): Query, ) -> Result, ApiError> { - let filter = JobFilter { - dut: q.dut.map(heimdall_core::DutId), + let base = JobFilter { + dut: q.dut.clone().map(heimdall_core::DutId), state_in: q.state.map(|s| vec![s]), + ..JobFilter::default() + }; + // Total is the count of the unpaginated query; the listing + // returns the actual page. + let total = app + .store + .count_jobs(base.clone()) + .await + .map_err(ApiError::from)?; + let paged = JobFilter { limit: q.limit, + offset: q.offset, + ..base }; - let jobs = app.store.list_jobs(filter).await.map_err(ApiError::from)?; - Ok(Json(ListResponse { jobs })) + let jobs = app.store.list_jobs(paged).await.map_err(ApiError::from)?; + Ok(Json(ListResponse { jobs, total })) } async fn create( State(app): State, Json(new): Json, ) -> Result<(StatusCode, Json), ApiError> { - let job = app.queue.submit(new).await.map_err(ApiError::from)?; + let dut_kind = app + .dut_registry + .lookup(&new.dut) + .map(|rec| rec.kind) + .ok_or_else(|| ApiError::BadRequest(format!("unknown dut `{}`", new.dut.0)))?; + let job = app + .queue + .submit(new, dut_kind) + .await + .map_err(ApiError::from)?; Ok((StatusCode::CREATED, Json(job))) } @@ -63,6 +94,40 @@ async fn get_one( Ok(Json(job)) } +#[derive(Serialize)] +struct JobLogsResponse { + logs: Vec, +} + +#[derive(Deserialize)] +struct LogsQuery { + #[serde(default)] + since: Option, + #[serde(default)] + limit: Option, +} + +const JOB_LOGS_DEFAULT_LIMIT: u32 = 500; +const JOB_LOGS_MAX_LIMIT: u32 = 5000; + +async fn logs( + State(app): State, + Path(id): Path, + Query(q): Query, +) -> Result, ApiError> { + let since = EventId(q.since.unwrap_or(0)); + let limit = q + .limit + .unwrap_or(JOB_LOGS_DEFAULT_LIMIT) + .min(JOB_LOGS_MAX_LIMIT); + let logs = app + .store + .list_job_logs(JobId(id), since, limit) + .await + .map_err(ApiError::from)?; + Ok(Json(JobLogsResponse { logs })) +} + async fn cancel( State(app): State, Path(id): Path, @@ -74,17 +139,149 @@ async fn cancel( .await .map_err(ApiError::from)? .ok_or(ApiError::NotFound)?; - if !matches!(job.state, JobState::Queued) { - return Err(ApiError::BadRequest(format!( + match job.state { + JobState::Queued => { + app.queue + .transition(job_id, JobState::Cancelled) + .await + .map_err(ApiError::from)?; + Ok(StatusCode::NO_CONTENT) + } + JobState::Running => { + // Fire the in-flight token; the worker is racing its + // run future against this token and will land on + // `JobState::Cancelled` once it observes the signal. + // If nothing was registered (e.g. job just transitioned + // to terminal before we got here), surface a clear error + // instead of silently flipping state out from under the + // worker. + if !app.cancellations.cancel(job_id) { + return Err(ApiError::BadRequest( + "running job has no cancellation handle registered".to_string(), + )); + } + Ok(StatusCode::NO_CONTENT) + } + other => Err(ApiError::BadRequest(format!( "cannot cancel job in state {:?}", - job.state - ))); + other + ))), } - app.queue - .transition(job_id, JobState::Cancelled) +} + +/// Re-submit a finished job as a fresh entry. The old row is left +/// untouched (history matters: a `Dead` audit trail vanishing on +/// restart would hide infrastructure incidents). The response +/// returns the *new* job, freshly created, freshly queued. +/// +/// Only terminal states qualify: `Done`, `Failed`, `Cancelled`, +/// `Dead`. Restarting a `Queued` or `Running` job is rejected so +/// operators can't accidentally double-book a DUT mid-flight. +async fn restart( + State(app): State, + Path(id): Path, +) -> Result<(StatusCode, Json), ApiError> { + let job_id = JobId(id); + let old = app + .store + .get_job(job_id) + .await + .map_err(ApiError::from)? + .ok_or(ApiError::NotFound)?; + match old.state.tag() { + JobStateTag::Queued | JobStateTag::Running => { + return Err(ApiError::BadRequest(format!( + "cannot restart job in non-terminal state {:?}", + old.state + ))); + } + JobStateTag::Done | JobStateTag::Failed | JobStateTag::Cancelled | JobStateTag::Dead => {} + } + // The DUT may have been removed from heimdall.toml since the + // original run. Surface that as a 400 rather than panicking or + // creating an unresolvable job. + let dut_kind = app + .dut_registry + .lookup(&old.dut) + .map(|rec| rec.kind) + .ok_or_else(|| { + ApiError::BadRequest(format!( + "dut `{}` is no longer registered; cannot restart", + old.dut.0 + )) + })?; + let new = NewJob { + dut: old.dut.clone(), + kind: old.kind.clone(), + campaign: old.campaign, + }; + let job = app + .queue + .submit(new, dut_kind) + .await + .map_err(ApiError::from)?; + Ok((StatusCode::CREATED, Json(job))) +} + +/// DELETE /jobs/:id drops a single terminal job and its associated +/// rows. Refuses non-terminal jobs (the store maps that to a +/// Config error which we surface as 400). Returns 204 on success, +/// 404 when the job doesn't exist. +async fn delete_one( + State(app): State, + Path(id): Path, +) -> Result { + let job_id = JobId(id); + match app.store.delete_job(job_id).await { + Ok(true) => Ok(StatusCode::NO_CONTENT), + Ok(false) => Err(ApiError::NotFound), + // The store returns Config("refusing to delete non-terminal + // job ...") when state isn't Done/Failed/Cancelled/Dead. Map + // that to 400 so the UI can render a sane message instead of + // an opaque 500. + Err(crate::error::DaemonError::Config(msg)) => Err(ApiError::BadRequest(msg)), + Err(e) => Err(ApiError::from(e)), + } +} + +#[derive(Deserialize)] +struct PruneQuery { + /// Only delete jobs created strictly before this UTC instant + /// (RFC 3339 / ISO 8601). When omitted, the cutoff is "now", + /// which together with the terminal-only restriction means + /// every Done / Failed / Cancelled / Dead row gets deleted. + /// Operators should always supply this for routine cleanup. + before: Option>, + /// Restrict to a specific terminal state (Done / Failed / + /// Cancelled / Dead). When omitted, all four are eligible. + /// Non-terminal tags are silently filtered out by the store. + state: Option, +} + +#[derive(Serialize)] +struct PruneResponse { + deleted: u64, +} + +/// DELETE /jobs?before=...&state=... bulk-deletes every terminal +/// job matching the filter. Returns the count of rows actually +/// deleted. In-flight jobs are silently skipped (the store +/// enforces the terminal-only invariant). +async fn prune( + State(app): State, + Query(q): Query, +) -> Result, ApiError> { + let filter = JobFilter { + state_in: q.state.map(|s| vec![s]), + created_before: q.before, + ..JobFilter::default() + }; + let deleted = app + .store + .delete_jobs(filter) .await .map_err(ApiError::from)?; - Ok(StatusCode::NO_CONTENT) + Ok(Json(PruneResponse { deleted })) } #[derive(Debug)] diff --git a/crates/heimdall-daemon/src/routes/metrics.rs b/crates/heimdall-daemon/src/routes/metrics.rs index ae2ae1c..63ecd5c 100644 --- a/crates/heimdall-daemon/src/routes/metrics.rs +++ b/crates/heimdall-daemon/src/routes/metrics.rs @@ -35,6 +35,7 @@ async fn metrics(State(app): State) -> impl IntoResponse { let mut done = 0u64; let mut failed = 0u64; let mut cancelled = 0u64; + let mut dead = 0u64; let mut verdict_pass = 0u64; let mut verdict_fail = 0u64; let mut verdict_skip = 0u64; @@ -54,6 +55,7 @@ async fn metrics(State(app): State) -> impl IntoResponse { } JobState::Failed(_) => failed += 1, JobState::Cancelled => cancelled += 1, + JobState::Dead => dead += 1, } } @@ -80,7 +82,8 @@ async fn metrics(State(app): State) -> impl IntoResponse { heimdall_jobs{{state=\"running\"}} {running}\n\ heimdall_jobs{{state=\"done\"}} {done}\n\ heimdall_jobs{{state=\"failed\"}} {failed}\n\ - heimdall_jobs{{state=\"cancelled\"}} {cancelled}" + heimdall_jobs{{state=\"cancelled\"}} {cancelled}\n\ + heimdall_jobs{{state=\"dead\"}} {dead}" ); let _ = writeln!( body, diff --git a/crates/heimdall-daemon/src/routes/mod.rs b/crates/heimdall-daemon/src/routes/mod.rs index f7728b3..2427d20 100644 --- a/crates/heimdall-daemon/src/routes/mod.rs +++ b/crates/heimdall-daemon/src/routes/mod.rs @@ -1,4 +1,6 @@ +pub mod about; pub mod campaigns; +pub mod disasm; pub mod duts; pub mod events; pub mod health; diff --git a/crates/heimdall-daemon/src/routes/web.rs b/crates/heimdall-daemon/src/routes/web.rs index f0ba17e..f042464 100644 --- a/crates/heimdall-daemon/src/routes/web.rs +++ b/crates/heimdall-daemon/src/routes/web.rs @@ -1,216 +1,96 @@ -//! Web UI: SSR for the initial `/` render (so first paint already shows -//! current jobs/campaigns/DUTs with the user's locale applied) plus -//! /assets/ for the CSS+JS bundle. The JS layer continues to CSR -//! live updates via /events + tick polling. - +//! Daemon-side adapter that mounts the `heimdall-web` router and +//! supplies the SSR index data by walking the daemon's stores. +//! +//! The router itself, the templates, and the static-asset embed all +//! live in the `heimdall-web` crate. This module exists only to plug +//! the daemon's `AppState` into the [`heimdall_web::WebContext`] +//! trait the router consumes. + +use async_trait::async_trait; use axum::Router; -use axum::body::Body; -use axum::extract::{Path, Query, State}; -use axum::http::{HeaderMap, StatusCode, header}; -use axum::response::{Html, IntoResponse, Response}; -use axum::routing::get; -use rust_embed::RustEmbed; -use serde::Deserialize; - -use askama::Template; use heimdall_i18n::Locale; +use heimdall_web::{CampaignRow, DutCardRow, IndexData, JobRow, WebContext, WebContextError}; use crate::dut_registry::ConnectionStatus; use crate::server::AppState; use crate::types::{JobFilter, JobState, VerdictSummary}; -// Assets dir is assembled by build.rs into $OUT_DIR/assets: static files -// (app.css, app.js) copied from `assets/`, plus SVGs rendered by the -// heimdall-logo Python package or pulled from $HEIMDALL_LOGO_SVGS. -#[derive(RustEmbed)] -#[folder = "$OUT_DIR/assets/"] -struct Assets; - pub fn router() -> Router { - Router::new() - .route("/", get(index)) - .route("/assets/*path", get(asset)) -} - -#[derive(Deserialize)] -struct LangQuery { - lang: Option, -} - -async fn index( - State(app): State, - headers: HeaderMap, - Query(q): Query, -) -> Response { - let locale = resolve_locale(&q.lang, &headers); - let ctx = match build_index_ctx(app, locale).await { - Ok(c) => c, - Err(e) => { - return Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::from(format!("index render failed: {e}"))) - .unwrap(); - } - }; - match ctx.render() { - Ok(html) => Html(html).into_response(), - Err(e) => Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::from(format!("template error: {e}"))) - .unwrap(), - } -} - -async fn asset(Path(path): Path) -> Response { - match Assets::get(&path) { - Some(file) => { - let mime = mime_guess::from_path(&path).first_or_octet_stream(); - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, mime.as_ref()) - .body(Body::from(file.data.into_owned())) - .unwrap() - } - None => Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Body::from("not found")) - .unwrap(), - } -} - -fn resolve_locale(query: &Option, headers: &HeaderMap) -> Locale { - if let Some(s) = query.as_deref() { - if let Some(l) = Locale::from_tag(s) { - return l; - } - } - if let Some(val) = headers - .get(header::ACCEPT_LANGUAGE) - .and_then(|v| v.to_str().ok()) - { - for chunk in val.split(',') { - let tag = chunk.split(';').next().unwrap_or("").trim(); - if let Some(l) = Locale::from_tag(tag) { - return l; - } - } - } - Locale::En -} - -#[derive(Template)] -#[template(path = "index.html")] -struct IndexTemplate { - locale: String, - jobs: Vec, - campaigns: Vec, - duts: Vec, - /// Locale used for `self.trans()` lookups. Held as enum so we don't have - /// to round-trip through a string per call. - #[allow(dead_code)] - locale_kind: Locale, -} - -impl IndexTemplate { - /// Translation helper called from the template. - pub fn trans(&self, key: &str) -> String { - heimdall_i18n::t_in(self.locale_kind, key) - } -} - -struct JobRow { - id_short: String, - dut: String, - kind: String, - state_class: String, - state_label: String, - created_at: String, -} - -struct CampaignRow { - id_short: String, - dut: String, - template: String, - state: String, - chip_serial: String, -} - -struct DutCardRow { - id: String, - kind: String, - chip_serial: String, - jtag_driver: String, - status_class: &'static str, - status_label: String, - has_netlist: bool, -} - -async fn build_index_ctx( - app: AppState, - locale: Locale, -) -> Result { - let jobs_raw = app.store.list_jobs(JobFilter::default()).await?; - let campaigns_raw = app.store.list_campaigns(Some(50)).await?; - let duts_raw: Vec<_> = app.dut_registry.iter().cloned().collect(); - let probes = duts_raw.iter().map(|d| d.jtag.probe_connection()); - let statuses: Vec<_> = futures::future::join_all(probes).await; - - let jobs: Vec = jobs_raw - .into_iter() - .map(|j| JobRow { - id_short: short_uuid(&j.id.0.to_string()), - dut: j.dut.0.clone(), - kind: job_kind_str(&j.kind), - state_class: job_state_class(&j.state), - state_label: job_state_label(&j.state), - created_at: j.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), - }) - .collect(); - - let campaigns: Vec = campaigns_raw - .into_iter() - .map(|c| CampaignRow { - id_short: short_uuid(&c.id.0.to_string()), - dut: c.dut.0.clone(), - template: c.template.name().to_string(), - state: format!("{:?}", c.state).to_lowercase(), - chip_serial: c.chip_serial.clone().unwrap_or_else(|| "-".into()), + heimdall_web::router::() +} + +#[async_trait] +impl WebContext for AppState { + async fn build_index_data(&self, locale: Locale) -> Result { + let jobs_raw = self + .store + .list_jobs(JobFilter::default()) + .await + .map_err(WebContextError::backend)?; + let campaigns_raw = self + .store + .list_campaigns(Some(50)) + .await + .map_err(WebContextError::backend)?; + let duts_raw: Vec<_> = self.dut_registry.iter().cloned().collect(); + let probes = duts_raw.iter().map(|d| d.jtag.probe_connection()); + let statuses: Vec<_> = futures::future::join_all(probes).await; + + let jobs: Vec = jobs_raw + .into_iter() + .map(|j| JobRow { + id_short: short_uuid(&j.id.0.to_string()), + dut: j.dut.0.clone(), + kind: job_kind_str(&j.kind), + state_class: job_state_class(&j.state), + state_label: job_state_label(&j.state), + created_at: j.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), + }) + .collect(); + + let campaigns: Vec = campaigns_raw + .into_iter() + .map(|c| CampaignRow { + id_short: short_uuid(&c.id.0.to_string()), + dut: c.dut.0.clone(), + template: c.template.name().to_string(), + state: format!("{:?}", c.state).to_lowercase(), + chip_serial: c.chip_serial.clone().unwrap_or_else(|| "-".into()), + }) + .collect(); + + let duts: Vec = duts_raw + .into_iter() + .zip(statuses) + .map(|(d, status)| { + let status_class = match status { + ConnectionStatus::Connected => "connected", + ConnectionStatus::Disconnected => "disconnected", + ConnectionStatus::Unknown => "idle", + }; + let status_label_key = match status { + ConnectionStatus::Connected => "common.status.connected", + ConnectionStatus::Disconnected => "common.status.disconnected", + ConnectionStatus::Unknown => "common.status.idle", + }; + DutCardRow { + id: d.id.0.clone(), + kind: dut_kind_str(d.kind), + chip_serial: d.chip_serial.clone().unwrap_or_else(|| "-".into()), + jtag_driver: jtag_driver_name(&d.jtag).to_string(), + status_class, + status_label: heimdall_i18n::t_in(locale, status_label_key), + has_netlist: d.netlist.is_some(), + } + }) + .collect(); + + Ok(IndexData { + jobs, + campaigns, + duts, }) - .collect(); - - let duts: Vec = duts_raw - .into_iter() - .zip(statuses) - .map(|(d, status)| { - let status_class = match status { - ConnectionStatus::Connected => "connected", - ConnectionStatus::Disconnected => "disconnected", - ConnectionStatus::Unknown => "idle", - }; - let status_label_key = match status { - ConnectionStatus::Connected => "common.status.connected", - ConnectionStatus::Disconnected => "common.status.disconnected", - ConnectionStatus::Unknown => "common.status.idle", - }; - DutCardRow { - id: d.id.0.clone(), - kind: dut_kind_str(d.kind), - chip_serial: d.chip_serial.clone().unwrap_or_else(|| "-".into()), - jtag_driver: jtag_driver_name(&d.jtag).to_string(), - status_class, - status_label: heimdall_i18n::t_in(locale, status_label_key), - has_netlist: d.netlist.is_some(), - } - }) - .collect(); - - Ok(IndexTemplate { - locale: locale.code().to_string(), - jobs, - campaigns, - duts, - locale_kind: locale, - }) + } } fn short_uuid(s: &str) -> String { @@ -218,9 +98,6 @@ fn short_uuid(s: &str) -> String { } fn job_kind_str(kind: &crate::types::JobKind) -> String { - // JobKind uses `#[serde(tag = "kind")]` with kebab-case variant names; - // extract that discriminator so a kind added later still renders without - // forcing a recompile here. serde_json::to_value(kind) .ok() .and_then(|v| v.get("kind").and_then(|k| k.as_str()).map(str::to_owned)) @@ -234,6 +111,7 @@ fn job_state_class(state: &JobState) -> String { JobState::Done(v) => format!("state-done verdict-{}", verdict_kind(v)), JobState::Failed(_) => "state-failed".into(), JobState::Cancelled => "state-cancelled".into(), + JobState::Dead => "state-dead".into(), } } @@ -244,6 +122,7 @@ fn job_state_label(state: &JobState) -> String { JobState::Done(v) => format!("done/{}", verdict_kind(v)), JobState::Failed(msg) => format!("failed: {msg}"), JobState::Cancelled => "cancelled".into(), + JobState::Dead => "dead".into(), } } @@ -257,7 +136,6 @@ fn verdict_kind(v: &VerdictSummary) -> &'static str { } fn dut_kind_str(kind: heimdall_core::DutKind) -> String { - // DutKind serializes as a kebab-case JSON string. Trim quotes. serde_json::to_string(&kind) .unwrap_or_else(|_| "\"custom\"".into()) .trim_matches('"') diff --git a/crates/heimdall-daemon/src/runtime.rs b/crates/heimdall-daemon/src/runtime.rs index 15bf945..09883f3 100644 --- a/crates/heimdall-daemon/src/runtime.rs +++ b/crates/heimdall-daemon/src/runtime.rs @@ -16,21 +16,44 @@ use crate::lease::{LeaseManager, LeaseTtl}; use crate::queue::JobQueue; use crate::server::{AppState, build_router}; use crate::store::{BlobStore, JobStore}; +use crate::types::{JobFilter, JobState, JobStateTag}; use crate::worker::Worker; pub struct DaemonHandles { + /// First bound address. Convenience alias for `local_addrs[0]` + /// so single-bind callers (every existing test) can read it + /// without indexing into a Vec. pub local_addr: SocketAddr, + /// Every address the daemon is actually listening on, in the + /// order they were bound. For single-bind callers this mirrors + /// `local_addr` as a one-element vec. + pub local_addrs: Vec, + /// axum-serve task for the first bound listener. Single-bind + /// callers abort this and `worker_task` to shut down cleanly. pub server_task: tokio::task::JoinHandle<()>, + /// Any additional axum-serve tasks (one per bind beyond the + /// first). Empty for single-bind starts. Multi-bind callers + /// must abort both `server_task` and every entry here. + pub extra_server_tasks: Vec>, pub worker_task: tokio::task::JoinHandle<()>, } +impl DaemonHandles { + /// Iterate over every server task: the first plus the extras. + /// Caller can `.for_each(|t| t.abort())` to shut down every + /// listener in one pass. + pub fn server_tasks(&self) -> impl Iterator> { + std::iter::once(&self.server_task).chain(self.extra_server_tasks.iter()) + } +} + pub async fn start( bind: SocketAddr, store: Arc, blobs: Arc, ) -> Result { start_inner( - bind, + vec![bind], store, blobs, Arc::new(DutRegistry::new()), @@ -45,7 +68,33 @@ pub async fn start_with_registry( blobs: Arc, registry: DriverRegistry, ) -> Result { - start_inner(bind, store, blobs, Arc::new(DutRegistry::new()), registry).await + start_inner( + vec![bind], + store, + blobs, + Arc::new(DutRegistry::new()), + registry, + ) + .await +} + +/// Start the daemon with a pre-populated DUT registry and the default mock +/// DriverRegistry. Useful for tests that need POST /jobs to find a matching +/// DUT without the overhead of loading a full ConfigFile. +pub async fn start_with_dut_registry( + bind: SocketAddr, + store: Arc, + blobs: Arc, + dut_registry: Arc, +) -> Result { + start_inner( + vec![bind], + store, + blobs, + dut_registry, + DriverRegistry::default_mock(), + ) + .await } /// Build a DutRegistry from the config file and start the daemon with both @@ -59,24 +108,80 @@ pub async fn start_with_config( ) -> Result { let dut_registry = Arc::new(build_registry(config)?); let driver_registry = DriverRegistry::default_with_registry(dut_registry.clone()); - start_inner(bind, store, blobs, dut_registry, driver_registry).await + start_inner(vec![bind], store, blobs, dut_registry, driver_registry).await +} + +/// Multi-bind variant of [`start_with_config`]. Spawns one axum-serve +/// task per address in `binds`. Use this when the daemon needs to +/// answer on both IPv4-all and IPv6-all simultaneously, or any other +/// combination of explicit listeners. `binds` must be non-empty. +pub async fn start_with_config_binds( + binds: Vec, + store: Arc, + blobs: Arc, + config: &ConfigFile, +) -> Result { + let dut_registry = Arc::new(build_registry(config)?); + let driver_registry = DriverRegistry::default_with_registry(dut_registry.clone()); + start_inner(binds, store, blobs, dut_registry, driver_registry).await +} + +/// Multi-bind variant of [`start`]. See [`start_with_config_binds`] +/// for the rationale. `binds` must be non-empty. +pub async fn start_binds( + binds: Vec, + store: Arc, + blobs: Arc, +) -> Result { + start_inner( + binds, + store, + blobs, + Arc::new(DutRegistry::new()), + DriverRegistry::default_mock(), + ) + .await } async fn start_inner( - bind: SocketAddr, + binds: Vec, store: Arc, blobs: Arc, dut_registry: Arc, driver_registry: DriverRegistry, ) -> Result { - if !bind.ip().is_loopback() { - heimdall_i18n::lwarn!("log.daemon.non_loopback_bind", addr = bind); + if binds.is_empty() { + return Err(DaemonError::Config( + "at least one bind address is required".into(), + )); + } + for bind in &binds { + if !bind.ip().is_loopback() { + heimdall_i18n::lwarn!("log.daemon.non_loopback_bind", addr = bind); + } } let bus = EventBus::new(store.clone(), 1024); let leases = LeaseManager::new(LeaseTtl::default()); let (queue, recv) = JobQueue::new(store.clone(), bus.clone()); + // Reconcile any jobs the prior process left mid-flight. A row at + // `Running` means the previous daemon was actively driving the + // DUT when it died; we have no way to pick up where it left off + // (hardware lease and in-memory state are gone), so flip to + // `Dead`. The web UI renders these with a distinct class so + // operators can spot them and re-submit. + reconcile_orphaned_jobs(store.as_ref(), &queue).await?; + let dut_state_cache = crate::dut_state::DutStateCache::new(); + // Background task that pipes DutStateSnapshot events into the cache. + // Spawned before the worker so the very first run's snapshots land + // in the cache. Aborted on shutdown alongside the other tasks via + // the existing DaemonHandles teardown path. + let _snapshot_task = crate::dut_state::spawn_snapshot_task(&bus, dut_state_cache.clone()); + + let loaded_programs = crate::loaded_programs::LoadedProgramCache::new(); + let isa_probes = crate::isa_probes::IsaProbeCache::new(); + let cancellations = crate::cancellations::CancellationRegistry::new(); let state = AppState { store: store.clone(), blobs, @@ -84,25 +189,98 @@ async fn start_inner( leases: leases.clone(), queue: queue.clone(), dut_registry, + dut_state_cache, + loaded_programs: loaded_programs.clone(), + isa_probes: isa_probes.clone(), + cancellations: cancellations.clone(), started_at: std::time::Instant::now(), }; - let worker = Worker::new_with_registry(queue, leases, driver_registry); + let worker = Worker::new_with_registry(queue, leases, driver_registry) + .with_dut_registry(state.dut_registry.clone()) + .with_loaded_program_cache(loaded_programs) + .with_isa_probe_cache(isa_probes) + .with_blob_store(state.blobs.clone()) + .with_cancellation_registry(cancellations); let worker_task = tokio::spawn(async move { worker.run(recv).await }); let app = build_router(state); - let listener = TcpListener::bind(bind).await.map_err(DaemonError::Io)?; - let local_addr = listener.local_addr().map_err(DaemonError::Io)?; - heimdall_i18n::linfo!("log.daemon.listening", addr = local_addr); - let server_task = tokio::spawn(async move { - if let Err(e) = axum::serve(listener, app).await { - warn!(error = %e, "axum serve exited"); - } - }); + let mut local_addrs = Vec::with_capacity(binds.len()); + let mut all_server_tasks = Vec::with_capacity(binds.len()); + for bind in &binds { + let listener = TcpListener::bind(bind).await.map_err(DaemonError::Io)?; + let local_addr = listener.local_addr().map_err(DaemonError::Io)?; + heimdall_i18n::linfo!("log.daemon.listening", addr = local_addr); + let app = app.clone(); + all_server_tasks.push(tokio::spawn(async move { + if let Err(e) = axum::serve(listener, app).await { + warn!(error = %e, "axum serve exited"); + } + })); + local_addrs.push(local_addr); + } + let local_addr = local_addrs[0]; + let server_task = all_server_tasks.remove(0); + let extra_server_tasks = all_server_tasks; Ok(DaemonHandles { local_addr, + local_addrs, server_task, + extra_server_tasks, worker_task, }) } + +/// Walk the store for jobs the previous daemon process left at +/// `Running` and flip each to `JobState::Dead`. Called once at boot +/// before the worker starts pulling new jobs. +/// +/// We deliberately don't try to resume Running jobs: their hardware +/// lease, cancellation token, and (for fuzz) per-iter program state +/// all lived in the prior process's memory and are now gone. Even +/// for tests that could in principle be replayed from scratch, the +/// DUT is in an unknown state. Operator re-submits, with a fresh +/// job id, is the only sound path. +/// +/// Queued jobs are also stranded by a restart (the in-memory mpsc +/// channel is gone), but for a different reason: they never ran, so +/// the right move is to re-enqueue them so the new worker picks +/// them up. We push their ids back into the queue's send side. +async fn reconcile_orphaned_jobs(store: &dyn JobStore, queue: &JobQueue) -> Result<()> { + let running = store + .list_jobs(JobFilter { + state_in: Some(vec![JobStateTag::Running]), + ..JobFilter::default() + }) + .await?; + for job in &running { + queue.transition(job.id, JobState::Dead).await?; + heimdall_i18n::lwarn!("log.daemon.reconcile_dead", job = job.id.0); + } + + let queued = store + .list_jobs(JobFilter { + state_in: Some(vec![JobStateTag::Queued]), + ..JobFilter::default() + }) + .await?; + for job in &queued { + // Best-effort: if the worker hasn't been spawned yet, the + // receiver is still alive and `send` succeeds. If the worker + // already exited (shouldn't happen at boot but guard anyway) + // we surface a Config error rather than silently dropping + // the work. + queue.requeue(job.id)?; + heimdall_i18n::linfo!("log.daemon.reconcile_requeue", job = job.id.0); + } + + if !running.is_empty() || !queued.is_empty() { + heimdall_i18n::linfo!( + "log.daemon.reconcile_summary", + dead = running.len(), + requeued = queued.len() + ); + } + Ok(()) +} diff --git a/crates/heimdall-daemon/src/server.rs b/crates/heimdall-daemon/src/server.rs index fa0735a..0ea42d8 100644 --- a/crates/heimdall-daemon/src/server.rs +++ b/crates/heimdall-daemon/src/server.rs @@ -6,9 +6,13 @@ use std::time::Instant; use axum::Router; use tower_http::trace::TraceLayer; +use crate::cancellations::CancellationRegistry; use crate::dut_registry::DutRegistry; +use crate::dut_state::DutStateCache; use crate::event_bus::EventBus; +use crate::isa_probes::IsaProbeCache; use crate::lease::LeaseManager; +use crate::loaded_programs::LoadedProgramCache; use crate::queue::JobQueue; use crate::store::{BlobStore, JobStore}; @@ -20,16 +24,35 @@ pub struct AppState { pub leases: LeaseManager, pub queue: JobQueue, pub dut_registry: Arc, + pub dut_state_cache: DutStateCache, + /// Per-job cache of "the program last loaded onto the DUT," used + /// by the disasm route to render what a fuzz iter is executing. + /// In-memory; daemon restart drops it and the next iter + /// repopulates. + pub loaded_programs: LoadedProgramCache, + /// Per-DUT JTAG-probed ISA cache. Populated lazily by the fuzz + /// worker the first time it dispatches against a given DUT; the + /// result feeds the generator's enabled-extension set unless the + /// operator overrode it via `[dut.isa]` in heimdall.toml. + pub isa_probes: IsaProbeCache, + /// In-flight job cancellation registry. The cancel HTTP route + /// looks up by JobId and fires the token; the worker races its + /// run future against the same token so the operator can stop + /// running fuzz sessions from the web UI without waiting for + /// the lease TTL. + pub cancellations: CancellationRegistry, /// Instant the daemon process started. Used by `/metrics` for uptime. pub started_at: Instant, } pub fn build_router(state: AppState) -> Router { Router::new() + .merge(crate::routes::about::router()) .merge(crate::routes::health::router()) .merge(crate::routes::jobs::router()) .merge(crate::routes::duts::router()) .merge(crate::routes::campaigns::router()) + .merge(crate::routes::disasm::router()) .merge(crate::routes::events::router()) .merge(crate::routes::metrics::router()) .merge(crate::routes::i18n::router()) diff --git a/crates/heimdall-daemon/src/store/mod.rs b/crates/heimdall-daemon/src/store/mod.rs index 1d94b2e..291796a 100644 --- a/crates/heimdall-daemon/src/store/mod.rs +++ b/crates/heimdall-daemon/src/store/mod.rs @@ -1,20 +1,75 @@ use async_trait::async_trait; use bytes::Bytes; +use chrono::{DateTime, Utc}; +use heimdall_core::{ArtifactKind, DutKind}; use crate::error::Result; use crate::types::{ - BlobId, Campaign, CampaignId, CampaignState, Event, EventId, Job, JobFilter, JobId, JobState, - NewJob, + BlobId, Campaign, CampaignId, CampaignState, Event, EventId, EventRecord, Job, JobFilter, + JobId, JobState, NewJob, }; +/// Durable pointer to the most-recent program bytes loaded onto a +/// DUT for a given job. The actual bytes live in a [`BlobStore`] +/// under `blob_id`; this struct just carries the routing metadata +/// the disasm route needs to render the response (artifact kind + +/// optional iter index for fuzz jobs). +#[derive(Debug, Clone)] +pub struct JobProgramRef { + pub blob_id: BlobId, + pub kind: ArtifactKind, + pub iter: Option, +} + #[async_trait] pub trait JobStore: Send + Sync { - async fn create_job(&self, new: NewJob) -> Result; + async fn create_job(&self, new: NewJob, dut_kind: DutKind) -> Result; async fn get_job(&self, id: JobId) -> Result>; async fn list_jobs(&self, filter: JobFilter) -> Result>; + /// Count jobs matching `filter` (ignoring `limit`/`offset`). + /// The web UI's pager uses this to render "page X of N" and the + /// prune dialog uses it to surface "this would delete K rows." + async fn count_jobs(&self, filter: JobFilter) -> Result; async fn update_state(&self, id: JobId, state: JobState) -> Result<()>; - async fn append_event(&self, ev: Event) -> Result; - async fn list_events_since(&self, since: EventId, limit: u32) -> Result>; + /// Delete a single terminal job and its associated rows + /// (events, JobLog entries, the job_programs mapping). Returns + /// `true` if a row was deleted, `false` if no such job existed. + /// Refuses non-terminal jobs (caller surfaces a 400) so we don't + /// drop a row out from under an in-flight worker. + async fn delete_job(&self, id: JobId) -> Result; + /// Bulk-delete every job matching `filter`. Only terminal jobs + /// are removed; `Queued` / `Running` matches are silently + /// skipped. Returns the number of rows actually deleted. The + /// prune route at the daemon level pipes + /// `JobFilter { created_before: Some(..), state_in: Some(..), .. }` + /// in here so operators can drop "every fuzz fail older than 7 + /// days" in one shot. + async fn delete_jobs(&self, filter: JobFilter) -> Result; + + /// Persist an event using the caller-supplied UTC timestamp. This is + /// the canonical write path: the [`EventBus`] stamps `Utc::now()` once + /// and threads the same value into both the broadcast frame and storage, + /// so wire-observed timestamps match what's in the DB. + async fn append_event_at(&self, ev: Event, ts: DateTime) -> Result; + + /// Convenience: stamp `Utc::now()` and call [`Self::append_event_at`]. + /// Library callers that don't need the storage timestamp to match a + /// previously-broadcast value should prefer this. + async fn append_event(&self, ev: Event) -> Result { + self.append_event_at(ev, Utc::now()).await + } + + async fn list_events_since(&self, since: EventId, limit: u32) -> Result>; + /// Return `Event::JobLog` rows for a single job, oldest-first, with + /// `id > since`. Used by the `/jobs/:id/logs` HTTP route to backfill + /// history when a client opens a job detail. Implementations should + /// push the filter down into storage rather than scanning all events. + async fn list_job_logs( + &self, + job: JobId, + since: EventId, + limit: u32, + ) -> Result>; async fn create_campaign(&self, campaign: Campaign) -> Result; async fn get_campaign(&self, id: CampaignId) -> Result>; async fn list_campaigns(&self, limit: Option) -> Result>; @@ -36,14 +91,36 @@ pub trait JobStore: Send + Sync { )) } - /// Insert an event with its original `EventId`. After import, future - /// `append_event` calls must produce IDs strictly greater than every - /// imported one. - async fn import_event(&self, _id: EventId, _ev: Event) -> Result<()> { + /// Insert an event with its original `EventId` and original UTC + /// timestamp. After import, future `append_event` calls must produce + /// IDs strictly greater than every imported one. + async fn import_event(&self, _id: EventId, _ts: DateTime, _ev: Event) -> Result<()> { Err(crate::error::DaemonError::Unsupported( "import_event not implemented for this JobStore", )) } + + /// Upsert "the most recent program bytes for this job" mapping. + /// The bytes themselves are written to the `BlobStore` separately + /// (the caller uploads, gets a `BlobId`, then hands the id here). + /// One row per job; subsequent calls overwrite, so the cache + /// always reflects the latest iter for fuzz workloads. + /// + /// Default impl is `Unsupported` so backends without a key-value + /// surface (memory-only stores) keep compiling. + async fn set_job_program(&self, _job: JobId, _program: JobProgramRef) -> Result<()> { + Err(crate::error::DaemonError::Unsupported( + "set_job_program not implemented for this JobStore", + )) + } + + /// Fetch the persisted program reference for a job, or `None` + /// when the job hasn't recorded one yet. Used by the disasm + /// route as a fallback when the in-memory `LoadedProgramCache` + /// is cold (typically right after a daemon restart). + async fn get_job_program(&self, _job: JobId) -> Result> { + Ok(None) + } } #[async_trait] diff --git a/crates/heimdall-daemon/src/store/sqlite.rs b/crates/heimdall-daemon/src/store/sqlite.rs index e93cab0..210e8f4 100644 --- a/crates/heimdall-daemon/src/store/sqlite.rs +++ b/crates/heimdall-daemon/src/store/sqlite.rs @@ -1,9 +1,10 @@ //! SQLite-backed JobStore. Behind the `sqlite` cargo feature. use async_trait::async_trait; -use chrono::Utc; +use chrono::{DateTime, Utc}; use heimdall_core::{DutId, DutKind}; use sqlx::Row; +use sqlx::sqlite::SqliteRow; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::{Pool, Sqlite}; use std::path::Path; @@ -12,8 +13,8 @@ use std::str::FromStr; use crate::error::{DaemonError, Result}; use crate::store::JobStore; use crate::types::{ - Campaign, CampaignId, CampaignState, CampaignTemplate, Event, EventId, Job, JobFilter, JobId, - JobKind, JobState, NewJob, + Campaign, CampaignId, CampaignState, CampaignTemplate, Event, EventId, EventRecord, Job, + JobFilter, JobId, JobKind, JobState, JobStateTag, NewJob, }; static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); @@ -68,6 +69,123 @@ fn dut_kind_from_str(s: &str) -> Result { .map_err(|e| DaemonError::Config(format!("dut_kind `{s}`: {e}"))) } +fn event_record_from_row(row: &SqliteRow) -> Result { + let id: i64 = row.try_get("id").map_err(DaemonError::Sqlx)?; + let created_at: String = row.try_get("created_at").map_err(DaemonError::Sqlx)?; + let payload: String = row.try_get("payload_json").map_err(DaemonError::Sqlx)?; + let ts = chrono::DateTime::parse_from_rfc3339(&created_at) + .map_err(|e| DaemonError::Config(format!("event.created_at: {e}")))? + .with_timezone(&Utc); + let event: Event = serde_json::from_str(&payload)?; + Ok(EventRecord { + id: EventId(id as u64), + ts, + event, + }) +} + +/// Render the WHERE clause + ordered bind list shared by list_jobs, +/// count_jobs, and the prune surface. Centralizing this means a +/// future filter dimension (e.g. campaign id) lights up every +/// caller in one diff. +fn jobs_where_clause(filter: &JobFilter) -> Result<(String, Vec)> { + let mut sql = String::from(" WHERE 1=1"); + let mut binds: Vec = Vec::new(); + if let Some(dut) = filter.dut.as_ref() { + sql.push_str(" AND dut = ?"); + binds.push(dut.0.clone()); + } + if let Some(tags) = &filter.state_in { + if !tags.is_empty() { + sql.push_str(" AND state_tag IN ("); + for (i, _) in tags.iter().enumerate() { + if i > 0 { + sql.push(','); + } + sql.push('?'); + } + sql.push(')'); + for tag in tags { + let s = serde_json::to_string(tag)?.trim_matches('"').to_string(); + binds.push(s); + } + } + } + if let Some(before) = filter.created_before.as_ref() { + sql.push_str(" AND created_at < ?"); + binds.push(before.to_rfc3339()); + } + Ok((sql, binds)) +} + +/// Tags the bulk-prune surface accepts. Anything in flight is +/// silently dropped so a stray operator filter can't drop a job out +/// from under a running worker. If the caller supplied no +/// state_in, default to "every terminal state" so a naked +/// `created_before = X` still does the right thing. +fn restrict_to_terminal(supplied: Option>) -> Vec { + let terminal = [ + JobStateTag::Done, + JobStateTag::Failed, + JobStateTag::Cancelled, + JobStateTag::Dead, + ]; + match supplied { + Some(tags) => tags.into_iter().filter(|t| terminal.contains(t)).collect(), + None => terminal.to_vec(), + } +} + +fn is_terminal(state: &JobState) -> bool { + matches!( + state.tag(), + JobStateTag::Done | JobStateTag::Failed | JobStateTag::Cancelled | JobStateTag::Dead, + ) +} + +/// Cascade-delete every row that references `job`. The schema has +/// foreign-key-like relationships (job_logs, events, job_programs) +/// but no actual ON DELETE CASCADE today, so we issue the deletes +/// here. Wraps the per-job sequence the prune transaction calls. +async fn delete_job_rows(pool: &sqlx::SqlitePool, job: JobId) -> Result<()> { + let mut tx = pool.begin().await?; + delete_job_rows_tx(&mut tx, job).await?; + tx.commit().await?; + Ok(()) +} + +async fn delete_job_rows_tx( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + job: JobId, +) -> Result<()> { + let id_str = job.0.to_string(); + // job_programs: per-job blob mapping written by the fuzz worker. + // We do NOT delete the underlying blob from the BlobStore here + // (this trait doesn't own one); the caller can wire blob + // collection separately if disk space matters. For sqlite + // alone the row drop is enough to break the disasm route's + // fallback lookup. + sqlx::query("DELETE FROM job_programs WHERE job_id = ?") + .bind(&id_str) + .execute(&mut **tx) + .await?; + // The events table stores its job id inside the JSON payload, + // not as a separate column; LIKE-match on the well-known + // `"job":""` shape is the cheapest scoped delete without + // a denormalised job_id column. Catches JobLog + JobCreated + + // JobStateChanged + DutStateSnapshot variants. + let needle = format!("%\"job\":\"{id_str}\"%"); + sqlx::query("DELETE FROM events WHERE payload_json LIKE ?") + .bind(&needle) + .execute(&mut **tx) + .await?; + sqlx::query("DELETE FROM jobs WHERE id = ?") + .bind(&id_str) + .execute(&mut **tx) + .await?; + Ok(()) +} + fn row_to_job(row: &sqlx::sqlite::SqliteRow) -> Result { let id_str: String = row.try_get("id").map_err(DaemonError::Sqlx)?; let dut: String = row.try_get("dut").map_err(DaemonError::Sqlx)?; @@ -145,11 +263,7 @@ fn row_to_campaign(row: &sqlx::sqlite::SqliteRow) -> Result { #[async_trait] impl JobStore for SqliteJobStore { - async fn create_job(&self, new: NewJob) -> Result { - // `dut_kind` isn't on `NewJob` yet. Encode a placeholder; the HTTP - // layer overrides it once the DUT registry has resolved the kind. - let dut_kind = DutKind::RiverRc1Nano; - + async fn create_job(&self, new: NewJob, dut_kind: DutKind) -> Result { let now = Utc::now(); let job = Job { id: JobId::new(), @@ -193,40 +307,69 @@ impl JobStore for SqliteJobStore { } async fn list_jobs(&self, filter: JobFilter) -> Result> { - let mut sql = String::from("SELECT * FROM jobs WHERE 1=1"); - if filter.dut.is_some() { - sql.push_str(" AND dut = ?"); - } - if let Some(tags) = &filter.state_in { - if !tags.is_empty() { - sql.push_str(" AND state_tag IN ("); - for (i, _) in tags.iter().enumerate() { - if i > 0 { - sql.push(','); - } - sql.push('?'); - } - sql.push(')'); - } - } - sql.push_str(" ORDER BY created_at DESC"); + let (where_sql, binds) = jobs_where_clause(&filter)?; + let mut sql = format!("SELECT * FROM jobs{where_sql} ORDER BY created_at DESC"); if let Some(limit) = filter.limit { sql.push_str(&format!(" LIMIT {limit}")); + if let Some(offset) = filter.offset { + sql.push_str(&format!(" OFFSET {offset}")); + } } let mut q = sqlx::query(&sql); - if let Some(dut) = filter.dut.as_ref() { - q = q.bind(&dut.0); - } - if let Some(tags) = filter.state_in.as_ref() { - for tag in tags { - let s = serde_json::to_string(tag)?.trim_matches('"').to_string(); - q = q.bind(s); - } + for b in &binds { + q = q.bind(b); } let rows = q.fetch_all(&self.pool).await?; rows.iter().map(row_to_job).collect() } + async fn count_jobs(&self, filter: JobFilter) -> Result { + let (where_sql, binds) = jobs_where_clause(&filter)?; + let sql = format!("SELECT COUNT(*) AS n FROM jobs{where_sql}"); + let mut q = sqlx::query(&sql); + for b in &binds { + q = q.bind(b); + } + let row = q.fetch_one(&self.pool).await?; + let n: i64 = sqlx::Row::try_get(&row, "n")?; + Ok(n.max(0) as u64) + } + + async fn delete_job(&self, id: JobId) -> Result { + // Resolve job first so we can reject non-terminal deletes + // with a clear signal back to the route handler (which maps + // it to a 400). Terminal states: Done / Failed / Cancelled / + // Dead. Anything else is in flight and refused. + let Some(existing) = self.get_job(id).await? else { + return Ok(false); + }; + if !is_terminal(&existing.state) { + return Err(crate::error::DaemonError::Config(format!( + "refusing to delete non-terminal job {id} in state {:?}", + existing.state.tag() + ))); + } + delete_job_rows(&self.pool, id).await?; + Ok(true) + } + + async fn delete_jobs(&self, filter: JobFilter) -> Result { + // Bulk delete: only terminal jobs. We force-restrict the + // filter to terminal state_tags so a caller can't drop + // Queued/Running rows even by mistake. + let restricted_filter = JobFilter { + state_in: Some(restrict_to_terminal(filter.state_in.clone())), + ..filter + }; + let victims = self.list_jobs(restricted_filter).await?; + let mut tx = self.pool.begin().await?; + for job in &victims { + delete_job_rows_tx(&mut tx, job.id).await?; + } + tx.commit().await?; + Ok(victims.len() as u64) + } + async fn update_state(&self, id: JobId, state: JobState) -> Result<()> { let now = Utc::now(); sqlx::query( @@ -251,8 +394,7 @@ impl JobStore for SqliteJobStore { Ok(()) } - async fn append_event(&self, ev: Event) -> Result { - let now = Utc::now(); + async fn append_event_at(&self, ev: Event, ts: DateTime) -> Result { let row = sqlx::query( r#" INSERT INTO events (payload_json, created_at) @@ -261,17 +403,17 @@ impl JobStore for SqliteJobStore { "#, ) .bind(serde_json::to_string(&ev)?) - .bind(now.to_rfc3339()) + .bind(ts.to_rfc3339()) .fetch_one(&self.pool) .await?; let id: i64 = row.try_get("id").map_err(DaemonError::Sqlx)?; Ok(EventId(id as u64)) } - async fn list_events_since(&self, since: EventId, limit: u32) -> Result> { + async fn list_events_since(&self, since: EventId, limit: u32) -> Result> { let rows = sqlx::query( r#" - SELECT id, payload_json + SELECT id, created_at, payload_json FROM events WHERE id > ?1 ORDER BY id ASC @@ -284,10 +426,36 @@ impl JobStore for SqliteJobStore { .await?; let mut out = Vec::with_capacity(rows.len()); for r in rows { - let id: i64 = r.try_get("id").map_err(DaemonError::Sqlx)?; - let payload: String = r.try_get("payload_json").map_err(DaemonError::Sqlx)?; - let ev: Event = serde_json::from_str(&payload)?; - out.push((EventId(id as u64), ev)); + out.push(event_record_from_row(&r)?); + } + Ok(out) + } + + async fn list_job_logs( + &self, + job: JobId, + since: EventId, + limit: u32, + ) -> Result> { + let rows = sqlx::query( + r#" + SELECT id, created_at, payload_json + FROM events + WHERE id > ?1 + AND json_extract(payload_json, '$.kind') = 'job-log' + AND json_extract(payload_json, '$.job') = ?2 + ORDER BY id ASC + LIMIT ?3 + "#, + ) + .bind(since.0 as i64) + .bind(job.0.to_string()) + .bind(limit as i64) + .fetch_all(&self.pool) + .await?; + let mut out = Vec::with_capacity(rows.len()); + for r in rows { + out.push(event_record_from_row(&r)?); } Ok(out) } @@ -401,8 +569,7 @@ impl JobStore for SqliteJobStore { Ok(()) } - async fn import_event(&self, id: EventId, ev: Event) -> Result<()> { - let now = Utc::now(); + async fn import_event(&self, id: EventId, ts: DateTime, ev: Event) -> Result<()> { sqlx::query( r#" INSERT INTO events (id, payload_json, created_at) @@ -411,11 +578,60 @@ impl JobStore for SqliteJobStore { ) .bind(id.0 as i64) .bind(serde_json::to_string(&ev)?) - .bind(now.to_rfc3339()) + .bind(ts.to_rfc3339()) .execute(&self.pool) .await?; // SQLite AUTOINCREMENT respects the highest inserted id, so future // append_event calls will get id > max(imported_id) automatically. Ok(()) } + + async fn set_job_program( + &self, + job: JobId, + program: crate::store::JobProgramRef, + ) -> Result<()> { + let kind_json = serde_json::to_string(&program.kind)?; + sqlx::query( + r#" + INSERT INTO job_programs (job_id, blob_id, kind, iter, recorded_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(job_id) DO UPDATE SET + blob_id = excluded.blob_id, + kind = excluded.kind, + iter = excluded.iter, + recorded_at = excluded.recorded_at + "#, + ) + .bind(job.0.to_string()) + .bind(&program.blob_id.0) + .bind(kind_json) + .bind(program.iter.map(|i| i as i64)) + .bind(Utc::now().to_rfc3339()) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get_job_program(&self, job: JobId) -> Result> { + let row = sqlx::query_as::<_, (String, String, Option)>( + r#" + SELECT blob_id, kind, iter + FROM job_programs + WHERE job_id = ?1 + "#, + ) + .bind(job.0.to_string()) + .fetch_optional(&self.pool) + .await?; + let Some((blob_id, kind_json, iter)) = row else { + return Ok(None); + }; + let kind: heimdall_core::ArtifactKind = serde_json::from_str(&kind_json)?; + Ok(Some(crate::store::JobProgramRef { + blob_id: crate::types::BlobId(blob_id), + kind, + iter: iter.map(|i| i as u64), + })) + } } diff --git a/crates/heimdall-daemon/src/types.rs b/crates/heimdall-daemon/src/types.rs index 6887321..f76e537 100644 --- a/crates/heimdall-daemon/src/types.rs +++ b/crates/heimdall-daemon/src/types.rs @@ -1,303 +1,35 @@ -use bytes::Bytes; -use chrono::{DateTime, Utc}; -use heimdall_core::{DutId, DutKind, Verdict}; -use serde::{Deserialize, Serialize}; -use std::fmt; -use uuid::Uuid; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(transparent)] -pub struct JobId(pub Uuid); - -impl JobId { - pub fn new() -> Self { - Self(Uuid::new_v4()) - } -} - -impl Default for JobId { - fn default() -> Self { - Self::new() - } -} - -impl fmt::Display for JobId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(transparent)] -pub struct LeaseId(pub Uuid); - -impl LeaseId { - pub fn new() -> Self { - Self(Uuid::new_v4()) - } -} - -impl Default for LeaseId { - fn default() -> Self { - Self::new() - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(transparent)] -pub struct EventId(pub u64); - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", tag = "kind")] -pub enum JobKind { - /// Runs the built-in "mock-hello" test against the DUT. - MockHello, - /// Loads an Aegis bitstream via JTAG. `descriptor_json` carries the - /// device layout (notably `total_bits`). `bitstream_b64` is the raw - /// bitstream, base64-encoded. - LoadAegisBitstream { - descriptor_json: String, - bitstream_b64: String, - }, - /// Load an Aegis bitstream, drive the configured input pads to the - /// supplied values, settle, then read output pads and diff against the - /// supplied expected_outputs. - RunAegisVector { - descriptor_json: String, - bitstream_b64: String, - #[serde(default)] - inputs: std::collections::BTreeMap, - #[serde(default)] - expected_outputs: std::collections::BTreeMap, - #[serde(default)] - settle_cycles: u64, - }, - /// Boots a precompiled River ELF via OpenOCD JTAG. Cycles is the - /// wait_halt budget in millisecond-equivalents (see RiverCpuDriver::run). - BootRiverElf { elf_b64: String, cycles: u64 }, - /// Generic. The daemon dispatches based on `name` and `payload`. - Named { - name: String, - payload: serde_json::Value, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", tag = "state", content = "detail")] -pub enum JobState { - Queued, - Running, - Done(VerdictSummary), - Failed(String), - Cancelled, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", tag = "kind")] -pub enum VerdictSummary { - Pass, - Fail { reason: String }, - Skip { reason: String }, - Error { message: String }, -} - -impl From<&Verdict> for VerdictSummary { - fn from(v: &Verdict) -> Self { - match v { - Verdict::Pass => Self::Pass, - Verdict::Fail { kind, .. } => Self::Fail { - reason: kind.to_string(), - }, - Verdict::Skip { reason } => Self::Skip { - reason: format!("{reason:?}"), - }, - Verdict::Error { message } => Self::Error { - message: message.clone(), - }, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(transparent)] -pub struct CampaignId(pub Uuid); - -impl CampaignId { - pub fn new() -> Self { - Self(Uuid::new_v4()) - } -} - -impl Default for CampaignId { - fn default() -> Self { - Self::new() - } -} - -impl fmt::Display for CampaignId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", tag = "kind")] -pub enum CampaignTemplate { - BringUp, - Characterization, - Release, - Custom { name: String }, -} - -impl CampaignTemplate { - pub fn name(&self) -> &str { - match self { - Self::BringUp => "bring-up", - Self::Characterization => "characterization", - Self::Release => "release", - Self::Custom { name } => name, +//! Daemon-side facade over [`heimdall_api`]: re-exports the wire +//! types verbatim so existing `heimdall_daemon::{JobKind, JobState, +//! ...}` imports keep working. Cross-crate conversions live in the +//! crate that owns the source-side type: +//! +//! - `From<&heimdall_core::Verdict>` lives in `heimdall-api` +//! (Verdict is from heimdall-core, which heimdall-api already +//! depends on). +//! - `From` lives in `heimdall-test` +//! (orphan rule needs the local `SnapshotSource`). + +pub use heimdall_api::*; + +/// Build an [`AboutInfo`] reflecting the running daemon binary. +/// `cfg!(feature = ...)` only sees the daemon's own feature flags, +/// not heimdall-api's, so the snapshot lives here rather than as a +/// `Default`-style constructor on the wire type. +pub fn current_about_info() -> AboutInfo { + AboutInfo { + version: heimdall_core::VERSION.to_string(), + build_profile: if cfg!(debug_assertions) { + "debug" + } else { + "release" } + .to_string(), + features: AboutFeatures { + sqlite: cfg!(feature = "sqlite"), + aegis: cfg!(feature = "aegis"), + river: cfg!(feature = "river"), + fuzzer: cfg!(feature = "fuzzer"), + cranelift: cfg!(feature = "cranelift"), + }, } } - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", tag = "state")] -pub enum CampaignState { - Pending, - Running, - Pass, - Fail, - Mixed, - Cancelled, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Campaign { - pub id: CampaignId, - pub dut: DutId, - pub chip_serial: Option, - pub template: CampaignTemplate, - pub state: CampaignState, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NewJob { - pub dut: DutId, - pub kind: JobKind, - #[serde(default)] - pub campaign: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Job { - pub id: JobId, - pub dut: DutId, - pub dut_kind: DutKind, - pub kind: JobKind, - pub campaign: Option, - pub state: JobState, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Lease { - pub id: LeaseId, - pub dut: DutId, - pub holder: JobId, - pub acquired_at: DateTime, - pub expires_at: DateTime, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case", tag = "kind")] -pub enum Event { - JobCreated { - job: JobId, - dut: DutId, - }, - JobStateChanged { - job: JobId, - state: JobState, - }, - JobLog { - job: JobId, - level: String, - message: String, - }, - LeaseAcquired { - lease: LeaseId, - dut: DutId, - holder: JobId, - }, - LeaseReleased { - lease: LeaseId, - dut: DutId, - }, - CampaignCreated { - campaign: CampaignId, - dut: DutId, - template: CampaignTemplate, - }, - CampaignStateChanged { - campaign: CampaignId, - state: CampaignState, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(transparent)] -pub struct BlobId(pub String); - -impl BlobId { - pub fn from_bytes(bytes: &[u8]) -> Self { - use sha2::{Digest, Sha256}; - let mut h = Sha256::new(); - h.update(bytes); - Self(hex::encode(h.finalize())) - } -} - -impl fmt::Display for BlobId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -#[derive(Debug, Clone, Default)] -pub struct JobFilter { - pub dut: Option, - pub state_in: Option>, - pub limit: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum JobStateTag { - Queued, - Running, - Done, - Failed, - Cancelled, -} - -impl JobState { - pub fn tag(&self) -> JobStateTag { - match self { - Self::Queued => JobStateTag::Queued, - Self::Running => JobStateTag::Running, - Self::Done(_) => JobStateTag::Done, - Self::Failed(_) => JobStateTag::Failed, - Self::Cancelled => JobStateTag::Cancelled, - } - } -} - -/// A blob stored in the BlobStore. Convenience wrapper. -#[derive(Debug, Clone)] -pub struct Blob { - pub id: BlobId, - pub bytes: Bytes, -} diff --git a/crates/heimdall-daemon/src/worker.rs b/crates/heimdall-daemon/src/worker.rs index 958e3c1..6e446b8 100644 --- a/crates/heimdall-daemon/src/worker.rs +++ b/crates/heimdall-daemon/src/worker.rs @@ -2,13 +2,17 @@ //! DriverRegistry, runs the resulting (driver, golden, test) trio via the //! existing Runner, and transitions state via JobQueue. +use std::sync::Arc; +use std::time::Duration; + use heimdall_driver::Dut; use heimdall_test::Runner; use tracing::{error, info, instrument}; +use crate::dut_registry::DutRegistry; use crate::error::Result; use crate::factory::DriverRegistry; -use crate::lease::LeaseManager; +use crate::lease::{LeaseManager, LeaseTtl}; use crate::queue::{JobQueue, JobQueueReceiver}; use crate::types::{Event, JobId, JobState, VerdictSummary}; @@ -16,6 +20,11 @@ pub struct Worker { queue: JobQueue, leases: LeaseManager, registry: DriverRegistry, + dut_registry: Arc, + loaded_programs: crate::loaded_programs::LoadedProgramCache, + isa_probes: crate::isa_probes::IsaProbeCache, + blobs: Option>, + cancellations: crate::cancellations::CancellationRegistry, } impl Worker { @@ -32,86 +41,559 @@ impl Worker { queue, leases, registry, + dut_registry: Arc::new(DutRegistry::new()), + loaded_programs: crate::loaded_programs::LoadedProgramCache::new(), + isa_probes: crate::isa_probes::IsaProbeCache::new(), + blobs: None, + cancellations: crate::cancellations::CancellationRegistry::new(), } } + pub fn with_dut_registry(mut self, dut_registry: Arc) -> Self { + self.dut_registry = dut_registry; + self + } + + /// Inject the daemon-wide loaded-program cache so per-iter fuzz + /// artifacts surface in `/jobs/:id/disasm`. Same handle the + /// AppState carries. + pub fn with_loaded_program_cache( + mut self, + cache: crate::loaded_programs::LoadedProgramCache, + ) -> Self { + self.loaded_programs = cache; + self + } + + /// Inject the daemon-wide ISA probe cache. Same handle AppState + /// carries; the worker's fuzz dispatch lazily probes a DUT's + /// `misa` on first contact and stores the result here so + /// subsequent fuzz jobs against the same DUT skip the JTAG + /// roundtrip. + pub fn with_isa_probe_cache(mut self, cache: crate::isa_probes::IsaProbeCache) -> Self { + self.isa_probes = cache; + self + } + + /// Inject the BlobStore the worker writes fuzz iter programs to. + /// Without it set, fuzz dispatches still work, but the disasm + /// panel won't survive a daemon restart (in-memory cache only). + /// The runtime constructor wires this from AppState's BlobStore. + pub fn with_blob_store(mut self, blobs: Arc) -> Self { + self.blobs = Some(blobs); + self + } + + /// Inject the cancellation registry. The worker registers a + /// token per in-flight job; the cancel HTTP route on the same + /// registry fires it to abort `engine.run()` / `run_one()` + /// from the UI. + pub fn with_cancellation_registry( + mut self, + cancellations: crate::cancellations::CancellationRegistry, + ) -> Self { + self.cancellations = cancellations; + self + } + #[instrument(skip(self, recv))] pub async fn run(self, mut recv: JobQueueReceiver) { - let Self { - queue, - leases, - registry, - } = self; while let Some(job_id) = recv.rx.recv().await { - let q = queue.clone(); - let l = leases.clone(); - let r = registry.clone(); - if let Err(e) = handle_one(job_id, q, l, r).await { + if let Err(e) = self.handle_one(job_id).await { error!(error = %e, "job dispatch failed"); } } } } -#[instrument(skip(queue, leases, registry))] -async fn handle_one( - job_id: JobId, - queue: JobQueue, - leases: LeaseManager, - registry: DriverRegistry, -) -> Result<()> { - let store = queue.store(); - let job = match store.get_job(job_id).await? { - Some(j) => j, - None => return Ok(()), - }; +impl Worker { + #[instrument(skip(self))] + async fn handle_one(&self, job_id: JobId) -> Result<()> { + let store = self.queue.store(); + let job = match store.get_job(job_id).await? { + Some(j) => j, + None => return Ok(()), + }; - queue.transition(job_id, JobState::Running).await?; - let lease = leases.acquire(job.dut.clone(), job_id).await?; - queue - .bus() - .publish(Event::LeaseAcquired { - lease: lease.id, - dut: job.dut.clone(), - holder: job_id, - }) - .await?; + self.queue.transition(job_id, JobState::Running).await?; + let lease_ttl = self + .dut_registry + .lookup(&job.dut) + .map(|r| LeaseTtl(Duration::from_secs(r.timeouts.lease_secs))) + .unwrap_or_default(); + let lease = self + .leases + .acquire_with_ttl(job.dut.clone(), job_id, lease_ttl) + .await?; + self.queue + .bus() + .publish(Event::LeaseAcquired { + lease: lease.id, + dut: job.dut.clone(), + holder: job_id, + }) + .await?; - let outcome = run_via_registry(&job, ®istry).await; - let next_state = match &outcome { - Ok(verdict) => JobState::Done(VerdictSummary::from(verdict)), - Err(msg) => JobState::Failed(msg.clone()), - }; + let logger = crate::job_logger::JobLogger::new(self.queue.bus().clone(), job_id); + logger + .log_keyed( + crate::types::LogLevel::Info, + "log.worker.dispatching", + &[("summary", job.kind.summary())], + ) + .await; - queue.transition(job_id, next_state).await?; - leases.release(&job.dut, lease.id).await?; - queue - .bus() - .publish(Event::LeaseReleased { - lease: lease.id, - dut: job.dut.clone(), - }) - .await?; - info!(job = %job_id, "completed"); - Ok(()) + let cancel_token = self.cancellations.register(job_id); + let outcome = tokio::select! { + res = self.run_via_registry(&job, job_id, logger.clone()) => res, + _ = cancel_token.cancelled() => Err(crate::error::DaemonError::Cancelled), + }; + self.cancellations.remove(job_id); + let next_state = match outcome { + Ok(verdict) => JobState::Done(VerdictSummary::from(&verdict)), + Err(crate::error::DaemonError::Cancelled) => { + logger + .log_keyed(crate::types::LogLevel::Warn, "log.worker.cancelled", &[]) + .await; + JobState::Cancelled + } + Err(err) => { + let msg = err.to_string(); + logger.log(crate::types::LogLevel::Error, msg.clone()).await; + JobState::Failed(msg) + } + }; + + self.queue.transition(job_id, next_state).await?; + // Intentionally keep the loaded-program entry past job + // termination so the post-mortem disasm panel still shows the + // last-iter program. The cache lives only in this daemon + // process and is bounded by jobs-since-restart; an LRU cap is + // a follow-up if growth ever becomes a concern. + self.leases.release(&job.dut, lease.id).await?; + self.queue + .bus() + .publish(Event::LeaseReleased { + lease: lease.id, + dut: job.dut.clone(), + }) + .await?; + info!(job = %job_id, "completed"); + Ok(()) + } + + async fn run_via_registry( + &self, + job: &crate::types::Job, + job_id: JobId, + logger: crate::job_logger::JobLogger, + ) -> std::result::Result { + let dispatch = self.registry.dispatch(job).await?; + match dispatch { + crate::factory::Dispatch::Test(bundle) => run_test_dispatch(job, bundle, logger).await, + crate::factory::Dispatch::Fuzz(fuzz) => { + self.run_fuzz_dispatch(job, job_id, fuzz, logger).await + } + } + } } -async fn run_via_registry( +async fn run_test_dispatch( job: &crate::types::Job, - registry: &DriverRegistry, -) -> std::result::Result { - let bundle = registry.dispatch(job).await.map_err(|e| e.to_string())?; - let runner = Runner::builder().build(); + bundle: crate::factory::DispatchBundle, + logger: crate::job_logger::JobLogger, +) -> std::result::Result { + let runner = Runner::builder() + .with_observer(Arc::new(logger) as Arc) + .build(); let mut driver = bundle.driver; let mut golden = bundle.golden; let test = bundle.test; let mut dut = Dut::new(job.dut.clone(), job.dut_kind); let res = runner .run_one(&*test, &mut dut, &mut *driver, &mut *golden) - .await - .map_err(|e| e.to_string())?; + .await?; Ok(res.verdict) } +/// Drive a fuzz dispatch to completion. Builds a `FuzzerEngine` from the +/// boxed driver + golden the factory returned (the `Box` blanket +/// impls in heimdall-driver / heimdall-golden satisfy the engine's trait +/// bounds), wires the `JobLogger` as the per-iteration stage observer, +/// runs the configured number of iterations, and rolls the aggregate +/// report into a single `Verdict` for the job state. +#[cfg(feature = "fuzzer")] +impl Worker { + async fn run_fuzz_dispatch( + &self, + job: &crate::types::Job, + job_id: JobId, + fuzz: crate::factory::FuzzDispatch, + logger: crate::job_logger::JobLogger, + ) -> std::result::Result { + let loaded_programs = &self.loaded_programs; + let isa_probes = &self.isa_probes; + let store = self.queue.store(); + let blobs = self.blobs.as_ref(); + use heimdall_core::StepBudget; + use heimdall_driver::Dut as DriverDut; + use heimdall_fuzzer::{ + BitFlipMutator, FuzzerEngine, Generator, RawAsmGen, RoundRobinScheduler, Rv32, Rv64, + }; + + let cfg = fuzz.config; + let mut driver = fuzz.driver; + let golden = fuzz.golden; + + // Resolve the DUT's ISA. Order of preference: + // 1. The [dut.isa] block from the registry (operator-authoritative). + // 2. JTAG-probed `misa` result (cached after first dispatch). + // 3. Library default RV64I+M for legacy configs. + // + // The probe only runs when the operator hasn't supplied a TOML + // override AND the cache is cold. Lazy + cached keeps repeat + // fuzz jobs cheap. + let probed = if cfg.isa.is_none() { + match isa_probes.get(&job.dut) { + Some(p) => Some(p), + None => { + // Cold cache: prepare the driver enough to read CSR + // misa, run the probe, store the result. We do this + // out-of-band before the engine starts; River's + // prepare() is idempotent so the engine's per-iter + // prepare runs again without issue. + let mut probe_dut = DriverDut::new(job.dut.clone(), job.dut_kind); + match driver.prepare(&mut probe_dut).await { + Ok(()) => match driver.probe_isa().await { + Ok(Some(probe)) => { + // `From for ProbedIsa` keeps + // this conversion declarative; the cache + // value and the local `Some(...)` use + // the same handle. + let entry: crate::isa_probes::ProbedIsa = probe.into(); + isa_probes.insert(job.dut.clone(), entry.clone()); + Some(entry) + } + Ok(None) => None, + Err(e) => { + // Probe failure isn't fatal: the worker + // falls back to the next precedence + // layer (default RV64+IM today, RV32I + // once the default-resolution change + // lands). Surface a log entry so an + // operator can see why the probe didn't + // populate the cache. + logger + .log( + crate::types::LogLevel::Warn, + format!("misa probe failed: {e}; falling back to defaults"), + ) + .await; + None + } + }, + Err(e) => { + logger + .log( + crate::types::LogLevel::Warn, + format!( + "isa probe prepare failed: {e}; falling back to defaults" + ), + ) + .await; + None + } + } + } + } + } else { + None + }; + + // resolve_isa prefers (1) the TOML spec, (2) the probe result, + // (3) the library default. The helper handles all three cases + // through a uniform IsaSpec view. + // `From<&ProbedIsa> for IsaSpec` is the registry shape; lets + // the precedence chain (TOML over probe over default) stay in + // a single Option<&IsaSpec> form. + let probe_as_spec = probed.as_ref().map(crate::dut_registry::IsaSpec::from); + let effective_spec = cfg.isa.as_ref().or(probe_as_spec.as_ref()); + let (xlen, ext_set) = resolve_isa(effective_spec); + + // Choose the generator. `Box` is fed to the engine + // builder via the blanket impl in heimdall_fuzzer::traits, so this + // is the single point where the dispatch picks a concrete kind. + // Cranelift requires the daemon to have been built with its + // feature; surface a clean config error if not. + let generator: Box = match cfg.generator { + crate::types::GeneratorKind::RawAsm => match xlen { + 32 => Box::new( + RawAsmGen::::new(cfg.insn_count).with_extensions(ext_set.iter().copied()), + ), + _ => Box::new( + RawAsmGen::::new(cfg.insn_count).with_extensions(ext_set.iter().copied()), + ), + }, + #[cfg(feature = "cranelift")] + crate::types::GeneratorKind::Cranelift => { + let g = + heimdall_fuzzer::CraneliftGen::rv64()?.with_ops_per_function(cfg.insn_count); + Box::new(g) + } + #[cfg(not(feature = "cranelift"))] + crate::types::GeneratorKind::Cranelift => { + return Err(crate::error::DaemonError::CraneliftFeatureDisabled); + } + }; + // Fuzz toolchain: wrap the generator's raw RV64 machine code in a + // minimal ELF so the River driver's `load` can place it at p_paddr + // and `run` can seed `dpc = entry`. Without this step the chain has + // no tool that accepts `ArtifactKind::RawBytes` and compile errors + // out before the first iteration ever reaches the DUT. + let tools = heimdall_tools::ToolChain::new().with(std::sync::Arc::new( + heimdall_tools::RawBytesToElfRiscv::new(), + )); + let runner = Runner::builder() + .with_tools(tools) + .with_observer(Arc::new(logger.clone()) as Arc) + .build(); + // Stash each iter's program bytes into the daemon-wide cache so the + // `/jobs/:id/disasm` route can serve "what is this fuzz job running + // right now" without re-deriving from the seed. The observer is + // called BEFORE the runner consumes the artifact, so the cache is + // populated for the entire run-and-observe window of every iter. + let cache_handle = loaded_programs.clone(); + let job_id_capture = job_id; + let program_observer: heimdall_fuzzer::ProgramCallback = + std::sync::Arc::new(move |iter, artifact: &heimdall_core::Artifact| { + cache_handle.record( + job_id_capture, + crate::loaded_programs::LoadedProgram { + iter, + kind: artifact.kind.clone(), + bytes: artifact.bytes.to_vec(), + }, + ); + }); + let mut engine = FuzzerEngine::builder() + .with_runner(runner) + .with_generator(generator) + .with_mutator(BitFlipMutator) + .with_scheduler(RoundRobinScheduler::new()) + .with_driver(driver) + .with_golden(golden) + .with_rng_seed(cfg.seed) + .with_step_budget(StepBudget::cycles(cfg.cycles)) + .with_strict_coverage(cfg.strict_coverage) + .with_program_observer(program_observer) + .build(); + let mut dut = Dut::new(job.dut.clone(), job.dut_kind); + let report_result = engine.run(&mut dut, cfg.iterations).await; + // Persist whatever the cache holds before propagating errors. We + // want the disasm panel to keep working after a daemon restart + // even when the engine bailed mid-run (driver flake, signal, + // etc.). Best-effort. Log on failure but don't surface, since + // the primary verdict path is what matters here. + if let Some(blobs) = blobs { + persist_latest_program(loaded_programs, blobs.as_ref(), &*store, job_id, &logger).await; + } + let report = report_result?; + + logger + .log( + crate::types::LogLevel::Info, + format!( + "fuzz complete: iterations={}, pass={}, fail={}, err={}, corpus={}, cov={}/{}", + report.iterations, + report.passes, + report.fails, + report.errors, + report.corpus_size, + report.coverage_bits, + report.silicon_coverage_bits, + ), + ) + .await; + + // Aggregate verdict: any fail or infrastructure error fails the job. + // Coverage divergences with strict_coverage already flipped Pass to Fail + // inside the engine, so they count toward `fails` here. + if report.errors > 0 { + Ok(heimdall_core::Verdict::Error { + message: format!("fuzz reported {} infra errors", report.errors), + }) + } else if report.fails > 0 { + Ok(heimdall_core::Verdict::Fail { + kind: heimdall_core::FailureKind::DiffMismatch { + field: "fuzz".into(), + got: heimdall_core::ValueRepr::U64(report.fails), + expected: heimdall_core::ValueRepr::U64(0), + }, + evidence: vec![heimdall_core::Evidence { + label: "fuzz-fails".into(), + detail: format!( + "{} of {} iterations failed", + report.fails, report.iterations + ), + }], + }) + } else { + Ok(heimdall_core::Verdict::Pass) + } + } +} + +/// Fallback for fuzz dispatch when the daemon was built without the +/// `fuzzer` feature. Reports an explicit configuration error so the +/// failure mode is loud rather than silently routing to the test path. +#[cfg(not(feature = "fuzzer"))] +impl Worker { + async fn run_fuzz_dispatch( + &self, + _job: &crate::types::Job, + _job_id: JobId, + _fuzz: crate::factory::FuzzDispatch, + _logger: crate::job_logger::JobLogger, + ) -> std::result::Result { + Err(crate::error::DaemonError::FuzzerFeatureDisabled) + } +} + +/// Write the latest cached program for `job` into the BlobStore and +/// record the mapping in the JobStore. Best-effort: failures are +/// logged but never propagated, since persistence is a +/// disaster-recovery aid (the disasm panel after a daemon restart), +/// not part of the primary verdict flow. +#[cfg(feature = "fuzzer")] +async fn persist_latest_program( + cache: &crate::loaded_programs::LoadedProgramCache, + blobs: &dyn crate::store::BlobStore, + store: &dyn crate::store::JobStore, + job: JobId, + logger: &crate::job_logger::JobLogger, +) { + let Some(program) = cache.latest(job) else { + return; + }; + let blob_id = match blobs.put(&program.bytes).await { + Ok(id) => id, + Err(e) => { + logger + .log( + crate::types::LogLevel::Warn, + format!("persist fuzz program: blob put failed: {e}"), + ) + .await; + return; + } + }; + let r = store + .set_job_program( + job, + crate::store::JobProgramRef { + blob_id, + kind: program.kind, + iter: Some(program.iter), + }, + ) + .await; + if let Err(e) = r { + logger + .log( + crate::types::LogLevel::Warn, + format!("persist fuzz program: set_job_program failed: {e}"), + ) + .await; + } +} + /// Convenience type alias matching the worker's JobStore expectation. pub type SharedJobStore = std::sync::Arc; + +/// Resolve the effective `(xlen, extensions)` for a fuzz session. +/// +/// Defers all the actual parsing to the `From<&IsaSpec>` impl that +/// lives on [`heimdall_fuzzer::ParsedIsa`]: the registry-side +/// `IsaSpec` carries strings, the typed `ParsedIsa` carries +/// `RvExtension` values, and the conversion silently drops +/// extension strings that aren't known to this build (forward-compat +/// for future ISA strings showing up in heimdall.toml). +/// +/// When no spec is supplied: default to RV64 + `{I, M}` so existing +/// pre-isa heimdall.toml files keep working unchanged. +#[cfg(feature = "fuzzer")] +fn resolve_isa( + spec: Option<&crate::dut_registry::IsaSpec>, +) -> (u8, Vec) { + use heimdall_fuzzer::RvExtension; + let Some(spec) = spec else { + return (64, vec![RvExtension::I, RvExtension::M]); + }; + let parsed: heimdall_fuzzer::ParsedIsa = spec.into(); + (parsed.xlen, parsed.extensions.into_iter().collect()) +} + +#[cfg(all(test, feature = "fuzzer"))] +mod tests { + use super::*; + use crate::dut_registry::IsaSpec; + use heimdall_fuzzer::RvExtension; + + #[test] + fn resolve_isa_default_when_unset() { + let (xlen, exts) = resolve_isa(None); + assert_eq!(xlen, 64); + assert!(exts.contains(&RvExtension::I)); + assert!(exts.contains(&RvExtension::M)); + } + + #[test] + fn resolve_isa_rv32i_minimal() { + let spec = IsaSpec { + xlen: 32, + extensions: vec!["i".into()], + }; + let (xlen, exts) = resolve_isa(Some(&spec)); + assert_eq!(xlen, 32); + // I is implicit; the helper always includes it. + assert!(exts.contains(&RvExtension::I)); + assert!(!exts.contains(&RvExtension::M)); + } + + #[test] + fn resolve_isa_rv64imac_zicsr_full() { + let spec = IsaSpec { + xlen: 64, + extensions: vec![ + "i".into(), + "m".into(), + "a".into(), + "c".into(), + "zicsr".into(), + ], + }; + let (xlen, exts) = resolve_isa(Some(&spec)); + assert_eq!(xlen, 64); + for ext in [ + RvExtension::I, + RvExtension::M, + RvExtension::A, + RvExtension::C, + RvExtension::Zicsr, + ] { + assert!(exts.contains(&ext), "expected {ext:?} in resolved set"); + } + } + + #[test] + fn resolve_isa_unknown_extension_silently_dropped() { + let spec = IsaSpec { + xlen: 64, + extensions: vec!["i".into(), "futureXYZ".into()], + }; + let (_, exts) = resolve_isa(Some(&spec)); + // Unknown entries don't crash the worker; the resolved set + // just doesn't include them. I is always present. + assert!(exts.contains(&RvExtension::I)); + } +} diff --git a/crates/heimdall-daemon/templates/index.html b/crates/heimdall-daemon/templates/index.html deleted file mode 100644 index d41391e..0000000 --- a/crates/heimdall-daemon/templates/index.html +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - {{ self.trans("web.title") }} - - - - -
-
{{ self.trans("web.brand") }}
-
{{ self.trans("common.status.connecting") }}
- -
- -
-
- - - - - - - - - - - - {% if jobs.is_empty() %} - - {% else %} - {% for job in jobs %} - - - - - - - - {% endfor %} - {% endif %} - -
{{ self.trans("tui.jobs.col_id") }}{{ self.trans("tui.jobs.col_dut") }}{{ self.trans("tui.duts.col_kind") }}{{ self.trans("tui.jobs.col_state") }}Created
{{ self.trans("tui.empty.no_jobs") }}
{{ job.id_short }}{{ job.dut }}{{ job.kind }}{{ job.state_label }}{{ job.created_at }}
-
- - -
-
- {{ self.trans("web.help.shortcuts") }} - {{ self.trans("web.help.live_updates") }} -
- - - diff --git a/crates/heimdall-daemon/tests/aegis_load_http.rs b/crates/heimdall-daemon/tests/aegis_load_http.rs index 32e98ab..857a61a 100644 --- a/crates/heimdall-daemon/tests/aegis_load_http.rs +++ b/crates/heimdall-daemon/tests/aegis_load_http.rs @@ -7,7 +7,10 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use base64::Engine; -use heimdall_daemon::{BlobStore, JobStore, LocalFsBlobStore, SqliteJobStore, runtime}; +use heimdall_core::DutKind; +use heimdall_daemon::{ + BlobStore, DutRecord, DutRegistry, JobStore, LocalFsBlobStore, SqliteJobStore, runtime, +}; use serde_json::json; use tempfile::TempDir; @@ -17,11 +20,14 @@ async fn start_daemon() -> (heimdall_daemon::DaemonHandles, TempDir) { let blobs = LocalFsBlobStore::open(tmp.path().to_path_buf()) .await .expect("blobs"); + let mut registry = DutRegistry::new(); + registry.insert(DutRecord::mock("luna1-1", DutKind::AegisLuna1)); let bind: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap(); - let handles = runtime::start( + let handles = runtime::start_with_dut_registry( bind, Arc::new(store) as Arc, Arc::new(blobs) as Arc, + Arc::new(registry), ) .await .expect("daemon start"); diff --git a/crates/heimdall-daemon/tests/aegis_openocd_spawn.rs b/crates/heimdall-daemon/tests/aegis_openocd_spawn.rs index 0273666..209051e 100644 --- a/crates/heimdall-daemon/tests/aegis_openocd_spawn.rs +++ b/crates/heimdall-daemon/tests/aegis_openocd_spawn.rs @@ -44,6 +44,8 @@ async fn openocd_spawn_failure_surfaces_as_job_failed() { bringup: None, netlist: None, spice_watches: vec![], + timeouts: Default::default(), + isa: None, }], transport: TransportSection { jtag: vec![JtagTransportCfg { diff --git a/crates/heimdall-daemon/tests/aegis_vector_http.rs b/crates/heimdall-daemon/tests/aegis_vector_http.rs index 3868095..c264f8d 100644 --- a/crates/heimdall-daemon/tests/aegis_vector_http.rs +++ b/crates/heimdall-daemon/tests/aegis_vector_http.rs @@ -38,6 +38,8 @@ async fn start_with_pinmap() -> (heimdall_daemon::DaemonHandles, TempDir) { bringup: None, netlist: None, spice_watches: vec![], + timeouts: Default::default(), + isa: None, }], transport: TransportSection { jtag: vec![JtagTransportCfg { diff --git a/crates/heimdall-daemon/tests/campaigns_http.rs b/crates/heimdall-daemon/tests/campaigns_http.rs index 8f97737..5758b14 100644 --- a/crates/heimdall-daemon/tests/campaigns_http.rs +++ b/crates/heimdall-daemon/tests/campaigns_http.rs @@ -5,7 +5,10 @@ use std::sync::Arc; use std::time::{Duration, Instant}; -use heimdall_daemon::{BlobStore, JobStore, LocalFsBlobStore, SqliteJobStore, runtime}; +use heimdall_core::DutKind; +use heimdall_daemon::{ + BlobStore, DutRecord, DutRegistry, JobStore, LocalFsBlobStore, SqliteJobStore, runtime, +}; use serde_json::json; use tempfile::TempDir; @@ -15,11 +18,14 @@ async fn start_daemon() -> (heimdall_daemon::DaemonHandles, TempDir) { let blobs = LocalFsBlobStore::open(tmp.path().to_path_buf()) .await .expect("blobs"); + let mut registry = DutRegistry::new(); + registry.insert(DutRecord::mock("mock-dut", DutKind::RiverRc1Nano)); let bind: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap(); - let handles = runtime::start( + let handles = runtime::start_with_dut_registry( bind, Arc::new(store) as Arc, Arc::new(blobs) as Arc, + Arc::new(registry), ) .await .expect("daemon start"); diff --git a/crates/heimdall-daemon/tests/disasm_http.rs b/crates/heimdall-daemon/tests/disasm_http.rs new file mode 100644 index 0000000..790dec9 --- /dev/null +++ b/crates/heimdall-daemon/tests/disasm_http.rs @@ -0,0 +1,370 @@ +//! Integration tests for `GET /jobs/:id/disasm`. Covers: +//! +//! - 404 for an unknown job id. +//! - 400 for job kinds that have no executable program to disassemble +//! (mock-hello, fuzz, aegis bitstream). +//! - 200 + JSON DisasmListing for a BootRiverElf job (skips when no +//! llvm-objdump is reachable, mirroring the spike-smoke skip). + +#![cfg(feature = "sqlite")] + +use std::sync::Arc; + +use heimdall_core::{DutId, DutKind}; +use heimdall_daemon::{ + BlobStore, DutRecord, DutRegistry, JobKind, JobStore, LocalFsBlobStore, NewJob, SqliteJobStore, + runtime, +}; +use tempfile::TempDir; + +async fn start_daemon() -> (heimdall_daemon::DaemonHandles, TempDir) { + let tmp = TempDir::new().expect("tmp"); + let store = SqliteJobStore::open_in_memory().await.expect("store"); + let blobs = LocalFsBlobStore::open(tmp.path().to_path_buf()) + .await + .expect("blobs"); + let mut registry = DutRegistry::new(); + registry.insert(DutRecord::mock("river-1", DutKind::RiverRc1Nano)); + let bind: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap(); + let handles = runtime::start_with_dut_registry( + bind, + Arc::new(store) as Arc, + Arc::new(blobs) as Arc, + Arc::new(registry), + ) + .await + .expect("daemon start"); + (handles, tmp) +} + +#[tokio::test] +async fn unknown_job_returns_404() { + let (handles, _tmp) = start_daemon().await; + let url = format!( + "http://{}/jobs/00000000-0000-0000-0000-000000000000/disasm", + handles.local_addr, + ); + let resp = reqwest::get(&url).await.expect("get"); + assert_eq!(resp.status(), 404); + handles.server_task.abort(); + handles.worker_task.abort(); +} + +async fn submit_job(client: &reqwest::Client, addr: std::net::SocketAddr, kind: JobKind) -> String { + let new = NewJob { + dut: DutId::new("river-1"), + kind, + campaign: None, + }; + let resp = client + .post(format!("http://{addr}/jobs")) + .json(&new) + .send() + .await + .expect("post"); + assert_eq!(resp.status(), 201, "expected 201; got {}", resp.status()); + let body: serde_json::Value = resp.json().await.expect("json"); + body["id"].as_str().expect("id field").to_string() +} + +#[tokio::test] +async fn mock_hello_job_disasm_returns_400() { + let (handles, _tmp) = start_daemon().await; + let client = reqwest::Client::new(); + let job_id = submit_job(&client, handles.local_addr, JobKind::MockHello).await; + let url = format!("http://{}/jobs/{job_id}/disasm", handles.local_addr); + let resp = client.get(&url).send().await.expect("get"); + assert_eq!(resp.status(), 400); + let body: serde_json::Value = resp.json().await.expect("json"); + let err = body["error"].as_str().unwrap_or(""); + assert!( + err.contains("mock-hello") || err.contains("no executable program"), + "error must mention the kind / no-program reason; got `{err}`", + ); + handles.server_task.abort(); + handles.worker_task.abort(); +} + +#[tokio::test] +async fn fuzz_job_disasm_before_any_iter_returns_helpful_400() { + let (handles, _tmp) = start_daemon().await; + let client = reqwest::Client::new(); + let job_id = submit_job( + &client, + handles.local_addr, + JobKind::Fuzz { + iterations: 1, + seed: 0, + insn_count: 4, + cycles: 100, + strict_coverage: false, + generator: heimdall_daemon::GeneratorKind::RawAsm, + }, + ) + .await; + let url = format!("http://{}/jobs/{job_id}/disasm", handles.local_addr); + // We hit /disasm right after submission. The worker may not have + // dispatched the job yet, OR it may have failed before generating + // any iter (the default registry doesn't have a fuzz factory). In + // either case the cache has no entry, so the route returns the + // "has not loaded any iter yet" 400 we want to pin here. If the + // worker DID populate the cache first (extremely fast on a hot + // CI), we get a 200 with a listing - also acceptable, just out of + // scope for this test. We poll briefly to keep the assertion stable. + let mut got_expected = false; + for _ in 0..20 { + let resp = client.get(&url).send().await.expect("get"); + if resp.status() == 400 { + let body: serde_json::Value = resp.json().await.expect("json"); + let err = body["error"].as_str().unwrap_or(""); + if err.contains("has not loaded any iter") { + got_expected = true; + break; + } + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + assert!( + got_expected, + "expected at least one 400 'has not loaded any iter yet' response \ + before the worker populates the cache", + ); + handles.server_task.abort(); + handles.worker_task.abort(); +} + +fn llvm_objdump_available() -> bool { + if std::env::var("HEIMDALL_LLVM_OBJDUMP_BIN") + .ok() + .filter(|p| !p.is_empty()) + .map(|p| std::path::Path::new(&p).is_file()) + .unwrap_or(false) + { + return true; + } + std::process::Command::new("which") + .arg("llvm-objdump") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +#[tokio::test] +async fn boot_river_elf_disasm_returns_json_listing() { + if !llvm_objdump_available() { + eprintln!("llvm-objdump unavailable; skipping"); + return; + } + let (handles, _tmp) = start_daemon().await; + let client = reqwest::Client::new(); + + // Real RV64 ELF carrying `addi a0, x0, 0x42; ebreak` at 0x10000. + // Built by the same heimdall-disasm helper the route exercises so + // the load-addr ELF semantics match end-to-end. + let code = [ + 0x13u8, 0x05, 0x20, 0x04, // addi a0, zero, 0x42 + 0x73, 0x00, 0x10, 0x00, // ebreak + ]; + let elf = wrap_rv64_elf_for_disasm(&code, 0x10000); + let elf_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &elf); + + let job_id = submit_job( + &client, + handles.local_addr, + JobKind::BootRiverElf { + elf_b64, + cycles: 100, + }, + ) + .await; + // Don't wait for the job to finish; disasm only inspects the + // stored job kind, not its terminal verdict. + let url = format!("http://{}/jobs/{job_id}/disasm", handles.local_addr); + let resp = client.get(&url).send().await.expect("get"); + assert_eq!( + resp.status(), + 200, + "expected 200; got {} body={}", + resp.status(), + resp.text().await.unwrap_or_default(), + ); + let listing: serde_json::Value = resp.json().await.expect("json"); + assert_eq!(listing["name"], "llvm-objdump"); + assert_eq!(listing["isa"], "rv64"); + let lines = listing["lines"].as_array().expect("lines array"); + assert!(!lines.is_empty(), "must produce >=1 disasm line"); + let first = &lines[0]; + assert_eq!(first["addr"], 0x10000); + let mnem = first["mnemonic"].as_str().unwrap_or(""); + assert!( + mnem == "li" || mnem == "addi", + "first mnemonic must be li/addi; got `{mnem}`", + ); + assert!( + lines.iter().any(|l| l["mnemonic"] == "ebreak"), + "listing must include an ebreak somewhere", + ); + + handles.server_task.abort(); + handles.worker_task.abort(); +} + +/// Minimal RV64 ELF builder mirroring +/// `heimdall_disasm::llvm_objdump::wrap_rv64_le_for_objdump`. Kept +/// inline so this test crate doesn't have to depend on +/// `heimdall-disasm` (which the daemon already pulls in transitively; +/// the wrap helper is intentionally private to the disasm crate). +fn wrap_rv64_elf_for_disasm(code: &[u8], load_addr: u64) -> Vec { + const EHDR: usize = 0x40; + const PHDR: usize = 0x38; + const SHDR: usize = 0x40; + let payload_off = EHDR + PHDR; + const SHSTRTAB: &[u8] = b"\0.text\0.shstrtab\0"; + let text_name: u32 = 1; + let shstrtab_name: u32 = 7; + let shstrtab_off = payload_off + code.len(); + let shstrtab_size = SHSTRTAB.len(); + let shdr_off = shstrtab_off + shstrtab_size; + let total = shdr_off + 3 * SHDR; + let mut buf = vec![0u8; total]; + buf[0..4].copy_from_slice(b"\x7fELF"); + buf[4] = 2; + buf[5] = 1; + buf[6] = 1; + buf[16..18].copy_from_slice(&2u16.to_le_bytes()); + buf[18..20].copy_from_slice(&243u16.to_le_bytes()); + buf[20..24].copy_from_slice(&1u32.to_le_bytes()); + buf[24..32].copy_from_slice(&load_addr.to_le_bytes()); + buf[32..40].copy_from_slice(&(EHDR as u64).to_le_bytes()); + buf[40..48].copy_from_slice(&(shdr_off as u64).to_le_bytes()); + buf[52..54].copy_from_slice(&(EHDR as u16).to_le_bytes()); + buf[54..56].copy_from_slice(&(PHDR as u16).to_le_bytes()); + buf[56..58].copy_from_slice(&1u16.to_le_bytes()); + buf[58..60].copy_from_slice(&(SHDR as u16).to_le_bytes()); + buf[60..62].copy_from_slice(&3u16.to_le_bytes()); + buf[62..64].copy_from_slice(&2u16.to_le_bytes()); + let ph = EHDR; + buf[ph..ph + 4].copy_from_slice(&1u32.to_le_bytes()); + buf[ph + 4..ph + 8].copy_from_slice(&5u32.to_le_bytes()); + buf[ph + 8..ph + 16].copy_from_slice(&(payload_off as u64).to_le_bytes()); + buf[ph + 16..ph + 24].copy_from_slice(&load_addr.to_le_bytes()); + buf[ph + 24..ph + 32].copy_from_slice(&load_addr.to_le_bytes()); + buf[ph + 32..ph + 40].copy_from_slice(&(code.len() as u64).to_le_bytes()); + buf[ph + 40..ph + 48].copy_from_slice(&(code.len() as u64).to_le_bytes()); + buf[ph + 48..ph + 56].copy_from_slice(&0x1000u64.to_le_bytes()); + buf[payload_off..payload_off + code.len()].copy_from_slice(code); + buf[shstrtab_off..shstrtab_off + shstrtab_size].copy_from_slice(SHSTRTAB); + let sh1 = shdr_off + SHDR; + buf[sh1..sh1 + 4].copy_from_slice(&text_name.to_le_bytes()); + buf[sh1 + 4..sh1 + 8].copy_from_slice(&1u32.to_le_bytes()); + buf[sh1 + 8..sh1 + 16].copy_from_slice(&0x6u64.to_le_bytes()); + buf[sh1 + 16..sh1 + 24].copy_from_slice(&load_addr.to_le_bytes()); + buf[sh1 + 24..sh1 + 32].copy_from_slice(&(payload_off as u64).to_le_bytes()); + buf[sh1 + 32..sh1 + 40].copy_from_slice(&(code.len() as u64).to_le_bytes()); + buf[sh1 + 48..sh1 + 56].copy_from_slice(&4u64.to_le_bytes()); + let sh2 = shdr_off + 2 * SHDR; + buf[sh2..sh2 + 4].copy_from_slice(&shstrtab_name.to_le_bytes()); + buf[sh2 + 4..sh2 + 8].copy_from_slice(&3u32.to_le_bytes()); + buf[sh2 + 24..sh2 + 32].copy_from_slice(&(shstrtab_off as u64).to_le_bytes()); + buf[sh2 + 32..sh2 + 40].copy_from_slice(&(shstrtab_size as u64).to_le_bytes()); + buf +} + +/// Regression test for "disasm doesn't last when the daemon +/// restarts": pre-seed a Fuzz job + a program-blob mapping into the +/// stores BEFORE starting the daemon, then hit /disasm and verify +/// the route reads from durable storage when the in-memory cache is +/// cold. Mirrors the scenario where an operator opens a past fuzz +/// job's disasm panel after a daemon restart. +#[tokio::test] +async fn fuzz_disasm_falls_back_to_blob_store_after_cold_cache() { + if !llvm_objdump_available() { + eprintln!("llvm-objdump unavailable; skipping"); + return; + } + use heimdall_core::{ArtifactKind, DutKind}; + use heimdall_daemon::{ + BlobStore, DutRecord, DutRegistry, GeneratorKind, JobKind, JobProgramRef, JobStore, + LocalFsBlobStore, NewJob, SqliteJobStore, runtime, + }; + + let tmp = TempDir::new().expect("tmp"); + let store = + Arc::new(SqliteJobStore::open_in_memory().await.expect("store")) as Arc; + let blobs = Arc::new( + LocalFsBlobStore::open(tmp.path().to_path_buf()) + .await + .expect("blobs"), + ) as Arc; + + // Create a Fuzz job in the store. The default registry path + // would also work but `create_job` is simpler here. + let job = store + .create_job( + NewJob { + dut: heimdall_core::DutId::new("river-1"), + kind: JobKind::Fuzz { + iterations: 1, + seed: 7, + insn_count: 4, + cycles: 100, + strict_coverage: false, + generator: GeneratorKind::RawAsm, + }, + campaign: None, + }, + DutKind::RiverRc1Nano, + ) + .await + .expect("create_job"); + + // Pre-seed the BlobStore + JobStore mapping with a real RV64 + // program. This simulates the worker having persisted on a + // previous daemon run. + let code: [u8; 8] = [ + 0x13, 0x05, 0x20, 0x04, // addi a0, zero, 0x42 + 0x73, 0x00, 0x10, 0x00, // ebreak + ]; + let blob_id = blobs.put(&code).await.expect("blob put"); + store + .set_job_program( + job.id, + JobProgramRef { + blob_id, + kind: ArtifactKind::RawBytes, + iter: Some(0), + }, + ) + .await + .expect("set_job_program"); + + // Now start the daemon against the SAME stores. AppState's + // loaded_programs cache is fresh and empty; the disasm route + // must fall back to the persisted mapping. + let mut registry = DutRegistry::new(); + registry.insert(DutRecord::mock("river-1", DutKind::RiverRc1Nano)); + let bind: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap(); + let handles = runtime::start_with_dut_registry(bind, store, blobs, Arc::new(registry)) + .await + .expect("daemon start"); + + let url = format!("http://{}/jobs/{}/disasm", handles.local_addr, job.id.0); + let resp = reqwest::Client::new().get(&url).send().await.expect("get"); + assert_eq!( + resp.status(), + 200, + "fuzz disasm with cold cache must read from durable storage; got {}", + resp.status(), + ); + let body: serde_json::Value = resp.json().await.expect("json"); + assert_eq!(body["iter"], 0, "iter must come from the persisted record"); + let lines = body["lines"].as_array().expect("lines"); + assert!(!lines.is_empty(), "must produce >=1 disasm line"); + assert!( + lines.iter().any(|l| l["mnemonic"] == "ebreak"), + "ebreak must appear in the listing (proves the bytes round-tripped)", + ); + + handles.server_task.abort(); + handles.worker_task.abort(); +} diff --git a/crates/heimdall-daemon/tests/dump_restore.rs b/crates/heimdall-daemon/tests/dump_restore.rs index 4ef32bb..35af666 100644 --- a/crates/heimdall-daemon/tests/dump_restore.rs +++ b/crates/heimdall-daemon/tests/dump_restore.rs @@ -3,7 +3,7 @@ #![cfg(feature = "sqlite")] use chrono::Utc; -use heimdall_core::DutId; +use heimdall_core::{DutId, DutKind}; use heimdall_daemon::{ BlobStore, Campaign, CampaignId, CampaignState, CampaignTemplate, Event, EventId, JobFilter, JobKind, JobStore, LocalFsBlobStore, NewJob, SqliteJobStore, dump as snapshot, @@ -36,19 +36,25 @@ async fn round_trip_preserves_jobs_campaigns_blobs() { src_store.create_campaign(camp.clone()).await.unwrap(); let j1 = src_store - .create_job(NewJob { - dut: DutId::new("dut-a"), - kind: JobKind::MockHello, - campaign: Some(camp.id), - }) + .create_job( + NewJob { + dut: DutId::new("dut-a"), + kind: JobKind::MockHello, + campaign: Some(camp.id), + }, + DutKind::RiverRc1Nano, + ) .await .unwrap(); let j2 = src_store - .create_job(NewJob { - dut: DutId::new("dut-b"), - kind: JobKind::MockHello, - campaign: None, - }) + .create_job( + NewJob { + dut: DutId::new("dut-b"), + kind: JobKind::MockHello, + campaign: None, + }, + DutKind::AegisLuna1, + ) .await .unwrap(); @@ -110,10 +116,10 @@ async fn round_trip_preserves_jobs_campaigns_blobs() { let restored_b2 = dst_blobs.get(&b2).await.unwrap().expect("b2 present"); assert_eq!(&restored_b2[..], b"another blob"); - // Event preserved with original id. + // Event preserved with original id and timestamp. let evs = dst_store.list_events_since(EventId(0), 100).await.unwrap(); assert_eq!(evs.len(), 1); - assert_eq!(evs[0].0, ev_id); + assert_eq!(evs[0].id, ev_id); // After restore, the destination store's next append_event must produce // an EventId strictly greater than every restored one. diff --git a/crates/heimdall-daemon/tests/dut_registry_http.rs b/crates/heimdall-daemon/tests/dut_registry_http.rs index 43b26c7..59196fb 100644 --- a/crates/heimdall-daemon/tests/dut_registry_http.rs +++ b/crates/heimdall-daemon/tests/dut_registry_http.rs @@ -35,6 +35,8 @@ async fn start_with_two_duts() -> (heimdall_daemon::DaemonHandles, TempDir) { bringup: None, netlist: None, spice_watches: vec![], + timeouts: Default::default(), + isa: None, }, DutCfg { id: "luna1-2".into(), @@ -45,6 +47,8 @@ async fn start_with_two_duts() -> (heimdall_daemon::DaemonHandles, TempDir) { bringup: None, netlist: None, spice_watches: vec![], + timeouts: Default::default(), + isa: None, }, ], transport: TransportSection { diff --git a/crates/heimdall-daemon/tests/duts_effective_isa.rs b/crates/heimdall-daemon/tests/duts_effective_isa.rs new file mode 100644 index 0000000..4ad85d0 --- /dev/null +++ b/crates/heimdall-daemon/tests/duts_effective_isa.rs @@ -0,0 +1,114 @@ +//! GET /duts must surface the resolved effective ISA per DUT so the +//! web UI can render the XLEN + extension chips strip. Verifies the +//! `effective_isa` envelope shape and the "config" source label +//! when no JTAG probe has run yet. + +#![cfg(feature = "sqlite")] + +use std::sync::Arc; + +use heimdall_core::DutKind; +use heimdall_daemon::{ + BlobStore, DutRecord, DutRegistry, IsaSpec, JobStore, LocalFsBlobStore, SqliteJobStore, runtime, +}; +use tempfile::TempDir; + +#[tokio::test] +async fn duts_route_includes_effective_isa_when_config_sets_one() { + let tmp = TempDir::new().expect("tmp"); + let store = SqliteJobStore::open_in_memory().await.expect("store"); + let blobs = LocalFsBlobStore::open(tmp.path().to_path_buf()) + .await + .expect("blobs"); + + let mut registry = DutRegistry::new(); + let mut rec = DutRecord::mock("river-1", DutKind::RiverRc1Nano); + rec.isa = Some(IsaSpec { + xlen: 64, + extensions: vec!["i".into(), "m".into(), "a".into(), "c".into()], + }); + registry.insert(rec); + + let bind: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap(); + let handles = runtime::start_with_dut_registry( + bind, + Arc::new(store) as Arc, + Arc::new(blobs) as Arc, + Arc::new(registry), + ) + .await + .expect("daemon start"); + + let url = format!("http://{}/duts", handles.local_addr); + let body: serde_json::Value = reqwest::get(&url) + .await + .expect("get") + .json() + .await + .expect("json"); + let duts = body["duts"].as_array().expect("duts array"); + assert_eq!(duts.len(), 1, "exactly one configured DUT"); + let isa = &duts[0]["effective_isa"]; + assert!( + !isa.is_null(), + "effective_isa must be present when config has one" + ); + assert_eq!(isa["xlen"], 64, "xlen surfaces verbatim from config"); + assert_eq!( + isa["source"], "config", + "source must be `config` when no JTAG probe has run" + ); + let exts = isa["extensions"].as_array().expect("extensions array"); + let names: Vec = exts + .iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .collect(); + assert_eq!( + names, + vec!["i".to_string(), "m".into(), "a".into(), "c".into()], + "extensions must round-trip in declared order" + ); + + handles.server_task.abort(); + handles.worker_task.abort(); +} + +/// When no isa spec is set on the DUT and no probe has run, the +/// `effective_isa` field is absent (skip_serializing_if) so the UI +/// renders "unknown" instead of lying with a default. +#[tokio::test] +async fn duts_route_omits_effective_isa_when_unresolved() { + let tmp = TempDir::new().expect("tmp"); + let store = SqliteJobStore::open_in_memory().await.expect("store"); + let blobs = LocalFsBlobStore::open(tmp.path().to_path_buf()) + .await + .expect("blobs"); + + let mut registry = DutRegistry::new(); + registry.insert(DutRecord::mock("noisa-1", DutKind::RiverRc1Nano)); + let bind: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap(); + let handles = runtime::start_with_dut_registry( + bind, + Arc::new(store) as Arc, + Arc::new(blobs) as Arc, + Arc::new(registry), + ) + .await + .expect("daemon start"); + + let url = format!("http://{}/duts", handles.local_addr); + let body: serde_json::Value = reqwest::get(&url) + .await + .expect("get") + .json() + .await + .expect("json"); + assert!( + body["duts"][0]["effective_isa"].is_null() + || body["duts"][0].get("effective_isa").is_none(), + "effective_isa must be absent when no spec or probe is available" + ); + + handles.server_task.abort(); + handles.worker_task.abort(); +} diff --git a/crates/heimdall-daemon/tests/http_smoke.rs b/crates/heimdall-daemon/tests/http_smoke.rs index 5367530..eaddcd7 100644 --- a/crates/heimdall-daemon/tests/http_smoke.rs +++ b/crates/heimdall-daemon/tests/http_smoke.rs @@ -7,9 +7,10 @@ use std::sync::Arc; use std::time::{Duration, Instant}; -use heimdall_core::DutId; +use heimdall_core::{DutId, DutKind}; use heimdall_daemon::{ - BlobStore, JobKind, JobStore, LocalFsBlobStore, NewJob, SqliteJobStore, runtime, + BlobStore, DutRecord, DutRegistry, JobKind, JobStore, LocalFsBlobStore, NewJob, SqliteJobStore, + runtime, }; use tempfile::TempDir; @@ -19,11 +20,17 @@ async fn start_daemon() -> (heimdall_daemon::DaemonHandles, TempDir) { let blobs = LocalFsBlobStore::open(tmp.path().to_path_buf()) .await .expect("blobs"); + let mut registry = DutRegistry::new(); + registry.insert(DutRecord::mock("mock-dut", DutKind::RiverRc1Nano)); + for i in 0..2 { + registry.insert(DutRecord::mock(format!("dut-{i}"), DutKind::RiverRc1Nano)); + } let bind: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap(); - let handles = runtime::start( + let handles = runtime::start_with_dut_registry( bind, Arc::new(store) as Arc, Arc::new(blobs) as Arc, + Arc::new(registry), ) .await .expect("daemon start"); @@ -34,6 +41,76 @@ fn base_url(addr: std::net::SocketAddr) -> String { format!("http://{addr}") } +/// `runtime::start_binds` accepts more than one address and spawns +/// an axum-serve task per listener. `/health` must answer on both, +/// proving the same router is shared across all bound interfaces. +#[tokio::test] +async fn multi_bind_serves_on_every_listener() { + use heimdall_daemon::runtime; + let tmp = TempDir::new().expect("tmp"); + let store = heimdall_daemon::SqliteJobStore::open_in_memory() + .await + .expect("store"); + let blobs = LocalFsBlobStore::open(tmp.path().to_path_buf()) + .await + .expect("blobs"); + let binds = vec![ + "127.0.0.1:0".parse().unwrap(), + "127.0.0.1:0".parse().unwrap(), + ]; + let handles = runtime::start_binds( + binds, + Arc::new(store) as Arc, + Arc::new(blobs) as Arc, + ) + .await + .expect("daemon start"); + assert_eq!( + handles.local_addrs.len(), + 2, + "two binds must surface two local_addrs" + ); + assert_eq!( + handles.extra_server_tasks.len(), + 1, + "n binds => 1 server_task + n-1 extras" + ); + for addr in &handles.local_addrs { + let url = format!("http://{addr}/health"); + let resp = reqwest::get(&url).await.expect("get"); + assert_eq!(resp.status(), 200, "/health must answer on {addr}"); + } + handles.server_task.abort(); + for t in handles.extra_server_tasks { + t.abort(); + } + handles.worker_task.abort(); +} + +#[tokio::test] +async fn about_returns_version_and_features() { + let (handles, _tmp) = start_daemon().await; + let url = format!("{}/about", base_url(handles.local_addr)); + let resp = reqwest::get(&url).await.expect("get"); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.expect("json"); + let v = body["version"].as_str().expect("version string"); + assert!(!v.is_empty(), "version must be non-empty"); + let profile = body["build_profile"].as_str().expect("profile"); + assert!( + profile == "debug" || profile == "release", + "unexpected build profile `{profile}`" + ); + // sqlite is on by default in this build; assert it's reported + // so callers know the features key is wired. + assert_eq!( + body["features"]["sqlite"], true, + "sqlite feature must be reported true in default test build" + ); + handles.server_task.abort(); + handles.worker_task.abort(); +} + #[tokio::test] async fn health_returns_ok() { let (handles, _tmp) = start_daemon().await; @@ -150,3 +227,455 @@ async fn get_unknown_job_returns_404() { handles.server_task.abort(); handles.worker_task.abort(); } + +/// Once a job has reached a terminal state (Done/Failed/Cancelled), +/// hitting POST /jobs/:id/cancel must return 400 rather than silently +/// flipping its state. Guards the "no skip-the-check knobs" rule: +/// terminal means terminal. +#[tokio::test] +async fn cancel_route_rejects_terminal_job() { + let (handles, _tmp) = start_daemon().await; + let client = reqwest::Client::new(); + let resp = client + .post(format!("{}/jobs", base_url(handles.local_addr))) + .json(&NewJob { + dut: DutId::new("mock-dut"), + kind: JobKind::MockHello, + campaign: None, + }) + .send() + .await + .expect("post"); + assert_eq!(resp.status(), 201); + let job: serde_json::Value = resp.json().await.expect("json"); + let job_id = job["id"].as_str().expect("id").to_string(); + + // Wait for terminal. MockHello completes well within this budget. + let url = format!("{}/jobs/{job_id}", base_url(handles.local_addr)); + let deadline = Instant::now() + Duration::from_secs(5); + let mut terminal = false; + while Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(50)).await; + let body: serde_json::Value = client + .get(&url) + .send() + .await + .expect("get") + .json() + .await + .expect("json"); + let s = body["state"]["state"].as_str().unwrap_or(""); + if matches!(s, "done" | "failed" | "cancelled") { + terminal = true; + break; + } + } + assert!(terminal, "mock job never reached terminal state"); + + let cancel_url = format!("{}/jobs/{job_id}/cancel", base_url(handles.local_addr)); + let resp = client.post(&cancel_url).send().await.expect("cancel post"); + assert_eq!( + resp.status(), + 400, + "cancel on a terminal job must reject with 400", + ); + + handles.server_task.abort(); + handles.worker_task.abort(); +} + +/// Simulates a daemon process that exited while a job was still +/// running: pre-seed a sqlite store with one Running job and one +/// stranded Queued job, then boot the daemon against the same store. +/// The Running job must land at `Dead` (terminal, distinct from +/// Cancelled/Failed) and the Queued job must reach a terminal state +/// once the new worker picks it up. Together these prove the +/// `reconcile_orphaned_jobs` pass in `runtime::start_inner`. +#[tokio::test] +async fn reconcile_dead_and_requeue_on_boot() { + use heimdall_core::DutKind; + use heimdall_daemon::{JobState, runtime}; + + let tmp = TempDir::new().expect("tmp"); + let store = Arc::new( + heimdall_daemon::SqliteJobStore::open_in_memory() + .await + .expect("store"), + ); + let blobs = Arc::new( + LocalFsBlobStore::open(tmp.path().to_path_buf()) + .await + .expect("blobs"), + ); + + // Seed: one job at Running (simulating mid-flight crash), one at + // Queued (simulating "never dispatched before crash"). + let running_job = store + .create_job( + NewJob { + dut: DutId::new("mock-dut"), + kind: JobKind::MockHello, + campaign: None, + }, + DutKind::RiverRc1Nano, + ) + .await + .expect("create running"); + store + .update_state(running_job.id, JobState::Running) + .await + .expect("flip to running"); + let queued_job = store + .create_job( + NewJob { + dut: DutId::new("mock-dut"), + kind: JobKind::MockHello, + campaign: None, + }, + DutKind::RiverRc1Nano, + ) + .await + .expect("create queued"); + + // Boot the daemon against the same store. Reconcile runs as part + // of start_inner; by the time start returns the orphan transitions + // have been written. + let mut registry = DutRegistry::new(); + registry.insert(DutRecord::mock("mock-dut", DutKind::RiverRc1Nano)); + let bind: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap(); + let handles = runtime::start_with_dut_registry( + bind, + store.clone() as Arc, + blobs as Arc, + Arc::new(registry), + ) + .await + .expect("daemon start"); + + // Verify the Running job is now Dead. + let url = format!("{}/jobs/{}", base_url(handles.local_addr), running_job.id.0); + let body: serde_json::Value = reqwest::Client::new() + .get(&url) + .send() + .await + .expect("get") + .json() + .await + .expect("json"); + assert_eq!( + body["state"]["state"], "dead", + "running job must be reconciled to dead; got {body}" + ); + + // Verify the previously-Queued job actually got picked up and + // ran to terminal in the new worker. MockHello completes well + // within this budget. + let q_url = format!("{}/jobs/{}", base_url(handles.local_addr), queued_job.id.0); + let deadline = Instant::now() + Duration::from_secs(5); + let mut terminal_seen = false; + while Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(50)).await; + let body: serde_json::Value = reqwest::Client::new() + .get(&q_url) + .send() + .await + .expect("get") + .json() + .await + .expect("json"); + let s = body["state"]["state"].as_str().unwrap_or(""); + if matches!(s, "done" | "failed") { + terminal_seen = true; + break; + } + } + assert!( + terminal_seen, + "re-queued job never reached a terminal state" + ); + + handles.server_task.abort(); + handles.worker_task.abort(); +} + +/// Restart on a terminal job must mint a fresh job (new id, fresh +/// created_at) with the same dut + kind + campaign, leaving the +/// original row untouched so the audit trail survives. +#[tokio::test] +async fn restart_route_clones_terminal_job() { + let (handles, _tmp) = start_daemon().await; + let client = reqwest::Client::new(); + let resp = client + .post(format!("{}/jobs", base_url(handles.local_addr))) + .json(&NewJob { + dut: DutId::new("mock-dut"), + kind: JobKind::MockHello, + campaign: None, + }) + .send() + .await + .expect("post"); + assert_eq!(resp.status(), 201); + let job: serde_json::Value = resp.json().await.expect("json"); + let orig_id = job["id"].as_str().expect("id").to_string(); + + // Wait until terminal so the restart is a legal transition. + let url = format!("{}/jobs/{orig_id}", base_url(handles.local_addr)); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(50)).await; + let body: serde_json::Value = client.get(&url).send().await.unwrap().json().await.unwrap(); + if matches!( + body["state"]["state"].as_str().unwrap_or(""), + "done" | "failed" | "cancelled" + ) { + break; + } + } + + let restart_url = format!("{}/jobs/{orig_id}/restart", base_url(handles.local_addr)); + let resp = client.post(&restart_url).send().await.expect("restart"); + assert_eq!(resp.status(), 201, "restart on terminal must return 201"); + let new_job: serde_json::Value = resp.json().await.expect("new json"); + let new_id = new_job["id"].as_str().expect("new id").to_string(); + assert_ne!(new_id, orig_id, "restart must mint a new job id"); + assert_eq!(new_job["dut"], "mock-dut", "dut must be cloned"); + assert_eq!(new_job["kind"]["kind"], "mock-hello", "kind must be cloned"); + + // Original row must still exist and still be terminal: no + // history rewrite. + let orig: serde_json::Value = client.get(&url).send().await.unwrap().json().await.unwrap(); + assert!( + matches!( + orig["state"]["state"].as_str().unwrap_or(""), + "done" | "failed" | "cancelled" | "dead" + ), + "original job must stay in its terminal state; got {orig}", + ); + + handles.server_task.abort(); + handles.worker_task.abort(); +} + +/// Restart on a non-terminal job (queued/running) must reject with +/// 400, so an operator can't accidentally double-book a DUT. +#[tokio::test] +async fn restart_route_rejects_non_terminal() { + let (handles, _tmp) = start_daemon().await; + let client = reqwest::Client::new(); + + // Use a sqlite store path that lets us pre-seed Running, since + // MockHello finishes before we could hit /restart otherwise. + // The default start_daemon already gives us a clean store; we + // POST a job, then immediately restart while it's almost + // certainly still queued/running. To make this deterministic, + // verify the response status code path rather than racing on + // exact state. + let resp = client + .post(format!("{}/jobs", base_url(handles.local_addr))) + .json(&NewJob { + dut: DutId::new("mock-dut"), + kind: JobKind::MockHello, + campaign: None, + }) + .send() + .await + .expect("post"); + assert_eq!(resp.status(), 201); + let job: serde_json::Value = resp.json().await.expect("json"); + let job_id = job["id"].as_str().expect("id").to_string(); + + // First wait for terminal so we know what comes next is legal, + // then prove restart-of-a-restart-target keeps working but + // restart-of-an-actively-Queued does not. Reverse-order check: + // submit a second job and immediately attempt restart while it + // races toward done. Read the response and accept either the + // 400 (race won by restart) or 201 (race won by worker, job + // already terminal). Both are valid; we only want to assert no + // 500/panic. + let restart_url = format!("{}/jobs/{job_id}/restart", base_url(handles.local_addr)); + let immediate = client.post(&restart_url).send().await.expect("immediate"); + assert!( + matches!(immediate.status().as_u16(), 201 | 400), + "immediate restart must be 201 or 400; got {}", + immediate.status() + ); + + handles.server_task.abort(); + handles.worker_task.abort(); +} + +/// Restart on an unknown job id must surface as 404. +#[tokio::test] +async fn restart_route_unknown_job_returns_404() { + let (handles, _tmp) = start_daemon().await; + let nonexistent = uuid::Uuid::nil(); + let url = format!( + "{}/jobs/{nonexistent}/restart", + base_url(handles.local_addr) + ); + let resp = reqwest::Client::new() + .post(&url) + .send() + .await + .expect("post"); + assert_eq!(resp.status(), 404); + handles.server_task.abort(); + handles.worker_task.abort(); +} + +/// `/jobs?limit=N&offset=M` slices the listing and `total` reports +/// the unpaginated count. Crucially, `total` ignores the page +/// window so the UI's "page X of N" math stays correct even on the +/// last page. +#[tokio::test] +async fn list_paginates_with_limit_and_offset() { + let (handles, _tmp) = start_daemon().await; + let client = reqwest::Client::new(); + // Post 5 jobs back-to-back. + for _ in 0..5 { + let resp = client + .post(format!("{}/jobs", base_url(handles.local_addr))) + .json(&NewJob { + dut: DutId::new("mock-dut"), + kind: JobKind::MockHello, + campaign: None, + }) + .send() + .await + .expect("post"); + assert_eq!(resp.status(), 201); + } + // Wait for the queue to drain. + tokio::time::sleep(Duration::from_millis(400)).await; + + let body: serde_json::Value = client + .get(format!( + "{}/jobs?limit=2&offset=1", + base_url(handles.local_addr) + )) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let jobs = body["jobs"].as_array().expect("jobs"); + assert_eq!(jobs.len(), 2, "limit=2 must return exactly 2 rows"); + assert_eq!( + body["total"].as_u64(), + Some(5), + "total must report the unpaginated row count" + ); + + handles.server_task.abort(); + handles.worker_task.abort(); +} + +/// `DELETE /jobs/:id` drops a terminal job; non-terminal rows are +/// rejected with 400; unknown ids return 404. +#[tokio::test] +async fn delete_route_removes_terminal_job_only() { + let (handles, _tmp) = start_daemon().await; + let client = reqwest::Client::new(); + let resp = client + .post(format!("{}/jobs", base_url(handles.local_addr))) + .json(&NewJob { + dut: DutId::new("mock-dut"), + kind: JobKind::MockHello, + campaign: None, + }) + .send() + .await + .expect("post"); + let job: serde_json::Value = resp.json().await.expect("json"); + let job_id = job["id"].as_str().expect("id").to_string(); + + // Wait for terminal. + let url = format!("{}/jobs/{job_id}", base_url(handles.local_addr)); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(50)).await; + let body: serde_json::Value = client.get(&url).send().await.unwrap().json().await.unwrap(); + if matches!( + body["state"]["state"].as_str().unwrap_or(""), + "done" | "failed" | "cancelled" + ) { + break; + } + } + let resp = client.delete(&url).send().await.expect("delete"); + assert_eq!(resp.status(), 204, "delete on terminal must return 204"); + // Confirm the row is actually gone. + let resp = client.get(&url).send().await.expect("get"); + assert_eq!(resp.status(), 404, "deleted job must surface as 404"); + // Idempotent: second delete returns 404 (nothing to remove). + let resp = client.delete(&url).send().await.expect("delete"); + assert_eq!(resp.status(), 404, "delete on missing job must return 404"); +} + +/// `DELETE /jobs?before=...` bulk-prunes terminal jobs created +/// before the cutoff. The prune route reports how many rows it +/// actually removed. +#[tokio::test] +async fn prune_route_drops_old_terminal_jobs() { + let (handles, _tmp) = start_daemon().await; + let client = reqwest::Client::new(); + for _ in 0..3 { + client + .post(format!("{}/jobs", base_url(handles.local_addr))) + .json(&NewJob { + dut: DutId::new("mock-dut"), + kind: JobKind::MockHello, + campaign: None, + }) + .send() + .await + .expect("post"); + } + tokio::time::sleep(Duration::from_millis(400)).await; + // Cutoff is 1 hour from now so every existing job qualifies. + let cutoff = chrono::Utc::now() + chrono::Duration::hours(1); + let resp = client + .delete(format!("{}/jobs", base_url(handles.local_addr))) + .query(&[("before", cutoff.to_rfc3339())]) + .send() + .await + .expect("delete"); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.expect("json"); + assert_eq!( + body["deleted"].as_u64(), + Some(3), + "prune must report 3 deletions" + ); + + // The listing should now be empty. + let body: serde_json::Value = client + .get(format!("{}/jobs", base_url(handles.local_addr))) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(body["total"].as_u64(), Some(0)); + handles.server_task.abort(); + handles.worker_task.abort(); +} + +/// Unknown job id on the cancel route must surface as 404, not 500. +#[tokio::test] +async fn cancel_route_unknown_job_returns_404() { + let (handles, _tmp) = start_daemon().await; + let nonexistent = uuid::Uuid::nil(); + let url = format!("{}/jobs/{nonexistent}/cancel", base_url(handles.local_addr)); + let resp = reqwest::Client::new() + .post(&url) + .send() + .await + .expect("post"); + assert_eq!(resp.status(), 404); + handles.server_task.abort(); + handles.worker_task.abort(); +} diff --git a/crates/heimdall-daemon/tests/job_log_stream.rs b/crates/heimdall-daemon/tests/job_log_stream.rs new file mode 100644 index 0000000..a648016 --- /dev/null +++ b/crates/heimdall-daemon/tests/job_log_stream.rs @@ -0,0 +1,167 @@ +//! End-to-end test: a MockHello job emits per-stage `job-log` events over +//! the `/events` WebSocket. Validates that the StageObserver -> JobLogger -> +//! EventBus -> WebSocket path stays wired end-to-end. + +#![cfg(feature = "sqlite")] + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use futures::{SinkExt, StreamExt}; +use heimdall_core::{DutId, DutKind}; +use heimdall_daemon::{ + BlobStore, DutRecord, DutRegistry, JobKind, JobStore, LocalFsBlobStore, NewJob, SqliteJobStore, + runtime, +}; +use tempfile::TempDir; +use tokio_tungstenite::{connect_async, tungstenite}; + +async fn start_daemon() -> (heimdall_daemon::DaemonHandles, TempDir) { + let tmp = TempDir::new().expect("tmp"); + let store = SqliteJobStore::open_in_memory().await.expect("store"); + let blobs = LocalFsBlobStore::open(tmp.path().to_path_buf()) + .await + .expect("blobs"); + let mut registry = DutRegistry::new(); + registry.insert(DutRecord::mock("log-dut", DutKind::RiverRc1Nano)); + let bind: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap(); + let handles = runtime::start_with_dut_registry( + bind, + Arc::new(store) as Arc, + Arc::new(blobs) as Arc, + Arc::new(registry), + ) + .await + .expect("daemon start"); + (handles, tmp) +} + +#[tokio::test] +async fn mock_hello_emits_stage_tagged_job_logs() { + let (handles, _tmp) = start_daemon().await; + + // Subscribe to /events FIRST so we don't miss early stage events. + let ws_url = format!("ws://{}/events", handles.local_addr); + let (mut ws, _) = connect_async(&ws_url).await.expect("ws connect"); + + // Post a MockHello job. + let client = reqwest::Client::new(); + let url = format!("http://{}/jobs", handles.local_addr); + let resp = client + .post(&url) + .json(&NewJob { + dut: DutId::new("log-dut"), + kind: JobKind::MockHello, + campaign: None, + }) + .send() + .await + .expect("post"); + assert_eq!(resp.status(), 201); + + // Collect events until we see at least one job-log per stage we care about. + let deadline = Instant::now() + Duration::from_secs(5); + let mut seen_prepare = false; + let mut seen_diff = false; + let mut seen_dispatch = false; + while Instant::now() < deadline && !(seen_prepare && seen_diff && seen_dispatch) { + let recv = tokio::time::timeout(Duration::from_millis(250), ws.next()).await; + let frame = match recv { + Ok(Some(Ok(f))) => f, + _ => continue, + }; + let text = match frame { + tungstenite::Message::Text(t) => t, + _ => continue, + }; + let v: serde_json::Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(_) => continue, + }; + // Every broadcast frame is a StampedEvent: `ts` flat-flattened + // alongside the event variant fields. + assert!(v["ts"].is_string(), "missing ts on frame: {v}"); + if v["kind"] != "job-log" { + continue; + } + let stage = v["stage"].as_str(); + let message = v["message"].as_str().unwrap_or(""); + match stage { + Some("prepare") => seen_prepare = true, + Some("diff") => seen_diff = true, + None if message.contains("dispatching") => seen_dispatch = true, + _ => {} + } + } + + assert!(seen_dispatch, "expected daemon-level dispatch log"); + assert!(seen_prepare, "expected stage=prepare log"); + assert!(seen_diff, "expected stage=diff log"); + + let _ = ws.send(tungstenite::Message::Close(None)).await; + + // After the run completes, /jobs/:id/logs should return the persisted + // history so a late-joining client can backfill. + let job_id = { + let jobs: serde_json::Value = client + .get(format!("http://{}/jobs", handles.local_addr)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + jobs["jobs"][0]["id"].as_str().unwrap().to_string() + }; + let history: serde_json::Value = client + .get(format!("http://{}/jobs/{job_id}/logs", handles.local_addr)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let logs = history["logs"].as_array().expect("logs array"); + assert!( + !logs.is_empty(), + "expected persisted job logs, got empty array" + ); + let kinds: Vec<&str> = logs + .iter() + .map(|l| l["kind"].as_str().unwrap_or("")) + .collect(); + assert!( + kinds.iter().all(|k| *k == "job-log"), + "all entries should be job-log events; got {kinds:?}" + ); + let stages: Vec<&str> = logs.iter().filter_map(|l| l["stage"].as_str()).collect(); + assert!(stages.contains(&"prepare"), "history missing prepare stage"); + assert!(stages.contains(&"diff"), "history missing diff stage"); + // Every entry carries a parseable UTC timestamp. + for l in logs { + let ts = l["ts"].as_str().expect("ts present on each entry"); + chrono::DateTime::parse_from_rfc3339(ts).expect("ts parses as RFC3339"); + } + + // since= returns only events strictly newer than that id. + let last_id = logs.last().unwrap()["id"].as_u64().unwrap(); + let empty_delta: serde_json::Value = client + .get(format!( + "http://{}/jobs/{job_id}/logs?since={last_id}", + handles.local_addr + )) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!( + empty_delta["logs"].as_array().unwrap().len(), + 0, + "since=last_id should return zero rows" + ); + + handles.server_task.abort(); + handles.worker_task.abort(); +} diff --git a/crates/heimdall-daemon/tests/netlist_http.rs b/crates/heimdall-daemon/tests/netlist_http.rs index ca99daa..403c232 100644 --- a/crates/heimdall-daemon/tests/netlist_http.rs +++ b/crates/heimdall-daemon/tests/netlist_http.rs @@ -53,6 +53,8 @@ async fn start_with_netlist( direction: PadDirection::Out, }, ], + timeouts: Default::default(), + isa: None, }, DutCfg { id: "spice-none".into(), @@ -63,6 +65,8 @@ async fn start_with_netlist( bringup: None, netlist: None, spice_watches: vec![], + timeouts: Default::default(), + isa: None, }, ], transport: TransportSection { @@ -183,6 +187,8 @@ async fn missing_netlist_file_rejected_at_startup() { bringup: None, netlist: Some(std::path::PathBuf::from("/nonexistent.sp")), spice_watches: vec![], + timeouts: Default::default(), + isa: None, }], transport: TransportSection { jtag: vec![JtagTransportCfg { diff --git a/crates/heimdall-daemon/tests/river_boot_http.rs b/crates/heimdall-daemon/tests/river_boot_http.rs index 44915ef..6890ac1 100644 --- a/crates/heimdall-daemon/tests/river_boot_http.rs +++ b/crates/heimdall-daemon/tests/river_boot_http.rs @@ -23,16 +23,22 @@ async fn boot_river_elf_dispatches_through_factory() { // Mock OpenOCD with enough responses to get through prepare + load + run. // Reg reads return parseable hex. load_image matches via prefix. let mut srv = MockOpenOcdServer::new() - .respond("reset init", "") + .respond("riscv dmi_write 0x10 0x0", "") + .respond("riscv dmi_write 0x10 0x1", "") + .respond("riscv dmi_read 0x10", "0x00000001") .respond("scan_chain", " 1 river.cpu Y 0xdeadbeef") .respond("halt", "") .respond("resume", "") .respond("load_image", "loaded 4 bytes in 0.001s (4 KiB/s)") .respond("reg pc", "pc (/64): 0x80000010"); - for i in 1..32u32 { + for name in [ + "zero", "ra", "sp", "gp", "tp", "t0", "t1", "t2", "fp", "s1", "a0", "a1", "a2", "a3", "a4", + "a5", "a6", "a7", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9", "s10", "s11", "t3", "t4", + "t5", "t6", + ] { srv = srv.respond( - format!("reg x{i}"), - format!("x{i} (/64): 0x0000000000000000"), + format!("reg {name}"), + format!("{name} (/64): 0x0000000000000000"), ); } srv = srv.respond("wait_halt 1000", ""); @@ -60,6 +66,8 @@ async fn boot_river_elf_dispatches_through_factory() { bringup: None, netlist: None, spice_watches: vec![], + timeouts: Default::default(), + isa: None, }], transport: TransportSection { jtag: vec![JtagTransportCfg { @@ -140,11 +148,14 @@ async fn boot_river_elf_dispatches_through_factory() { "expected terminal done|failed; got `{last_state}`" ); - // Sanity: the mock should have received at least the prepare-phase commands. let received = server.received().await; assert!( - received.iter().any(|c| c == "reset init"), - "expected mock to see `reset init`; got {received:?}" + received.iter().any(|c| c == "riscv dmi_write 0x10 0x0"), + "expected mock to see dmactive=0 write; got {received:?}" + ); + assert!( + received.iter().any(|c| c == "riscv dmi_write 0x10 0x1"), + "expected mock to see dmactive=1 write; got {received:?}" ); handles.server_task.abort(); diff --git a/crates/heimdall-daemon/tests/river_openocd_spawn.rs b/crates/heimdall-daemon/tests/river_openocd_spawn.rs index 76ee7be..9bdcbd7 100644 --- a/crates/heimdall-daemon/tests/river_openocd_spawn.rs +++ b/crates/heimdall-daemon/tests/river_openocd_spawn.rs @@ -39,6 +39,8 @@ async fn river_openocd_spawn_failure_surfaces_as_job_failed() { bringup: None, netlist: None, spice_watches: vec![], + timeouts: Default::default(), + isa: None, }], transport: TransportSection { jtag: vec![JtagTransportCfg { diff --git a/crates/heimdall-daemon/tests/ssr.rs b/crates/heimdall-daemon/tests/ssr.rs index a6a8793..c366d1e 100644 --- a/crates/heimdall-daemon/tests/ssr.rs +++ b/crates/heimdall-daemon/tests/ssr.rs @@ -37,6 +37,8 @@ async fn start_with_one_dut() -> (heimdall_daemon::DaemonHandles, TempDir) { bringup: None, netlist: None, spice_watches: vec![], + timeouts: Default::default(), + isa: None, }], transport: TransportSection { jtag: vec![JtagTransportCfg { diff --git a/crates/heimdall-daemon/tests/store_sqlite.rs b/crates/heimdall-daemon/tests/store_sqlite.rs index 7cb97fe..2ea9d2e 100644 --- a/crates/heimdall-daemon/tests/store_sqlite.rs +++ b/crates/heimdall-daemon/tests/store_sqlite.rs @@ -2,7 +2,7 @@ #![cfg(feature = "sqlite")] -use heimdall_core::DutId; +use heimdall_core::{DutId, DutKind}; use heimdall_daemon::{ Campaign, CampaignId, CampaignState, CampaignTemplate, Event, JobFilter, JobKind, JobState, JobStateTag, JobStore, NewJob, SqliteJobStore, VerdictSummary, @@ -16,16 +16,20 @@ async fn store() -> SqliteJobStore { async fn create_get_roundtrip() { let store = store().await; let job = store - .create_job(NewJob { - dut: DutId::new("d1"), - kind: JobKind::MockHello, - campaign: None, - }) + .create_job( + NewJob { + dut: DutId::new("d1"), + kind: JobKind::MockHello, + campaign: None, + }, + DutKind::RiverRc1Small, + ) .await .unwrap(); let back = store.get_job(job.id).await.unwrap().expect("present"); assert_eq!(back.id, job.id); assert_eq!(back.dut, job.dut); + assert_eq!(back.dut_kind, DutKind::RiverRc1Small); assert!(matches!(back.state, JobState::Queued)); assert!(matches!(back.kind, JobKind::MockHello)); } @@ -44,11 +48,14 @@ async fn get_missing_returns_none() { async fn update_state_transitions() { let store = store().await; let job = store - .create_job(NewJob { - dut: DutId::new("d1"), - kind: JobKind::MockHello, - campaign: None, - }) + .create_job( + NewJob { + dut: DutId::new("d1"), + kind: JobKind::MockHello, + campaign: None, + }, + DutKind::RiverRc1Nano, + ) .await .unwrap(); store.update_state(job.id, JobState::Running).await.unwrap(); @@ -65,19 +72,25 @@ async fn update_state_transitions() { async fn list_filters_by_state() { let store = store().await; let a = store - .create_job(NewJob { - dut: DutId::new("d1"), - kind: JobKind::MockHello, - campaign: None, - }) + .create_job( + NewJob { + dut: DutId::new("d1"), + kind: JobKind::MockHello, + campaign: None, + }, + DutKind::RiverRc1Nano, + ) .await .unwrap(); let _b = store - .create_job(NewJob { - dut: DutId::new("d2"), - kind: JobKind::MockHello, - campaign: None, - }) + .create_job( + NewJob { + dut: DutId::new("d2"), + kind: JobKind::MockHello, + campaign: None, + }, + DutKind::RiverRc1Nano, + ) .await .unwrap(); store @@ -104,22 +117,104 @@ async fn list_filters_by_state() { assert_eq!(done[0].id, a.id); } +#[tokio::test] +async fn list_job_logs_filters_by_kind_and_job_and_since() { + let store = store().await; + let job_a = heimdall_daemon::JobId::new(); + let job_b = heimdall_daemon::JobId::new(); + + // Mix of JobLog rows for two different jobs and a non-log event. + let id1 = store + .append_event(Event::JobLog { + job: job_a, + level: heimdall_daemon::LogLevel::Info, + message: "a1".into(), + stage: Some("prepare".into()), + i18n_key: None, + i18n_args: Default::default(), + }) + .await + .unwrap(); + let _ = store + .append_event(Event::JobLog { + job: job_b, + level: heimdall_daemon::LogLevel::Info, + message: "b1".into(), + stage: None, + i18n_key: None, + i18n_args: Default::default(), + }) + .await + .unwrap(); + let id3 = store + .append_event(Event::JobLog { + job: job_a, + level: heimdall_daemon::LogLevel::Warn, + message: "a2".into(), + stage: Some("load".into()), + i18n_key: None, + i18n_args: Default::default(), + }) + .await + .unwrap(); + let _ = store + .append_event(Event::JobCreated { + job: job_a, + dut: DutId::new("d1"), + }) + .await + .unwrap(); + + let all_a = store + .list_job_logs(job_a, heimdall_daemon::EventId(0), 10) + .await + .unwrap(); + assert_eq!(all_a.len(), 2); + let msgs: Vec<_> = all_a + .iter() + .map(|rec| match &rec.event { + Event::JobLog { message, .. } => message.clone(), + _ => "".into(), + }) + .collect(); + assert_eq!(msgs, vec!["a1".to_string(), "a2".to_string()]); + // Each record carries a stamped UTC timestamp. + for rec in &all_a { + assert!(rec.ts.timestamp() > 0, "ts must be populated: {rec:?}"); + } + + // since= filters to events strictly newer than id1. + let delta = store.list_job_logs(job_a, id1, 10).await.unwrap(); + assert_eq!(delta.len(), 1); + assert_eq!(delta[0].id.0, id3.0); + match &delta[0].event { + Event::JobLog { message, .. } => assert_eq!(message, "a2"), + other => panic!("unexpected: {other:?}"), + } +} + #[tokio::test] async fn append_and_list_events() { let store = store().await; let id1 = store .append_event(Event::JobLog { job: heimdall_daemon::JobId::new(), - level: "info".into(), + level: heimdall_daemon::LogLevel::Info, message: "hello".into(), + stage: None, + i18n_key: None, + i18n_args: Default::default(), }) .await .unwrap(); let id2 = store .append_event(Event::JobLog { job: heimdall_daemon::JobId::new(), - level: "info".into(), + level: heimdall_daemon::LogLevel::Info, message: "world".into(), + stage: None, + i18n_key: None, + i18n_args: Default::default(), }) .await .unwrap(); @@ -169,11 +264,14 @@ async fn job_with_campaign_id_roundtrips() { store.create_campaign(campaign).await.unwrap(); let job = store - .create_job(NewJob { - dut: DutId::new("d1"), - kind: JobKind::MockHello, - campaign: Some(campaign_id), - }) + .create_job( + NewJob { + dut: DutId::new("d1"), + kind: JobKind::MockHello, + campaign: Some(campaign_id), + }, + DutKind::RiverRc1Nano, + ) .await .unwrap(); assert_eq!(job.campaign, Some(campaign_id)); @@ -186,6 +284,55 @@ async fn job_with_campaign_id_roundtrips() { assert_eq!(jobs[0].id, job.id); } +#[tokio::test] +async fn job_program_round_trips_via_sqlite() { + use heimdall_core::ArtifactKind; + use heimdall_daemon::{BlobId, JobProgramRef}; + + let store = store().await; + let job = store + .create_job( + NewJob { + dut: DutId::new("river-1"), + kind: JobKind::MockHello, + campaign: None, + }, + DutKind::RiverRc1Nano, + ) + .await + .unwrap(); + // Nothing recorded yet. + assert!(store.get_job_program(job.id).await.unwrap().is_none()); + + let r = JobProgramRef { + blob_id: BlobId("deadbeef".into()), + kind: ArtifactKind::RawBytes, + iter: Some(7), + }; + store.set_job_program(job.id, r.clone()).await.unwrap(); + let got = store + .get_job_program(job.id) + .await + .unwrap() + .expect("present"); + assert_eq!(got.blob_id.0, r.blob_id.0); + assert!(matches!(got.kind, ArtifactKind::RawBytes)); + assert_eq!(got.iter, Some(7)); + + // Upsert: subsequent writes overwrite. Pin that the table holds + // exactly the latest, not a history. + let r2 = JobProgramRef { + blob_id: BlobId("c0ffee".into()), + kind: ArtifactKind::ElfRiscv, + iter: Some(42), + }; + store.set_job_program(job.id, r2.clone()).await.unwrap(); + let got2 = store.get_job_program(job.id).await.unwrap().unwrap(); + assert_eq!(got2.blob_id.0, "c0ffee"); + assert!(matches!(got2.kind, ArtifactKind::ElfRiscv)); + assert_eq!(got2.iter, Some(42)); +} + #[tokio::test] async fn update_campaign_state() { let store = store().await; diff --git a/crates/heimdall-daemon/tests/web_smoke.rs b/crates/heimdall-daemon/tests/web_smoke.rs index fd159cc..5ec40ee 100644 --- a/crates/heimdall-daemon/tests/web_smoke.rs +++ b/crates/heimdall-daemon/tests/web_smoke.rs @@ -1,5 +1,13 @@ -//! Smoke test for the embedded web UI. Verifies GET / returns the index HTML -//! and GET /assets/{app.css,app.js} return the corresponding files. +//! Smoke test for the embedded web UI. Verifies GET / returns the +//! index HTML and the bundled assets are served at /assets/. +//! +//! The frontend ships as a single rolldown-bundled, minified +//! `app.js`. Content-grep tests pull that one file. The minifier +//! mangles identifiers and strips comments, so substring matches +//! that survive minification need to be either string literals +//! (URLs, CSS class names, event-kind tags) or call-site shapes +//! the runtime preserves. Identifier-name greps from the earlier +//! per-module era no longer work and have been retired here. #![cfg(feature = "sqlite")] @@ -8,6 +16,20 @@ use std::sync::Arc; use heimdall_daemon::{BlobStore, JobStore, LocalFsBlobStore, SqliteJobStore, runtime}; use tempfile::TempDir; +/// Fetch the bundled frontend JS. Minified, so callers grep for +/// string literals the bundler can't rewrite (URLs, CSS class +/// names, DOM attribute strings). +async fn fetch_bundle_js(addr: std::net::SocketAddr) -> String { + let url = format!("http://{addr}/assets/app.js"); + let resp = reqwest::get(&url).await.expect("get"); + assert_eq!( + resp.status(), + 200, + "bundled app.js must be served from /assets/", + ); + resp.text().await.expect("text") +} + async fn start_daemon() -> (heimdall_daemon::DaemonHandles, TempDir) { let tmp = TempDir::new().expect("tmp"); let store = SqliteJobStore::open_in_memory().await.expect("store"); @@ -62,14 +84,52 @@ async fn css_contains_tokyo_night_palette() { handles.worker_task.abort(); } +/// The build.rs lucide pipeline must (a) stage `lucide.ttf` so it's +/// served at `/assets/lucide.ttf` and (b) inject @font-face + per-icon +/// classes into app.css so the frontend can resolve `.icon-restart` +/// and friends to glyphs. +#[tokio::test] +async fn lucide_icon_font_and_classes_are_served() { + let (handles, _tmp) = start_daemon().await; + let base = format!("http://{}", handles.local_addr); + + let font = reqwest::get(format!("{base}/assets/lucide.ttf")) + .await + .expect("get font"); + assert_eq!(font.status(), 200, "ttf must be served"); + let bytes = font.bytes().await.expect("body"); + assert!( + bytes.len() > 1024, + "lucide.ttf looks truncated ({} bytes)", + bytes.len() + ); + + let css = reqwest::get(format!("{base}/assets/app.css")) + .await + .expect("get css") + .text() + .await + .expect("text"); + assert!( + css.contains("@font-face") && css.contains("'Lucide'"), + "app.css must declare the Lucide @font-face block" + ); + for class in ["icon-restart", "icon-row-closed", "icon-row-open"] { + assert!(css.contains(class), "app.css must define .{class} rule"); + } + + handles.server_task.abort(); + handles.worker_task.abort(); +} + #[tokio::test] async fn js_is_served() { let (handles, _tmp) = start_daemon().await; - let url = format!("http://{}/assets/app.js", handles.local_addr); - let resp = reqwest::get(&url).await.expect("get"); - assert_eq!(resp.status(), 200); - let body = resp.text().await.expect("text"); - assert!(body.contains("/events"), "js should subscribe to /events"); + let js = fetch_bundle_js(handles.local_addr).await; + assert!( + js.contains("/events"), + "frontend bundle should reference /events WebSocket URL", + ); handles.server_task.abort(); handles.worker_task.abort(); } @@ -95,11 +155,18 @@ async fn index_includes_duts_view() { #[tokio::test] async fn js_fetches_duts() { let (handles, _tmp) = start_daemon().await; - let url = format!("http://{}/assets/app.js", handles.local_addr); - let resp = reqwest::get(&url).await.expect("get"); - let body = resp.text().await.expect("text"); - assert!(body.contains("refreshDuts")); - assert!(body.contains("netlist-panel")); + let js = fetch_bundle_js(handles.local_addr).await; + // String literals survive minification; we grep for the + // /duts URL the refreshDuts() helper fetches and the CSS + // class name the dut card renders into. + assert!( + js.contains("/duts"), + "bundle should reference the /duts API" + ); + assert!( + js.contains("netlist-panel"), + "bundle should reference the .netlist-panel CSS class", + ); handles.server_task.abort(); handles.worker_task.abort(); } @@ -114,22 +181,133 @@ async fn html_marks_translatable_elements_with_data_i18n() { assert!(body.contains("data-i18n=\"web.tabs.jobs\"")); assert!(body.contains("data-i18n=\"web.tabs.campaigns\"")); assert!(body.contains("data-i18n=\"web.tabs.duts\"")); + assert!(body.contains("data-i18n=\"web.tabs.about\"")); assert!(body.contains("data-i18n=\"web.duts.no_duts\"")); handles.server_task.abort(); handles.worker_task.abort(); } +/// SSR must include the About tab + an empty `#view-about` +/// section the JS later fills with the `/about` response. +#[tokio::test] +async fn index_includes_about_view() { + let (handles, _tmp) = start_daemon().await; + let url = format!("http://{}/", handles.local_addr); + let body = reqwest::get(&url) + .await + .expect("get") + .text() + .await + .expect("text"); + assert!( + body.contains("data-view=\"about\""), + "About tab missing from index.html" + ); + assert!( + body.contains("id=\"view-about\""), + "About view section missing" + ); + assert!( + body.contains("id=\"about-version\""), + "About panel missing #about-version slot" + ); + handles.server_task.abort(); + handles.worker_task.abort(); +} + +/// The frontend bundle must reference the `/about` endpoint so a +/// click on the About tab triggers the fetch. +#[tokio::test] +async fn bundle_fetches_about_endpoint() { + let (handles, _tmp) = start_daemon().await; + let js = fetch_bundle_js(handles.local_addr).await; + assert!( + js.contains("/about"), + "bundle should reference the /about API endpoint" + ); + handles.server_task.abort(); + handles.worker_task.abort(); +} + #[tokio::test] async fn js_fetches_and_applies_i18n_catalog() { let (handles, _tmp) = start_daemon().await; - let url = format!("http://{}/assets/app.js", handles.local_addr); + let js = fetch_bundle_js(handles.local_addr).await; + // Identifier names (`initI18n`) get mangled by the minifier so + // the grep targets unminifiable strings: the API URL and the + // DOM attribute string the catalog applies to. + assert!(js.contains("/i18n.json"), "bundle should fetch /i18n.json"); + assert!( + js.contains("data-i18n"), + "bundle should walk [data-i18n] elements", + ); + handles.server_task.abort(); + handles.worker_task.abort(); +} + +#[tokio::test] +async fn new_job_panel_present_in_ssr_html() { + let (handles, _tmp) = start_daemon().await; + let url = format!("http://{}/", handles.local_addr); let body = reqwest::get(&url).await.unwrap().text().await.unwrap(); - assert!(body.contains("/i18n.json"), "JS should fetch /i18n.json"); + // The New Job button + form should ship pre-rendered so the + // operator sees the trigger affordance before any JS runs. + assert!( + body.contains(r#"id="open-new-job""#), + "New Job button missing from SSR HTML", + ); + assert!( + body.contains(r#"id="new-job-form""#), + "New Job form missing from SSR HTML", + ); + // Variant fieldsets for every supported JobKind. + for kind in [ + "fuzz", + "boot-river-elf", + "load-aegis-bitstream", + "run-aegis-vector", + "named", + ] { + assert!( + body.contains(&format!(r#"nj-kind nj-kind-{kind} hidden"#)), + "missing fieldset for kind `{kind}`", + ); + } + handles.server_task.abort(); + handles.worker_task.abort(); +} + +#[tokio::test] +async fn bundle_wires_new_job_submit_and_cancel_to_jobs_api() { + let (handles, _tmp) = start_daemon().await; + let js = fetch_bundle_js(handles.local_addr).await; + // Submit handler POSTs to /jobs; cancel handler POSTs to + // /jobs//cancel. Both URL fragments are string literals + // minification preserves verbatim. + assert!( + js.contains("/jobs/") && js.contains("/cancel"), + "bundle should reference /jobs//cancel", + ); + assert!( + js.contains("data-cancel-job"), + "bundle should emit a [data-cancel-job] attribute on the cancel button", + ); assert!( - body.contains("data-i18n"), - "JS should walk [data-i18n] elements" + js.contains("/restart") && js.contains("data-restart-job"), + "bundle should reference /jobs//restart and emit a \ + [data-restart-job] button attribute", ); - assert!(body.contains("initI18n"), "JS should expose initI18n()"); + // FileReader/base64 helper signal: the kebab-case JobKind + // discriminators land in the wire payload as quoted strings. + for k in [ + "mock-hello", + "fuzz", + "boot-river-elf", + "load-aegis-bitstream", + "run-aegis-vector", + ] { + assert!(js.contains(k), "bundle should mention JobKind tag `{k}`",); + } handles.server_task.abort(); handles.worker_task.abort(); } @@ -137,18 +315,18 @@ async fn js_fetches_and_applies_i18n_catalog() { #[tokio::test] async fn js_renders_connection_status_badge() { let (handles, _tmp) = start_daemon().await; - let js_url = format!("http://{}/assets/app.js", handles.local_addr); + let js = fetch_bundle_js(handles.local_addr).await; let css_url = format!("http://{}/assets/app.css", handles.local_addr); - let js = reqwest::get(&js_url).await.unwrap().text().await.unwrap(); let css = reqwest::get(&css_url).await.unwrap().text().await.unwrap(); - // JS must read the field and render a status pill class. + // JS must read the field name and render a status pill class. + // Both are string literals minification preserves. assert!( js.contains("connection_status"), - "JS should read d.connection_status" + "bundle should read d.connection_status", ); assert!( js.contains("dut-status-"), - "JS should emit a dut-status- class" + "bundle should emit a dut-status- class", ); // CSS must style all three states. assert!(css.contains(".dut-status-connected")); diff --git a/crates/heimdall-disasm/Cargo.toml b/crates/heimdall-disasm/Cargo.toml new file mode 100644 index 0000000..05d5d8e --- /dev/null +++ b/crates/heimdall-disasm/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "heimdall-disasm" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +description = "Disassembly of program artifacts for Heimdall (initial: llvm-objdump subprocess; future: in-process RISC-V decoder)." + +[features] +default = [] +# Enables `#[derive(ts_rs::TS)]` on wire types so the +# `gen_ts_bindings` test target can emit TypeScript interfaces into +# crates/heimdall-web/frontend/src/bindings/. Off by default so +# production builds don't pull in the ts-rs derive crate. +bindings = ["dep:ts-rs", "heimdall-core/bindings"] + +[dependencies] +heimdall-core.workspace = true +async-trait.workspace = true +thiserror.workspace = true +tracing.workspace = true +tokio = { workspace = true } +tempfile.workspace = true +serde = { workspace = true } +ts-rs = { workspace = true, optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/heimdall-disasm/src/error.rs b/crates/heimdall-disasm/src/error.rs new file mode 100644 index 0000000..77238c6 --- /dev/null +++ b/crates/heimdall-disasm/src/error.rs @@ -0,0 +1,18 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DisasmError { + #[error("artifact kind {kind:?} is not supported by disassembler `{disassembler}`")] + UnsupportedArtifact { + disassembler: &'static str, + kind: heimdall_core::ArtifactKind, + }, + #[error("llvm-objdump binary `{path}` is not on PATH or is not executable")] + ObjdumpNotFound { path: String }, + #[error("llvm-objdump exited with status {status}: {stderr}")] + ObjdumpBadExit { status: i32, stderr: String }, + #[error("could not parse llvm-objdump output: {reason}")] + ParseError { reason: String }, + #[error(transparent)] + Io(#[from] std::io::Error), +} diff --git a/crates/heimdall-disasm/src/lib.rs b/crates/heimdall-disasm/src/lib.rs new file mode 100644 index 0000000..c96d7a7 --- /dev/null +++ b/crates/heimdall-disasm/src/lib.rs @@ -0,0 +1,21 @@ +//! Human-readable disassembly of Heimdall program artifacts. +//! +//! The MVP wraps `llvm-objdump` as an out-of-process disassembler. The +//! trait surface is shaped so an in-process Rust decoder (e.g. a +//! workspace-internal RV64 disassembler) can drop in later without +//! touching callers. The intent is "view what the DUT is executing" +//! for the web UI and TUI: render the instruction stream with the +//! current observe PC highlighted. +//! +//! Library-first: callers are the daemon route + future TUI; this crate +//! has zero web/HTTP surface of its own. + +pub mod error; +pub mod listing; +pub mod llvm_objdump; +pub mod trait_def; + +pub use error::DisasmError; +pub use listing::{DisasmLine, DisasmListing}; +pub use llvm_objdump::LlvmObjdumpDisassembler; +pub use trait_def::{DisasmOpts, Disassembler, Isa, Result}; diff --git a/crates/heimdall-disasm/src/listing.rs b/crates/heimdall-disasm/src/listing.rs new file mode 100644 index 0000000..68fd6f5 --- /dev/null +++ b/crates/heimdall-disasm/src/listing.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; + +/// One decoded instruction. `addr` is the architectural address the +/// instruction lives at (so the UI can highlight the line whose addr +/// matches the observed pc). `bytes` is the raw machine code in little- +/// endian order; useful for both render and bug-bounty triage. The +/// mnemonic and operands are pre-split so the UI can style them +/// independently (mnemonics bold, operands dim, register names with +/// the ABI overlay we already use for State rendering). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(ts_rs::TS), ts(export))] +pub struct DisasmLine { + pub addr: u64, + pub bytes: Vec, + pub mnemonic: String, + pub operands: String, +} + +/// Structured output of [`crate::Disassembler::disassemble`]. `name` +/// is the disassembler identifier so the UI / API consumer can show +/// "decoded by llvm-objdump 21.1.8" or similar. `isa` is the ISA the +/// listing was decoded against, so a downstream consumer doesn't have +/// to thread DisasmOpts through to render. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(ts_rs::TS), ts(export))] +pub struct DisasmListing { + pub name: String, + pub isa: crate::trait_def::Isa, + pub lines: Vec, +} + +impl DisasmListing { + /// Find the listing line for a given architectural address. Returns + /// the index into `lines` so the caller can render and scroll to + /// it. Linear scan; lines are typically < 1k entries for fuzz + /// programs and < 100k even for bring-up firmware. + pub fn line_index_for_addr(&self, addr: u64) -> Option { + self.lines.iter().position(|l| l.addr == addr) + } +} diff --git a/crates/heimdall-disasm/src/llvm_objdump.rs b/crates/heimdall-disasm/src/llvm_objdump.rs new file mode 100644 index 0000000..31f27eb --- /dev/null +++ b/crates/heimdall-disasm/src/llvm_objdump.rs @@ -0,0 +1,426 @@ +//! Disassembler implementation that shells out to `llvm-objdump`. +//! +//! llvm-objdump only operates on real object files (it has no `-b +//! binary` raw-blob mode the way GNU objdump does), and it only +//! disassembles content that lives inside a SECTION header. The +//! [`heimdall_tools::raw_rv64_elf`] wrapper used by the fuzz path +//! emits a section-less ELF (its only sections are `null` + an empty +//! `.shstrtab`) because that satisfies spike's fesvr loader, which +//! walks PT_LOAD instead of sections. Feeding that to llvm-objdump +//! produces an empty listing because there's no SHT_PROGBITS section +//! covering the code. +//! +//! So this module carries its OWN minimal-ELF wrapper geared for +//! llvm-objdump: a single `.text` SHT_PROGBITS section flagged +//! `ALLOC|EXECINSTR` whose `sh_addr` is the caller-supplied +//! [`DisasmOpts::raw_base_addr`], paired with a PT_LOAD pointing at +//! the same offset. ELF inputs are passed through unchanged so a +//! real bring-up firmware renders at its linked addresses. +//! +//! The binary path defaults to `llvm-objdump` (PATH-resolved) but can +//! be overridden so the daemon can pin a specific nix-store path in +//! its config without leaking that detail into the trait. + +use std::io::Write as _; +use std::path::PathBuf; +use std::process::Stdio; + +use async_trait::async_trait; +use heimdall_core::{Artifact, ArtifactKind}; +use tempfile::NamedTempFile; +use tokio::process::Command; +use tracing::instrument; + +use crate::error::DisasmError; +use crate::listing::{DisasmLine, DisasmListing}; +use crate::trait_def::{DisasmOpts, Disassembler, Result}; + +/// Disassembler backed by an external `llvm-objdump` binary. +pub struct LlvmObjdumpDisassembler { + binary: PathBuf, +} + +impl LlvmObjdumpDisassembler { + /// Construct against an explicit binary path. The daemon's config + /// should pass the nix-store-resolved path; ad-hoc CLI tooling can + /// pass `"llvm-objdump"` to use the PATH lookup. + pub fn new(binary: impl Into) -> Self { + Self { + binary: binary.into(), + } + } + + /// PATH-resolved default. Useful for tests + the + /// `heimdall doctor`-style probes where the user has the tool + /// installed somewhere on PATH. + pub fn from_path() -> Self { + Self::new("llvm-objdump") + } +} + +#[async_trait] +impl Disassembler for LlvmObjdumpDisassembler { + fn name(&self) -> &str { + "llvm-objdump" + } + + fn supports(&self, kind: ArtifactKind) -> bool { + matches!(kind, ArtifactKind::ElfRiscv | ArtifactKind::RawBytes) + } + + #[instrument(skip(self, artifact, opts), fields(kind = ?artifact.kind))] + async fn disassemble(&self, artifact: &Artifact, opts: &DisasmOpts) -> Result { + if !self.supports(artifact.kind.clone()) { + return Err(DisasmError::UnsupportedArtifact { + disassembler: "llvm-objdump", + kind: artifact.kind.clone(), + }); + } + // For RawBytes we wrap into a minimal ELF with a SHT_PROGBITS + // `.text` section so llvm-objdump has something to disassemble. + let bytes_to_disasm: std::borrow::Cow<'_, [u8]> = match &artifact.kind { + ArtifactKind::ElfRiscv => std::borrow::Cow::Borrowed(&artifact.bytes), + ArtifactKind::RawBytes => { + let _ = opts.isa; // ISA pinning today is RV64-only; wrapper hard-codes RV64. + std::borrow::Cow::Owned(wrap_rv64_le_for_objdump( + &artifact.bytes, + opts.raw_base_addr, + )) + } + _ => unreachable!("supports() gate above"), + }; + let mut file = NamedTempFile::new()?; + file.write_all(&bytes_to_disasm)?; + let path = file.into_temp_path(); + + let mut cmd = Command::new(&self.binary); + cmd.arg("-d"); + // Tell llvm-objdump exactly which extensions the DUT has + // enabled. Strict-on-purpose: a `mul` decoding as `` + // means codegen emitted an M-ext instruction on a non-M + // part, which is the bug we want surfaced. Skip the flag + // entirely when the extension list is empty so we get the + // ISA's default base behavior. + if !opts.extensions.is_empty() { + let mattr: String = opts + .extensions + .iter() + .map(|e| format!("+{e}")) + .collect::>() + .join(","); + cmd.arg(format!("--mattr={mattr}")); + } + cmd.arg(&path); + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let output = cmd.output().await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + DisasmError::ObjdumpNotFound { + path: self.binary.display().to_string(), + } + } else { + DisasmError::Io(e) + } + })?; + if !output.status.success() { + return Err(DisasmError::ObjdumpBadExit { + status: output.status.code().unwrap_or(-1), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let lines = parse_objdump_output(&stdout)?; + Ok(DisasmListing { + name: "llvm-objdump".into(), + isa: opts.isa, + lines, + }) + } +} + +/// Parse `llvm-objdump -d` / `-D` text output into a flat +/// `Vec`. +/// +/// Lines we care about look like: +/// ` 10000: 93 00 00 00 addi ra, zero, 0` +/// ` 10000: 93000000 addi ra, zero, 0` (no-space form) +/// ` 10004: 02 01 c.add sp, t0` (compressed) +/// +/// Header / section / symbol lines that aren't disassembly are +/// skipped. The parser is forgiving: it tolerates 1..=4-byte +/// instructions (covering both RV32C compressed and RV64I), variable +/// whitespace, and the no-space hex form some llvm-objdump versions +/// emit when the line is short. It rejects (returns +/// [`DisasmError::ParseError`]) only when EVERY line in a file fails +/// to match, since that signals a format we never expected. +pub fn parse_objdump_output(text: &str) -> Result> { + let mut lines = Vec::new(); + let mut any_skipped_non_disasm = false; + for raw_line in text.lines() { + // Strip leading whitespace. + let line = raw_line.trim_start(); + // Lines we keep have the form `:[]`. + let Some((addr_str, rest)) = line.split_once(':') else { + any_skipped_non_disasm = true; + continue; + }; + if addr_str.is_empty() || !addr_str.chars().all(|c| c.is_ascii_hexdigit()) { + any_skipped_non_disasm = true; + continue; + } + let Ok(addr) = u64::from_str_radix(addr_str, 16) else { + any_skipped_non_disasm = true; + continue; + }; + // After the colon: leading whitespace, then hex bytes (possibly + // space-separated or run together), then whitespace, then + // mnemonic, then optional operands. + let rest = rest.trim_start(); + let (bytes_part, after_bytes) = split_at_first_word_boundary(rest); + let Some(bytes) = parse_byte_hex_run(bytes_part) else { + any_skipped_non_disasm = true; + continue; + }; + let after_bytes = after_bytes.trim_start(); + let (mnemonic, operands_part) = match after_bytes.split_once(char::is_whitespace) { + Some((m, ops)) => (m.to_string(), ops.trim().to_string()), + None => (after_bytes.to_string(), String::new()), + }; + if mnemonic.is_empty() { + any_skipped_non_disasm = true; + continue; + } + lines.push(DisasmLine { + addr, + bytes, + mnemonic, + operands: operands_part, + }); + } + if lines.is_empty() { + return Err(DisasmError::ParseError { + reason: format!( + "no disassembly lines recognised (input had {} characters, \ + saw_non_disasm_lines={})", + text.len(), + any_skipped_non_disasm, + ), + }); + } + Ok(lines) +} + +/// Split `s` at the first run of whitespace, returning (`head`, `tail`). +/// We can't just `split_whitespace` because the bytes block itself +/// contains internal whitespace (`93 00 00 00`); but the BOUNDARY +/// between bytes-block and mnemonic is always a tab on every +/// llvm-objdump emit I have notes for. Detect that by walking until +/// we hit a tab, OR until we hit a non-hex non-space character (the +/// mnemonic's first letter). +fn split_at_first_word_boundary(s: &str) -> (&str, &str) { + let mut last_byte_end = 0usize; + for (i, c) in s.char_indices() { + if c == '\t' { + return (&s[..i], &s[i + 1..]); + } + if c.is_ascii_hexdigit() || c == ' ' { + if c.is_ascii_hexdigit() { + last_byte_end = i + c.len_utf8(); + } + continue; + } + // First non-hex non-space char: start of mnemonic. The byte + // block ends at the last hex char we saw. + return (&s[..last_byte_end], &s[i..]); + } + (s, "") +} + +/// Parse a sequence of 1..=4 hex bytes from a string. The bytes can +/// be space-separated (`93 00 00 00`) or run together +/// (`93000000`). Returns `None` if the run has an odd count of hex +/// characters or is empty. +fn parse_byte_hex_run(s: &str) -> Option> { + let stripped: String = s.chars().filter(|c| !c.is_whitespace()).collect(); + if stripped.is_empty() || stripped.len() % 2 != 0 { + return None; + } + if !stripped.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let mut bytes = Vec::with_capacity(stripped.len() / 2); + for chunk in stripped.as_bytes().chunks(2) { + let s = std::str::from_utf8(chunk).ok()?; + bytes.push(u8::from_str_radix(s, 16).ok()?); + } + Some(bytes) +} + +/// Build a minimal RV64 little-endian ELF llvm-objdump can disassemble. +/// +/// Layout: EHDR (0x40) + PHDR (0x38) + code + `.text` SHDR + `.shstrtab` +/// payload + 3 SHDRs (null, `.text`, `.shstrtab`). The `.text` section +/// is SHT_PROGBITS, `SHF_ALLOC | SHF_EXECINSTR`, `sh_addr = load_addr`, +/// `sh_offset = payload_off`, `sh_size = code.len()`. That's enough +/// for `llvm-objdump -d` to print "Disassembly of section .text:" at +/// the configured base address. +fn wrap_rv64_le_for_objdump(code: &[u8], load_addr: u64) -> Vec { + const EHDR: usize = 0x40; + const PHDR: usize = 0x38; + const SHDR: usize = 0x40; + let payload_off = EHDR + PHDR; + // String table contains "\0.text\0.shstrtab\0". + const SHSTRTAB: &[u8] = b"\0.text\0.shstrtab\0"; + let text_name: u32 = 1; // offset of ".text" in SHSTRTAB + let shstrtab_name: u32 = 7; // offset of ".shstrtab" + let shstrtab_off = payload_off + code.len(); + let shstrtab_size = SHSTRTAB.len(); + let shdr_off = shstrtab_off + shstrtab_size; + let total = shdr_off + 3 * SHDR; + let mut buf = vec![0u8; total]; + + buf[0..4].copy_from_slice(b"\x7fELF"); + buf[4] = 2; // ELFCLASS64 + buf[5] = 1; // ELFDATA2LSB + buf[6] = 1; // EI_VERSION + buf[16..18].copy_from_slice(&2u16.to_le_bytes()); // ET_EXEC + buf[18..20].copy_from_slice(&243u16.to_le_bytes()); // EM_RISCV + buf[20..24].copy_from_slice(&1u32.to_le_bytes()); // e_version + buf[24..32].copy_from_slice(&load_addr.to_le_bytes()); // e_entry + buf[32..40].copy_from_slice(&(EHDR as u64).to_le_bytes()); // e_phoff + buf[40..48].copy_from_slice(&(shdr_off as u64).to_le_bytes()); // e_shoff + buf[52..54].copy_from_slice(&(EHDR as u16).to_le_bytes()); // e_ehsize + buf[54..56].copy_from_slice(&(PHDR as u16).to_le_bytes()); // e_phentsize + buf[56..58].copy_from_slice(&1u16.to_le_bytes()); // e_phnum + buf[58..60].copy_from_slice(&(SHDR as u16).to_le_bytes()); // e_shentsize + buf[60..62].copy_from_slice(&3u16.to_le_bytes()); // e_shnum + buf[62..64].copy_from_slice(&2u16.to_le_bytes()); // e_shstrndx = shdr[2] + + let ph = EHDR; + buf[ph..ph + 4].copy_from_slice(&1u32.to_le_bytes()); // PT_LOAD + buf[ph + 4..ph + 8].copy_from_slice(&5u32.to_le_bytes()); // R+X + buf[ph + 8..ph + 16].copy_from_slice(&(payload_off as u64).to_le_bytes()); + buf[ph + 16..ph + 24].copy_from_slice(&load_addr.to_le_bytes()); + buf[ph + 24..ph + 32].copy_from_slice(&load_addr.to_le_bytes()); + buf[ph + 32..ph + 40].copy_from_slice(&(code.len() as u64).to_le_bytes()); + buf[ph + 40..ph + 48].copy_from_slice(&(code.len() as u64).to_le_bytes()); + buf[ph + 48..ph + 56].copy_from_slice(&0x1000u64.to_le_bytes()); + + buf[payload_off..payload_off + code.len()].copy_from_slice(code); + buf[shstrtab_off..shstrtab_off + shstrtab_size].copy_from_slice(SHSTRTAB); + + // shdr[0]: null (all zero, already set). + // shdr[1]: .text + let sh1 = shdr_off + SHDR; + buf[sh1..sh1 + 4].copy_from_slice(&text_name.to_le_bytes()); // sh_name + buf[sh1 + 4..sh1 + 8].copy_from_slice(&1u32.to_le_bytes()); // SHT_PROGBITS + buf[sh1 + 8..sh1 + 16].copy_from_slice(&0x6u64.to_le_bytes()); // ALLOC|EXECINSTR + buf[sh1 + 16..sh1 + 24].copy_from_slice(&load_addr.to_le_bytes()); // sh_addr + buf[sh1 + 24..sh1 + 32].copy_from_slice(&(payload_off as u64).to_le_bytes()); // sh_offset + buf[sh1 + 32..sh1 + 40].copy_from_slice(&(code.len() as u64).to_le_bytes()); // sh_size + buf[sh1 + 48..sh1 + 56].copy_from_slice(&4u64.to_le_bytes()); // sh_addralign + // shdr[2]: .shstrtab + let sh2 = shdr_off + 2 * SHDR; + buf[sh2..sh2 + 4].copy_from_slice(&shstrtab_name.to_le_bytes()); + buf[sh2 + 4..sh2 + 8].copy_from_slice(&3u32.to_le_bytes()); // SHT_STRTAB + buf[sh2 + 24..sh2 + 32].copy_from_slice(&(shstrtab_off as u64).to_le_bytes()); + buf[sh2 + 32..sh2 + 40].copy_from_slice(&(shstrtab_size as u64).to_le_bytes()); + buf +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Verbatim snippet of `llvm-objdump -d` against an RV64 ELF on the + /// nix-pinned llvm-21 build. Covers the canonical form: section + /// header, symbol header, hex bytes with internal spaces, tab + /// separator before mnemonic. + const SAMPLE_OBJDUMP_OUTPUT: &str = "\ +disasm.elf:\tfile format elf64-littleriscv + +Disassembly of section .text: + +0000000000010000 <_start>: + 10000: 93 00 00 00 \taddi\tra, zero, 0 + 10004: 13 01 00 00 \taddi\tsp, zero, 0 + 10008: 73 00 10 00 \tebreak +"; + + #[test] + fn parses_canonical_output() { + let lines = parse_objdump_output(SAMPLE_OBJDUMP_OUTPUT).unwrap(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0].addr, 0x10000); + assert_eq!(lines[0].bytes, vec![0x93, 0x00, 0x00, 0x00]); + assert_eq!(lines[0].mnemonic, "addi"); + assert_eq!(lines[0].operands, "ra, zero, 0"); + assert_eq!(lines[2].addr, 0x10008); + assert_eq!(lines[2].mnemonic, "ebreak"); + assert!(lines[2].operands.is_empty(), "ebreak takes no operands"); + } + + /// llvm-objdump on some builds collapses the byte block to a + /// no-space form when the bytes fit in a single 4-byte chunk: + /// ` 10000: 93000000 \taddi\tra, zero, 0` + /// Parser must accept both forms. + #[test] + fn parses_no_space_bytes_form() { + let text = " 10000: 93000000 \taddi\tra, zero, 0\n"; + let lines = parse_objdump_output(text).unwrap(); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].bytes, vec![0x93, 0x00, 0x00, 0x00]); + assert_eq!(lines[0].mnemonic, "addi"); + } + + /// Compressed (RVC) instructions are 2 bytes wide. Parser must + /// accept them and not assume 4-byte width. + #[test] + fn parses_compressed_two_byte_instruction() { + let text = " 10004: 02 01 \tc.add\tsp, t0\n"; + let lines = parse_objdump_output(text).unwrap(); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].bytes, vec![0x02, 0x01]); + assert_eq!(lines[0].mnemonic, "c.add"); + assert_eq!(lines[0].operands, "sp, t0"); + } + + #[test] + fn skips_header_lines() { + let text = "\ +disasm.elf:\tfile format elf64-littleriscv + +Disassembly of section .text: + +0000000000010000 <_start>: + 10000: 93 00 00 00 \taddi\tra, zero, 0 +"; + let lines = parse_objdump_output(text).unwrap(); + assert_eq!(lines.len(), 1, "only the actual disasm line should land"); + } + + /// Empty input or all-noise input must surface as a hard + /// ParseError. Silent empty listings would let an + /// llvm-objdump-version mismatch quietly nerf the UI. + #[test] + fn empty_output_is_a_parse_error() { + let err = parse_objdump_output("").unwrap_err(); + assert!(matches!(err, DisasmError::ParseError { .. })); + let err = parse_objdump_output("garbage\nlines\n").unwrap_err(); + assert!(matches!(err, DisasmError::ParseError { .. })); + } + + #[test] + fn byte_run_rejects_odd_hex_count() { + assert!(parse_byte_hex_run("abc").is_none()); + assert!(parse_byte_hex_run("").is_none()); + } + + #[test] + fn byte_run_accepts_space_or_run_together() { + assert_eq!(parse_byte_hex_run("93 00").unwrap(), vec![0x93, 0x00]); + assert_eq!(parse_byte_hex_run("9300").unwrap(), vec![0x93, 0x00]); + } +} diff --git a/crates/heimdall-disasm/src/trait_def.rs b/crates/heimdall-disasm/src/trait_def.rs new file mode 100644 index 0000000..94f4967 --- /dev/null +++ b/crates/heimdall-disasm/src/trait_def.rs @@ -0,0 +1,85 @@ +use async_trait::async_trait; +use heimdall_core::Artifact; +use serde::{Deserialize, Serialize}; + +use crate::error::DisasmError; +use crate::listing::DisasmListing; + +pub type Result = std::result::Result; + +/// Instruction-set tag the disassembler needs to know about. RV64 today, +/// aarch64/x86 follow when other DUT families come online. Carrying ISA +/// in the opts (rather than inferring from artifact kind) keeps the +/// trait honest about the multi-ISA future and avoids surprise behavior +/// when a RawBytes blob is fed in without an ELF header to sniff. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[cfg_attr(feature = "bindings", derive(ts_rs::TS), ts(export))] +pub enum Isa { + Rv64, +} + +impl Isa { + /// llvm-objdump triple for this ISA. Used when disassembling raw + /// machine code (`-b binary`) where there is no ELF header. + pub fn llvm_triple(self) -> &'static str { + match self { + Isa::Rv64 => "riscv64", + } + } +} + +/// Options that control how an artifact is disassembled. +#[derive(Debug, Clone)] +pub struct DisasmOpts { + pub isa: Isa, + /// Base address to apply to raw-bytes artifacts. Ignored for ELF + /// inputs (they carry their own load address via PT_LOAD/p_paddr). + /// Default matches the fuzz path's [`FUZZ_LOAD_ADDR`] (0x10000). + /// + /// [`FUZZ_LOAD_ADDR`]: ../../heimdall_tools/raw_rv64_elf/constant.FUZZ_LOAD_ADDR.html + pub raw_base_addr: u64, + /// Extension flags to hand to llvm-objdump as `--mattr=+,+`. + /// This is intentionally what the DUT actually has enabled, not a + /// permissive superset: if the codegen accidentally emits an + /// instruction the DUT can't execute (e.g. a `mul` on a non-M + /// part), we want the listing to show `` so the bug + /// surfaces in review. Empty means base ISA only. + pub extensions: Vec, +} + +impl Default for DisasmOpts { + fn default() -> Self { + Self { + isa: Isa::Rv64, + raw_base_addr: 0x10000, + extensions: Vec::new(), + } + } +} + +/// Turn a program artifact into a structured listing for rendering. +/// +/// The trait is async because the initial implementation shells out; +/// in-process implementations are free to return immediately. Errors +/// must NOT be swallowed: a UI that silently shows "no disassembly" +/// when llvm-objdump is missing or the artifact is unsupported is +/// worse than one that says exactly which step failed. Consumers +/// surface the error verbatim ([`feedback_failures_should_fail`]). +#[async_trait] +pub trait Disassembler: Send + Sync { + /// Stable identifier for diagnostics and choosing among multiple + /// available implementations. Keep it slug-like; the daemon route + /// echoes it back in the JSON response. + fn name(&self) -> &str; + + /// True when this disassembler understands `kind`. Today every + /// concrete impl supports ElfRiscv + RawBytes (both feed into + /// llvm-objdump via different entry points), but isolating the + /// capability check on the trait lets future Aegis-bitstream or + /// SPICE-netlist "disassemblers" join the family without expanding + /// the artifact-kind switch in every caller. + fn supports(&self, kind: heimdall_core::ArtifactKind) -> bool; + + async fn disassemble(&self, artifact: &Artifact, opts: &DisasmOpts) -> Result; +} diff --git a/crates/heimdall-disasm/tests/gen_ts_bindings.rs b/crates/heimdall-disasm/tests/gen_ts_bindings.rs new file mode 100644 index 0000000..df151c5 --- /dev/null +++ b/crates/heimdall-disasm/tests/gen_ts_bindings.rs @@ -0,0 +1,35 @@ +//! Emits the TypeScript bindings for heimdall-disasm's wire types into +//! `crates/heimdall-web/frontend/src/bindings/`. Run with +//! `cargo test -p heimdall-disasm --features bindings`. The emitted +//! files are checked into git; rerun this test whenever you change +//! a type that the frontend consumes. + +#![cfg(feature = "bindings")] + +use heimdall_disasm::{DisasmLine, DisasmListing, Isa}; +use ts_rs::{Config, TS}; + +#[test] +fn export_disasm_bindings() { + let cfg = Config::new() + // u64 fields (DisasmLine::addr, ...) come across the wire as + // plain JSON numbers (serde encodes them that way for values + // fitting in safe-integer range), and the frontend treats them + // as `number`. Default `bigint` would force annoying casts. + .with_large_int("number") + // ES-module imports in the bundle are served as `.js`. ts-rs + // adds the dot separator itself, so we pass the bare suffix + // (`"js"`, not `".js"`) and the resulting import reads + // `./Foo.js`, matching what the browser fetches from /assets/. + .with_import_extension(Some("js")); + // export_all() recursively exports referenced types too, so calling + // it on the top-level DisasmListing pulls Isa + DisasmLine along. + // Each #[ts(export_to = ...)] attribute on those types decides + // where the .ts file lands. + DisasmListing::export_all(&cfg).expect("export DisasmListing"); + // Defensive: also export Isa standalone in case DisasmListing + // changes to no longer reference it (the bindings/ checked-in + // file would otherwise rot). + Isa::export(&cfg).expect("export Isa"); + DisasmLine::export(&cfg).expect("export DisasmLine"); +} diff --git a/crates/heimdall-disasm/tests/llvm_objdump_smoke.rs b/crates/heimdall-disasm/tests/llvm_objdump_smoke.rs new file mode 100644 index 0000000..aafa0d6 --- /dev/null +++ b/crates/heimdall-disasm/tests/llvm_objdump_smoke.rs @@ -0,0 +1,167 @@ +//! Live llvm-objdump smoke test. Skips when no `llvm-objdump` is +//! reachable; flips on when `HEIMDALL_LLVM_OBJDUMP_BIN` points at one, +//! or when `llvm-objdump` resolves via PATH. Mirrors the spike_smoke +//! pattern so a thin sandbox doesn't surface as a false negative. + +use std::path::PathBuf; + +use heimdall_core::{Artifact, ArtifactKind}; +use heimdall_disasm::{DisasmOpts, Disassembler, Isa, LlvmObjdumpDisassembler}; + +fn objdump_binary() -> Option { + if let Ok(p) = std::env::var("HEIMDALL_LLVM_OBJDUMP_BIN") { + let path = PathBuf::from(p); + if path.is_file() { + return Some(path); + } + } + if let Ok(out) = std::process::Command::new("which") + .arg("llvm-objdump") + .output() + { + if out.status.success() { + let p = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim()); + if p.is_file() { + return Some(p); + } + } + } + None +} + +/// `addi a0, x0, 0x42; ebreak` as raw RV64 LE bytes. Same payload the +/// fuzz raw-bytes-to-elf wrapper uses in its own tests, so this test +/// is comparing apples to apples with the rest of the pipeline. +const RV64_HELLO: &[u8] = &[ + 0x13, 0x05, 0x20, 0x04, // addi a0, zero, 0x42 + 0x73, 0x00, 0x10, 0x00, // ebreak +]; + +#[tokio::test] +async fn raw_bytes_disasm_against_real_llvm_objdump() { + let Some(bin) = objdump_binary() else { + eprintln!("llvm-objdump not found; skipping"); + return; + }; + let d = LlvmObjdumpDisassembler::new(bin); + let artifact = Artifact::new(ArtifactKind::RawBytes, RV64_HELLO.to_vec()); + let opts = DisasmOpts { + isa: Isa::Rv64, + raw_base_addr: 0x10000, + extensions: Vec::new(), + }; + let listing = d.disassemble(&artifact, &opts).await.expect("disassemble"); + + assert_eq!(listing.isa, Isa::Rv64); + assert!(!listing.lines.is_empty(), "must produce >=1 disasm line"); + // First instruction lands at the configured base address. + assert_eq!( + listing.lines[0].addr, 0x10000, + "first instruction must be at raw_base_addr; got {:#x}", + listing.lines[0].addr, + ); + // li a0, 0x42 must show as either `li` (objdump pseudo) or `addi` + // (canonical encoding). Both are correct decoders of the bytes. + let first = &listing.lines[0]; + assert!( + first.mnemonic == "li" || first.mnemonic == "addi", + "first mnemonic must be li/addi; got `{}`", + first.mnemonic, + ); + // Look for an ebreak somewhere in the listing. Some llvm-objdump + // versions emit it as a single mnemonic, others as `ebreak` with + // no operands - either way the bytes should round-trip. + assert!( + listing.lines.iter().any(|l| l.mnemonic == "ebreak"), + "expected an `ebreak` mnemonic in the listing; got: {:?}", + listing + .lines + .iter() + .map(|l| l.mnemonic.as_str()) + .collect::>(), + ); +} + +#[tokio::test] +async fn line_index_for_addr_locates_observed_pc() { + let Some(bin) = objdump_binary() else { + eprintln!("llvm-objdump not found; skipping"); + return; + }; + let d = LlvmObjdumpDisassembler::new(bin); + let artifact = Artifact::new(ArtifactKind::RawBytes, RV64_HELLO.to_vec()); + let listing = d + .disassemble(&artifact, &DisasmOpts::default()) + .await + .expect("disassemble"); + // Observed pc = base + 4 lands on the ebreak. + let idx = listing.line_index_for_addr(0x10004).expect("pc must match"); + assert_eq!(listing.lines[idx].addr, 0x10004); + assert!(listing.line_index_for_addr(0xdeadbeef).is_none()); +} + +/// M-extension instructions decode as `mul`/`div`/etc. when the +/// caller declares M is enabled. +#[tokio::test] +async fn mext_decodes_when_extension_enabled() { + let Some(bin) = objdump_binary() else { + eprintln!("llvm-objdump not found; skipping"); + return; + }; + // `mul a0, a1, a2` LE-encoded. + let bytes: &[u8] = &[0x33, 0x85, 0xc5, 0x02]; + let d = LlvmObjdumpDisassembler::new(bin); + let artifact = Artifact::new(ArtifactKind::RawBytes, bytes.to_vec()); + let opts = DisasmOpts { + extensions: vec!["m".into()], + ..DisasmOpts::default() + }; + let listing = d.disassemble(&artifact, &opts).await.expect("disassemble"); + let first = &listing.lines[0]; + assert_eq!( + first.mnemonic, "mul", + "M-ext mul must decode as `mul` when M is enabled (got `{}`)", + first.mnemonic, + ); +} + +/// Strictness contract: the same `mul` bytes must NOT decode when +/// M isn't in the enabled-extensions list. The point of this is +/// that an `` row in the disasm UI is a real signal that +/// codegen emitted something the DUT can't execute, not a +/// disassembler misconfiguration we masked over with a permissive +/// `--mattr` superset. +#[tokio::test] +async fn mext_stays_unknown_when_extension_omitted() { + let Some(bin) = objdump_binary() else { + eprintln!("llvm-objdump not found; skipping"); + return; + }; + let bytes: &[u8] = &[0x33, 0x85, 0xc5, 0x02]; // `mul a0, a1, a2` + let d = LlvmObjdumpDisassembler::new(bin); + let artifact = Artifact::new(ArtifactKind::RawBytes, bytes.to_vec()); + // Empty extensions list -> base I only. + let listing = d + .disassemble(&artifact, &DisasmOpts::default()) + .await + .expect("disassemble"); + let first = &listing.lines[0]; + assert_ne!( + first.mnemonic, "mul", + "mul must NOT decode when M is absent; the strictness is the feature" + ); +} + +#[tokio::test] +async fn missing_binary_surfaces_clean_error() { + let d = LlvmObjdumpDisassembler::new("/this/path/definitely/does/not/exist/llvm-objdump"); + let artifact = Artifact::new(ArtifactKind::RawBytes, RV64_HELLO.to_vec()); + let err = d + .disassemble(&artifact, &DisasmOpts::default()) + .await + .expect_err("missing binary must error, not pretend to succeed"); + assert!( + matches!(err, heimdall_disasm::DisasmError::ObjdumpNotFound { .. }), + "wrong variant: {err:?}", + ); +} diff --git a/crates/heimdall-driver/Cargo.toml b/crates/heimdall-driver/Cargo.toml index 14a98cf..96869f1 100644 --- a/crates/heimdall-driver/Cargo.toml +++ b/crates/heimdall-driver/Cargo.toml @@ -14,7 +14,7 @@ description = "TestDriver trait and concrete drivers (River RISC-V CPU, Aegis FP [features] default = [] -river = ["heimdall-transport/openocd", "heimdall-transport/serial"] +river = ["heimdall-transport/openocd", "heimdall-transport/serial", "dep:elf"] hil = ["river", "heimdall-golden/spike", "heimdall-tools/clang-asm"] aegis = ["heimdall-golden/aegis", "dep:aegis-ip"] @@ -30,8 +30,13 @@ tokio = { workspace = true } tempfile = { workspace = true } aegis-ip = { workspace = true, optional = true } serde_json = { workspace = true } +elf = { workspace = true, optional = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } -heimdall-transport = { workspace = true, features = ["openocd", "bitbang-jtag"] } +heimdall-transport = { workspace = true, features = [ + "openocd", + "bitbang-jtag", +] } heimdall-test.workspace = true +heimdall-mock-openocd.workspace = true diff --git a/crates/heimdall-driver/src/error.rs b/crates/heimdall-driver/src/error.rs index 943fec3..ecf330f 100644 --- a/crates/heimdall-driver/src/error.rs +++ b/crates/heimdall-driver/src/error.rs @@ -14,4 +14,8 @@ pub enum DriverError { IdcodeMismatch { got: u32, expected: u32 }, #[error("driver state error: {0}")] State(&'static str), + /// Parse/protocol-level failure that isn't a transport or tool error. + /// Used for malformed ELF inputs, unparseable OpenOCD replies, etc. + #[error("protocol: {0}")] + Protocol(String), } diff --git a/crates/heimdall-driver/src/lib.rs b/crates/heimdall-driver/src/lib.rs index f7e0d3e..bc709b5 100644 --- a/crates/heimdall-driver/src/lib.rs +++ b/crates/heimdall-driver/src/lib.rs @@ -13,4 +13,4 @@ pub mod aegis; pub use error::DriverError; pub use mock::MockDriver; -pub use trait_def::{Dut, Result, TestDriver}; +pub use trait_def::{Dut, IsaProbe, Result, TestDriver}; diff --git a/crates/heimdall-driver/src/river/debug_module.rs b/crates/heimdall-driver/src/river/debug_module.rs index e7a80d1..57e2608 100644 --- a/crates/heimdall-driver/src/river/debug_module.rs +++ b/crates/heimdall-driver/src/river/debug_module.rs @@ -54,11 +54,99 @@ pub struct DebugModule<'a, T: OpenocdRpc> { pub jtag: &'a mut T, } +/// DMI address of `dmcontrol` per the RISC-V External Debug Spec. +const DMI_DMCONTROL: u32 = 0x10; +/// `dmcontrol.dmactive` bit. Setting this from 0 to 1 reinitialises the +/// debug module without touching the upstream JTAG adapter. +const DMCONTROL_DMACTIVE: u32 = 1 << 0; + +/// DMI address of `data0`, the low half of the abstract-command data +/// register. For 64-bit access-register reads the value lands in +/// `data0:data1` (low:high). +const DMI_DATA0: u32 = 0x04; +/// DMI address of `data1`, the high half of the abstract-command data +/// register on RV64 access-register reads. +const DMI_DATA1: u32 = 0x05; +/// DMI address of `abstractcs`. Used to poll `busy` and read `cmderr`. +const DMI_ABSTRACTCS: u32 = 0x16; +/// DMI address of `command`. Writing here kicks off an abstract command. +const DMI_COMMAND: u32 = 0x17; + +/// `abstractcs.busy` bit. Set while the DM is processing an abstract +/// command; the host must wait for it to clear before reading data +/// registers or issuing the next command. +const ABSTRACTCS_BUSY: u32 = 1 << 12; +/// `abstractcs.cmderr` mask (bits 10:8). Non-zero means the previous +/// command failed; values: 1=busy-on-issue, 2=not-supported, +/// 3=exception, 4=halt/resume, 5=bus, 6=reserved, 7=other. +const ABSTRACTCS_CMDERR_MASK: u32 = 0b111 << 8; +const ABSTRACTCS_CMDERR_SHIFT: u32 = 8; + +/// Access-Register command body for an RV64 register READ (cmdtype=0). +/// Layout (RISC-V Debug Spec 0.13.2 Table 3.6): +/// bits 23:20: aarsize = 3 (8 bytes / 64-bit) +/// bit 19 : aarpostincrement = 0 +/// bit 18 : postexec = 0 +/// bit 17 : transfer = 1 (do the actual access) +/// bit 16 : write = 0 (read) +/// bits 15:0 : regno +fn access_register_read_rv64(regno: u16) -> u32 { + (3u32 << 20) | (1u32 << 17) | regno as u32 +} + +/// Maximum number of `abstractcs` polls while waiting for `busy` to +/// drop. Tuned generously: a healthy DM clears `busy` in well under +/// a millisecond; if it has not cleared after this many polls the +/// target is either wedged or the debug-bus link is broken and we +/// want to surface the timeout rather than spin forever. +const ABSTRACTCS_POLL_LIMIT: u32 = 4096; + +/// Convert a RISC-V architectural GPR index (0..31) to its abstract- +/// command regno per the Debug Spec: GPRs sit at 0x1000..0x101f, so +/// `regno = 0x1000 | reg`. +pub fn gpr_abstract_regno(reg: u8) -> u16 { + 0x1000 | (reg as u16 & 0x1f) +} + +/// CSR regno for `dpc` (Debug PC). Read this when the core is halted +/// in Debug Mode to get the PC at the point of halt; the architectural +/// `pc` register has no abstract-command regno of its own. +pub const CSR_REGNO_DPC: u16 = 0x7b1; + impl<'a, T: OpenocdRpc> DebugModule<'a, T> { pub fn new(jtag: &'a mut T) -> Self { Self { jtag } } + /// Reset the DM by cycling `dmcontrol.dmactive` over DMI. Unlike OpenOCD's + /// `reset init`, this does NOT re-run examine or churn the adapter, so it + /// survives slow remote_bitbang links (River HDL sim) that drop on heavy + /// reset handshakes. + pub async fn dm_reset(&mut self) -> Result<(), TransportError> { + self.jtag + .rpc(&format!("riscv dmi_write 0x{DMI_DMCONTROL:x} 0x0")) + .await?; + self.jtag + .rpc(&format!( + "riscv dmi_write 0x{DMI_DMCONTROL:x} 0x{DMCONTROL_DMACTIVE:x}" + )) + .await?; + let raw = self + .jtag + .rpc(&format!("riscv dmi_read 0x{DMI_DMCONTROL:x}")) + .await?; + let trimmed = raw.trim(); + let value = parse_hex_word(trimmed).ok_or_else(|| { + TransportError::Protocol(format!("dm_reset: unparseable dmcontrol `{trimmed}`")) + })?; + if value & DMCONTROL_DMACTIVE == 0 { + return Err(TransportError::Protocol(format!( + "dm_reset: dmactive never came up (dmcontrol=0x{value:08x})" + ))); + } + Ok(()) + } + pub async fn halt(&mut self) -> Result<(), TransportError> { self.jtag.rpc("halt").await?; Ok(()) @@ -95,6 +183,106 @@ impl<'a, T: OpenocdRpc> DebugModule<'a, T> { Ok(parse_reg_response(&raw)?) } + /// Write a CPU register (GPR or CSR) by name via OpenOCD's `reg` + /// command. Used by [`RiverCpuDriver::run`] to seed `dpc` with the ELF + /// entry before resuming. + pub async fn write_reg(&mut self, name: &str, value: u64) -> Result<(), TransportError> { + // OpenOCD treats the second arg as hex when prefixed with 0x. Keep + // the format consistent so the RPC echo doesn't surprise parsers. + self.jtag.rpc(&format!("reg {name} 0x{value:x}")).await?; + Ok(()) + } + + /// Read a register's 64-bit value via the DM's abstract-command + /// interface, bypassing OpenOCD's register cache. + /// + /// Some OpenOCD builds (verified against nixpkgs openocd-0.12.0, + /// 2026-06-02) don't refresh their register cache when the core + /// self-halts via `ebreak`, so `reg ` returns stale values + /// without ever issuing an access-register command on the wire. + /// Going through DMI directly sidesteps the cache: we write the + /// `command` register, wait for `abstractcs.busy` to clear, check + /// `cmderr`, and read `data0`/`data1` from the DM. The bytes that + /// come back are whatever the target's DM actually holds right + /// now, not whatever OpenOCD remembers. + /// + /// `regno` is the Debug-Spec register number: + /// - GPR x -> [`gpr_abstract_regno`] (0x1000 | i) + /// - CSR -> the CSR address itself (e.g. dpc -> 0x7b1) + /// + /// Caller must ensure the core is halted in Debug Mode before + /// calling. Reading registers from a running core is implementation- + /// defined and on most cores returns `cmderr=halt/resume`. + pub async fn read_register_via_abstract(&mut self, regno: u16) -> Result { + let cmd = access_register_read_rv64(regno); + self.jtag + .rpc(&format!("riscv dmi_write 0x{DMI_COMMAND:x} 0x{cmd:x}")) + .await?; + self.wait_abstract_done(regno).await?; + let lo = self.dmi_read_u32(DMI_DATA0).await?; + let hi = self.dmi_read_u32(DMI_DATA1).await?; + Ok(((hi as u64) << 32) | (lo as u64)) + } + + /// Convenience wrapper around [`Self::read_register_via_abstract`] + /// for GPRs. Indexed by architectural register number (0..31). x0 + /// is hardwired to zero on the architecture but reading it via the + /// abstract command is still well-defined (returns 0); leaving the + /// validity check here so callers get a clear error on out-of- + /// range indices. + pub async fn read_gpr_via_abstract(&mut self, reg: u8) -> Result { + if reg >= 32 { + return Err(TransportError::Protocol(format!( + "invalid RV GPR index {reg} (must be 0..31)" + ))); + } + self.read_register_via_abstract(gpr_abstract_regno(reg)) + .await + } + + /// Read a 32-bit DMI register via OpenOCD's `riscv dmi_read`. The + /// reply format is a bare `0x` token on its own line. Already + /// the foundation under [`Self::dm_reset`]; lifted to a method here + /// because the abstract-command path needs to read `abstractcs`, + /// `data0`, and `data1` repeatedly. + async fn dmi_read_u32(&mut self, dmi_addr: u32) -> Result { + let raw = self + .jtag + .rpc(&format!("riscv dmi_read 0x{dmi_addr:x}")) + .await?; + parse_hex_word(raw.trim()).ok_or_else(|| { + TransportError::Protocol(format!( + "dmi_read 0x{dmi_addr:x}: unparseable response `{raw}`" + )) + }) + } + + /// Poll `abstractcs` until `busy=0`, then surface a clean error if + /// `cmderr` is non-zero. Caller passes the regno that was being + /// accessed for diagnostics. The poll budget is a fixed count + /// rather than a wall-clock duration so unit tests don't depend on + /// timer behavior; on healthy hardware `busy` drops in the first + /// poll. + async fn wait_abstract_done(&mut self, regno: u16) -> Result<(), TransportError> { + for _ in 0..ABSTRACTCS_POLL_LIMIT { + let cs = self.dmi_read_u32(DMI_ABSTRACTCS).await?; + if cs & ABSTRACTCS_BUSY == 0 { + let cmderr = (cs & ABSTRACTCS_CMDERR_MASK) >> ABSTRACTCS_CMDERR_SHIFT; + if cmderr != 0 { + return Err(TransportError::Protocol(format!( + "abstractcs.cmderr=0x{cmderr:x} reading regno=0x{regno:x} \ + (abstractcs=0x{cs:08x})" + ))); + } + return Ok(()); + } + } + Err(TransportError::Protocol(format!( + "abstractcs.busy never cleared in {ABSTRACTCS_POLL_LIMIT} polls \ + reading regno=0x{regno:x}" + ))) + } + pub async fn write_mem(&mut self, addr: u64, bytes: &[u8]) -> Result { let mut file = NamedTempFile::new()?; use std::io::Write as _; @@ -105,3 +293,12 @@ impl<'a, T: OpenocdRpc> DebugModule<'a, T> { Ok(parse_load_image_response(&raw)?) } } + +/// Parse a 32-bit value out of OpenOCD's `riscv dmi_read` reply. Reply is +/// usually a bare `0x12345678`, but tolerate trailing whitespace and the rare +/// `0x...` prefix on its own line. +fn parse_hex_word(s: &str) -> Option { + let token = s.split_whitespace().next()?; + let stripped = token.strip_prefix("0x").unwrap_or(token); + u32::from_str_radix(stripped, 16).ok() +} diff --git a/crates/heimdall-driver/src/river/diff.rs b/crates/heimdall-driver/src/river/diff.rs index 4805cf5..86f084f 100644 --- a/crates/heimdall-driver/src/river/diff.rs +++ b/crates/heimdall-driver/src/river/diff.rs @@ -1,10 +1,34 @@ use heimdall_core::{Evidence, FailureKind, State, Verdict}; +/// State keys that the architectural diff intentionally ignores. These are +/// observed and recorded on both DUT and golden snapshots for debugging, +/// but they're not comparable as a verdict input because the two sides +/// stop at different points and the value is fundamentally "where am I +/// right now" rather than "what did the program compute". +/// +/// `pc` belongs here because: +/// - DUT free-runs and is force-halted by JTAG `wait_halt` at an arbitrary +/// instruction boundary. +/// - Spike runs exactly `max_cycles` and stops. +/// - Random fuzz programs without a clean ebreak halt at different points +/// in the trap path, so PCs reliably disagree even when GPR results +/// match. Comparing pc here would mask every real GPR-level divergence +/// behind a noise-floor pc-mismatch. +/// +/// Tests that need a deterministic stopping point (e.g. bring-up firmware +/// with a known ebreak) can still inspect `dut_state.fields.get("pc")` +/// outside the diff. +const DIFF_IGNORED_KEYS: &[&str] = &["pc"]; + /// Architectural-state diff. Considers every key in `golden` that is also -/// present in `dut`. Keys only in dut are not checked (they may be auxiliary -/// info the golden does not model). +/// present in `dut`, except keys in [`DIFF_IGNORED_KEYS`]. Keys only in +/// dut are not checked (they may be auxiliary info the golden does not +/// model). pub fn diff_states(dut: &State, golden: &State) -> Verdict { for (k, expected) in &golden.fields { + if DIFF_IGNORED_KEYS.contains(&k.as_str()) { + continue; + } match dut.fields.get(k) { Some(got) if got == expected => continue, Some(got) => { @@ -22,9 +46,8 @@ pub fn diff_states(dut: &State, golden: &State) -> Verdict { } None => { return Verdict::Fail { - kind: FailureKind::DiffMismatch { + kind: FailureKind::MissingField { field: k.clone(), - got: heimdall_core::ValueRepr::Bool(false), expected: expected.clone(), }, evidence: vec![Evidence { @@ -45,16 +68,48 @@ mod tests { #[test] fn match_passes() { - let s = State::new().with("a0", ValueRepr::U64(1)); + let s = State::new().with("x10", ValueRepr::U64(1)); let v = diff_states(&s, &s); assert!(matches!(v, Verdict::Pass)); } #[test] fn mismatch_reports_field() { - let dut = State::new().with("a0", ValueRepr::U64(1)); - let golden = State::new().with("a0", ValueRepr::U64(2)); + let dut = State::new().with("x10", ValueRepr::U64(1)); + let golden = State::new().with("x10", ValueRepr::U64(2)); let v = diff_states(&dut, &golden); assert!(matches!(v, Verdict::Fail { .. })); } + + #[test] + fn pc_mismatch_does_not_fail() { + // The DUT halted at the entry PC, spike halted somewhere else in + // its trap path. GPRs match; the verdict must be Pass. + let dut = State::new() + .with("x10", ValueRepr::U64(0x42)) + .with("pc", ValueRepr::U64(0x10000)); + let golden = State::new() + .with("x10", ValueRepr::U64(0x42)) + .with("pc", ValueRepr::U64(0x0)); + let v = diff_states(&dut, &golden); + assert!(matches!(v, Verdict::Pass), "got {v:?}"); + } + + #[test] + fn gpr_mismatch_still_fails_even_if_pc_disagrees() { + // pc skipped, but x11 mismatch survives the diff. + let dut = State::new() + .with("x11", ValueRepr::U64(0x1)) + .with("pc", ValueRepr::U64(0x10000)); + let golden = State::new() + .with("x11", ValueRepr::U64(0x2)) + .with("pc", ValueRepr::U64(0x0)); + match diff_states(&dut, &golden) { + Verdict::Fail { + kind: FailureKind::DiffMismatch { field, .. }, + .. + } => assert_eq!(field, "x11"), + other => panic!("expected fail on x11, got {other:?}"), + } + } } diff --git a/crates/heimdall-driver/src/river/elf_loader.rs b/crates/heimdall-driver/src/river/elf_loader.rs new file mode 100644 index 0000000..51a6cfc --- /dev/null +++ b/crates/heimdall-driver/src/river/elf_loader.rs @@ -0,0 +1,172 @@ +//! Parse a precompiled RV ELF into the segments that should be written to +//! the DUT plus the entry-point PC. Used by `RiverCpuDriver::load` to drop +//! firmware at the right physical addresses and by `run` to seed `dpc` +//! before the resume. +//! +//! Bare-metal RV: linker scripts set LMA == VMA, so the `p_paddr` field is +//! the address the bytes must end up at. Segments with `p_filesz < p_memsz` +//! have a BSS tail that the loader is responsible for zero-filling. + +use elf::ElfBytes; +use elf::abi::PT_LOAD; +use elf::endian::AnyEndian; + +use crate::error::DriverError; + +/// One PT_LOAD segment ready to be written to the DUT. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoadableSegment { + /// Physical load address (`p_paddr`). Bytes go here. + pub paddr: u64, + /// Concrete bytes copied out of the ELF (`p_filesz` worth). Caller is + /// expected to write these verbatim to `paddr`. + pub bytes: Vec, + /// Number of trailing zero bytes the loader must materialise after + /// `bytes` to satisfy `p_memsz`. Skipped for segments with no BSS. + pub zero_tail: u64, +} + +/// Parsed view of an RV ELF: PT_LOAD segments plus the entry PC. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedElf { + pub entry: u64, + pub segments: Vec, +} + +/// Parse `bytes` as a RISC-V ELF and pull out every PT_LOAD segment. +/// Empty `p_filesz` segments are dropped, but their `p_memsz` tail still +/// shows up as `zero_tail` so a caller-side `write_mem` (or skip) can be +/// driven without a second pass over the ELF. +pub fn parse(bytes: &[u8]) -> Result { + let file = ElfBytes::::minimal_parse(bytes) + .map_err(|e| DriverError::Protocol(format!("river boot-elf parse: {e}")))?; + let phdrs = file + .segments() + .ok_or_else(|| DriverError::Protocol("river boot-elf has no program headers".into()))?; + let mut segments = Vec::new(); + for ph in phdrs { + if ph.p_type != PT_LOAD { + continue; + } + let file_sz = ph.p_filesz as usize; + let off = ph.p_offset as usize; + let end = off.checked_add(file_sz).ok_or_else(|| { + DriverError::Protocol("river boot-elf: segment offset overflow".into()) + })?; + if end > bytes.len() { + return Err(DriverError::Protocol(format!( + "river boot-elf: segment offset {off}+{file_sz} exceeds file size {}", + bytes.len() + ))); + } + let data = bytes[off..end].to_vec(); + let zero_tail = ph.p_memsz.saturating_sub(ph.p_filesz); + // Skip purely-empty headers (no bytes AND no bss). PT_LOAD with + // `memsz==filesz==0` shows up in some toolchain outputs and is a + // no-op. + if data.is_empty() && zero_tail == 0 { + continue; + } + segments.push(LoadableSegment { + paddr: ph.p_paddr, + bytes: data, + zero_tail, + }); + } + if segments.is_empty() { + return Err(DriverError::Protocol( + "river boot-elf has no loadable PT_LOAD segments".into(), + )); + } + Ok(ParsedElf { + entry: file.ehdr.e_entry, + segments, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Hand-build a minimal 64-bit little-endian RV ELF with one PT_LOAD + /// segment: 4 bytes of code at paddr=vaddr=0x10000, entry=0x100b0. + /// Used by both the parser unit tests and the integration tests so the + /// shape stays in one place. + pub(super) fn build_minimal_rv64_elf() -> Vec { + // ELF64 header is 0x40 bytes, one program header is 0x38 bytes. + // We put 4 bytes of payload right after the program header. + let mut buf = vec![0u8; 0x40 + 0x38 + 4]; + // e_ident + buf[0..4].copy_from_slice(b"\x7fELF"); + buf[4] = 2; // EI_CLASS = ELFCLASS64 + buf[5] = 1; // EI_DATA = ELFDATA2LSB + buf[6] = 1; // EI_VERSION = 1 + // e_type = ET_EXEC (2), e_machine = EM_RISCV (243) + buf[16..18].copy_from_slice(&2u16.to_le_bytes()); + buf[18..20].copy_from_slice(&243u16.to_le_bytes()); + // e_version + buf[20..24].copy_from_slice(&1u32.to_le_bytes()); + // e_entry = 0x100b0 + buf[24..32].copy_from_slice(&0x100b0u64.to_le_bytes()); + // e_phoff = 0x40 + buf[32..40].copy_from_slice(&0x40u64.to_le_bytes()); + // e_shoff = 0 (no sections) + // e_flags = 0 + // e_ehsize = 0x40 + buf[52..54].copy_from_slice(&0x40u16.to_le_bytes()); + // e_phentsize = 0x38, e_phnum = 1 + buf[54..56].copy_from_slice(&0x38u16.to_le_bytes()); + buf[56..58].copy_from_slice(&1u16.to_le_bytes()); + + // Program header at offset 0x40. + let ph = 0x40; + // p_type = PT_LOAD (1) + buf[ph..ph + 4].copy_from_slice(&1u32.to_le_bytes()); + // p_flags = R+X (5) + buf[ph + 4..ph + 8].copy_from_slice(&5u32.to_le_bytes()); + // p_offset = 0x78 (right after the program header) + buf[ph + 8..ph + 16].copy_from_slice(&0x78u64.to_le_bytes()); + // p_vaddr = 0x10000, p_paddr = 0x10000 + buf[ph + 16..ph + 24].copy_from_slice(&0x10000u64.to_le_bytes()); + buf[ph + 24..ph + 32].copy_from_slice(&0x10000u64.to_le_bytes()); + // p_filesz = 4, p_memsz = 4 (no bss) + buf[ph + 32..ph + 40].copy_from_slice(&4u64.to_le_bytes()); + buf[ph + 40..ph + 48].copy_from_slice(&4u64.to_le_bytes()); + // p_align = 0x1000 + buf[ph + 48..ph + 56].copy_from_slice(&0x1000u64.to_le_bytes()); + + // Payload: 4 magic bytes the test asserts on. + buf[0x78..0x7c].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); + buf + } + + #[test] + fn parses_minimal_rv64_elf() { + let bytes = build_minimal_rv64_elf(); + let parsed = parse(&bytes).expect("parse"); + assert_eq!(parsed.entry, 0x100b0); + assert_eq!(parsed.segments.len(), 1); + let seg = &parsed.segments[0]; + assert_eq!(seg.paddr, 0x10000); + assert_eq!(seg.bytes, vec![0xAA, 0xBB, 0xCC, 0xDD]); + assert_eq!(seg.zero_tail, 0); + } + + #[test] + fn rejects_non_elf_bytes() { + let err = parse(b"not an elf").unwrap_err(); + assert!(matches!(err, DriverError::Protocol(_))); + } + + #[test] + fn segment_with_bss_tail_reports_zero_tail() { + // Take the minimal ELF and bump the memsz so memsz > filesz. + let mut bytes = build_minimal_rv64_elf(); + let ph = 0x40; + // p_memsz = 16 (12 bytes of BSS after the 4 bytes of code). + bytes[ph + 40..ph + 48].copy_from_slice(&16u64.to_le_bytes()); + let parsed = parse(&bytes).expect("parse"); + assert_eq!(parsed.segments[0].bytes.len(), 4); + assert_eq!(parsed.segments[0].zero_tail, 12); + } +} diff --git a/crates/heimdall-driver/src/river/mod.rs b/crates/heimdall-driver/src/river/mod.rs index 9e0088e..3bfa61e 100644 --- a/crates/heimdall-driver/src/river/mod.rs +++ b/crates/heimdall-driver/src/river/mod.rs @@ -11,20 +11,26 @@ use crate::trait_def::{Dut, Result, TestDriver}; pub mod coverage; pub mod debug_module; pub mod diff; +pub mod elf_loader; pub mod observe; +/// Default wall-clock ceiling for a wait-halt. Tuned for real silicon. Slow +/// HDL simulations override per-DUT via [`RiverCpuDriver::with_wait_halt_max`]. +const DEFAULT_WAIT_HALT_MAX: Duration = Duration::from_secs(30); + +/// Floor for a wait-halt. Even a cycles=0 stimulus must wait long enough for +/// OpenOCD's poll loop to spot a halt. +const WAIT_HALT_MIN: Duration = Duration::from_secs(1); + /// Convert a stimulus budget into a wait-halt timeout. Cycles are treated as -/// milliseconds with a sane floor and ceiling because we don't yet have a real -/// cycles-to-wall-clock model for River silicon. Once that lands this becomes -/// a per-DUT calibration. -fn budget_to_wait_timeout(budget: heimdall_core::StepBudget) -> Duration { - const MIN: Duration = Duration::from_secs(1); - const MAX: Duration = Duration::from_secs(30); +/// milliseconds because we do not yet have a cycles-to-wall-clock model for +/// River silicon. The clamp limits a runaway budget. +fn budget_to_wait_timeout(budget: heimdall_core::StepBudget, max: Duration) -> Duration { let d = match budget { heimdall_core::StepBudget::Cycles { count } => Duration::from_millis(count), heimdall_core::StepBudget::Duration { millis } => Duration::from_millis(millis), }; - d.clamp(MIN, MAX) + d.clamp(WAIT_HALT_MIN, max) } pub struct RiverCpuDriver @@ -35,7 +41,12 @@ where pub jtag: T, pub uart: Option>, pub expect_idcode: Option, + wait_halt_max: Duration, last_silicon_coverage: Option, + /// ELF entry recorded by the most recent `load`. Used by `run` to seed + /// `dpc` before resume so the core actually executes the firmware + /// instead of whatever `dpc` held after the DM reset. + pending_entry: Option, } impl RiverCpuDriver @@ -48,7 +59,9 @@ where jtag, uart: None, expect_idcode: None, + wait_halt_max: DEFAULT_WAIT_HALT_MAX, last_silicon_coverage: None, + pending_entry: None, } } @@ -56,6 +69,13 @@ where self.expect_idcode = Some(idcode); self } + + /// Set the wall-clock ceiling on per-stimulus wait-halt. Slow HDL sim + /// DUTs need >=120s. Real silicon stays at the default. + pub fn with_wait_halt_max(mut self, d: Duration) -> Self { + self.wait_halt_max = d; + self + } } #[async_trait] @@ -74,9 +94,10 @@ where #[instrument(skip(self, _dut))] async fn prepare(&mut self, _dut: &mut Dut) -> Result<()> { self.jtag.open().await?; - self.jtag - .reset(heimdall_transport::ResetTarget::DebugModule) - .await?; + { + let mut dm = debug_module::DebugModule::new(&mut self.jtag); + dm.dm_reset().await?; + } let chain = self.jtag.scan_idcode().await?; if let (Some(expected), Some(got)) = (self.expect_idcode, chain.first().copied()) { if got != expected { @@ -103,20 +124,60 @@ where #[instrument(skip(self, _dut, image))] async fn load(&mut self, _dut: &mut Dut, image: &Artifact) -> Result<()> { + // Parse the ELF and walk every PT_LOAD segment to its physical + // load address. The previous implementation wrote the raw ELF + // file bytes to a hardcoded 0x80000000, so the bring-up firmware + // never sat at its linked address and the core ran through empty + // memory at resume. + let parsed = elf_loader::parse(&image.bytes)?; let mut dm = debug_module::DebugModule::new(&mut self.jtag); dm.halt().await?; - dm.write_mem(0x8000_0000, &image.bytes).await?; + for seg in &parsed.segments { + if !seg.bytes.is_empty() { + dm.write_mem(seg.paddr, &seg.bytes).await?; + } + // BSS tail: write zeros so the core sees a clean region. Cheap + // in absolute bytes since bring-up firmware is tiny. + if seg.zero_tail > 0 { + let zeros = vec![0u8; seg.zero_tail as usize]; + let tail_addr = seg.paddr + seg.bytes.len() as u64; + dm.write_mem(tail_addr, &zeros).await?; + } + } + self.pending_entry = Some(parsed.entry); Ok(()) } async fn run(&mut self, _dut: &mut Dut, stim: &Stimulus) -> Result { - let timeout = budget_to_wait_timeout(stim.budget); + let timeout = budget_to_wait_timeout(stim.budget, self.wait_halt_max); let started = std::time::Instant::now(); + let entry = self.pending_entry.ok_or(DriverError::State( + "river run() called before load(); pending_entry unset", + ))?; { let mut dm = debug_module::DebugModule::new(&mut self.jtag); + // Seed dpc so resume jumps to the ELF entry instead of running + // from whatever pc the DM reset left behind (~0, parks at 0x388 + // running through empty memory). + dm.write_reg("dpc", entry).await?; dm.resume().await?; - dm.wait_halt(timeout).await?; + if let Err(e) = dm.wait_halt(timeout).await { + // Program never halted within the budget. Force-halt the + // CPU so the next iteration's load+resume isn't fighting + // a still-running program (and rbb sockets don't get + // wedged by a runaway). A halt failure here is logged + // and swallowed. The original wait_halt error is the + // one the caller needs to see. + if let Err(halt_err) = dm.halt().await { + tracing::warn!( + error = %halt_err, + "force-halt after wait_halt timeout failed; \ + subsequent iterations may be wedged" + ); + } + return Err(e.into()); + } } let state = observe::snapshot_xregs_pc(&mut self.jtag).await?; @@ -147,4 +208,101 @@ where self.jtag.close().await?; Ok(()) } + + async fn probe_isa(&mut self) -> Result> { + // Halt the core so the abstract-command read of CSR misa is + // well-defined. The defensive halt + short wait_halt mirror + // `observe::snapshot_xregs_pc`'s prelude on broken openocd + // builds (the same 0.12.0 cache-staleness issue). Probing + // from a running core is implementation-defined and usually + // returns cmderr=halt/resume. + let mut dm = debug_module::DebugModule::new(&mut self.jtag); + dm.halt().await.map_err(DriverError::from)?; + dm.wait_halt(Duration::from_millis(500)) + .await + .map_err(DriverError::from)?; + // CSR 0x301 = misa. The abstract command returns the full + // 64-bit XLEN-extended value on RV64 and the low 32 bits on + // RV32. The MXL field tells us which. + const CSR_MISA: u16 = 0x301; + let raw = dm + .read_register_via_abstract(CSR_MISA) + .await + .map_err(DriverError::from)?; + Ok(Some(parse_misa(raw))) + } +} + +/// Decode CSR `misa` (RISC-V Privileged Spec). Layout: +/// - `MXL` (bits XLEN-1:XLEN-2): 1=RV32, 2=RV64, 3=RV128. +/// - Bit 0..25: extension bitmap, bit i set means extension `'a' + i` +/// is implemented. +/// +/// We read a 64-bit value over the wire (the DMI abstract-command +/// path) regardless of actual XLEN. On RV64 cores MXL is at bits +/// 62:63. On RV32 cores the upper bits are typically zero and MXL +/// sits at bits 30:31 of the low 32-bit half. We probe both +/// positions and prefer whichever encodes a known value. +pub fn parse_misa(value: u64) -> crate::trait_def::IsaProbe { + let low32 = value as u32; + let mxl_rv64 = ((value >> 62) & 0x3) as u8; + let mxl_rv32 = ((low32 >> 30) & 0x3) as u8; + let xlen = if mxl_rv64 == 2 { + 64 + } else if mxl_rv32 == 1 { + 32 + } else { + // Fall back to RV32 when neither MXL field decodes cleanly. + // RV32 is the safer default since RV32I is the universal base. + 32 + }; + let mut extensions = Vec::new(); + for i in 0..26u32 { + if (low32 & (1u32 << i)) != 0 { + let letter = (b'a' + i as u8) as char; + extensions.push(letter.to_string()); + } + } + crate::trait_def::IsaProbe { xlen, extensions } +} + +#[cfg(test)] +mod misa_tests { + use super::*; + + #[test] + fn parse_misa_rv64imac() { + // MXL=2 at bits 62:63 (RV64), extensions I+M+A+C. + let i = 1u64 << 8; + let m = 1u64 << 12; + let a = 1u64 << 0; + let c = 1u64 << 2; + let mxl = 2u64 << 62; + let raw = mxl | i | m | a | c; + let probe = parse_misa(raw); + assert_eq!(probe.xlen, 64); + assert!(probe.extensions.contains(&"i".to_string())); + assert!(probe.extensions.contains(&"m".to_string())); + assert!(probe.extensions.contains(&"a".to_string())); + assert!(probe.extensions.contains(&"c".to_string())); + } + + #[test] + fn parse_misa_rv32i_minimal() { + // MXL=1 at bits 30:31 (RV32), only I set. + let mxl: u32 = 1 << 30; + let i: u32 = 1 << 8; + let raw = (mxl | i) as u64; + let probe = parse_misa(raw); + assert_eq!(probe.xlen, 32); + assert_eq!(probe.extensions, vec!["i".to_string()]); + } + + #[test] + fn parse_misa_unknown_mxl_defaults_to_rv32() { + // Neither bit position holds a recognised MXL value. + let probe = parse_misa(0); + assert_eq!(probe.xlen, 32); + assert!(probe.extensions.is_empty()); + } } diff --git a/crates/heimdall-driver/src/river/observe.rs b/crates/heimdall-driver/src/river/observe.rs index 43b1c87..aaf1696 100644 --- a/crates/heimdall-driver/src/river/observe.rs +++ b/crates/heimdall-driver/src/river/observe.rs @@ -3,22 +3,44 @@ use heimdall_core::{State, ValueRepr}; use heimdall_transport::openocd::OpenocdRpc; -use super::debug_module::DebugModule; +use super::debug_module::{CSR_REGNO_DPC, DebugModule}; /// Read x1..x31 and PC into a State snapshot. Call only when the CPU is -/// halted. +/// halted. GPRs are stored under HARDWARE names (`x1`..`x31`), the +/// canonical Heimdall convention. Frontends translate to ABI names +/// (`ra`, `sp`, `fp`, `a0`, ...) at render time via the `gpr_abi_name` +/// helper on `DebugModule`. Every register is recorded, including +/// zero-valued ones, so a "missing" key in the diff genuinely means +/// the observe never completed rather than "register happened to be +/// zero". +/// +/// **DMI-bypass reads.** The observe path used to go through OpenOCD's +/// `reg ` command, which is mediated by OpenOCD's register cache. +/// Some OpenOCD builds (verified against nixpkgs openocd-0.12.0, +/// 2026-06-02) don't invalidate that cache on a target's self-halt via +/// `ebreak`: `reg ` returns the value the cache last sampled and +/// never issues an access-register command on the wire. To stay +/// independent of that quirk, every read here goes through +/// [`DebugModule::read_register_via_abstract`], which writes the DM's +/// `command` register, polls `abstractcs.busy`, checks `cmderr`, and +/// reads `data0`/`data1` directly. The bytes are whatever the DM +/// holds *now*, regardless of OpenOCD's cache state. PC comes from the +/// `dpc` CSR (0x7b1) because the architectural PC has no abstract- +/// command regno of its own. It's only well-defined to read while the +/// core is halted in Debug Mode, which `run()` already guarantees. +/// Failures from the bypass propagate (not swallowed) since observe +/// builds the verdict input. Silently masking a transport-level error +/// here would taint the diff and downstream coverage/divergence work. pub async fn snapshot_xregs_pc( jtag: &mut T, ) -> Result { let mut dm = DebugModule::new(jtag); let mut state = State::new(); for i in 1..32u8 { - let v = dm.read_gpr(i).await?; - if v != 0 { - state = state.with(format!("x{i}"), ValueRepr::U64(v)); - } + let v = dm.read_gpr_via_abstract(i).await?; + state = state.with(format!("x{i}"), ValueRepr::U64(v)); } - let pc = dm.read_csr("pc").await?; + let pc = dm.read_register_via_abstract(CSR_REGNO_DPC).await?; state = state.with("pc", ValueRepr::U64(pc)); Ok(state) } diff --git a/crates/heimdall-driver/src/trait_def.rs b/crates/heimdall-driver/src/trait_def.rs index 9313e5b..ba18d10 100644 --- a/crates/heimdall-driver/src/trait_def.rs +++ b/crates/heimdall-driver/src/trait_def.rs @@ -6,9 +6,23 @@ use crate::error::DriverError; pub type Result = std::result::Result; +/// Result of a JTAG-side ISA probe (e.g. reading CSR misa via DMI). +/// Strings here are canonical extension identifiers: single misa +/// letters (`"i"`, `"m"`, ...) or Z-style names (`"zicsr"`, +/// `"zifencei"`) so downstream callers (the daemon's fuzz factory) +/// don't need to depend on the fuzzer's `RvExtension` enum to +/// consume probe results. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IsaProbe { + /// XLEN in bits (32 or 64). Derived from `misa.MXL`. + pub xlen: u8, + /// Extension identifiers as misa letters or Z-style names. + pub extensions: Vec, +} + /// A Dut is a handle to a physical DUT plus the transports the driver /// has been handed for it. Concrete transport types live behind dyn pointers -/// in the driver impl; this struct is just identity + metadata. +/// in the driver impl. This struct is just identity + metadata. pub struct Dut { pub id: DutId, pub kind: DutKind, @@ -38,9 +52,74 @@ pub trait TestDriver: Send + Sync { async fn release(&mut self, dut: &mut Dut) -> Result<()>; /// Silicon-side coverage from the most recent run/observe. Default - /// returns None; drivers that can extract a coverage signal (e.g., PC + /// returns None. Drivers that can extract a coverage signal (e.g., PC /// trace via debug module) override this. fn coverage(&self) -> Option<&dyn heimdall_golden::CoverageSource> { None } + + /// Probe the DUT's architectural ISA capabilities. For RISC-V + /// drivers this typically reads the `misa` CSR via the debug + /// module's abstract-command interface, decodes `MXL` for XLEN + /// and the lower 26 bits as the extension bitmap, and returns + /// `Some(IsaProbe)`. Implementations that can't determine the + /// ISA (mock drivers, non-CPU drivers, transports that can't + /// reach a CSR) return `Ok(None)`. The default returns `Ok(None)` + /// so existing impls don't need to change. + /// + /// The caller is responsible for ensuring the driver is in a + /// state where the probe can execute (transport open, debug + /// module reachable). In practice that's "after `prepare()` has + /// run at least once." + async fn probe_isa(&mut self) -> Result> { + Ok(None) + } +} + +/// Blanket forward so `Box` satisfies `TestDriver` itself. +/// The daemon hands the worker a boxed driver out of the factory, and the +/// fuzzer engine wants its `D: TestDriver` bound to hold on that boxed +/// value. Without this impl every caller would have to deref the box +/// manually or the engine would have to keep dedicated trait-object +/// fields. Cheap forwarding through `(**self)` lets one engine type cover +/// both concrete-driver and daemon-dispatched call paths. +#[async_trait] +impl TestDriver for Box { + fn target(&self) -> DutKind { + (**self).target() + } + fn required_transports(&self) -> &[TransportKind] { + (**self).required_transports() + } + async fn prepare(&mut self, dut: &mut Dut) -> Result<()> { + (**self).prepare(dut).await + } + async fn compile( + &mut self, + input: &Artifact, + tools: &heimdall_tools::ToolChain, + ) -> Result { + (**self).compile(input, tools).await + } + async fn load(&mut self, dut: &mut Dut, image: &Artifact) -> Result<()> { + (**self).load(dut, image).await + } + async fn run(&mut self, dut: &mut Dut, stimulus: &Stimulus) -> Result { + (**self).run(dut, stimulus).await + } + async fn observe(&mut self, dut: &mut Dut) -> Result { + (**self).observe(dut).await + } + async fn diff(&self, dut_state: &State, golden_state: &State) -> Verdict { + (**self).diff(dut_state, golden_state).await + } + async fn release(&mut self, dut: &mut Dut) -> Result<()> { + (**self).release(dut).await + } + fn coverage(&self) -> Option<&dyn heimdall_golden::CoverageSource> { + (**self).coverage() + } + async fn probe_isa(&mut self) -> Result> { + (**self).probe_isa().await + } } diff --git a/crates/heimdall-driver/tests/common/mod.rs b/crates/heimdall-driver/tests/common/mod.rs deleted file mode 100644 index 5965a8e..0000000 --- a/crates/heimdall-driver/tests/common/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Shared test helpers for heimdall-driver integration tests. - -pub mod mock_openocd; diff --git a/crates/heimdall-driver/tests/mock_openocd_smoke.rs b/crates/heimdall-driver/tests/mock_openocd_smoke.rs index 5760775..2f210b7 100644 --- a/crates/heimdall-driver/tests/mock_openocd_smoke.rs +++ b/crates/heimdall-driver/tests/mock_openocd_smoke.rs @@ -2,9 +2,7 @@ #![cfg(feature = "river")] -mod common; - -use common::mock_openocd::MockOpenOcdServer; +use heimdall_mock_openocd::MockOpenOcdServer; use heimdall_transport::Transport; use heimdall_transport::openocd::OpenOcdJtagTransport; diff --git a/crates/heimdall-driver/tests/river_boot_elf.rs b/crates/heimdall-driver/tests/river_boot_elf.rs new file mode 100644 index 0000000..9df0b9c --- /dev/null +++ b/crates/heimdall-driver/tests/river_boot_elf.rs @@ -0,0 +1,228 @@ +//! Integration test: RiverCpuDriver::load parses an ELF and writes each +//! PT_LOAD segment to its physical address; ::run seeds dpc with the entry +//! before resuming. Verified against MockOpenOcdServer by inspecting the +//! commands the driver issued. + +#![cfg(feature = "river")] + +use std::time::Duration; + +use heimdall_core::{Artifact, ArtifactKind, DutId, DutKind, StepBudget, Stimulus}; +use heimdall_driver::river::RiverCpuDriver; +use heimdall_driver::{Dut, TestDriver}; +use heimdall_mock_openocd::MockOpenOcdServer; +use heimdall_transport::openocd::OpenOcdJtagTransport; + +/// Construct a minimal RV64 ELF with one PT_LOAD segment of 4 bytes at +/// paddr=0x10000, entry=0x100b0. Mirrors the hand-builder in the elf_loader +/// unit tests so the wire-shape stays consistent between layers. +fn rv64_elf() -> Vec { + let mut buf = vec![0u8; 0x40 + 0x38 + 4]; + buf[0..4].copy_from_slice(b"\x7fELF"); + buf[4] = 2; + buf[5] = 1; + buf[6] = 1; + buf[16..18].copy_from_slice(&2u16.to_le_bytes()); + buf[18..20].copy_from_slice(&243u16.to_le_bytes()); + buf[20..24].copy_from_slice(&1u32.to_le_bytes()); + buf[24..32].copy_from_slice(&0x100b0u64.to_le_bytes()); + buf[32..40].copy_from_slice(&0x40u64.to_le_bytes()); + buf[52..54].copy_from_slice(&0x40u16.to_le_bytes()); + buf[54..56].copy_from_slice(&0x38u16.to_le_bytes()); + buf[56..58].copy_from_slice(&1u16.to_le_bytes()); + + let ph = 0x40; + buf[ph..ph + 4].copy_from_slice(&1u32.to_le_bytes()); + buf[ph + 4..ph + 8].copy_from_slice(&5u32.to_le_bytes()); + buf[ph + 8..ph + 16].copy_from_slice(&0x78u64.to_le_bytes()); + buf[ph + 16..ph + 24].copy_from_slice(&0x10000u64.to_le_bytes()); + buf[ph + 24..ph + 32].copy_from_slice(&0x10000u64.to_le_bytes()); + buf[ph + 32..ph + 40].copy_from_slice(&4u64.to_le_bytes()); + buf[ph + 40..ph + 48].copy_from_slice(&4u64.to_le_bytes()); + buf[ph + 48..ph + 56].copy_from_slice(&0x1000u64.to_le_bytes()); + buf[0x78..0x7c].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); + buf +} + +#[tokio::test] +async fn load_writes_segment_to_paddr_and_run_seeds_dpc() { + let server = MockOpenOcdServer::new() + .respond("halt", "") + .respond("resume", "") + .respond("wait_halt 1000", "") + .respond("reg dpc 0x100b0", "") + .respond_prefix("load_image", "loaded 4 bytes in 0.000s (4 KiB/s)") + // DMI-bypass observe reads: abstractcs (poll), data0 (lo), data1 (hi). + // abstractcs=0 -> busy=0, cmderr=0 on first poll. data0/data1=0 -> regs + // all read 0. The command write at 0x17 can be any value; mock falls + // back to empty for unmatched `riscv dmi_write 0x17 ...` commands. + .respond("riscv dmi_read 0x16", "0x00000000") + .respond("riscv dmi_read 0x4", "0x00000000") + .respond("riscv dmi_read 0x5", "0x00000000") + .start() + .await; + + let mut transport = OpenOcdJtagTransport::new(server.addr()); + use heimdall_transport::Transport; + transport.open().await.expect("connect"); + + let mut driver = RiverCpuDriver::new(DutKind::RiverRc1Nano, transport) + .with_wait_halt_max(Duration::from_secs(1)); + let mut dut = Dut::new(DutId::new("river-1"), DutKind::RiverRc1Nano); + + let image = Artifact::new(ArtifactKind::ElfRiscv, rv64_elf()); + driver.load(&mut dut, &image).await.expect("load"); + driver + .run(&mut dut, &Stimulus::new(StepBudget::cycles(1000))) + .await + .expect("run"); + + let received = server.received().await; + + // load_image issued at the PT_LOAD's paddr (0x10000), not the legacy + // hardcoded 0x80000000. + let load_cmd = received + .iter() + .find(|c| c.starts_with("load_image")) + .expect("load_image command must be issued"); + assert!( + load_cmd.contains(" 0x10000 bin"), + "segment must be written at p_paddr=0x10000; got: {load_cmd}" + ); + assert!( + !load_cmd.contains("0x80000000"), + "load must NOT use the old hardcoded 0x80000000; got: {load_cmd}" + ); + + // dpc seeded with the ELF entry (0x100b0) BEFORE resume. + let dpc_idx = received + .iter() + .position(|c| c == "reg dpc 0x100b0") + .expect("dpc must be written"); + let resume_idx = received + .iter() + .position(|c| c == "resume") + .expect("resume must be issued"); + assert!( + dpc_idx < resume_idx, + "dpc must be set BEFORE resume; got dpc@{dpc_idx} resume@{resume_idx}" + ); + + server.shutdown().await; +} + +/// Regression for the openocd 0.12.0 stale-register-cache surprise: +/// observe must read registers via DMI abstract commands (`riscv +/// dmi_write 0x17 ` followed by `riscv dmi_read +/// 0x16/0x04/0x05`) rather than the cache-mediated `reg `. +/// OpenOCD 0.12.0 doesn't invalidate its reg cache on the core's +/// self-halt via ebreak, so `reg ` would return stale zeros +/// without ever issuing an access-register on the wire. Pinning the +/// DMI ordering here so future refactors don't quietly fall back to +/// `reg `. +#[tokio::test] +async fn observe_reads_registers_via_dmi_abstract_commands() { + let server = MockOpenOcdServer::new() + .respond("halt", "") + .respond("resume", "") + .respond("wait_halt 1000", "") + .respond("reg dpc 0x100b0", "") + .respond_prefix("load_image", "loaded 4 bytes in 0.000s (4 KiB/s)") + // abstractcs poll: busy=0, cmderr=0 on first read. + .respond("riscv dmi_read 0x16", "0x00000000") + // data0/data1 reads. Values irrelevant for ordering; the + // test only asserts the DMI shape made it onto the wire. + .respond("riscv dmi_read 0x4", "0x00000000") + .respond("riscv dmi_read 0x5", "0x00000000") + .start() + .await; + + let mut transport = OpenOcdJtagTransport::new(server.addr()); + use heimdall_transport::Transport; + transport.open().await.expect("connect"); + + let mut driver = RiverCpuDriver::new(DutKind::RiverRc1Nano, transport) + .with_wait_halt_max(Duration::from_secs(1)); + let mut dut = Dut::new(DutId::new("river-1"), DutKind::RiverRc1Nano); + + let image = Artifact::new(ArtifactKind::ElfRiscv, rv64_elf()); + driver.load(&mut dut, &image).await.expect("load"); + driver + .run(&mut dut, &Stimulus::new(StepBudget::cycles(1000))) + .await + .expect("run"); + + let received = server.received().await; + + // The cache-mediated `reg ` form is the regression mode. + // Permitted exceptions: the dpc-write before resume (`reg dpc `) + // and `reg pc` is NOT allowed; pc must come from `dpc` via DMI. + let cache_reads: Vec<&String> = received + .iter() + .filter(|c| c.starts_with("reg ") && !c.starts_with("reg dpc ")) + .collect(); + assert!( + cache_reads.is_empty(), + "observe must NOT fall back to OpenOCD's cache-mediated `reg `; got: {cache_reads:?}", + ); + + // The first DMI write to 0x17 is the access-register command for x1 + // (regno = 0x1001). All 31 GPR reads + the dpc read produce 32 such + // command writes, each followed by an abstractcs poll and two data + // reads. Assert at least one access-register command write went out + // and the first data0/data1 reads come after the run's wait_halt. + let run_wait_idx = received + .iter() + .position(|c| c == "wait_halt 1000") + .expect("run's wait_halt must be issued"); + // x1 access-register command: aarsize=3, transfer=1, regno=0x1001 + // -> (3 << 20) | (1 << 17) | 0x1001 = 0x00321001. + let first_cmd_idx = received + .iter() + .position(|c| c == "riscv dmi_write 0x17 0x321001") + .expect("observe must issue an access-register write for x1 (regno=0x1001)"); + let first_data0_idx = received + .iter() + .position(|c| c == "riscv dmi_read 0x4") + .expect("observe must read data0 (DMI 0x04) for the GPR low half"); + assert!( + run_wait_idx < first_cmd_idx, + "DMI access-register must come AFTER the run's wait_halt; \ + got run_wait@{run_wait_idx} cmd@{first_cmd_idx}", + ); + assert!( + first_cmd_idx < first_data0_idx, + "command write must precede data0 read; got cmd@{first_cmd_idx} data0@{first_data0_idx}", + ); + + // The last access-register write reads dpc (CSR regno 0x7b1) for the + // PC observation: aarsize=3 << 20 | transfer=1 << 17 | 0x07b1 + // = 0x300000 | 0x020000 | 0x0007b1 = 0x3207b1. + assert!( + received + .iter() + .any(|c| c == "riscv dmi_write 0x17 0x3207b1"), + "observe must issue an access-register write for dpc (regno=0x7b1) to read pc", + ); + + server.shutdown().await; +} + +#[tokio::test] +async fn run_without_prior_load_errors() { + let server = MockOpenOcdServer::new().start().await; + let mut transport = OpenOcdJtagTransport::new(server.addr()); + use heimdall_transport::Transport; + transport.open().await.expect("connect"); + let mut driver = RiverCpuDriver::new(DutKind::RiverRc1Nano, transport); + let mut dut = Dut::new(DutId::new("river-1"), DutKind::RiverRc1Nano); + let err = driver + .run(&mut dut, &Stimulus::new(StepBudget::cycles(1000))) + .await + .expect_err("run before load must fail"); + assert!( + format!("{err}").contains("pending_entry unset"), + "expected pending_entry diagnostic; got `{err}`" + ); + server.shutdown().await; +} diff --git a/crates/heimdall-driver/tests/river_debug_module.rs b/crates/heimdall-driver/tests/river_debug_module.rs index f942334..ea28f8d 100644 --- a/crates/heimdall-driver/tests/river_debug_module.rs +++ b/crates/heimdall-driver/tests/river_debug_module.rs @@ -3,17 +3,15 @@ #![cfg(feature = "river")] -mod common; - use std::time::Duration; -use common::mock_openocd::MockOpenOcdServer; use heimdall_driver::river::debug_module::DebugModule; +use heimdall_mock_openocd::MockOpenOcdServer; use heimdall_transport::Transport; use heimdall_transport::TransportError; use heimdall_transport::openocd::OpenOcdJtagTransport; -async fn open_transport(server: &common::mock_openocd::RunningServer) -> OpenOcdJtagTransport { +async fn open_transport(server: &heimdall_mock_openocd::RunningServer) -> OpenOcdJtagTransport { let mut t = OpenOcdJtagTransport::new(server.addr()); t.open().await.expect("connect to mock"); t @@ -86,6 +84,63 @@ async fn read_gpr_parses_value() { server.shutdown().await; } +#[tokio::test] +async fn write_reg_formats_value_as_hex() { + let server = MockOpenOcdServer::new() + .respond("reg dpc 0x100b0", "") + .start() + .await; + let mut transport = open_transport(&server).await; + let mut dm = DebugModule::new(&mut transport); + dm.write_reg("dpc", 0x100b0).await.expect("write_reg"); + transport.close().await.unwrap(); + + let received = server.received().await; + assert_eq!(received, vec!["reg dpc 0x100b0".to_string()]); + server.shutdown().await; +} + +#[tokio::test] +async fn dm_reset_cycles_dmactive() { + let server = MockOpenOcdServer::new() + .respond("riscv dmi_write 0x10 0x0", "") + .respond("riscv dmi_write 0x10 0x1", "") + .respond("riscv dmi_read 0x10", "0x00000001") + .start() + .await; + let mut transport = open_transport(&server).await; + let mut dm = DebugModule::new(&mut transport); + dm.dm_reset().await.expect("dm_reset"); + transport.close().await.unwrap(); + + let received = server.received().await; + assert_eq!( + received, + vec![ + "riscv dmi_write 0x10 0x0".to_string(), + "riscv dmi_write 0x10 0x1".to_string(), + "riscv dmi_read 0x10".to_string(), + ] + ); + server.shutdown().await; +} + +#[tokio::test] +async fn dm_reset_fails_when_dmactive_stays_zero() { + let server = MockOpenOcdServer::new() + .respond("riscv dmi_write 0x10 0x0", "") + .respond("riscv dmi_write 0x10 0x1", "") + .respond("riscv dmi_read 0x10", "0x00000000") + .start() + .await; + let mut transport = open_transport(&server).await; + let mut dm = DebugModule::new(&mut transport); + let err = dm.dm_reset().await.expect_err("expected protocol error"); + assert!(matches!(err, TransportError::Protocol(_)), "got {err:?}"); + transport.close().await.unwrap(); + server.shutdown().await; +} + #[tokio::test] async fn read_csr_parses_pc() { let server = MockOpenOcdServer::new() @@ -99,3 +154,103 @@ async fn read_csr_parses_pc() { transport.close().await.unwrap(); server.shutdown().await; } + +#[tokio::test] +async fn read_register_via_abstract_composes_a0_then_reads_data() { + // Access-Register for x10 (a0): regno = 0x1000 | 10 = 0x100a. + // (3 << 20) | (1 << 17) | 0x100a = 0x32100a. + // abstractcs comes back with busy=0, cmderr=0 (full word zero). + // data0/data1 carry the low/high halves of the value. + let server = MockOpenOcdServer::new() + .respond("riscv dmi_write 0x17 0x32100a", "") + .respond("riscv dmi_read 0x16", "0x00000000") + .respond("riscv dmi_read 0x4", "0x12345678") // low + .respond("riscv dmi_read 0x5", "0xdeadbeef") // high + .start() + .await; + let mut transport = open_transport(&server).await; + let mut dm = DebugModule::new(&mut transport); + let v = dm.read_gpr_via_abstract(10).await.expect("dmi read x10"); + assert_eq!(v, 0xdead_beef_1234_5678); + transport.close().await.unwrap(); + + let received = server.received().await; + assert_eq!( + received, + vec![ + "riscv dmi_write 0x17 0x32100a".to_string(), + "riscv dmi_read 0x16".to_string(), + "riscv dmi_read 0x4".to_string(), + "riscv dmi_read 0x5".to_string(), + ], + "wire shape: command, abstractcs poll, data0, data1", + ); + server.shutdown().await; +} + +#[tokio::test] +async fn read_register_via_abstract_polls_until_busy_clears() { + // First abstractcs read returns busy=1. Second returns busy=0. + // The mock fires its canned `riscv dmi_read 0x16` response on EVERY + // matching command, so we need a separate exact-match for each of + // the two poll cycles. Driving that via a stateful counter using the + // prefix machinery is overkill. Instead we craft the test to use + // the same response and the busy-bit absent on the wire isn't + // important here. Just confirm at least one abstractcs poll happens. + // + // For a busy-clear-after-N-polls test we'd need a stateful mock + // (out of scope for the minimal MockOpenOcdServer). What this test + // pins is the single-poll happy path and the data fetch order. + let server = MockOpenOcdServer::new() + .respond("riscv dmi_write 0x17 0x321001", "") + .respond("riscv dmi_read 0x16", "0x00000000") + .respond("riscv dmi_read 0x4", "0x00000001") + .respond("riscv dmi_read 0x5", "0x00000000") + .start() + .await; + let mut transport = open_transport(&server).await; + let mut dm = DebugModule::new(&mut transport); + let v = dm.read_gpr_via_abstract(1).await.expect("dmi read x1"); + assert_eq!(v, 1); + transport.close().await.unwrap(); + server.shutdown().await; +} + +#[tokio::test] +async fn read_register_via_abstract_surfaces_cmderr() { + // abstractcs=0x00000300 -> busy=0, cmderr=3 (exception while + // executing the abstract command). Must propagate as a Protocol + // error so observe failures surface clearly. + let server = MockOpenOcdServer::new() + .respond("riscv dmi_write 0x17 0x321001", "") + .respond("riscv dmi_read 0x16", "0x00000300") + .start() + .await; + let mut transport = open_transport(&server).await; + let mut dm = DebugModule::new(&mut transport); + let err = dm + .read_gpr_via_abstract(1) + .await + .expect_err("expected cmderr propagation"); + let msg = format!("{err}"); + assert!( + msg.contains("cmderr=0x3"), + "error must mention cmderr value; got `{msg}`", + ); + transport.close().await.unwrap(); + server.shutdown().await; +} + +#[tokio::test] +async fn read_register_via_abstract_rejects_oob_gpr_index() { + let server = MockOpenOcdServer::new().start().await; + let mut transport = open_transport(&server).await; + let mut dm = DebugModule::new(&mut transport); + let err = dm + .read_gpr_via_abstract(32) + .await + .expect_err("32 is out of GPR range"); + assert!(matches!(err, TransportError::Protocol(_)), "got {err:?}"); + transport.close().await.unwrap(); + server.shutdown().await; +} diff --git a/crates/heimdall-eda/Cargo.toml b/crates/heimdall-eda/Cargo.toml index 9e0eff1..88fd782 100644 --- a/crates/heimdall-eda/Cargo.toml +++ b/crates/heimdall-eda/Cargo.toml @@ -31,6 +31,7 @@ async-trait = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +indicatif = { workspace = true } # heimdall-daemon's `aegis` feature pulls in heimdall-transport/linux-cdev # for chardev GPIO, which only builds on Linux. Darwin gets a daemon diff --git a/crates/heimdall-eda/src/cmd/daemon.rs b/crates/heimdall-eda/src/cmd/daemon.rs index 1e2164b..60823b0 100644 --- a/crates/heimdall-eda/src/cmd/daemon.rs +++ b/crates/heimdall-eda/src/cmd/daemon.rs @@ -9,9 +9,13 @@ use heimdall_daemon::dump as snapshot; #[derive(Debug, ClapArgs)] pub struct ServeArgs { - /// Address to bind. Non-loopback addresses emit a startup warning. - #[arg(long, default_value = "127.0.0.1:7777")] - pub bind: SocketAddr, + /// Address to bind. Pass `--bind` zero or more times: when omitted, + /// the daemon listens on `[::]:7777`, which on Linux accepts both + /// IPv4 and IPv6 traffic through the same dual-stack socket. When + /// one or more are given, only those are bound. Non-loopback + /// addresses emit a startup warning. + #[arg(long)] + pub bind: Vec, /// Path to the sqlite database file. Created if missing. #[arg(long, default_value = "heimdall.db")] @@ -117,7 +121,10 @@ pub async fn restore(args: RestoreArgs) -> Result<()> { } pub async fn serve(args: ServeArgs, cfg_path: Option) -> Result<()> { - heimdall_i18n::linfo!("log.daemon.starting", bind = args.bind); + let binds = resolve_binds(&args.bind); + for bind in &binds { + heimdall_i18n::linfo!("log.daemon.starting", bind = bind); + } let store = SqliteJobStore::open(&args.store_path) .await @@ -134,12 +141,12 @@ pub async fn serve(args: ServeArgs, cfg_path: Option) -> Result<()> { host = config.host.name, duts = config.duts.len(), ); - heimdall::daemon::start_with_config(args.bind, Arc::new(store), Arc::new(blobs), &config) + heimdall::daemon::start_with_config_binds(binds, Arc::new(store), Arc::new(blobs), &config) .await .map_err(eyre::Report::from)? } else { heimdall_i18n::linfo!("log.daemon.no_config"); - heimdall::daemon::start(args.bind, Arc::new(store), Arc::new(blobs)) + heimdall::daemon::start_binds(binds, Arc::new(store), Arc::new(blobs)) .await .map_err(eyre::Report::from)? }; @@ -147,6 +154,25 @@ pub async fn serve(args: ServeArgs, cfg_path: Option) -> Result<()> { tokio::signal::ctrl_c().await.map_err(eyre::Report::from)?; heimdall_i18n::linfo!("log.daemon.shutting_down"); handles.server_task.abort(); + for task in &handles.extra_server_tasks { + task.abort(); + } handles.worker_task.abort(); Ok(()) } + +/// Project the operator's `--bind` flags onto the actual listener set +/// the runtime will spin up. An empty list (operator didn't supply +/// `--bind` at all) maps to a single IPv6 wildcard listener +/// (`[::]:7777`) which, with the Linux kernel's default +/// `IPV6_V6ONLY=0`, accepts both IPv4 and IPv6 connections through +/// the same socket. Binding 0.0.0.0:7777 alongside it would just +/// collide with `EADDRINUSE`; operators who need explicit +/// per-stack listeners can pass them via repeated `--bind`. +fn resolve_binds(supplied: &[SocketAddr]) -> Vec { + if !supplied.is_empty() { + return supplied.to_vec(); + } + use std::net::Ipv6Addr; + vec![SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 7777)] +} diff --git a/crates/heimdall-eda/src/cmd/fuzz.rs b/crates/heimdall-eda/src/cmd/fuzz.rs index 9bb927b..130c8f8 100644 --- a/crates/heimdall-eda/src/cmd/fuzz.rs +++ b/crates/heimdall-eda/src/cmd/fuzz.rs @@ -1,10 +1,20 @@ +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; +use std::time::{Duration, Instant}; + use clap::Args as ClapArgs; use eyre::Result; -use heimdall::core::{DutId, DutKind, State, StepBudget, ValueRepr}; +use heimdall::core::{DutId, DutKind, State, StepBudget, ValueRepr, Verdict}; use heimdall::driver::{Dut, MockDriver}; -use heimdall::fuzzer::{BitFlipMutator, FuzzerEngine, RawAsmGen, RoundRobinScheduler, Rv64}; +use heimdall::fuzzer::{ + BitFlipMutator, FuzzerEngine, IterCallback, RawAsmGen, RoundRobinScheduler, Rv64, +}; use heimdall::golden::MockGoldenModel; use heimdall::test::Runner; +use indicatif::{ProgressBar, ProgressStyle}; +use serde::Serialize; #[derive(Debug, ClapArgs)] pub struct Args { @@ -27,16 +37,83 @@ pub struct Args { /// Step budget (cycles) per iteration. #[arg(long, default_value_t = 1000)] pub cycles: u64, + + /// Submit the fuzz session as a daemon job against the named DUT + /// instead of running locally with MockDriver. The daemon at + /// `--daemon-url` must have the DUT in its registry and the + /// `RiverFuzzFactory` enabled (built with `--features fuzzer,river`). + #[arg(long)] + pub remote_dut: Option, + + /// Strict coverage mode: a sim-vs-silicon divergence flips a passing + /// iteration's verdict to fail. Off by default. Only consulted for + /// remote (daemon) submissions; the local path uses the engine + /// default. + #[arg(long, default_value_t = false)] + pub strict_coverage: bool, + + /// Maximum wall-clock time to poll the daemon for the fuzz job to + /// reach a terminal state. Defaults to enough headroom for slow sim + /// rigs; bump for very long-running campaigns. + #[arg(long, default_value_t = 3600)] + pub poll_timeout_secs: u64, +} + +pub async fn run( + args: Args, + _cfg_path: Option, + daemon_url: &str, +) -> Result<()> { + if let Some(dut) = args.remote_dut.clone() { + return run_remote(args, dut, daemon_url).await; + } + run_local(args).await } -pub async fn run(args: Args, _cfg_path: Option) -> Result<()> { +async fn run_local(args: Args) -> Result<()> { let target = parse_target(&args.target)?; tracing::info!(target = ?target, iterations = args.iterations, seed = args.seed, "fuzz start"); - let driver = MockDriver::new(target).with_state(State::new().with("a0", ValueRepr::U64(0x42))); + let driver = MockDriver::new(target).with_state(State::new().with("x10", ValueRepr::U64(0x42))); let golden = MockGoldenModel::new(target); let mut dut = Dut::new(DutId::new("mock-dut"), target); + let bar = ProgressBar::new(args.iterations).with_style( + ProgressStyle::with_template( + "{spinner:.cyan} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} {msg} ({eta})", + ) + .expect("valid template") + .progress_chars("=>-"), + ); + bar.enable_steady_tick(std::time::Duration::from_millis(120)); + + let passes = Arc::new(AtomicU64::new(0)); + let fails = Arc::new(AtomicU64::new(0)); + let errors = Arc::new(AtomicU64::new(0)); + let skips = Arc::new(AtomicU64::new(0)); + let cb: IterCallback = { + let bar = bar.clone(); + let passes = passes.clone(); + let fails = fails.clone(); + let errors = errors.clone(); + let skips = skips.clone(); + Arc::new(move |done, _total, verdict| { + match verdict { + Verdict::Pass => passes.fetch_add(1, Ordering::Relaxed), + Verdict::Fail { .. } => fails.fetch_add(1, Ordering::Relaxed), + Verdict::Skip { .. } => skips.fetch_add(1, Ordering::Relaxed), + Verdict::Error { .. } => errors.fetch_add(1, Ordering::Relaxed), + }; + bar.set_position(done); + bar.set_message(format!( + "pass={} fail={} err={}", + passes.load(Ordering::Relaxed), + fails.load(Ordering::Relaxed), + errors.load(Ordering::Relaxed), + )); + }) + }; + let mut engine = FuzzerEngine::builder() .with_runner(Runner::builder().build()) .with_generator(RawAsmGen::::new(args.insn_count)) @@ -46,9 +123,14 @@ pub async fn run(args: Args, _cfg_path: Option) -> Result<() .with_golden(golden) .with_rng_seed(args.seed) .with_step_budget(StepBudget::cycles(args.cycles)) + .with_iter_callback(cb) .build(); let report = engine.run(&mut dut, args.iterations).await?; + bar.finish_with_message(format!( + "pass={} fail={} err={}", + report.passes, report.fails, report.errors + )); tracing::info!( iterations = report.iterations, passes = report.passes, @@ -69,6 +151,115 @@ pub async fn run(args: Args, _cfg_path: Option) -> Result<() Ok(()) } +/// Submit the fuzz session as a `JobKind::Fuzz` to the daemon and poll +/// `/jobs/:id` until it reaches a terminal state. Streams a coarse-grained +/// progress bar based on the polled `state.state`; per-iteration progress +/// lives in the daemon's job-log stream which the web UI / TUI render. +async fn run_remote(args: Args, dut: String, daemon_url: &str) -> Result<()> { + let body = NewFuzzJob { + dut: dut.clone(), + kind: FuzzKindPayload { + kind: "fuzz", + iterations: args.iterations, + seed: args.seed, + insn_count: args.insn_count, + cycles: args.cycles, + strict_coverage: args.strict_coverage, + }, + }; + let client = reqwest::Client::new(); + let url = format!("{daemon_url}/jobs"); + let resp = client + .post(&url) + .json(&body) + .send() + .await + .map_err(eyre::Report::from)?; + if !resp.status().is_success() { + let status = resp.status(); + let detail = resp.text().await.unwrap_or_default(); + return Err(eyre::eyre!("daemon returned {status}: {detail}")); + } + let created: serde_json::Value = resp.json().await.map_err(eyre::Report::from)?; + let job_id = created["id"] + .as_str() + .ok_or_else(|| eyre::eyre!("daemon response missing job id: {created}"))? + .to_string(); + tracing::info!(job = %job_id, dut = %dut, "fuzz job submitted"); + + let bar = ProgressBar::new_spinner().with_style( + ProgressStyle::with_template("{spinner:.cyan} [{elapsed_precise}] {msg}") + .expect("valid template"), + ); + bar.enable_steady_tick(Duration::from_millis(250)); + bar.set_message(format!("submitted (job={})", short_id(&job_id))); + + let deadline = Instant::now() + Duration::from_secs(args.poll_timeout_secs); + let job_url = format!("{daemon_url}/jobs/{job_id}"); + loop { + if Instant::now() >= deadline { + bar.finish_with_message("poll timed out"); + return Err(eyre::eyre!( + "fuzz job {job_id} did not reach terminal state within {}s", + args.poll_timeout_secs + )); + } + tokio::time::sleep(Duration::from_millis(500)).await; + let resp = client + .get(&job_url) + .send() + .await + .map_err(eyre::Report::from)?; + if !resp.status().is_success() { + continue; + } + let body: serde_json::Value = resp.json().await.map_err(eyre::Report::from)?; + let state = body["state"]["state"].as_str().unwrap_or(""); + bar.set_message(format!("state={state}")); + match state { + "done" => { + let verdict = body["state"]["detail"]["kind"].as_str().unwrap_or("?"); + bar.finish_with_message(format!("done/{verdict}")); + tracing::info!(job = %job_id, verdict, "fuzz job complete"); + if verdict != "pass" { + return Err(eyre::eyre!("fuzz verdict was {verdict}, not pass")); + } + return Ok(()); + } + "failed" => { + let detail = body["state"]["detail"].as_str().unwrap_or("(no detail)"); + bar.finish_with_message(format!("failed: {detail}")); + return Err(eyre::eyre!("fuzz job failed: {detail}")); + } + "cancelled" => { + bar.finish_with_message("cancelled"); + return Err(eyre::eyre!("fuzz job was cancelled")); + } + _ => continue, + } + } +} + +#[derive(Serialize)] +struct NewFuzzJob { + dut: String, + kind: FuzzKindPayload, +} + +#[derive(Serialize)] +struct FuzzKindPayload { + kind: &'static str, + iterations: u64, + seed: u64, + insn_count: usize, + cycles: u64, + strict_coverage: bool, +} + +fn short_id(s: &str) -> &str { + if s.len() > 8 { &s[..8] } else { s } +} + fn parse_target(s: &str) -> Result { match s { "aegis-luna1" => Ok(DutKind::AegisLuna1), diff --git a/crates/heimdall-eda/src/cmd/run.rs b/crates/heimdall-eda/src/cmd/run.rs index 81955a4..b3a1c1c 100644 --- a/crates/heimdall-eda/src/cmd/run.rs +++ b/crates/heimdall-eda/src/cmd/run.rs @@ -1,9 +1,13 @@ +use std::sync::Arc; + +use async_trait::async_trait; use clap::Args as ClapArgs; use eyre::Result; use heimdall::core::{Artifact, ArtifactKind, DutId, DutKind, State, StepBudget, ValueRepr}; use heimdall::driver::{Dut, MockDriver}; use heimdall::golden::MockGoldenModel; -use heimdall::test::{BuildCtx, Plan, Runner, Test, TestError}; +use heimdall::test::{BuildCtx, Plan, Runner, Stage, StageMessage, StageObserver, Test, TestError}; +use indicatif::{ProgressBar, ProgressStyle}; #[derive(Debug, ClapArgs)] pub struct Args { @@ -25,25 +29,55 @@ impl Test for MockHello { async fn build(&self, _ctx: &mut BuildCtx<'_>) -> Result { Ok(Plan { input: Artifact::new(ArtifactKind::Asm, &b"li a0, 0x42"[..]), - expected: State::new().with("a0", ValueRepr::U64(0x42)), + expected: State::new().with("x10", ValueRepr::U64(0x42)), budget: StepBudget::cycles(1000), inputs: std::collections::BTreeMap::new(), }) } } +/// Wires `Stage` notifications from the runner into an indicatif spinner. +/// Each stage transition advances the bar position and updates the message, +/// so the user sees `prepare`, `compile`, `load`, ... scroll past as they +/// happen instead of just an opaque final verdict line. +struct StageBar { + bar: ProgressBar, +} + +const STAGE_STEPS: u64 = 11; + +#[async_trait] +impl StageObserver for StageBar { + async fn stage(&self, stage: Stage, message: StageMessage) { + let args: Vec<(&str, &str)> = message.args.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let rendered = heimdall::i18n::t_args(message.key, &args); + self.bar.set_message(format!("{stage}: {rendered}")); + self.bar.inc(1); + } +} + pub async fn run(args: Args, _cfg_path: Option) -> Result<()> { if args.test != "mock-hello" { return Err(eyre::eyre!("only `mock-hello` is supported")); } - let runner = Runner::builder().build(); + let bar = ProgressBar::new(STAGE_STEPS).with_style( + ProgressStyle::with_template( + "{spinner:.cyan} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} {msg}", + ) + .expect("valid template") + .progress_chars("=>-"), + ); + bar.enable_steady_tick(std::time::Duration::from_millis(120)); + let observer: Arc = Arc::new(StageBar { bar: bar.clone() }); + let runner = Runner::builder().with_observer(observer).build(); let mut driver = MockDriver::new(DutKind::RiverRc1Nano) - .with_state(State::new().with("a0", ValueRepr::U64(0x42))); + .with_state(State::new().with("x10", ValueRepr::U64(0x42))); let mut golden = MockGoldenModel::new(DutKind::RiverRc1Nano); let mut dut = Dut::new(DutId::new("mock-dut"), DutKind::RiverRc1Nano); let res = runner .run_one(&MockHello, &mut dut, &mut driver, &mut golden) .await?; + bar.finish_with_message(format!("verdict={:?}", res.verdict)); tracing::info!(verdict = ?res.verdict, elapsed = ?res.elapsed, "run complete"); if !res.verdict.is_pass() { return Err(eyre::eyre!("verdict not pass: {:?}", res.verdict)); diff --git a/crates/heimdall-eda/src/main.rs b/crates/heimdall-eda/src/main.rs index 47d4575..021ebc3 100644 --- a/crates/heimdall-eda/src/main.rs +++ b/crates/heimdall-eda/src/main.rs @@ -55,7 +55,7 @@ async fn main() -> Result<()> { match cli.command { cli::Cmd::Probe(args) => cmd::probe::run(args, cli.config).await, cli::Cmd::Run(args) => cmd::run::run(args, cli.config).await, - cli::Cmd::Fuzz(args) => cmd::fuzz::run(args, cli.config).await, + cli::Cmd::Fuzz(args) => cmd::fuzz::run(args, cli.config, &cli.daemon_url).await, cli::Cmd::Daemon(daemon_cmd) => match daemon_cmd { cli::DaemonCmd::Serve(args) => cmd::daemon::serve(args, cli.config).await, cli::DaemonCmd::Dump(args) => cmd::daemon::dump(args).await, diff --git a/crates/heimdall-fuzzer/Cargo.toml b/crates/heimdall-fuzzer/Cargo.toml index 68cd944..38adfad 100644 --- a/crates/heimdall-fuzzer/Cargo.toml +++ b/crates/heimdall-fuzzer/Cargo.toml @@ -15,7 +15,11 @@ description = "Coverage-guided hardware fuzzer for Heimdall: generators, mutator [features] default = [] aegis = ["heimdall-golden/aegis", "dep:aegis-ip"] -cranelift = ["dep:cranelift-codegen", "dep:cranelift-frontend", "dep:target-lexicon"] +cranelift = [ + "dep:cranelift-codegen", + "dep:cranelift-frontend", + "dep:target-lexicon", +] [dependencies] heimdall-core.workspace = true @@ -28,6 +32,7 @@ tracing.workspace = true tokio = { workspace = true } bytes.workspace = true rand.workspace = true +serde = { workspace = true } serde_json = { workspace = true } aegis-ip = { workspace = true, optional = true } @@ -39,3 +44,7 @@ target-lexicon = { workspace = true, optional = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tracing-subscriber.workspace = true +heimdall-tools.workspace = true +heimdall-transport.workspace = true +async-trait.workspace = true +clap.workspace = true diff --git a/crates/heimdall-fuzzer/examples/dump_seed.rs b/crates/heimdall-fuzzer/examples/dump_seed.rs new file mode 100644 index 0000000..fa036e0 --- /dev/null +++ b/crates/heimdall-fuzzer/examples/dump_seed.rs @@ -0,0 +1,118 @@ +//! Dump the deterministic RawAsmGen-produced program bytes for a given +//! `(--seed, --iter)` pair, so the River counterpart (or anyone) can run +//! the exact same program directly on emu + spike outside the JTAG path +//! and classify divergences without re-driving the whole fuzz session. +//! +//! Layout mirrors `FuzzerEngine::run`'s state-consumption order so the +//! bytes match what a real fuzz run produces. The default scheduler is +//! `RoundRobinScheduler`. At iter 0 the corpus is empty so the choice is +//! always `GenerateFresh` and the body only depends on the engine seed. +//! For iter > 0 the choice can be `MutateAt(idx)`, which depends on +//! prior verdicts (corpus growth). We replicate the rng-consumption +//! pattern but not verdict-driven mutation, so iter > 0 here matches the +//! `--mock-mode` case where every iter is generate-fresh. For triage of +//! a single failing iter, run with `--iter ` and check the artifact +//! against the daemon's log. +//! +//! Build + run: +//! cargo run -p heimdall-fuzzer --example dump_seed -- \ +//! --seed 77 --iter 0 --insn-count 16 --output /tmp/fuzz-seed-77.bin +//! +//! Output is raw bytes ready to wrap in an ELF (FUZZ_LOAD_ADDR=0x10000) +//! or feed straight into the emulator's `--memory ram` region. + +use clap::Parser; +use heimdall_core::SeedId; +use heimdall_fuzzer::traits::Generator; +use heimdall_fuzzer::{EBREAK_INSN, RawAsmGen, Rv64}; +use rand::RngCore; +use rand::SeedableRng; +use rand::rngs::StdRng; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +struct Args { + /// Engine RNG seed (same value you pass to `heimdall fuzz --seed`). + #[arg(long)] + seed: u64, + /// Which iteration to dump. iter 0 is always generate-fresh. Higher + /// iterations replicate iter-0 generation logic per iter (does not + /// model mutation-driven scheduling). + #[arg(long, default_value_t = 0)] + iter: u64, + /// Body instruction count (same as `heimdall fuzz --insn-count`). + #[arg(long, default_value_t = 16)] + insn_count: usize, + /// Where to write the raw bytes. `-` writes to stdout. + #[arg(long, short = 'o', default_value = "-")] + output: PathBuf, + /// Suppress the trailing ebreak. Matches `--ebreak-terminator false` + /// on the engine path. + #[arg(long, default_value_t = false)] + no_ebreak: bool, + /// Suppress the 31-insn register-zeroing prologue. Mirrors + /// `RawAsmGen::with_zero_init_prologue(false)` on the engine path. + /// Use this when comparing against a model that re-zeroes GPRs + /// externally, otherwise the default (on) is what fuzz runs see. + #[arg(long, default_value_t = false)] + no_prologue: bool, + /// Also print a short hex summary to stderr so the bytes are inline- + /// quotable on the bus. + #[arg(long, default_value_t = true)] + summary: bool, +} + +fn main() -> std::io::Result<()> { + let args = Args::parse(); + let mut rng = StdRng::seed_from_u64(args.seed); + let mut generator = RawAsmGen::::new(args.insn_count) + .with_ebreak_terminator(!args.no_ebreak) + .with_zero_init_prologue(!args.no_prologue); + + // Replicate FuzzerEngine's per-iter consumption: one next_u64() for + // the SeedId, then generator.generate(&mut rng, seed_id). + let mut artifact_bytes: Vec = Vec::new(); + for _ in 0..=args.iter { + let seed_id = SeedId(rng.next_u64()); + artifact_bytes = generator.generate(&mut rng, seed_id).bytes.to_vec(); + } + + if args.output.as_os_str() == "-" { + use std::io::Write as _; + std::io::stdout().write_all(&artifact_bytes)?; + } else { + std::fs::write(&args.output, &artifact_bytes)?; + eprintln!( + "wrote {} bytes to {}", + artifact_bytes.len(), + args.output.display() + ); + } + + if args.summary { + let head: Vec = artifact_bytes + .chunks_exact(4) + .take(8) + .map(|c| { + let w = u32::from_le_bytes([c[0], c[1], c[2], c[3]]); + format!("{w:08x}") + }) + .collect(); + eprintln!( + "len={} bytes, first 8 words: {}", + artifact_bytes.len(), + head.join(" ") + ); + if !args.no_ebreak { + let last = u32::from_le_bytes([ + artifact_bytes[artifact_bytes.len() - 4], + artifact_bytes[artifact_bytes.len() - 3], + artifact_bytes[artifact_bytes.len() - 2], + artifact_bytes[artifact_bytes.len() - 1], + ]); + assert_eq!(last, EBREAK_INSN, "last word must be ebreak"); + eprintln!("trailing ebreak: 0x{last:08x}"); + } + } + Ok(()) +} diff --git a/crates/heimdall-fuzzer/src/engine.rs b/crates/heimdall-fuzzer/src/engine.rs index 0bdfd6c..6108d0e 100644 --- a/crates/heimdall-fuzzer/src/engine.rs +++ b/crates/heimdall-fuzzer/src/engine.rs @@ -63,8 +63,27 @@ where silicon_coverage_map: crate::coverage::CoverageMap, strict_coverage: bool, divergences: Vec, + iter_observer: Option, + program_observer: Option, } +/// Per-iteration progress callback. Called after each iteration with +/// `(completed, total, last_verdict)`. Wrapped in `Arc` because the engine +/// is consumed by `run()` but the caller may want to retain a clone (e.g. +/// to keep an `indicatif::ProgressBar` alive past `run()`). +pub type IterCallback = std::sync::Arc; + +/// Per-iteration "the engine is about to feed this artifact to the +/// runner" callback. Called BEFORE `run_one`, with `(iter_index, &artifact)`. +/// Used by the daemon to surface "what is the fuzzer running right now" +/// in the web UI: the daemon stashes the bytes into a cache keyed by +/// job id, and the `/jobs/:id/disasm` route serves them to the +/// frontend. The callback receives a borrow so it can clone the bytes +/// without forcing every caller to pay the clone when no observer is +/// registered. +pub type ProgramCallback = + std::sync::Arc; + pub struct FuzzerEngineBuilder { runner: Option, generator: Option, @@ -75,6 +94,8 @@ pub struct FuzzerEngineBuilder { rng_seed: u64, step_budget: StepBudget, strict_coverage: bool, + iter_observer: Option, + program_observer: Option, } impl Default for FuzzerEngineBuilder @@ -96,6 +117,8 @@ where rng_seed: 0, step_budget: StepBudget::cycles(1000), strict_coverage: false, + iter_observer: None, + program_observer: None, } } } @@ -146,6 +169,25 @@ where self } + /// Register a per-iteration progress callback. Invoked after each + /// iteration completes with `(index, total, last_verdict)`. Used by the + /// CLI to drive an `indicatif::ProgressBar`. + pub fn with_iter_callback(mut self, cb: IterCallback) -> Self { + self.iter_observer = Some(cb); + self + } + + /// Register a per-iteration program-bytes observer. Invoked BEFORE + /// the runner consumes the artifact, with `(iter, &artifact)`. The + /// daemon uses this to feed its `LoadedProgramCache` so the + /// `/jobs/:id/disasm` route can serve "what is the fuzzer doing + /// right now" listings. The CLI path leaves it unset; no extra + /// clone happens when the observer is absent. + pub fn with_program_observer(mut self, cb: ProgramCallback) -> Self { + self.program_observer = Some(cb); + self + } + pub fn build(self) -> FuzzerEngine { FuzzerEngine { runner: self.runner.expect("runner not set"), @@ -161,6 +203,8 @@ where silicon_coverage_map: crate::coverage::CoverageMap::default(), strict_coverage: self.strict_coverage, divergences: Vec::new(), + iter_observer: self.iter_observer, + program_observer: self.program_observer, } } } @@ -213,6 +257,16 @@ where } }; + // Surface the program bytes BEFORE the runner consumes + // them: the daemon's web UI wants "what is the fuzzer + // running right now," which is the freshly-built artifact + // for this iter regardless of whether the run subsequently + // fails. The callback gets a borrow so clones only happen + // when an observer is registered. + if let Some(cb) = &self.program_observer { + cb(iter, &artifact); + } + let test = AdHocFuzzTest::new( format!("fuzz-{seed_id}"), self.driver.target(), @@ -220,10 +274,34 @@ where self.step_budget, ); - let res = self + // A `run_one` error means the per-iteration test infra (driver, + // golden, runner) failed before producing a verdict. For fuzz + // that is a routine outcome: a generated program might trap + // or loop forever, the driver's wait_halt times out, etc. We + // count it as a per-iteration `Verdict::Error`, fire the + // observer callback, and KEEP GOING rather than tearing the + // whole fuzz session down on the first bad seed. The driver + // is responsible for leaving the DUT in a state where the + // next iteration's load+resume can proceed (River driver + // force-halts on wait_halt timeout). + let res = match self .runner .run_one(&test, dut, &mut self.driver, &mut self.golden) - .await?; + .await + { + Ok(r) => r, + Err(e) => { + warn!(?e, iteration = iter, "fuzz iteration errored, continuing"); + errors += 1; + let verdict = Verdict::Error { + message: e.to_string(), + }; + if let Some(cb) = &self.iter_observer { + cb(iter + 1, iterations, &verdict); + } + continue; + } + }; match &res.verdict { Verdict::Pass => passes += 1, @@ -292,6 +370,10 @@ where if matches!(res.verdict, Verdict::Error { .. }) { warn!(?res.verdict, iteration = iter, "fuzz iteration errored"); } + + if let Some(cb) = &self.iter_observer { + cb(iter + 1, iterations, &res.verdict); + } } Ok(FuzzReport { diff --git a/crates/heimdall-fuzzer/src/generator/cranelift_gen.rs b/crates/heimdall-fuzzer/src/generator/cranelift_gen.rs index 9ab3fba..eb9d267 100644 --- a/crates/heimdall-fuzzer/src/generator/cranelift_gen.rs +++ b/crates/heimdall-fuzzer/src/generator/cranelift_gen.rs @@ -20,7 +20,7 @@ use heimdall_core::{Artifact, ArtifactKind, DutKind, SeedId}; use rand::{Rng, RngCore}; use target_lexicon::Triple; -use crate::generator::raw_asm::{IsaTag, Rv64}; +use crate::generator::raw_asm::{IsaTag, Rv64, ZERO_INIT_PROLOGUE_LEN, addi_xn_zero}; use crate::traits::Generator; /// Random binary i64 operation classes Cranelift IR can emit. @@ -51,33 +51,62 @@ pub struct CraneliftGen { /// Number of operations in the generated function. The IR includes one /// instruction per op plus the prologue/epilogue. pub ops_per_function: usize, + /// When true (default), prepend the same 31-insn `addi xN, x0, 0` + /// prologue RawAsmGen uses so differential fuzz against spike sees + /// the same all-zero GPR entry state on both sides regardless of + /// the bootrom's seeded values. + pub zero_init_prologue: bool, isa: OwnedTargetIsa, _isa: PhantomData, } +/// Concrete error returned by [`CraneliftGen::rv64`]. Each variant +/// pinpoints which step of the Cranelift backend initialisation +/// failed so callers can log a structured error instead of a flat +/// string. +#[derive(Debug, thiserror::Error)] +pub enum CraneliftInitError { + #[error("cranelift setting `{key}`: {detail}")] + Setting { key: &'static str, detail: String }, + #[error("triple parse: {0}")] + Triple(String), + #[error("isa lookup: {0}")] + IsaLookup(String), + #[error("isa finish: {0}")] + IsaFinish(String), +} + impl CraneliftGen { /// Construct a generator targeting riscv64-unknown-elf with the default /// cranelift backend settings (no optimizations beyond defaults). - pub fn rv64() -> Result { + pub fn rv64() -> Result { let mut flag_builder = settings::builder(); flag_builder .set("opt_level", "speed") - .map_err(|e| format!("flag set opt_level: {e}"))?; + .map_err(|e| CraneliftInitError::Setting { + key: "opt_level", + detail: e.to_string(), + })?; flag_builder .set("is_pic", "false") - .map_err(|e| format!("flag set is_pic: {e}"))?; + .map_err(|e| CraneliftInitError::Setting { + key: "is_pic", + detail: e.to_string(), + })?; let flags = settings::Flags::new(flag_builder); let triple: Triple = "riscv64-unknown-elf" .parse() - .map_err(|e| format!("triple parse: {e}"))?; - let isa_builder = lookup(triple).map_err(|e| format!("isa lookup: {e}"))?; + .map_err(|e: target_lexicon::ParseError| CraneliftInitError::Triple(e.to_string()))?; + let isa_builder = + lookup(triple).map_err(|e| CraneliftInitError::IsaLookup(e.to_string()))?; let isa = isa_builder .finish(flags) - .map_err(|e| format!("isa finish: {e}"))?; + .map_err(|e| CraneliftInitError::IsaFinish(e.to_string()))?; Ok(Self { ops_per_function: 8, + zero_init_prologue: true, isa, _isa: PhantomData, }) @@ -87,6 +116,13 @@ impl CraneliftGen { self.ops_per_function = n; self } + + /// Toggle the zero-init prologue. Off when the caller wants + /// bootloader-seeded GPR state to flow into the body unchanged. + pub fn with_zero_init_prologue(mut self, on: bool) -> Self { + self.zero_init_prologue = on; + self + } } impl Generator for CraneliftGen { @@ -146,7 +182,20 @@ impl Generator for CraneliftGen { .compile(&*self.isa, &mut ctrl_plane) .expect("cranelift compile"); // `code_buffer()` gives the raw instruction bytes. - let bytes = compiled.code_buffer().to_vec(); + let body = compiled.code_buffer(); + + let prologue_len = if self.zero_init_prologue { + ZERO_INIT_PROLOGUE_LEN + } else { + 0 + }; + let mut bytes = Vec::with_capacity(prologue_len * 4 + body.len()); + if self.zero_init_prologue { + for n in 1..32u32 { + bytes.extend_from_slice(&addi_xn_zero(n).to_le_bytes()); + } + } + bytes.extend_from_slice(body); Artifact::new(ArtifactKind::RawBytes, bytes) } diff --git a/crates/heimdall-fuzzer/src/generator/mod.rs b/crates/heimdall-fuzzer/src/generator/mod.rs index 601c290..99a7e9b 100644 --- a/crates/heimdall-fuzzer/src/generator/mod.rs +++ b/crates/heimdall-fuzzer/src/generator/mod.rs @@ -8,10 +8,13 @@ pub mod bitstream; #[cfg(feature = "cranelift")] pub mod cranelift_gen; -pub use raw_asm::{RawAsmGen, Rv64}; +pub use raw_asm::{ + EBREAK_INSN, IsaStringError, IsaTag, ParsedIsa, RawAsmGen, Rv32, Rv64, RvExtension, + parse_isa_string, +}; #[cfg(feature = "aegis")] pub use bitstream::BitstreamGen; #[cfg(feature = "cranelift")] -pub use cranelift_gen::CraneliftGen; +pub use cranelift_gen::{CraneliftGen, CraneliftInitError}; diff --git a/crates/heimdall-fuzzer/src/generator/raw_asm.rs b/crates/heimdall-fuzzer/src/generator/raw_asm.rs index f2d6cb5..746e91e 100644 --- a/crates/heimdall-fuzzer/src/generator/raw_asm.rs +++ b/crates/heimdall-fuzzer/src/generator/raw_asm.rs @@ -1,16 +1,23 @@ -//! Per-ISA raw instruction generator. RV64 today; aarch64/x86 are follow-ups. +//! Per-ISA raw instruction generator. RV32 + RV64 today. aarch64/x86 are +//! follow-ups when those DUT families come online. +use std::collections::HashSet; use std::marker::PhantomData; use heimdall_core::{Artifact, ArtifactKind, DutKind, SeedId}; use rand::{Rng, RngCore}; +use serde::{Deserialize, Serialize}; use crate::traits::Generator; -/// Marker types for the supported ISAs. +/// Marker types for the supported ISAs. `xlen()` returns the +/// architectural register width in bits so encoders that depend on +/// it (shift shamt width: 5-bit on RV32, 6-bit on RV64) can pick the +/// right encoding at compile time without runtime branching. pub trait IsaTag: Send + Sync + 'static { fn target_dut() -> DutKind; fn name() -> &'static str; + fn xlen() -> u8; } #[derive(Debug, Clone, Copy)] @@ -23,24 +30,345 @@ impl IsaTag for Rv64 { fn name() -> &'static str { "raw-asm-rv64" } + fn xlen() -> u8 { + 64 + } +} + +/// RV32 marker. No concrete DUT in the registry today. The target_dut +/// returns the same RiverRc1Nano variant as RV64 since `DutKind` is +/// XLEN-agnostic. Future commits add a real RV32 DutKind variant when a +/// RV32 DUT shows up. +#[derive(Debug, Clone, Copy)] +pub struct Rv32; + +impl IsaTag for Rv32 { + fn target_dut() -> DutKind { + DutKind::RiverRc1Nano + } + fn name() -> &'static str { + "raw-asm-rv32" + } + fn xlen() -> u8 { + 32 + } +} + +/// Subset of the RISC-V ISA the generator may emit. Names match the +/// canonical extension letters / Zxxx identifiers, mirroring what +/// Harbor's `lib/src/riscv/extensions/` exposes. The full set is +/// listed for forward compat: today only `I` and `M` actually have +/// instruction encodings in [`Class::ALL`], but downstream config +/// (TOML / `misa` probe) can carry the others as advisory information +/// and future PRs add the encodings without changing the config +/// surface. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RvExtension { + /// Base integer. Always implicitly enabled. + I, + /// Multiply / divide. Fault-free, register-only. + M, + /// Atomics. Memory-bearing, so requires bounded address logic before + /// the fuzzer can emit safely. + A, + /// Single-precision FP. + F, + /// Double-precision FP. + D, + /// Compressed (16-bit) instructions. + C, + /// Bitmanip umbrella (Zba/Zbb/Zbs/Zbc per the B-extension spec). + B, + /// Vector. + V, + /// Hypervisor. + H, + /// Conditional move. + Zicond, + /// CSR access. + Zicsr, + /// Instruction fence. + Zifencei, + /// Half-precision FP minimum. + Zfhmin, +} + +impl RvExtension { + /// Letter on the misa CSR for base I/M/A/F/D/C/B/V/H, or None for + /// Zxxx-style extensions which don't appear in misa. + pub fn misa_letter(self) -> Option { + match self { + RvExtension::I => Some('i'), + RvExtension::M => Some('m'), + RvExtension::A => Some('a'), + RvExtension::F => Some('f'), + RvExtension::D => Some('d'), + RvExtension::C => Some('c'), + RvExtension::B => Some('b'), + RvExtension::V => Some('v'), + RvExtension::H => Some('h'), + RvExtension::Zicond + | RvExtension::Zicsr + | RvExtension::Zifencei + | RvExtension::Zfhmin => None, + } + } +} + +/// Parse an extension from its misa letter. Lowercase / uppercase +/// both accepted. `'g'` is rejected here. It's an umbrella alias +/// (I+M+A+F+D) and only meaningful inside the `rvNN` parser, +/// not as a single-extension identifier. +impl TryFrom for RvExtension { + type Error = IsaStringError; + fn try_from(c: char) -> Result { + match c.to_ascii_lowercase() { + 'i' => Ok(RvExtension::I), + 'm' => Ok(RvExtension::M), + 'a' => Ok(RvExtension::A), + 'f' => Ok(RvExtension::F), + 'd' => Ok(RvExtension::D), + 'c' => Ok(RvExtension::C), + 'b' => Ok(RvExtension::B), + 'v' => Ok(RvExtension::V), + 'h' => Ok(RvExtension::H), + _ => Err(IsaStringError::UnknownLetter(c)), + } + } +} + +/// Parse an extension from either a single misa letter (`"i"`) or a +/// Z-style identifier (`"zicsr"`, `"zifencei"`, ...). The TOML +/// `[dut.isa] extensions = [...]` list mixes both forms. This impl +/// is the single normalisation point. +impl std::str::FromStr for RvExtension { + type Err = IsaStringError; + fn from_str(s: &str) -> Result { + let lower = s.to_ascii_lowercase(); + match lower.as_str() { + "zicond" => Ok(RvExtension::Zicond), + "zicsr" => Ok(RvExtension::Zicsr), + "zifencei" => Ok(RvExtension::Zifencei), + "zfhmin" => Ok(RvExtension::Zfhmin), + other if other.len() == 1 => RvExtension::try_from(other.chars().next().unwrap()), + _ => Err(IsaStringError::UnknownZName(s.to_string())), + } + } +} + +/// Error from parsing an ISA string. Variants distinguish the three +/// failure shapes operators and probe-decoders care about: the +/// `rvNN` prefix is missing, the XLEN field isn't 32/64, a misa +/// letter doesn't resolve to a known extension, or a Z-style chunk +/// after an underscore doesn't match a known name. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum IsaStringError { + #[error("isa string must start with `rv32` or `rv64`")] + MissingRvPrefix, + #[error("isa xlen must be 32 or 64")] + BadXlen, + #[error("unknown isa extension letter `{0}`")] + UnknownLetter(char), + #[error("unknown Z-extension `{0}`")] + UnknownZName(String), +} + +/// Parsed form of a canonical ISA string. `xlen` is 32 or 64. The +/// `extensions` set always contains [`RvExtension::I`] (the base +/// integer extension is implicit). +/// +/// Construct via [`std::str::FromStr`]: +/// `let isa: ParsedIsa = "rv64imac_zicsr".parse()?;` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedIsa { + pub xlen: u8, + pub extensions: HashSet, +} + +impl std::str::FromStr for ParsedIsa { + type Err = IsaStringError; + fn from_str(s: &str) -> Result { + let lower = s.to_ascii_lowercase(); + let rest = lower + .strip_prefix("rv") + .ok_or(IsaStringError::MissingRvPrefix)?; + let (xlen, body) = if let Some(rest) = rest.strip_prefix("32") { + (32u8, rest) + } else if let Some(rest) = rest.strip_prefix("64") { + (64u8, rest) + } else { + return Err(IsaStringError::BadXlen); + }; + + let mut extensions: HashSet = HashSet::new(); + extensions.insert(RvExtension::I); + + // Split on `_` so `"rv64imac_zicsr_zifencei"` parses cleanly: + // the first chunk is letter-encoded, subsequent chunks are + // Z-style. + let mut parts = body.split('_'); + if let Some(letters) = parts.next() { + for c in letters.chars() { + // `g` is the umbrella shorthand = I+M+A+F+D. + if c == 'g' { + extensions.extend([ + RvExtension::M, + RvExtension::A, + RvExtension::F, + RvExtension::D, + ]); + continue; + } + extensions.insert(RvExtension::try_from(c)?); + } + } + for part in parts { + extensions.insert(part.parse::()?); + } + Ok(ParsedIsa { xlen, extensions }) + } +} + +/// Build a [`ParsedIsa`] from a TOML-style `(xlen, ["i", "m", ...])` +/// pair. Unlike the [`std::str::FromStr`] path, unknown extension +/// strings are silently dropped here. The per-DUT config layer is +/// forward-compat and must keep working when a future heimdall.toml +/// names an extension this version of the fuzzer doesn't recognise. +/// [`RvExtension::I`] is always added. +impl<'a, I> From<(u8, I)> for ParsedIsa +where + I: IntoIterator, +{ + fn from((xlen, names): (u8, I)) -> Self { + let mut extensions: HashSet = HashSet::new(); + extensions.insert(RvExtension::I); + for n in names { + if let Ok(ext) = n.parse::() { + extensions.insert(ext); + } + } + ParsedIsa { xlen, extensions } + } +} + +/// Thin shim around `"...".parse::()` that yields the +/// `(xlen, extensions)` tuple older callers expected. Prefer +/// `ParsedIsa::from_str` in new code. +pub fn parse_isa_string(s: &str) -> Result<(u8, HashSet), IsaStringError> { + let ParsedIsa { xlen, extensions } = s.parse()?; + Ok((xlen, extensions)) } /// Generates a fixed-length sequence of legal RV64 instructions drawn from a -/// small subset. Output: ArtifactKind::RawBytes, length = 4 * instructions. +/// small register-to-register ALU subset (LUI/OP-IMM/OP), optionally +/// preceded by a register-zeroing prologue and terminated by `ebreak` so +/// the program has a deterministic architectural stop point. +/// +/// Output: `ArtifactKind::RawBytes`, length = `4 * (prologue? + instructions + ebreak?)`. +/// With both defaults on (the typical fuzz path) that's +/// `4 * (31 + instructions + 1)` bytes. +/// +/// Why the prologue default is on: spike's bootrom seeds `t0` to the +/// program entry and `a1` to the DTB pointer before jumping to user +/// code. Real silicon / the River emulator enter user code with all-zero +/// GPRs. Differential fuzz needs both sides to enter the body in +/// identical architectural state, otherwise any body insn that reads +/// `t0` or `a1` cascades the initial-state mismatch into the GPRs the +/// diff inspects. Spike's interactive `reg ` debug +/// command is read-only, so we can't fix this from the spike harness +/// side. Baking 31 `addi xN, x0, 0` instructions into the program +/// itself runs symmetrically on both models. Programs that genuinely +/// want non-zero initial state (rare in fuzz, useful for directed +/// regression vectors) can disable via [`Self::with_zero_init_prologue`]. +/// +/// Why the ebreak default is on: when used as a differential fuzz input +/// against a golden ISS (e.g. Spike), DUT and golden need to halt at the +/// same instruction or GPR comparison turns into pure trap-handling +/// alignment noise. The DUT is force-halted by a wait_halt budget. Spike +/// stops at exactly `r `. They never agree without an explicit halt +/// marker in the program. A trailing `ebreak` flips both into a halted +/// state at the same retired-instruction count, so any remaining GPR +/// divergence is genuinely architectural. Crash/liveness fuzzing that +/// doesn't compare against a golden can disable the terminator via +/// [`Self::with_ebreak_terminator`]. pub struct RawAsmGen { pub instructions: usize, + /// When true (default), prepend 31 `addi xN, x0, 0` instructions + /// (N=1..31) to neutralise any bootloader-seeded GPR state. Aligns + /// spike's bootrom-stamped initial state with the DUT's all-zero + /// reset. + pub zero_init_prologue: bool, + /// When true (default), append an `ebreak` (0x00100073) after the + /// random body. Aligns spike and DUT halts for differential fuzz. + pub ebreak_terminator: bool, + /// Extensions the generator may draw instructions from. + /// `RvExtension::I` is always implicitly included (the base + /// integer encoder is the only one that can produce arithmetic + /// without depending on M/F/A/etc.). Defaults to `{I, M}` so the + /// generator behaves as it always has when callers don't supply + /// a configuration. + pub extensions: HashSet, _isa: PhantomData, } impl RawAsmGen { pub fn new(instructions: usize) -> Self { + let mut extensions = HashSet::new(); + extensions.insert(RvExtension::I); + extensions.insert(RvExtension::M); Self { instructions, + zero_init_prologue: true, + ebreak_terminator: true, + extensions, _isa: PhantomData, } } + + /// Toggle the zero-init prologue. Off when the caller wants + /// bootloader-seeded GPR state to flow into the body unchanged. + pub fn with_zero_init_prologue(mut self, on: bool) -> Self { + self.zero_init_prologue = on; + self + } + + /// Toggle the trailing ebreak. Off for raw crash/liveness fuzzing + /// where halting policy is owned by the driver's wait_halt budget. + /// On (default) for differential fuzz against a golden. + pub fn with_ebreak_terminator(mut self, on: bool) -> Self { + self.ebreak_terminator = on; + self + } + + /// Replace the enabled-extension set. The `I` extension is always + /// added back unconditionally. Without it the random pool would + /// be empty and `generate` would panic. Extensions that don't yet + /// have an instruction encoding in [`Class::ALL`] (everything + /// other than `I` and `M` today) are accepted and stored, but + /// silently contribute nothing to the random pool until follow-up + /// PRs add their classes. + pub fn with_extensions(mut self, exts: impl IntoIterator) -> Self { + let mut set: HashSet = exts.into_iter().collect(); + set.insert(RvExtension::I); + self.extensions = set; + self + } } +/// RV64 EBREAK encoding (SYSTEM opcode, funct12=1). +pub const EBREAK_INSN: u32 = 0x0010_0073; + +/// Encode `addi xN, x0, 0` to write 0 into GPR `n`. ADDI is OP-IMM with +/// funct3=0, immediate=0, rs1=x0. +pub const fn addi_xn_zero(n: u32) -> u32 { + ((n & 0x1f) << 7) | 0b0010011 +} + +/// Number of registers the zero-init prologue clears: x1..x31. x0 is +/// hardwired to zero so we skip it. +pub const ZERO_INIT_PROLOGUE_LEN: usize = 31; + impl Generator for RawAsmGen { fn target(&self) -> DutKind { I::target_dut() @@ -51,50 +379,223 @@ impl Generator for RawAsmGen { } fn generate(&mut self, rng: &mut dyn RngCore, _seed: SeedId) -> Artifact { - let mut bytes = Vec::with_capacity(self.instructions * 4); + let prologue = if self.zero_init_prologue { + ZERO_INIT_PROLOGUE_LEN + } else { + 0 + }; + let extra = usize::from(self.ebreak_terminator); + let mut bytes = Vec::with_capacity((prologue + self.instructions + extra) * 4); + if self.zero_init_prologue { + for n in 1..32u32 { + bytes.extend_from_slice(&addi_xn_zero(n).to_le_bytes()); + } + } + // Pre-filter the class pool once per generate() call. The + // empty pool would mean only I-classless extensions were + // enabled, which contradicts with_extensions()'s invariant + // that I is always present. Assert it loudly. + let pool: Vec = Class::ALL + .iter() + .copied() + .filter(|c| self.extensions.contains(&c.extension())) + .collect(); + assert!( + !pool.is_empty(), + "raw-asm generator class pool is empty (enabled exts: {:?})", + self.extensions, + ); for _ in 0..self.instructions { - let insn = random_rv64_insn(rng); + let insn = random_insn(rng, &pool, I::xlen()); bytes.extend_from_slice(&insn.to_le_bytes()); } + if self.ebreak_terminator { + bytes.extend_from_slice(&EBREAK_INSN.to_le_bytes()); + } Artifact::new(ArtifactKind::RawBytes, bytes) } } +/// Instruction classes the fuzzer emits. All are register-only or +/// register-immediate (no memory, no branches, no system) so every +/// encoding is fault-free regardless of register contents. The pool +/// covers RV64I integer + shift forms plus the M-extension's +/// mul/div/rem family. That's enough surface for differential fuzz +/// against spike to cover the entire arithmetic ISA without needing +/// address-bounding logic for loads/stores. +/// +/// When adding a class here, increment `Class::COUNT` and the +/// `random_rv64_insn` match arm pool, and (if the class can produce a +/// fault) consider whether it belongs in the safe set at all. #[derive(Debug, Clone, Copy)] enum Class { + // U-format Lui, + // I-format ALU (rd = rs1 op imm12) Addi, + Andi, Ori, Xori, + Slti, + Sltiu, + // I-format shift (rd = rs1 << / >> shamt6). RV64 uses a 6-bit + // shamt. The funct7-equivalent top bit must be 0 for SLLI/SRLI + // and 0x20 (logical 0x10 in funct6 view) for SRAI. + Slli, + Srli, + Srai, + // R-format ALU (rd = rs1 op rs2) Add, + Sub, + And, Or, Xor, + Slt, + Sltu, + Sll, + Srl, + Sra, + // R-format M-extension (rd = rs1 op rs2). Div/rem by zero is + // architecturally defined (returns -1 / dividend respectively), + // so these are still fault-free. + Mul, + Mulh, + Div, + Divu, + Rem, + Remu, +} + +impl Class { + /// The RISC-V extension this class belongs to. The random picker + /// filters [`Class::ALL`] by the generator's enabled extension set + /// to honour the per-DUT ISA configuration. + pub fn extension(self) -> RvExtension { + match self { + Class::Lui + | Class::Addi + | Class::Andi + | Class::Ori + | Class::Xori + | Class::Slti + | Class::Sltiu + | Class::Slli + | Class::Srli + | Class::Srai + | Class::Add + | Class::Sub + | Class::And + | Class::Or + | Class::Xor + | Class::Slt + | Class::Sltu + | Class::Sll + | Class::Srl + | Class::Sra => RvExtension::I, + Class::Mul | Class::Mulh | Class::Div | Class::Divu | Class::Rem | Class::Remu => { + RvExtension::M + } + } + } + + /// All variants in a fixed order, used by `random_rv64_insn` to + /// pick uniformly without enumerating each match arm twice. + const ALL: [Class; 26] = [ + Class::Lui, + Class::Addi, + Class::Andi, + Class::Ori, + Class::Xori, + Class::Slti, + Class::Sltiu, + Class::Slli, + Class::Srli, + Class::Srai, + Class::Add, + Class::Sub, + Class::And, + Class::Or, + Class::Xor, + Class::Slt, + Class::Sltu, + Class::Sll, + Class::Srl, + Class::Sra, + Class::Mul, + Class::Mulh, + Class::Div, + Class::Divu, + Class::Rem, + Class::Remu, + ]; } -fn random_rv64_insn(rng: &mut dyn RngCore) -> u32 { - let class = match rng.r#gen::() % 7 { - 0 => Class::Lui, - 1 => Class::Addi, - 2 => Class::Ori, - 3 => Class::Xori, - 4 => Class::Add, - 5 => Class::Or, - _ => Class::Xor, +const OPCODE_LUI: u32 = 0b0110111; +const OPCODE_OP_IMM: u32 = 0b0010011; +const OPCODE_OP: u32 = 0b0110011; + +/// Shift-immediate encoder. The shamt field is XLEN-aware: RV32 uses +/// a 5-bit shamt (top 7 bits of imm12 encode the funct: 0x00 / 0x20), +/// RV64 widens it to 6 bits (top 6 bits encode the funct: 0x00 / +/// 0x10 viewed as 6-bit). Both end up at the same RISC-V word +/// encoding pattern. We just shift the funct mask by the right +/// number of bits. +fn encode_shift_i(shamt: u32, rs1: u32, funct3: u32, rd: u32, srai: bool, xlen: u8) -> u32 { + let (shamt_bits, top_mask) = if xlen == 32 { + (5u32, if srai { 0b0100000 } else { 0u32 } << 5) + } else { + // RV64 + (6u32, if srai { 0b010000 } else { 0u32 } << 6) }; + let mask = (1u32 << shamt_bits) - 1; + let imm = top_mask | (shamt & mask); + encode_i(imm, rs1, funct3, rd, OPCODE_OP_IMM) +} + +fn random_shamt(rng: &mut dyn RngCore, xlen: u8) -> u32 { + let max = if xlen == 32 { 32 } else { 64 }; + rng.gen_range(0..max) +} + +fn random_insn(rng: &mut dyn RngCore, pool: &[Class], xlen: u8) -> u32 { + let class = pool[rng.gen_range(0..pool.len())]; let rd: u32 = rng.gen_range(0..32); let rs1: u32 = rng.gen_range(0..32); let rs2: u32 = rng.gen_range(0..32); match class { Class::Lui => { let imm20: u32 = rng.gen_range(0..(1u32 << 20)); - encode_u(imm20, rd, 0b0110111) + encode_u(imm20, rd, OPCODE_LUI) } - Class::Addi => encode_i(random_imm12(rng), rs1, 0b000, rd, 0b0010011), - Class::Ori => encode_i(random_imm12(rng), rs1, 0b110, rd, 0b0010011), - Class::Xori => encode_i(random_imm12(rng), rs1, 0b100, rd, 0b0010011), - Class::Add => encode_r(0b0000000, rs2, rs1, 0b000, rd, 0b0110011), - Class::Or => encode_r(0b0000000, rs2, rs1, 0b110, rd, 0b0110011), - Class::Xor => encode_r(0b0000000, rs2, rs1, 0b100, rd, 0b0110011), + // OP-IMM ALU + Class::Addi => encode_i(random_imm12(rng), rs1, 0b000, rd, OPCODE_OP_IMM), + Class::Andi => encode_i(random_imm12(rng), rs1, 0b111, rd, OPCODE_OP_IMM), + Class::Ori => encode_i(random_imm12(rng), rs1, 0b110, rd, OPCODE_OP_IMM), + Class::Xori => encode_i(random_imm12(rng), rs1, 0b100, rd, OPCODE_OP_IMM), + Class::Slti => encode_i(random_imm12(rng), rs1, 0b010, rd, OPCODE_OP_IMM), + Class::Sltiu => encode_i(random_imm12(rng), rs1, 0b011, rd, OPCODE_OP_IMM), + // OP-IMM shift + Class::Slli => encode_shift_i(random_shamt(rng, xlen), rs1, 0b001, rd, false, xlen), + Class::Srli => encode_shift_i(random_shamt(rng, xlen), rs1, 0b101, rd, false, xlen), + Class::Srai => encode_shift_i(random_shamt(rng, xlen), rs1, 0b101, rd, true, xlen), + // OP base + Class::Add => encode_r(0b0000000, rs2, rs1, 0b000, rd, OPCODE_OP), + Class::Sub => encode_r(0b0100000, rs2, rs1, 0b000, rd, OPCODE_OP), + Class::And => encode_r(0b0000000, rs2, rs1, 0b111, rd, OPCODE_OP), + Class::Or => encode_r(0b0000000, rs2, rs1, 0b110, rd, OPCODE_OP), + Class::Xor => encode_r(0b0000000, rs2, rs1, 0b100, rd, OPCODE_OP), + Class::Slt => encode_r(0b0000000, rs2, rs1, 0b010, rd, OPCODE_OP), + Class::Sltu => encode_r(0b0000000, rs2, rs1, 0b011, rd, OPCODE_OP), + Class::Sll => encode_r(0b0000000, rs2, rs1, 0b001, rd, OPCODE_OP), + Class::Srl => encode_r(0b0000000, rs2, rs1, 0b101, rd, OPCODE_OP), + Class::Sra => encode_r(0b0100000, rs2, rs1, 0b101, rd, OPCODE_OP), + // OP M-extension (funct7 = 0x01) + Class::Mul => encode_r(0b0000001, rs2, rs1, 0b000, rd, OPCODE_OP), + Class::Mulh => encode_r(0b0000001, rs2, rs1, 0b001, rd, OPCODE_OP), + Class::Div => encode_r(0b0000001, rs2, rs1, 0b100, rd, OPCODE_OP), + Class::Divu => encode_r(0b0000001, rs2, rs1, 0b101, rd, OPCODE_OP), + Class::Rem => encode_r(0b0000001, rs2, rs1, 0b110, rd, OPCODE_OP), + Class::Remu => encode_r(0b0000001, rs2, rs1, 0b111, rd, OPCODE_OP), } } @@ -135,12 +636,71 @@ mod tests { } #[test] - fn output_length_matches_instructions() { + fn default_output_includes_prologue_and_terminator() { + // Default-on prologue (31) + body (N) + ebreak (1) = (N+32)*4. let mut g = RawAsmGen::::new(16); let a = g.generate(&mut rng(), SeedId(0)); + assert_eq!(a.bytes.len(), (16 + ZERO_INIT_PROLOGUE_LEN + 1) * 4); + } + + #[test] + fn terminator_and_prologue_off_yields_exact_body_length() { + let mut g = RawAsmGen::::new(16) + .with_ebreak_terminator(false) + .with_zero_init_prologue(false); + let a = g.generate(&mut rng(), SeedId(0)); assert_eq!(a.bytes.len(), 16 * 4); } + #[test] + fn trailing_ebreak_is_last_instruction() { + let mut g = RawAsmGen::::new(8); + let a = g.generate(&mut rng(), SeedId(0)); + let last = u32::from_le_bytes([ + a.bytes[a.bytes.len() - 4], + a.bytes[a.bytes.len() - 3], + a.bytes[a.bytes.len() - 2], + a.bytes[a.bytes.len() - 1], + ]); + assert_eq!(last, EBREAK_INSN, "last word must be ebreak"); + } + + #[test] + fn prologue_is_31_addi_xn_x0_zero() { + let mut g = RawAsmGen::::new(0).with_ebreak_terminator(false); + let a = g.generate(&mut rng(), SeedId(0)); + assert_eq!(a.bytes.len(), ZERO_INIT_PROLOGUE_LEN * 4); + for n in 1..32u32 { + let off = ((n - 1) as usize) * 4; + let w = u32::from_le_bytes([ + a.bytes[off], + a.bytes[off + 1], + a.bytes[off + 2], + a.bytes[off + 3], + ]); + assert_eq!( + w, + addi_xn_zero(n), + "prologue insn {n} must be addi xN, x0, 0", + ); + // Sanity-check the encoding: OP-IMM opcode, funct3=0, + // rs1=x0, imm=0, rd=N. + assert_eq!(w & 0x7f, 0b0010011, "opcode"); + assert_eq!((w >> 12) & 0x7, 0, "funct3"); + assert_eq!((w >> 15) & 0x1f, 0, "rs1=x0"); + assert_eq!(w >> 20, 0, "imm=0"); + assert_eq!((w >> 7) & 0x1f, n, "rd=xN"); + } + } + + #[test] + fn prologue_off_skips_zeroing_block() { + let mut g = RawAsmGen::::new(4).with_zero_init_prologue(false); + let a = g.generate(&mut rng(), SeedId(0)); + // No prologue: just 4 body insns + 1 ebreak. + assert_eq!(a.bytes.len(), 5 * 4); + } + #[test] fn artifact_kind_is_raw_bytes() { let mut g = RawAsmGen::::new(4); @@ -149,8 +709,14 @@ mod tests { } #[test] - fn every_instruction_has_known_opcode() { - let mut g = RawAsmGen::::new(64); + fn every_body_instruction_has_known_opcode() { + // Body opcodes must stay within the ALU subset (LUI, OP-IMM, + // OP). M-extension lives under OP (funct7=0x01). Shifts live + // under OP-IMM/OP with their own funct3. The trailing ebreak + // is a SYSTEM opcode and is checked separately. + let mut g = RawAsmGen::::new(64) + .with_ebreak_terminator(false) + .with_zero_init_prologue(false); let a = g.generate(&mut rng(), SeedId(0)); let chunks: Vec = a .bytes @@ -160,12 +726,71 @@ mod tests { for insn in chunks { let opcode = insn & 0x7f; assert!( - opcode == 0b0110111 || opcode == 0b0010011 || opcode == 0b0110011, + opcode == OPCODE_LUI || opcode == OPCODE_OP_IMM || opcode == OPCODE_OP, "unexpected opcode 0b{opcode:07b}" ); } } + /// The expanded class pool must actually surface every category we + /// declared in `Class::ALL`. Picking a large body count and a + /// fixed seed gives near-100% probability of hitting each class. + /// The test fails if a class drops out of the random pool (e.g. + /// a future refactor accidentally truncates `Class::ALL`). + #[test] + fn class_pool_covers_lui_op_imm_op_and_m_extension() { + let mut g = RawAsmGen::::new(2000) + .with_ebreak_terminator(false) + .with_zero_init_prologue(false); + let a = g.generate(&mut rng(), SeedId(0)); + let chunks: Vec = a + .bytes + .chunks_exact(4) + .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect(); + + let mut saw_lui = false; + let mut saw_op_imm_shift = false; + let mut saw_op_imm_alu = false; + let mut saw_op_base = false; + let mut saw_op_sub_or_sra = false; + let mut saw_m_ext = false; + for insn in chunks { + let opcode = insn & 0x7f; + let funct3 = (insn >> 12) & 0x7; + let funct7 = (insn >> 25) & 0x7f; + match opcode { + OPCODE_LUI => saw_lui = true, + OPCODE_OP_IMM => { + if funct3 == 0b001 || funct3 == 0b101 { + saw_op_imm_shift = true; + } else { + saw_op_imm_alu = true; + } + } + OPCODE_OP => { + if funct7 == 0b0000001 { + saw_m_ext = true; + } else if funct7 == 0b0100000 { + saw_op_sub_or_sra = true; + } else { + saw_op_base = true; + } + } + other => panic!("unexpected opcode 0b{other:07b}"), + } + } + assert!(saw_lui, "Lui must appear"); + assert!( + saw_op_imm_shift, + "OP-IMM shifts (slli/srli/srai) must appear" + ); + assert!(saw_op_imm_alu, "OP-IMM ALU (addi/andi/...) must appear"); + assert!(saw_op_base, "OP base (add/and/...) must appear"); + assert!(saw_op_sub_or_sra, "OP funct7=0x20 (sub/sra) must appear",); + assert!(saw_m_ext, "M-extension (mul/div/rem) must appear"); + } + #[test] fn deterministic_for_same_seed() { let mut g1 = RawAsmGen::::new(8); @@ -181,4 +806,215 @@ mod tests { assert_eq!(g.target(), DutKind::RiverRc1Nano); assert_eq!(g.name(), "raw-asm-rv64"); } + + #[test] + fn rv32_marker_reports_xlen_32_and_distinct_name() { + let g = RawAsmGen::::new(1); + assert_eq!(g.name(), "raw-asm-rv32"); + assert_eq!(Rv32::xlen(), 32); + assert_eq!(Rv64::xlen(), 64); + } + + /// RV32 shift-immediate must fit in 5 bits of shamt. The encoder + /// must NOT generate a 6-bit shamt when xlen=32, since that would + /// be illegal RV32 encoding (the top bit overlaps with funct7's + /// arithmetic-vs-logical flag and would corrupt SRAI/SRLI). + #[test] + fn rv32_shift_immediate_uses_5bit_shamt() { + // Force the generator to emit shift-immediates by restricting + // extensions to just I and using a large body count. + let mut g = RawAsmGen::::new(500) + .with_ebreak_terminator(false) + .with_zero_init_prologue(false); + let a = g.generate(&mut rng(), SeedId(0)); + let chunks: Vec = a + .bytes + .chunks_exact(4) + .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect(); + let mut saw_shift = false; + for insn in chunks { + let opcode = insn & 0x7f; + let funct3 = (insn >> 12) & 0x7; + if opcode == OPCODE_OP_IMM && (funct3 == 0b001 || funct3 == 0b101) { + saw_shift = true; + // The imm12 field is bits 20..32. The top 7 bits of + // imm12 are bits 25..32 of the instruction. For RV32 + // shifts the shamt sits at bits 20..25 (5 bits) and + // the funct7 at bits 25..32 must be 0 (SLLI/SRLI) or + // 0x20 (SRAI). The bit at position 25 (shamt[5]) + // must always be zero. + let shamt_top_bit = (insn >> 25) & 1; + assert_eq!( + shamt_top_bit, 0, + "RV32 shamt[5] must be zero in shift insn 0x{insn:08x}", + ); + } + } + assert!(saw_shift, "test must have observed at least one shift insn"); + } + + #[test] + fn rv64_shift_immediate_uses_6bit_shamt() { + // RV64 shamt[5] CAN be 1 (shamts 32..63 are legal). Across a + // big sample, at least one shift should set that top bit. + let mut g = RawAsmGen::::new(500) + .with_ebreak_terminator(false) + .with_zero_init_prologue(false); + let a = g.generate(&mut rng(), SeedId(0)); + let chunks: Vec = a + .bytes + .chunks_exact(4) + .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect(); + let mut saw_wide_shamt = false; + for insn in chunks { + let opcode = insn & 0x7f; + let funct3 = (insn >> 12) & 0x7; + if opcode == OPCODE_OP_IMM && (funct3 == 0b001 || funct3 == 0b101) { + let shamt_top_bit = (insn >> 25) & 1; + if shamt_top_bit == 1 { + saw_wide_shamt = true; + break; + } + } + } + assert!( + saw_wide_shamt, + "RV64 random shifts should produce shamts >= 32 (top bit set)", + ); + } + + /// Extensions filter the class pool: disabling M must drop + /// mul/div/rem from the random output. + #[test] + fn disabling_m_extension_drops_mul_div_rem() { + let mut g = RawAsmGen::::new(500) + .with_ebreak_terminator(false) + .with_zero_init_prologue(false) + .with_extensions([RvExtension::I]); + let a = g.generate(&mut rng(), SeedId(0)); + let chunks: Vec = a + .bytes + .chunks_exact(4) + .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect(); + for insn in chunks { + let opcode = insn & 0x7f; + let funct7 = (insn >> 25) & 0x7f; + assert_ne!( + (opcode, funct7), + (OPCODE_OP, 0b0000001), + "M-extension insn leaked into I-only pool: 0x{insn:08x}", + ); + } + } + + #[test] + fn isa_string_parser_basic_rv64imac() { + let (xlen, exts) = parse_isa_string("rv64imac").expect("parse"); + assert_eq!(xlen, 64); + assert!(exts.contains(&RvExtension::I)); + assert!(exts.contains(&RvExtension::M)); + assert!(exts.contains(&RvExtension::A)); + assert!(exts.contains(&RvExtension::C)); + } + + #[test] + fn isa_string_parser_rv32i_minimal() { + let (xlen, exts) = parse_isa_string("rv32i").expect("parse"); + assert_eq!(xlen, 32); + assert_eq!(exts.len(), 1); + assert!(exts.contains(&RvExtension::I)); + } + + #[test] + fn isa_string_parser_g_shorthand_expands_to_imafd() { + let (xlen, exts) = parse_isa_string("rv64g").expect("parse"); + assert_eq!(xlen, 64); + for ext in [ + RvExtension::I, + RvExtension::M, + RvExtension::A, + RvExtension::F, + RvExtension::D, + ] { + assert!(exts.contains(&ext), "G shorthand must include {ext:?}"); + } + } + + #[test] + fn isa_string_parser_zicsr_via_underscore_chunk() { + let (xlen, exts) = parse_isa_string("rv64imac_zicsr_zifencei").expect("parse"); + assert_eq!(xlen, 64); + assert!(exts.contains(&RvExtension::Zicsr)); + assert!(exts.contains(&RvExtension::Zifencei)); + } + + #[test] + fn isa_string_parser_rejects_missing_rv_prefix() { + let err = parse_isa_string("64imac").unwrap_err(); + assert_eq!(err, IsaStringError::MissingRvPrefix); + } + + #[test] + fn isa_string_parser_rejects_unknown_letter() { + let err = parse_isa_string("rv64ixq").unwrap_err(); + assert!( + matches!(err, IsaStringError::UnknownLetter('x')), + "expected UnknownLetter('x'), got {err:?}", + ); + } + + #[test] + fn rv_extension_try_from_char() { + assert_eq!(RvExtension::try_from('i').unwrap(), RvExtension::I); + assert_eq!(RvExtension::try_from('M').unwrap(), RvExtension::M); + assert!(matches!( + RvExtension::try_from('x'), + Err(IsaStringError::UnknownLetter('x')), + )); + } + + #[test] + fn rv_extension_from_str_letter_and_zname() { + let i: RvExtension = "i".parse().unwrap(); + assert_eq!(i, RvExtension::I); + let zicsr: RvExtension = "zicsr".parse().unwrap(); + assert_eq!(zicsr, RvExtension::Zicsr); + let bad: Result = "rocketship".parse(); + assert!( + matches!(bad, Err(IsaStringError::UnknownZName(_))), + "got {bad:?}", + ); + } + + #[test] + fn parsed_isa_from_str_round_trips_through_parse_isa_string() { + let parsed: ParsedIsa = "rv64imac_zicsr".parse().unwrap(); + let (xlen, exts) = parse_isa_string("rv64imac_zicsr").unwrap(); + assert_eq!(parsed.xlen, xlen); + assert_eq!(parsed.extensions, exts); + } + + #[test] + fn parsed_isa_from_tuple_drops_unknown_extensions() { + // Forward-compat path: a TOML config naming a future + // extension shouldn't break a downscoped fuzz session. The + // From<(u8, IntoIter<&str>)> impl silently filters them. + let parsed: ParsedIsa = (64u8, ["i", "m", "futureXYZ", "zicsr"].into_iter()).into(); + assert_eq!(parsed.xlen, 64); + assert!(parsed.extensions.contains(&RvExtension::I)); + assert!(parsed.extensions.contains(&RvExtension::M)); + assert!(parsed.extensions.contains(&RvExtension::Zicsr)); + assert_eq!(parsed.extensions.len(), 3, "futureXYZ must be dropped"); + } + + #[test] + fn parsed_isa_always_contains_i() { + // Even when the input doesn't mention it: I is implicit. + let parsed: ParsedIsa = (32u8, ["m"].into_iter()).into(); + assert!(parsed.extensions.contains(&RvExtension::I)); + assert!(parsed.extensions.contains(&RvExtension::M)); + } } diff --git a/crates/heimdall-fuzzer/src/lib.rs b/crates/heimdall-fuzzer/src/lib.rs index 33b9a84..2ed139e 100644 --- a/crates/heimdall-fuzzer/src/lib.rs +++ b/crates/heimdall-fuzzer/src/lib.rs @@ -15,12 +15,17 @@ pub use corpus::{Corpus, CorpusEntry, VerdictTag}; pub use coverage::{CoverageDiff, CoverageMap, CoverageSnapshot, DEFAULT_BUCKETS}; pub use error::{FuzzerError, Result}; #[cfg(feature = "cranelift")] -pub use generator::CraneliftGen; -pub use generator::{RawAsmGen, Rv64}; +pub use generator::{CraneliftGen, CraneliftInitError}; +pub use generator::{ + EBREAK_INSN, IsaStringError, IsaTag, ParsedIsa, RawAsmGen, Rv32, Rv64, RvExtension, + parse_isa_string, +}; pub use mutator::{BitFlipMutator, ByteFlipMutator, SpliceMutator}; pub use scheduler::{PowerScheduler, RoundRobinScheduler}; -pub use engine::{DivergenceFinding, FuzzReport, FuzzerEngine, FuzzerEngineBuilder}; +pub use engine::{ + DivergenceFinding, FuzzReport, FuzzerEngine, FuzzerEngineBuilder, IterCallback, ProgramCallback, +}; pub use fuzz_test::AdHocFuzzTest; #[cfg(feature = "aegis")] pub use generator::BitstreamGen; diff --git a/crates/heimdall-fuzzer/src/traits.rs b/crates/heimdall-fuzzer/src/traits.rs index ecfd9d3..659cb3f 100644 --- a/crates/heimdall-fuzzer/src/traits.rs +++ b/crates/heimdall-fuzzer/src/traits.rs @@ -9,6 +9,24 @@ pub trait Generator: Send + Sync { fn generate(&mut self, rng: &mut dyn RngCore, seed: SeedId) -> Artifact; } +/// Blanket impl so callers that need a runtime choice between +/// generators (e.g. the daemon worker switching on +/// `JobKind::Fuzz.generator`) can hand a `Box` to the +/// engine builder without duplicating the build pipeline per concrete +/// type. The trait stays object-safe because every method takes +/// `&mut self` or `&self` and returns owned values. +impl Generator for Box { + fn target(&self) -> DutKind { + (**self).target() + } + fn name(&self) -> &str { + (**self).name() + } + fn generate(&mut self, rng: &mut dyn RngCore, seed: SeedId) -> Artifact { + (**self).generate(rng, seed) + } +} + /// Mutates a parent Artifact. Returns a new Artifact with the same kind. pub trait Mutator: Send + Sync { fn name(&self) -> &str; diff --git a/crates/heimdall-fuzzer/tests/engine_mock.rs b/crates/heimdall-fuzzer/tests/engine_mock.rs index fe3c7ba..1b0ff50 100644 --- a/crates/heimdall-fuzzer/tests/engine_mock.rs +++ b/crates/heimdall-fuzzer/tests/engine_mock.rs @@ -1,16 +1,22 @@ //! End-to-end fuzzer integration test using mocks throughout. -use heimdall_core::{DutId, DutKind, State, StepBudget, ValueRepr}; -use heimdall_driver::{Dut, MockDriver}; +use async_trait::async_trait; +use heimdall_core::{ + Artifact, ArtifactKind, DutId, DutKind, Observation, State, StepBudget, Stimulus, ValueRepr, + Verdict, +}; +use heimdall_driver::error::DriverError; +use heimdall_driver::{Dut, MockDriver, TestDriver}; use heimdall_fuzzer::{BitFlipMutator, FuzzerEngine, RawAsmGen, RoundRobinScheduler, Rv64}; use heimdall_golden::MockGoldenModel; use heimdall_test::Runner; +use heimdall_transport::TransportKind; #[tokio::test] async fn fuzz_ten_iterations_against_mocks() { // Driver and golden both default to a0=0x42; every iteration's diff Passes. let driver = MockDriver::new(DutKind::RiverRc1Nano) - .with_state(State::new().with("a0", ValueRepr::U64(0x42))); + .with_state(State::new().with("x10", ValueRepr::U64(0x42))); let golden = MockGoldenModel::new(DutKind::RiverRc1Nano); let mut dut = Dut::new(DutId::new("d1"), DutKind::RiverRc1Nano); @@ -34,3 +40,140 @@ async fn fuzz_ten_iterations_against_mocks() { assert_eq!(report.errors, 0); assert!(report.corpus_size > 0, "corpus should grow"); } + +/// Driver whose `run()` always errors. Used to confirm that one bad +/// iteration doesn't take down the whole fuzz session: the engine should +/// catch the error, count it under `errors`, and keep going. Models the +/// real-world case where a randomly-generated program traps or loops +/// forever and the wait_halt timeout propagates a `TransportError` up to +/// the runner. +struct AlwaysErroringDriver { + target: DutKind, +} + +#[async_trait] +impl TestDriver for AlwaysErroringDriver { + fn target(&self) -> DutKind { + self.target + } + fn required_transports(&self) -> &[TransportKind] { + &[] + } + async fn prepare(&mut self, _dut: &mut Dut) -> Result<(), DriverError> { + Ok(()) + } + async fn compile( + &mut self, + input: &Artifact, + _tools: &heimdall_tools::ToolChain, + ) -> Result { + let mut a = input.clone(); + a.kind = ArtifactKind::ElfRiscv; + Ok(a) + } + async fn load(&mut self, _dut: &mut Dut, _image: &Artifact) -> Result<(), DriverError> { + Ok(()) + } + async fn run(&mut self, _dut: &mut Dut, _stim: &Stimulus) -> Result { + Err(DriverError::State("simulated wait_halt timeout")) + } + async fn observe(&mut self, _dut: &mut Dut) -> Result { + Ok(State::new()) + } + async fn diff(&self, _dut: &State, _golden: &State) -> Verdict { + Verdict::Pass + } + async fn release(&mut self, _dut: &mut Dut) -> Result<(), DriverError> { + Ok(()) + } +} + +#[tokio::test] +async fn fuzz_continues_past_per_iteration_driver_errors() { + let driver = AlwaysErroringDriver { + target: DutKind::RiverRc1Nano, + }; + let golden = MockGoldenModel::new(DutKind::RiverRc1Nano); + let mut dut = Dut::new(DutId::new("d-err"), DutKind::RiverRc1Nano); + + let mut engine = FuzzerEngine::builder() + .with_runner(Runner::builder().build()) + .with_generator(RawAsmGen::::new(4)) + .with_mutator(BitFlipMutator) + .with_scheduler(RoundRobinScheduler::new()) + .with_driver(driver) + .with_golden(golden) + .with_rng_seed(0xfeedface) + .with_step_budget(StepBudget::cycles(100)) + .build(); + + // Engine.run MUST resolve to Ok with errors == iterations, NOT + // propagate the driver's error out and abort the whole session. + let report = engine + .run(&mut dut, 5) + .await + .expect("engine should not abort"); + assert_eq!(report.iterations, 5); + assert_eq!( + report.errors, 5, + "every iteration's driver error should land under `errors`" + ); + assert_eq!(report.passes, 0); + assert_eq!(report.fails, 0); +} + +/// The daemon's web UI shows "what is the fuzzer running right now" +/// by registering a [`heimdall_fuzzer::ProgramCallback`] that copies +/// each iter's artifact bytes into a per-job cache. Pin the contract +/// here so a future refactor that drops the observer call shows up +/// as a unit-test failure rather than a silent regression in the UI. +#[tokio::test] +async fn program_observer_fires_per_iter_with_each_artifact() { + use std::sync::Arc; + use std::sync::Mutex; + + let driver = MockDriver::new(DutKind::RiverRc1Nano) + .with_state(State::new().with("x10", ValueRepr::U64(0))); + let golden = MockGoldenModel::new(DutKind::RiverRc1Nano) + .with_state(State::new().with("x10", ValueRepr::U64(0))); + let mut dut = Dut::new(DutId::new("d-obs"), DutKind::RiverRc1Nano); + + type CapturedIter = (u64, Vec, ArtifactKind); + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let captured_clone = captured.clone(); + let observer: heimdall_fuzzer::ProgramCallback = Arc::new(move |iter, artifact: &Artifact| { + captured_clone + .lock() + .unwrap() + .push((iter, artifact.bytes.to_vec(), artifact.kind.clone())); + }); + + let mut engine = FuzzerEngine::builder() + .with_runner(Runner::builder().build()) + .with_generator(RawAsmGen::::new(4)) + .with_mutator(BitFlipMutator) + .with_scheduler(RoundRobinScheduler::new()) + .with_driver(driver) + .with_golden(golden) + .with_rng_seed(0x1234_5678) + .with_step_budget(StepBudget::cycles(100)) + .with_program_observer(observer) + .build(); + + let _ = engine.run(&mut dut, 3).await.expect("run"); + + let log = captured.lock().unwrap(); + assert_eq!(log.len(), 3, "observer must fire exactly once per iter"); + // Iter indices are 0..N in order. + for (i, (iter, _, _)) in log.iter().enumerate() { + assert_eq!(*iter, i as u64, "iter index must be sequential"); + } + // All artifacts are RawBytes per the RawAsmGen contract. + for (_, _, kind) in log.iter() { + assert!(matches!(kind, ArtifactKind::RawBytes)); + } + // Each artifact has bytes (prologue + body + ebreak by default). + for (_, bytes, _) in log.iter() { + assert!(!bytes.is_empty(), "every iter must surface non-empty bytes"); + } +} diff --git a/crates/heimdall-golden/Cargo.toml b/crates/heimdall-golden/Cargo.toml index 7041972..e610c3a 100644 --- a/crates/heimdall-golden/Cargo.toml +++ b/crates/heimdall-golden/Cargo.toml @@ -14,13 +14,13 @@ description = "Golden reference models for Heimdall: spike RISC-V ISS, Aegis sim [features] default = [] -spike = [] +spike = ["dep:elf"] aegis = ["dep:aegis-ip", "dep:aegis-sim", "dep:serde_json"] spice = [] [dependencies] heimdall-core.workspace = true -aegis-ip = { workspace = true, optional = true } +aegis-ip = { workspace = true, optional = true } aegis-sim = { workspace = true, optional = true } async-trait.workspace = true thiserror.workspace = true @@ -28,6 +28,7 @@ tracing.workspace = true tokio = { workspace = true } serde_json = { workspace = true, optional = true } tempfile.workspace = true +elf = { workspace = true, optional = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/heimdall-golden/src/lib.rs b/crates/heimdall-golden/src/lib.rs index 2a41b7a..5b7b3da 100644 --- a/crates/heimdall-golden/src/lib.rs +++ b/crates/heimdall-golden/src/lib.rs @@ -21,7 +21,10 @@ pub use aegis::AegisGoldenModel; pub mod spice; #[cfg(feature = "spice")] -pub use spice::{RawTrace, SpiceCoverage, SpiceDir, SpiceGoldenModel, SpiceWatch, parse_raw_ascii}; +pub use spice::{ + RawTrace, SpiceCoverage, SpiceDir, SpiceGoldenModel, SpiceRawParseError, SpiceWatch, + parse_raw_ascii, +}; #[cfg(feature = "spice")] pub use spice::render::{ diff --git a/crates/heimdall-golden/src/mock.rs b/crates/heimdall-golden/src/mock.rs index 18c2d0c..cedf04c 100644 --- a/crates/heimdall-golden/src/mock.rs +++ b/crates/heimdall-golden/src/mock.rs @@ -47,7 +47,9 @@ pub struct MockGoldenModel { impl MockGoldenModel { pub fn new(target: DutKind) -> Self { - let fixed_state = State::new().with("a0", ValueRepr::U64(0x42)); + // Heimdall convention: State uses hardware register names (x10) not + // ABI names (a0). Frontends translate to ABI on render. + let fixed_state = State::new().with("x10", ValueRepr::U64(0x42)); Self { target, fixed_state, diff --git a/crates/heimdall-golden/src/spice/mod.rs b/crates/heimdall-golden/src/spice/mod.rs index 54adb4a..5c995b3 100644 --- a/crates/heimdall-golden/src/spice/mod.rs +++ b/crates/heimdall-golden/src/spice/mod.rs @@ -18,7 +18,7 @@ use tracing::{debug, instrument}; use crate::error::GoldenError; use crate::trait_def::{CoverageSource, GoldenModel, Result, StepOutcome}; -pub use parse::{RawTrace, parse_raw_ascii}; +pub use parse::{RawTrace, SpiceRawParseError, parse_raw_ascii}; /// Configuration for a SPICE node watched by the golden. #[derive(Debug, Clone)] diff --git a/crates/heimdall-golden/src/spice/parse.rs b/crates/heimdall-golden/src/spice/parse.rs index 4aa488d..1e060a5 100644 --- a/crates/heimdall-golden/src/spice/parse.rs +++ b/crates/heimdall-golden/src/spice/parse.rs @@ -1,5 +1,15 @@ //! Minimal ngspice ASCII .raw parser. Extracts per-variable traces. +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SpiceRawParseError { + #[error("No. Variables: parse failed: {0}")] + NVars(#[source] std::num::ParseIntError), + #[error("No. Points: parse failed: {0}")] + NPoints(#[source] std::num::ParseIntError), +} + #[derive(Debug, Clone)] pub struct RawTrace { pub name: String, @@ -51,7 +61,7 @@ impl RawTrace { ///