diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70ec4aa6..4229c332 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: restore-keys: ${{ runner.os }}-bun- - name: Install Rust toolchain - uses: leynos/shared-actions/.github/actions/setup-rust@c2b856998a4438bfdaa71c90cde1b03044e5d260 + uses: leynos/shared-actions/.github/actions/setup-rust@e4c6b0e200a057edf927c45c298e7ddf229b3934 - name: Install uv id: setup-uv @@ -91,6 +91,9 @@ jobs: mmdc --version bun --version + - name: Install cargo-audit + run: cargo binstall --no-confirm cargo-audit@0.22.1 + - name: Audit run: make audit @@ -153,6 +156,9 @@ jobs: whitaker --all -- --manifest-path Cargo.toml --workspace --all-targets --all-features whitaker --all -- --manifest-path backend/Cargo.toml --all-targets --all-features + - name: Architecture lint + run: make lint-architecture + - name: Install nextest uses: taiki-e/install-action@db22c42b5af88356329b9a8056bb2c2f026d5a10 with: @@ -166,6 +172,9 @@ jobs: env: # Increase GitHub API rate limits for postgresql_embedded downloads. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Pin the embedded PostgreSQL version so postgresql_archive skips release-listing queries. + # Use the semver "exact" prefix so no wildcard resolution is attempted. + POSTGRESQL_VERSION: "=16.10.0" # Keep backend selection explicit for strict PG_TEST_BACKEND validation. PG_TEST_BACKEND: postgresql_embedded # Root-path bootstrap requires the worker binary for privilege demotion. diff --git a/Cargo.lock b/Cargo.lock index d6162f1d..feb9f2cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.9.1", + "bitflags", "bytes", "futures-core", "futures-sink", @@ -30,7 +30,7 @@ dependencies = [ "actix-service", "actix-utils", "base64 0.22.1", - "bitflags 2.9.1", + "bitflags", "brotli", "bytes", "bytestring", @@ -39,7 +39,7 @@ dependencies = [ "flate2", "foldhash 0.1.5", "futures-core", - "h2", + "h2 0.3.27", "http 0.2.12", "httparse", "httpdate", @@ -65,7 +65,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -219,7 +219,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -284,7 +284,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -410,9 +410,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "apalis-codec" @@ -438,7 +438,7 @@ dependencies = [ "futures-util", "pin-project", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "tower-layer", "tower-service", "tracing", @@ -458,7 +458,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "ulid", ] @@ -473,7 +473,7 @@ dependencies = [ "chrono", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -509,7 +509,7 @@ dependencies = [ "rstest", "rstest-bdd 0.4.0", "rstest-bdd-macros 0.4.0", - "syn 2.0.104", + "syn 2.0.117", "tempfile", ] @@ -578,7 +578,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -639,7 +639,7 @@ dependencies = [ "derive_more 2.0.1", "futures-core", "futures-util", - "h2", + "h2 0.3.27", "http 0.2.12", "itoa", "log", @@ -695,7 +695,7 @@ dependencies = [ "postgresql_embedded", "prometheus", "rand 0.8.5", - "reqwest", + "reqwest 0.12.24", "rstest", "rstest-bdd 0.5.0", "rstest-bdd-macros 0.5.0", @@ -704,7 +704,7 @@ dependencies = [ "sha2", "sqlx", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "tracing-subscriber", @@ -795,18 +795,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bb8" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89aabfae550a5c44b43ab941844ffcd2e993cb6900b342debf59e9ea74acdb8" -dependencies = [ - "async-trait", - "futures-util", - "parking_lot 0.12.4", - "tokio", -] - [[package]] name = "bb8" version = "0.9.1" @@ -814,7 +802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "457d7ed3f888dfd2c7af56d4975cade43c622f74bdcddfed6d4352f57acc6310" dependencies = [ "futures-util", - "parking_lot 0.12.4", + "parking_lot", "portable-atomic", "tokio", ] @@ -825,7 +813,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1063effc7f6cf848bcbcc6e31b5962be75215835587d3109607c643d616f66" dependencies = [ - "bb8 0.9.1", + "bb8", "redis", ] @@ -838,12 +826,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.9.1" @@ -873,7 +855,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -926,9 +908,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bytestring" @@ -960,7 +942,7 @@ dependencies = [ "io-lifetimes 2.0.4", "ipnet", "maybe-owned", - "rustix 1.1.2", + "rustix 1.1.4", "rustix-linux-procfs", "windows-sys 0.59.0", "winx", @@ -968,9 +950,9 @@ dependencies = [ [[package]] name = "cap-primitives" -version = "4.0.0" +version = "4.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2738131575dbd2ca9e8e144a102ba4e534ca726e2446f1e92090a1cc08fe283" +checksum = "cdadbd7c002d3a484b35243669abdae85a0ebaded5a61117169dc3400f9a7ff0" dependencies = [ "ambient-authority", "fs-set-times", @@ -978,7 +960,7 @@ dependencies = [ "io-lifetimes 3.0.1", "ipnet", "maybe-owned", - "rustix 1.1.2", + "rustix 1.1.4", "rustix-linux-procfs", "windows-sys 0.61.2", "winx", @@ -994,19 +976,19 @@ dependencies = [ "cap-primitives 3.4.5", "io-extras 0.18.4", "io-lifetimes 2.0.4", - "rustix 1.1.2", + "rustix 1.1.4", ] [[package]] name = "cap-std" -version = "4.0.0" +version = "4.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d9210de501a941735b489ee5c41501283259ddf3fec9884ace40def0a80a11" +checksum = "7281235d6e96d3544ca18bba9049be92f4190f8d923e3caef1b5f66cfa752608" dependencies = [ - "cap-primitives 4.0.0", + "cap-primitives 4.0.2", "io-extras 0.19.0", "io-lifetimes 3.0.1", - "rustix 1.1.2", + "rustix 1.1.4", ] [[package]] @@ -1032,6 +1014,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.42" @@ -1075,7 +1068,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1099,7 +1092,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1255,6 +1248,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 = "crc" version = "3.3.0" @@ -1325,13 +1327,13 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.9.1", + "bitflags", "crossterm_winapi", "derive_more 2.0.1", "document-features", "mio", - "parking_lot 0.12.4", - "rustix 1.1.2", + "parking_lot", + "rustix 1.1.4", "signal-hook", "signal-hook-mio", "winapi", @@ -1364,7 +1366,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1376,38 +1378,14 @@ dependencies = [ "cipher", ] -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - [[package]] name = "darling" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.104", + "darling_core", + "darling_macro", ] [[package]] @@ -1421,18 +1399,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.104", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1441,9 +1408,9 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core 0.21.3", + "darling_core", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1457,7 +1424,7 @@ dependencies = [ "hashbrown 0.14.5", "lock_api", "once_cell", - "parking_lot_core 0.9.11", + "parking_lot_core", ] [[package]] @@ -1473,12 +1440,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -1489,7 +1456,7 @@ checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1502,7 +1469,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1523,7 +1490,7 @@ dependencies = [ "convert_case 0.7.1", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", "unicode-xid", ] @@ -1535,14 +1502,15 @@ checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" [[package]] name = "diesel" -version = "2.2.12" +version = "2.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229850a212cd9b84d4f0290ad9d294afc0ae70fccaa8949dbe8b43ffafa1e20c" +checksum = "9940fb8467a0a06312218ed384185cb8536aa10d8ec017d0ce7fad2c1bd882d5" dependencies = [ - "bitflags 2.9.1", + "bitflags", "byteorder", "chrono", "diesel_derives", + "downcast-rs", "itoa", "pq-sys", "serde_json", @@ -1551,14 +1519,15 @@ dependencies = [ [[package]] name = "diesel-async" -version = "0.5.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51a307ac00f7c23f526a04a77761a0519b9f0eb2838ebf5b905a58580095bdcb" +checksum = "b95864e58597509106f1fddfe0600de7e589e1fddddd87f54eee0a49fd111bbc" dependencies = [ - "async-trait", - "bb8 0.8.6", + "bb8", "diesel", + "futures-core", "futures-util", + "pin-project-lite", "scoped-futures", "tokio", "tokio-postgres", @@ -1566,22 +1535,22 @@ dependencies = [ [[package]] name = "diesel_derives" -version = "2.2.7" +version = "2.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b96984c469425cb577bf6f17121ecb3e4fe1e81de5d8f780dd372802858d756" +checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5" dependencies = [ "diesel_table_macro_syntax", "dsl_auto_type", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "diesel_migrations" -version = "2.2.0" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a73ce704bad4231f001bff3314d91dce4aba0770cee8b233991859abc15c1f6" +checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8" dependencies = [ "diesel", "migrations_internals", @@ -1590,11 +1559,11 @@ dependencies = [ [[package]] name = "diesel_table_macro_syntax" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" dependencies = [ - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1674,7 +1643,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1698,18 +1667,24 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dsl_auto_type" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ae9aca7527f85f26dd76483eb38533fd84bd571065da1739656ef71c5ff5b" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" dependencies = [ - "darling 0.20.11", + "darling", "either", "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1818,7 +1793,7 @@ version = "0.1.0" dependencies = [ "base-d", "camino", - "cap-std 4.0.0", + "cap-std 4.0.2", "fake", "rand 0.9.2", "rand_chacha 0.9.0", @@ -1827,7 +1802,7 @@ dependencies = [ "rstest-bdd-macros 0.5.0", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "uuid", ] @@ -1883,7 +1858,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" dependencies = [ "atomic", - "parking_lot 0.12.4", + "parking_lot", "pear", "serde", "serde_yaml", @@ -1926,13 +1901,13 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.2" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", - "libz-rs-sys", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -2001,7 +1976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" dependencies = [ "memchr", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -2070,7 +2045,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" dependencies = [ "io-lifetimes 2.0.4", - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.59.0", ] @@ -2091,9 +2066,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -2101,9 +2076,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" @@ -2124,37 +2099,37 @@ checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" dependencies = [ "futures-core", "lock_api", - "parking_lot 0.12.4", + "parking_lot", ] [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" @@ -2164,9 +2139,9 @@ checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -2176,7 +2151,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -2275,11 +2249,25 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasi 0.14.2+wasi-0.2.4", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + [[package]] name = "ghash" version = "0.5.1" @@ -2301,7 +2289,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.104", + "syn 2.0.117", "textwrap", "thiserror 1.0.69", "typed-builder", @@ -2338,6 +2326,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.3.1", + "indexmap 2.12.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "halfbrown" version = "0.4.0" @@ -2577,6 +2584,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2 0.4.14", "http 1.3.1", "http-body", "httparse", @@ -2672,7 +2680,7 @@ dependencies = [ "i18n-embed-impl", "intl-memoizer", "log", - "parking_lot 0.12.4", + "parking_lot", "rust-embed", "sys-locale", "thiserror 1.0.69", @@ -2689,7 +2697,7 @@ dependencies = [ "i18n-config", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -2845,6 +2853,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -2936,18 +2950,6 @@ dependencies = [ "similar", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "intl-memoizer" version = "0.5.3" @@ -3008,33 +3010,12 @@ version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" -[[package]] -name = "io-uring" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" -dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "libc", -] - [[package]] name = "ipnet" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" -[[package]] -name = "iri-string" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -3077,10 +3058,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -3111,7 +3094,7 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -3129,11 +3112,17 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.175" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -3147,9 +3136,9 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.9.1", + "bitflags", "libc", - "redox_syscall 0.5.17", + "redox_syscall", ] [[package]] @@ -3163,15 +3152,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libz-rs-sys" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221" -dependencies = [ - "zlib-rs", -] - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3180,9 +3160,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -3322,9 +3302,9 @@ dependencies = [ [[package]] name = "migrations_internals" -version = "2.2.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bda1634d70d5bd53553cf15dca9842a396e8c799982a3ad22998dc44d961f24" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" dependencies = [ "serde", "toml 0.9.8", @@ -3332,9 +3312,9 @@ dependencies = [ [[package]] name = "migrations_macros" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffb161cc72176cb37aa47f1fc520d3ef02263d67d661f44f05d05a079e1237fd" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" dependencies = [ "migrations_internals", "proc-macro2", @@ -3364,6 +3344,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -3439,7 +3420,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -3471,7 +3452,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.1", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -3520,19 +3501,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] -name = "num-format" -version = "0.4.4" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" -dependencies = [ - "arrayvec", - "itoa", -] +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -3597,7 +3568,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.9.1", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -3614,7 +3585,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -3670,7 +3641,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror 2.0.17", + "thiserror 2.0.18", "toml 0.9.8", "uncased", "xdg", @@ -3694,7 +3665,7 @@ dependencies = [ "ortho_config_macros 0.7.0", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "toml 0.9.8", "tracing", "uncased", @@ -3711,7 +3682,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -3723,7 +3694,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -3756,7 +3727,7 @@ dependencies = [ "rstest-bdd-macros 0.5.0", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", ] @@ -3766,17 +3737,6 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" -[[package]] -name = "parking_lot" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" -dependencies = [ - "instant", - "lock_api", - "parking_lot_core 0.8.6", -] - [[package]] name = "parking_lot" version = "0.12.4" @@ -3784,21 +3744,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ "lock_api", - "parking_lot_core 0.9.11", -] - -[[package]] -name = "parking_lot_core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" -dependencies = [ - "cfg-if", - "instant", - "libc", - "redox_syscall 0.2.16", - "smallvec", - "winapi", + "parking_lot_core", ] [[package]] @@ -3809,7 +3755,7 @@ checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.17", + "redox_syscall", "smallvec", "windows-targets 0.52.6", ] @@ -3846,7 +3792,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -3898,7 +3844,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" dependencies = [ "memchr", - "thiserror 2.0.17", + "thiserror 2.0.18", "ucd-trie", ] @@ -3922,7 +3868,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -3937,12 +3883,12 @@ dependencies = [ [[package]] name = "pg-embed-setup-unpriv" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3331937f47bb304d7aa1c3337c4ac52fdd9db5063c399a3e4dad5ecec274b72" +checksum = "aa77812ed248cff4ab03feaec586c99343c47077e52981d0b18202b5687f425d" dependencies = [ "camino", - "cap-std 4.0.0", + "cap-std 4.0.2", "clap", "color-eyre", "dashmap", @@ -3963,7 +3909,7 @@ dependencies = [ "serde_with", "sha2", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "uncased", @@ -4007,14 +3953,14 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -4056,7 +4002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -4113,17 +4059,16 @@ dependencies = [ [[package]] name = "postgresql_archive" -version = "0.20.0" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e80b519badc77389e9bb48944da8b639eaddc0ab9bb0e921be233c11348f3655" +checksum = "422bfe2de3e0776c9129d431849985cded3bf67a694d9e93ed316c93c47c1367" dependencies = [ "async-trait", "flate2", "futures-util", "hex", - "num-format", "regex-lite", - "reqwest", + "reqwest 0.13.3", "reqwest-middleware", "reqwest-retry", "reqwest-tracing", @@ -4134,37 +4079,37 @@ dependencies = [ "tar", "target-triple", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "url", ] [[package]] name = "postgresql_commands" -version = "0.20.0" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20698f9f0fddfa20bb0f8db60c47c7c1996b781c8e4bc2d09182d6cab66da25c" +checksum = "446b4cef09bfbe79023089b63505f3e5c87854cf495fd608579df2a80bdde9e0" dependencies = [ - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", ] [[package]] name = "postgresql_embedded" -version = "0.20.0" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c73524453412018db32b4929cc0b58ecb2d04cff679e9d397ab32880fbe7a0ca" +checksum = "78b32842157348a60f4c600603ed16dd5e3d30b159dabf70a60a9d44934f80a7" dependencies = [ "anyhow", "postgresql_archive", "postgresql_commands", - "rand 0.9.2", + "rand 0.10.1", "semver", "sqlx", "target-triple", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "url", @@ -4245,6 +4190,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "proc-macro-crate" version = "3.3.0" @@ -4301,7 +4256,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", "version_check", "yansi", ] @@ -4316,9 +4271,9 @@ dependencies = [ "fnv", "lazy_static", "memchr", - "parking_lot 0.12.4", + "parking_lot", "protobuf", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -4386,7 +4341,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2 0.6.0", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -4394,9 +4349,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.3", @@ -4407,7 +4362,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -4442,6 +4397,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -4463,6 +4424,17 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4501,6 +4473,12 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rayon" version = "1.11.0" @@ -4544,22 +4522,13 @@ dependencies = [ "xxhash-rust", ] -[[package]] -name = "redox_syscall" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags 1.3.2", -] - [[package]] name = "redox_syscall" version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.1", + "bitflags", ] [[package]] @@ -4581,7 +4550,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -4601,7 +4570,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -4629,9 +4598,9 @@ dependencies = [ [[package]] name = "regex-lite" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" @@ -4660,11 +4629,9 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -4675,7 +4642,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -4684,31 +4650,72 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", "webpki-roots 1.0.6", ] +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "h2 0.4.14", + "http 1.3.1", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + [[package]] name = "reqwest-middleware" -version = "0.4.2" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" +checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" dependencies = [ "anyhow", "async-trait", "http 1.3.1", - "reqwest", + "reqwest 0.13.3", "serde", - "thiserror 1.0.69", + "thiserror 2.0.18", "tower-service", ] [[package]] name = "reqwest-retry" -version = "0.7.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c73e4195a6bfbcb174b790d9b3407ab90646976c55de58a6515da25d851178" +checksum = "fe2412db2af7d2268e7a5406be0431f37d9eb67ff390f35b395716f5f06c2eaa" dependencies = [ "anyhow", "async-trait", @@ -4716,39 +4723,38 @@ dependencies = [ "getrandom 0.2.16", "http 1.3.1", "hyper", - "parking_lot 0.11.2", - "reqwest", + "reqwest 0.13.3", "reqwest-middleware", "retry-policies", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tracing", - "wasm-timer", + "wasmtimer", ] [[package]] name = "reqwest-tracing" -version = "0.5.8" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d70ea85f131b2ee9874f0b160ac5976f8af75f3c9badfe0d955880257d10bd83" +checksum = "b5e5af0cd6fc3d3c8f703d597af70b6e4e62432c63157b49419fa1ffaf481702" dependencies = [ "anyhow", "async-trait", "getrandom 0.2.16", "http 1.3.1", "matchit", - "reqwest", + "reqwest 0.13.3", "reqwest-middleware", "tracing", ] [[package]] name = "retry-policies" -version = "0.4.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5875471e6cab2871bc150ecb8c727db5113c9338cc3354dc5ee3425b6aa40a1c" +checksum = "dc05fbf560421a0357a750cbe78c7ca19d4923918490daabba313d5dbc871e47" dependencies = [ - "rand 0.8.5", + "rand 0.10.1", ] [[package]] @@ -4930,7 +4936,7 @@ dependencies = [ "regex", "rstest-bdd-patterns 0.4.0", "rstest-bdd-policy 0.4.0", - "syn 2.0.104", + "syn 2.0.117", "thiserror 1.0.69", "walkdir", ] @@ -4954,7 +4960,7 @@ dependencies = [ "regex", "rstest-bdd-patterns 0.5.0", "rstest-bdd-policy 0.5.0", - "syn 2.0.104", + "syn 2.0.117", "thiserror 1.0.69", "walkdir", ] @@ -5007,7 +5013,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.104", + "syn 2.0.117", "unicode-ident", ] @@ -5017,7 +5023,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.9.1", + "bitflags", "fallible-iterator 0.3.0", "fallible-streaming-iterator", "hashlink 0.9.1", @@ -5045,7 +5051,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.104", + "syn 2.0.117", "walkdir", ] @@ -5086,7 +5092,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.1", + "bitflags", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5095,14 +5101,14 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.9.1", + "bitflags", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -5113,7 +5119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" dependencies = [ "once_cell", - "rustix 1.1.2", + "rustix 1.1.4", ] [[package]] @@ -5142,9 +5148,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -5236,7 +5242,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.1", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -5261,9 +5267,9 @@ checksum = "16c2f82143577edb4921b71ede051dac62ca3c16084e918bf7b40c96ae10eb33" [[package]] name = "semver" -version = "1.0.26" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -5292,19 +5298,20 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -5376,10 +5383,10 @@ version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e" dependencies = [ - "darling 0.21.3", + "darling", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -5402,7 +5409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5413,7 +5420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5645,7 +5652,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -5663,7 +5670,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -5686,7 +5693,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.104", + "syn 2.0.117", "tokio", "url", ] @@ -5699,7 +5706,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.9.1", + "bitflags", "byteorder", "bytes", "chrono", @@ -5729,7 +5736,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "whoami", ] @@ -5742,7 +5749,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.9.1", + "bitflags", "byteorder", "chrono", "crc", @@ -5767,7 +5774,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "whoami", ] @@ -5792,7 +5799,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "url", ] @@ -5845,9 +5852,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -5871,7 +5878,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -5885,9 +5892,9 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", @@ -5896,20 +5903,20 @@ dependencies = [ [[package]] name = "target-triple" -version = "0.1.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac9aa371f599d22256307c24a9d748c041e548cbf599f35d890f9d365361790" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -5919,7 +5926,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.60.2", ] @@ -5951,11 +5958,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -5966,18 +5973,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -5991,30 +5998,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -6047,33 +6054,30 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.47.1" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", - "parking_lot 0.12.4", + "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", "socket2 0.6.0", "tokio-macros", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -6099,7 +6103,7 @@ dependencies = [ "futures-channel", "futures-util", "log", - "parking_lot 0.12.4", + "parking_lot", "percent-encoding", "phf", "pin-project-lite", @@ -6252,20 +6256,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.9.1", + "bitflags", "bytes", "futures-util", "http 1.3.1", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -6282,9 +6286,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -6294,20 +6298,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -6403,7 +6407,7 @@ checksum = "29a3151c41d0b13e3d011f98adc24434560ef06673a155a6c7f66b9879eecce2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -6478,7 +6482,7 @@ checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" dependencies = [ "proc-macro-hack", "quote", - "syn 2.0.104", + "syn 2.0.117", "unic-langid-impl", ] @@ -6575,9 +6579,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -6619,7 +6623,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.104", + "syn 2.0.117", "uuid", ] @@ -6733,6 +6737,24 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + [[package]] name = "wasite" version = "0.1.0" @@ -6741,48 +6763,32 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.104", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6790,26 +6796,48 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.104", - "wasm-bindgen-backend", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.12.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -6824,25 +6852,49 @@ dependencies = [ ] [[package]] -name = "wasm-timer" -version = "0.2.5" +name = "wasm-streams" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ - "futures", + "futures-util", "js-sys", - "parking_lot 0.11.2", - "pin-utils", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.12.0", + "semver", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -6938,7 +6990,7 @@ dependencies = [ "geo", "log", "osmpbf", - "reqwest", + "reqwest 0.12.24", "rusqlite", "serde", "serde_json", @@ -7014,7 +7066,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -7025,7 +7077,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -7298,17 +7350,111 @@ version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ - "bitflags 2.9.1", + "bitflags", "windows-sys 0.59.0", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.1", + "bitflags", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.12.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.12.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.12.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] @@ -7324,7 +7470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.2", + "rustix 1.1.4", ] [[package]] @@ -7374,7 +7520,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", "synstructure", ] @@ -7395,7 +7541,7 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -7415,7 +7561,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", "synstructure", ] @@ -7455,7 +7601,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -7474,9 +7620,15 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.5.1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zopfli" diff --git a/Makefile b/Makefile index ff8e1a81..ede56749 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ SHELL := bash BUN_PATH := $(HOME)/.bun/bin:$(PATH) +CARGO ?= cargo KUBE_VERSION ?= 1.31.0 export PATH := $(HOME)/.cargo/bin:$(HOME)/.bun/bin:$(HOME)/.local/bin:$(HOME)/go/bin:$(CURDIR)/node_modules/.bin:$(PATH) +CARGO_AUDIT_IGNORES := --ignore RUSTSEC-2023-0071 define ensure_tool @command -v $(1) >/dev/null 2>&1 || { \ @@ -39,10 +41,10 @@ YAMLLINT_VERSION ?= 1.35.1 OPENAPI_SPEC ?= spec/openapi.json # Place one consolidated PHONY declaration near the top of the file -.PHONY: all clean be fe fe-build openapi gen docker-up docker-down fmt lint test test-rust test-frontend typecheck deps lockfile lint-specs \ - check-fmt markdownlint markdownlint-docs mermaid-lint nixie yamllint audit \ - lint-rust lint-frontend lint-asyncapi lint-openapi lint-makefile lint-actions \ - lint-architecture workspace-sync +.PHONY: all clean be fe fe-build openapi gen docker-up docker-down fmt lint test test-rust test-frontend typecheck deps lockfile lint-specs audit \ + check-fmt markdownlint markdownlint-docs mermaid-lint nixie yamllint audit-node rust-audit \ + lint-rust lint-frontend lint-asyncapi lint-openapi lint-makefile lint-actions \ + lint-architecture workspace-sync workspace-sync: ./scripts/sync_workspace_members.py @@ -208,11 +210,18 @@ $(NODE_MODULES_STAMP): $(PNPM_LOCK_FILE) package.json typecheck: deps ; for dir in $(TS_WORKSPACES); do $(call exec_or_bunx,tsc,--noEmit -p $$dir/tsconfig.json,typescript@$(TSC_VERSION)) || exit 1; done -audit: deps - pnpm -r install +audit: audit-node rust-audit + +audit-node: deps pnpm -r --if-present run audit pnpm run audit:validate +rust-audit: + $(call ensure_tool,cargo-audit) + # RUSTSEC-2023-0071 is in SQLx's optional MySQL support; this workspace only enables PostgreSQL. + # Install cargo-audit with: cargo binstall --no-confirm cargo-audit@0.22.1 + $(CARGO) audit --file Cargo.lock $(CARGO_AUDIT_IGNORES) + lockfile: pnpm install --lockfile-only git diff --exit-code pnpm-lock.yaml diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 51208501..eec787a8 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -22,7 +22,7 @@ clap = { version = "4", features = ["derive"] } async-trait = "0.1.89" color-eyre = "0.6.5" -pg_embedded_setup_unpriv = { package = "pg-embed-setup-unpriv", version = "0.5.0" } +pg_embedded_setup_unpriv = { package = "pg-embed-setup-unpriv", version = "0.5.1" } postgresql_embedded = { version = "0.20.0", features = ["tokio"] } postgres = { version = "0.19.12", features = ["with-uuid-1"] } paste = "1.0.15" @@ -37,9 +37,9 @@ apalis-postgres = "1.0.0-rc.6" sqlx = { version = "0.8", default-features = false, features = ["postgres", "runtime-tokio-rustls"] } # Diesel ORM with PostgreSQL and async support -diesel = { version = "2.2", features = ["postgres", "uuid", "chrono", "serde_json"] } -diesel-async = { version = "0.5", features = ["bb8", "postgres"] } -diesel_migrations = "2.2" +diesel = { version = "2.3", features = ["postgres", "uuid", "chrono", "serde_json"] } +diesel-async = { version = "0.8", features = ["bb8", "postgres"] } +diesel_migrations = "2.3" chrono = { version = "0.4", features = ["serde"] } mockable = { version = "0.3", features = ["clock"] } wildside-data = { git = "https://github.com/leynos/wildside-engine.git", package = "wildside-data", rev = "2db6cbfb8ec68b611bc74fef6504f044c377b851" } diff --git a/backend/src/inbound/http/schemas.rs b/backend/src/inbound/http/schemas.rs index c1b5f3f9..dfad28aa 100644 --- a/backend/src/inbound/http/schemas.rs +++ b/backend/src/inbound/http/schemas.rs @@ -46,7 +46,7 @@ pub enum ErrorCodeSchema { #[schema(as = crate::domain::Error)] #[expect( dead_code, - reason = "Used only for OpenAPI schema generation via utoipa" + reason = "Used only for OpenAPI schema generation via utoipa; tracked by docs/backend-roadmap.md 3.5.1 (see `#360`)" )] pub struct ErrorSchema { /// Stable machine-readable error code. @@ -69,7 +69,7 @@ pub struct ErrorSchema { #[schema(as = crate::domain::User)] #[expect( dead_code, - reason = "Used only for OpenAPI schema generation via utoipa" + reason = "Used only for OpenAPI schema generation via utoipa; tracked by docs/backend-roadmap.md 3.5.1 (see `#360`)" )] pub struct UserSchema { /// Stable user identifier. @@ -124,7 +124,7 @@ pub struct InterestThemeIdSchema(pub String); #[schema(as = crate::domain::UserInterests)] #[expect( dead_code, - reason = "Used only for OpenAPI schema generation via utoipa" + reason = "Used only for OpenAPI schema generation via utoipa; tracked by docs/backend-roadmap.md 3.5.1 (see `#360`)" )] pub struct UserInterestsSchema { /// Stable user identifier. diff --git a/backend/src/outbound/persistence/models.rs b/backend/src/outbound/persistence/models.rs index 9a047f7a..edb400b1 100644 --- a/backend/src/outbound/persistence/models.rs +++ b/backend/src/outbound/persistence/models.rs @@ -21,7 +21,10 @@ pub(crate) struct UserRow { pub id: Uuid, pub display_name: String, pub created_at: DateTime, - #[expect(dead_code, reason = "schema field for future audit trail support")] + #[expect( + dead_code, + reason = "schema field for future audit trail support tracked by docs/backend-roadmap.md 3.5.1 (see `#360`)" + )] pub updated_at: DateTime, } @@ -179,9 +182,15 @@ pub(crate) struct WalkSessionRow { pub primary_stats: serde_json::Value, pub secondary_stats: serde_json::Value, pub highlighted_poi_ids: Vec, - #[expect(dead_code, reason = "schema field for auditing support")] + #[expect( + dead_code, + reason = "schema field for auditing support tracked by docs/backend-roadmap.md 3.5.1 (see `#360`)" + )] pub created_at: DateTime, - #[expect(dead_code, reason = "schema field for auditing support")] + #[expect( + dead_code, + reason = "schema field for auditing support tracked by docs/backend-roadmap.md 3.5.1 (see `#360`)" + )] pub updated_at: DateTime, } diff --git a/backend/tests/example_data_runs_bdd.rs b/backend/tests/example_data_runs_bdd.rs index c8d93b0e..cb6176cd 100644 --- a/backend/tests/example_data_runs_bdd.rs +++ b/backend/tests/example_data_runs_bdd.rs @@ -32,7 +32,13 @@ struct RuntimeHandle(Arc); /// The inner field is never read directly; it exists to keep the temporary /// database alive for the duration of the test. #[derive(Clone)] -struct DatabaseHandle(#[expect(dead_code)] Arc); +struct DatabaseHandle( + #[expect( + dead_code, + reason = "keeps the temporary database alive for docs/backend-roadmap.md 2.4.3 BDD coverage" + )] + Arc, +); /// Test world holding repository and test results. #[derive(Default, ScenarioState)] diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 8d9f4dad..16f036d8 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -23,6 +23,7 @@ All suites run through the same quality gateways: - `make check-fmt` - `make lint` +- `make audit` - `make test` ## Front-end development @@ -64,9 +65,14 @@ TypeScript, tokens, and documentation gates aligned: make deps make fmt make lint +make audit make test ``` +`make audit` checks frontend and Rust dependencies. It expects Corepack to be +enabled so `pnpm` is available locally and in CI, and it requires +`cargo-audit` for the Rust dependency check. + The front-end package uses Bun-compatible workspace scripts, Vite `^7.3.2`, React 19, React DOM 18, TanStack Query 5, Tailwind CSS `^3`, DaisyUI `^4`, Zod 3, TypeScript 5, Vitest 3, and Orval 8. TanStack Router, Radix UI, @@ -147,6 +153,7 @@ front-end gates plus the repository-wide commit gates: ```bash make check-fmt make lint +make audit make test ``` @@ -154,6 +161,39 @@ When a phase introduces a new lint, accessibility, Playwright, or semantic CSS gate, update this guide and the corresponding phase ExecPlan under `docs/execplans/` in the same change. +### Dependency audit helper modules + +The JavaScript dependency-audit flow is split by responsibility: + +- `security/audit-utils.js` is the orchestration surface used by package + scripts. It exports `runAuditJson(auditIo?)` and + `collectAdvisories(auditJson)`, and re-exports the lower-level package-data + and reporting helpers. +- `security/audit-package-data.js` owns pure JSON parsing, `pnpm ls` package + tree handling, installed-version maps, and npm bulk-advisory normalization. + Its public helpers are `parseJsonOutput(payloadText, commandLabel, options?)`, + `loadPackageTrees(auditIo, assertCompletedProcess)`, + `buildVersionMap(packageTrees)`, + `collectInstalledPackageVersions(auditIo, assertCompletedProcess)`, and + `normalizeBulkAdvisories(bulkPayload)`. +- `security/audit-reporting.js` owns advisory partitioning and stderr output. + Its public helpers are `partitionAdvisoriesById(advisories, allowedIds)` and + `reportUnexpectedAdvisories(unexpected, heading, reportingIo = defaultReportingIo)`. + The optional `reportingIo` adapter must expose an `error(...args)` method; + pass a custom adapter in tests to capture output without writing to stderr. + When omitted, `defaultReportingIo` delegates to `console.error`. +- `security/audit-exception-policy.js` owns exception-ledger date policy. Its + public helper is `assertNoExpired(entries, currentDate?, policyIo?)`. +- `security/validate-audit.js` applies repository policy to the parsed audit + results and the exception ledger. + +Effectful audit helpers must receive external dependencies through the +`auditIo` adapter rather than reading process state directly. The default +adapter wraps `spawnSync`, `execFileSync`, `fetch`, timers, and `getEnv(name)`; +tests should inject an adapter with the same methods when they need to control +command results, registry configuration, network responses, or timeout +behaviour. + ## Embedded PostgreSQL integration tests Backend integration and behavioural suites that require PostgreSQL use the @@ -267,8 +307,8 @@ When adding a new behaviour: `#[then]`. 3. Add or update the scenario binding function with `#[scenario(...)]`. 4. Keep fixture naming consistent across scenario binding and step functions. -5. Run all three gates before commit: - `make check-fmt`, `make lint`, and `make test`. +5. Run all commit gates before commit: + `make check-fmt`, `make lint`, `make audit`, and `make test`. When migrating existing suites, prefer incremental edits that preserve scenario intent and avoid broad rewrites that obscure regressions. @@ -385,10 +425,11 @@ When adding a new shared workspace crate: 9. Ensure all test files stay under 400 lines; split by feature when needed. 10. Run the repository quality gates before committing: `make check-fmt` - to verify formatting, `make lint` to verify linting, and `make test` to - run the test suites. Documentation build and validation is performed - separately via the `RUSTDOCFLAGS="--cfg docsrs -D warnings" cargo doc -p - --no-deps` command described in step 4 above. + to verify formatting, `make lint` to verify linting, `make audit` to + verify dependency audits, and `make test` to run the test suites. + Documentation build and validation is performed separately via the + `RUSTDOCFLAGS="--cfg docsrs -D warnings" cargo doc -p + --no-deps` command described in step 4 above. ## Redis cache adapter testing diff --git a/docs/repository-structure.md b/docs/repository-structure.md index dfad30fe..93958018 100644 --- a/docs/repository-structure.md +++ b/docs/repository-structure.md @@ -94,9 +94,16 @@ myapp/ │ ├─ openapi.json # generated by backend/CI │ └─ asyncapi.yaml # authored or generated │ -├─ security/ # dependency audit allowlist +├─ security/ # dependency audit policy and helpers │ ├─ audit-exceptions.json # time-bound audit exceptions -│ └─ audit-exceptions.schema.json # JSON Schema for the allowlist +│ ├─ audit-exceptions.schema.json # JSON Schema for the allowlist +│ ├─ audit-exception-policy.js # exception date validation +│ ├─ audit-package-data.js # package tree and advisory normalization +│ ├─ audit-reporting.js # advisory partitioning and stderr output +│ ├─ audit-utils.js # PNPM audit orchestration and re-exports +│ ├─ constants.js # shared audit constants +│ ├─ validate-audit.js # repository audit-policy validator +│ └─ validator-patch.js # audit schema validator patching │ ├─ deploy/ │ ├─ docker/ # Dockerfiles @@ -518,11 +525,17 @@ docker-down: cd deploy && docker compose down ``` -Use `make audit` to validate the audit exception allowlist against its schema -and expiry dates. The shared helper tries `pnpm audit --json` first and falls -back to npm's bulk advisory endpoint when the registry retires pnpm's legacy -audit endpoints. Enable Corepack (`corepack enable` and `corepack prepare -pnpm@10.15.1 --activate`) so `pnpm` is available in local and CI environments. +Use `make audit` to audit frontend and Rust dependencies. The frontend audit +validates the audit exception allowlist against its schema and expiry dates. +The shared helper tries `pnpm audit --json` first and falls back to npm's bulk +advisory endpoint when the registry retires pnpm's legacy audit endpoints. +The Rust audit runs `cargo audit` against the consolidated root `Cargo.lock`. +It intentionally leaves cargo-audit's native text output intact so maintainers +can inspect the upstream RUSTSEC identifier, affected crate, patched versions, +and dependency path without a lossy translation layer. The Makefile pins the +audit to `Cargo.lock` and applies only the documented `CARGO_AUDIT_IGNORES`. +Enable Corepack (`corepack enable` and `corepack prepare pnpm@10.15.1 +--activate`) so `pnpm` is available in local and CI environments. ### pnpm setup sequence diff --git a/frontend-pwa/scripts/audit-utils.test.mjs b/frontend-pwa/scripts/audit-utils.test.mjs index 01ae619d..560820ed 100644 --- a/frontend-pwa/scripts/audit-utils.test.mjs +++ b/frontend-pwa/scripts/audit-utils.test.mjs @@ -16,14 +16,16 @@ const originalFetch = globalThis.fetch; /** * Create a pnpm-like child-process result for audit command tests. - * @param {{ status?: number, stdout?: string, error?: Error | undefined }} [options={}] Result overrides. - * @param {number} [options.status=0] Process exit status. + * @param {{ status?: number | null, stdout?: string, error?: Error | undefined, signal?: string | null }} [options={}] Result overrides. + * @param {number | null} [options.status=0] Process exit status. * @param {string} [options.stdout=''] Command stdout payload. * @param {Error | undefined} [options.error=undefined] Spawn error to surface. - * @returns {{ error: Error | undefined, status: number, stdout: string }} Mocked pnpm result object. + * @param {string | null} [options.signal=null] Signal that terminated the process. + * @returns {{ error: Error | undefined, signal: string | null, status: number | null, stdout: string }} Mocked pnpm result object. + * @example createPnpmResult({ status: 1, stdout: 'foo', error: new Error('boom'), signal: null }); // { error: Error('boom'), signal: null, status: 1, stdout: 'foo' } */ -function createPnpmResult({ status = 0, stdout = '', error = undefined } = {}) { - return { error, status, stdout }; +function createPnpmResult({ status = 0, stdout = '', error = undefined, signal = null } = {}) { + return { error, signal, status, stdout }; } /** @@ -61,6 +63,16 @@ async function loadAuditUtils() { return module; } +describe('buildVersionMap', () => { + it('rejects non-plain dependency tree objects', async () => { + const { buildVersionMap } = await loadAuditUtils(); + + expect(() => buildVersionMap(new Map())).toThrow( + 'pnpm ls returned an invalid dependency tree payload.', + ); + }); +}); + describe('runAuditJson', () => { beforeEach(() => { vi.resetModules(); @@ -143,6 +155,41 @@ describe('runAuditJson', () => { expect(execFileSyncMock).not.toHaveBeenCalled(); }); + it.each([ + { + command: 'audit', + expectedError: 'pnpm audit was terminated by signal SIGTERM.', + firstResult: createPnpmResult({ status: null, signal: 'SIGTERM' }), + }, + { + command: 'ls', + expectedError: 'pnpm ls was terminated by signal SIGTERM.', + firstResult: createPnpmResult({ + status: 1, + stdout: JSON.stringify({ + error: { + code: 'ERR_PNPM_AUDIT_BAD_RESPONSE', + message: + 'The audit endpoint responded with 410: {"error":"This endpoint is being retired. Use the bulk advisory endpoint instead."}', + }, + }), + }), + secondResult: createPnpmResult({ status: null, signal: 'SIGTERM' }), + }, + ])('throws when pnpm $command is signalled', async ({ + expectedError, + firstResult, + secondResult, + }) => { + spawnSyncMock.mockReturnValueOnce(firstResult); + if (secondResult) { + spawnSyncMock.mockReturnValueOnce(secondResult); + } + const { runAuditJson } = await loadAuditUtils(); + + await expect(runAuditJson()).rejects.toThrow(expectedError); + }); + it('falls back to the bulk advisory endpoint when pnpm audit hits the retired endpoint', async () => { setupRetiredPnpmAudit([ { @@ -293,4 +340,37 @@ describe('runAuditJson', () => { 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk', ); }); + + it('omits github advisory IDs when the bulk payload lacks a GHSA URL', async () => { + setupRetiredEndpointFallback(); + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => + JSON.stringify({ + validator: [ + { + id: 100000, + url: 'https://example.test/advisories/100000', + title: 'Registry advisory', + }, + ], + }), + }); + const { runAuditJson } = await loadAuditUtils(); + + const result = await runAuditJson(); + + expect(result.json.advisories).toEqual({ + 'validator:100000': expect.objectContaining({ + id: 100000, + [packageNameKey]: 'validator', + title: 'Registry advisory', + url: 'https://example.test/advisories/100000', + }), + }); + expect(result.json.advisories['validator:100000']).not.toHaveProperty(githubAdvisoryIdKey); + assertFallbackSpawnCalls(); + }); }); diff --git a/package.json b/package.json index a72f601a..7a392dc9 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "devDependencies": { "@biomejs/biome": "^2.3.1", - "@mermaid-js/mermaid-cli": "^11.12.0", - "ajv": "^8.18.0", + "@mermaid-js/mermaid-cli": "^11.15.0", + "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "markdownlint-cli": "^0.45.0", + "fast-check": "^4.8.0", + "markdownlint-cli": "^0.48.0", "puppeteer": "^23.11.1", "validator": "^13.15.23", "vite": "^7.3.2", @@ -42,8 +43,8 @@ "overrides": { "@isaacs/brace-expansion": "5.0.1", "brace-expansion@<5.0.6": "5.0.6", - "ajv": "8.18.0", "fast-uri": "3.1.2", + "ajv": "8.20.0", "glob": "11.1.0", "js-yaml": "4.1.1", "lodash": "4.18.1", @@ -52,7 +53,7 @@ "minimatch": "10.2.3", "lodash-es": "4.18.1", "pino": "9.13.1", - "qs": "6.14.2", + "qs": "6.15.2", "rollup": "4.59.0", "basic-ftp": "5.3.1", "dompurify": "3.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a3aca44..3c56837c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: overrides: '@isaacs/brace-expansion': 5.0.1 brace-expansion@<5.0.6: 5.0.6 - ajv: 8.18.0 fast-uri: 3.1.2 + ajv: 8.20.0 glob: 11.1.0 js-yaml: 4.1.1 lodash: 4.18.1 @@ -17,7 +17,7 @@ overrides: minimatch: 10.2.3 lodash-es: 4.18.1 pino: 9.13.1 - qs: 6.14.2 + qs: 6.15.2 rollup: 4.59.0 basic-ftp: 5.3.1 dompurify: 3.4.0 @@ -42,17 +42,20 @@ importers: specifier: ^2.3.1 version: 2.3.1 '@mermaid-js/mermaid-cli': - specifier: ^11.12.0 - version: 11.12.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@18.3.24)(puppeteer@23.11.1(typescript@5.9.2)) + specifier: ^11.15.0 + version: 11.15.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@18.3.24)(puppeteer@23.11.1(typescript@5.9.2)) ajv: - specifier: 8.18.0 - version: 8.18.0 + specifier: 8.20.0 + version: 8.20.0 ajv-formats: specifier: ^3.0.1 - version: 3.0.1(ajv@8.18.0) + version: 3.0.1(ajv@8.20.0) + fast-check: + specifier: ^4.8.0 + version: 4.8.0 markdownlint-cli: - specifier: ^0.45.0 - version: 0.45.0 + specifier: ^0.48.0 + version: 0.48.0 puppeteer: specifier: ^23.11.1 version: 23.11.1(typescript@5.9.2) @@ -493,6 +496,10 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@fortawesome/fontawesome-free@7.2.0': + resolution: {integrity: sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg==} + engines: {node: '>=6'} + '@gerrit0/mini-shiki@3.21.0': resolution: {integrity: sha512-9PrsT5DjZA+w3lur/aOIx3FlDeHdyCEFlv9U+fmsVyjPZh61G5SYURQ/1ebe2U63KbDmI2V8IhIUegWb8hjOyg==} @@ -571,12 +578,22 @@ packages: peerDependencies: tslib: '2' - '@mermaid-js/mermaid-cli@11.12.0': - resolution: {integrity: sha512-a0swOS6PByXKi0dZnLQQIhbtUEu7ubc6bojmIqXqvUPq7mIJukCNEvVBTv6IAbuEWqB3Ti8QntupoGdz3ej+kg==} + '@mermaid-js/layout-elk@0.2.1': + resolution: {integrity: sha512-MX9jwhMyd5zDcFsYcl3duDUkKhjVRUCGEQrdCeNV5hCIR6+3FuDDbRbFmvVbAu15K1+juzsYGG+K8MDvCY1Amg==} + peerDependencies: + mermaid: 11.15.0 + + '@mermaid-js/layout-tidy-tree@0.2.2': + resolution: {integrity: sha512-8RmjDXjKJBxqTS1mICStm8zWRM45fSzs0SOrkp28+KsOGS2YEMFMVTwwRU8CsC6M1L+pDYZVjf1m9AC1c9Wndg==} + peerDependencies: + mermaid: 11.15.0 + + '@mermaid-js/mermaid-cli@11.15.0': + resolution: {integrity: sha512-rmz9ELKtmKQvRcYJGI2e509FK9yCBvmEVfHeRSYkleGqo6qqh8LFooxRPCqq04uVx3JHMp9g/vmM85gi/QFFlQ==} engines: {node: ^18.19 || >=20.0} hasBin: true peerDependencies: - puppeteer: ^23 + puppeteer: ^23 || ^24 '@mermaid-js/mermaid-zenuml@0.2.2': resolution: {integrity: sha512-sUjwk4NWUpy9uaHypYSIGJDks10ZaZo5CHH9lx9xcmyqv9w7yvd4vecUmlUQxmlHStYO+aqSkYKX5/gFjDfypw==} @@ -1087,7 +1104,7 @@ packages: ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: - ajv: 8.18.0 + ajv: 8.20.0 peerDependenciesMeta: ajv: optional: true @@ -1095,13 +1112,13 @@ packages: ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: - ajv: 8.18.0 + ajv: 8.20.0 peerDependenciesMeta: ajv: optional: true - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} @@ -1343,14 +1360,14 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} - commander@14.0.0: - resolution: {integrity: sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==} - engines: {node: '>=20'} - commander@14.0.2: resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1651,6 +1668,9 @@ packages: electron-to-chromium@1.5.215: resolution: {integrity: sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==} + elkjs@0.9.3: + resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1743,6 +1763,10 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true + fast-check@4.8.0: + resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1811,6 +1835,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2166,10 +2194,19 @@ packages: engines: {node: '>=20'} hasBin: true + markdownlint-cli@0.48.0: + resolution: {integrity: sha512-NkZQNu2E0Q5qLEEHwWj674eYISTLD4jMHkBzDobujXd1kv+yCxi8jOaD/rZoQNW1FBBMMGQpuW5So8B51N/e0A==} + engines: {node: '>=20'} + hasBin: true + markdownlint@0.38.0: resolution: {integrity: sha512-xaSxkaU7wY/0852zGApM8LdlIfGCW8ETZ0Rr62IQtAnUMlMuifsg09vWJcNYeL4f0anvr8Vo4ZQar8jGpV0btQ==} engines: {node: '>=20'} + markdownlint@0.40.0: + resolution: {integrity: sha512-UKybllYNheWac61Ia7T6fzuQNDZimFIpCg2w6hHjgV1Qu0w1TV0LlSgryUGzM0bkKQCBhy2FDhEELB73Kb0kAg==} + engines: {node: '>=20'} + marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} @@ -2357,6 +2394,10 @@ packages: resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-limit@6.2.0: + resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} + engines: {node: '>=18'} + p-locate@6.0.0: resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2554,8 +2595,11 @@ packages: deprecated: < 24.15.0 is no longer supported hasBin: true - qs@6.14.2: - resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + pure-rand@8.4.0: + resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} queue-microtask@1.2.3: @@ -2775,6 +2819,10 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@8.1.0: + resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} + engines: {node: '>=20'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -3444,6 +3492,8 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@fortawesome/fontawesome-free@7.2.0': {} + '@gerrit0/mini-shiki@3.21.0': dependencies: '@shikijs/engine-oniguruma': 3.21.0 @@ -3537,14 +3587,32 @@ snapshots: '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) tslib: 2.8.1 - '@mermaid-js/mermaid-cli@11.12.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@18.3.24)(puppeteer@23.11.1(typescript@5.9.2))': + '@mermaid-js/layout-elk@0.2.1(mermaid@11.15.0)': + dependencies: + d3: 7.9.0 + elkjs: 0.9.3 + mermaid: 11.15.0 + + '@mermaid-js/layout-tidy-tree@0.2.2(mermaid@11.15.0)': + dependencies: + d3: 7.9.0 + mermaid: 11.15.0 + optional: true + + '@mermaid-js/mermaid-cli@11.15.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@18.3.24)(puppeteer@23.11.1(typescript@5.9.2))': dependencies: + '@fortawesome/fontawesome-free': 7.2.0 + '@mermaid-js/layout-elk': 0.2.1(mermaid@11.15.0) '@mermaid-js/mermaid-zenuml': 0.2.2(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@18.3.24)(mermaid@11.15.0) chalk: 5.6.2 - commander: 14.0.0 + commander: 13.1.0 import-meta-resolve: 4.2.0 + katex: 0.16.47 mermaid: 11.15.0 + p-limit: 6.2.0 puppeteer: 23.11.1(typescript@5.9.2) + optionalDependencies: + '@mermaid-js/layout-tidy-tree': 0.2.2(mermaid@11.15.0) transitivePeerDependencies: - '@babel/core' - '@babel/template' @@ -3835,9 +3903,9 @@ snapshots: '@scalar/json-magic': 0.9.0 '@scalar/openapi-types': 0.5.3 '@scalar/openapi-upgrader': 0.1.7 - ajv: 8.18.0 - ajv-draft-04: 1.0.0(ajv@8.18.0) - ajv-formats: 3.0.1(ajv@8.18.0) + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + ajv-formats: 3.0.1(ajv@8.20.0) jsonpointer: 5.0.1 leven: 4.1.0 yaml: 2.8.3 @@ -4188,15 +4256,15 @@ snapshots: agent-base@7.1.4: {} - ajv-draft-04@1.0.0(ajv@8.18.0): + ajv-draft-04@1.0.0(ajv@8.20.0): optionalDependencies: - ajv: 8.18.0 + ajv: 8.20.0 - ajv-formats@3.0.1(ajv@8.18.0): + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: - ajv: 8.18.0 + ajv: 8.20.0 - ajv@8.18.0: + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 fast-uri: 3.1.2 @@ -4424,10 +4492,10 @@ snapshots: commander@13.1.0: {} - commander@14.0.0: {} - commander@14.0.2: {} + commander@14.0.3: {} + commander@4.1.1: {} commander@7.2.0: {} @@ -4737,6 +4805,8 @@ snapshots: electron-to-chromium@1.5.215: {} + elkjs@0.9.3: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -4852,6 +4922,10 @@ snapshots: transitivePeerDependencies: - supports-color + fast-check@4.8.0: + dependencies: + pure-rand: 8.4.0 + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -4913,6 +4987,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5238,6 +5314,23 @@ snapshots: transitivePeerDependencies: - supports-color + markdownlint-cli@0.48.0: + dependencies: + commander: 14.0.3 + deep-extend: 0.6.0 + ignore: 7.0.5 + js-yaml: 4.1.1 + jsonc-parser: 3.3.1 + jsonpointer: 5.0.1 + markdown-it: 14.1.1 + markdownlint: 0.40.0 + minimatch: 10.2.3 + run-con: 1.3.2 + smol-toml: 1.6.1 + tinyglobby: 0.2.15 + transitivePeerDependencies: + - supports-color + markdownlint@0.38.0: dependencies: micromark: 4.0.2 @@ -5251,6 +5344,20 @@ snapshots: transitivePeerDependencies: - supports-color + markdownlint@0.40.0: + dependencies: + micromark: 4.0.2 + micromark-core-commonmark: 2.0.3 + micromark-extension-directive: 4.0.0 + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-math: 3.1.0 + micromark-util-types: 2.0.2 + string-width: 8.1.0 + transitivePeerDependencies: + - supports-color + marked@16.4.2: {} marked@4.3.0: {} @@ -5570,6 +5677,10 @@ snapshots: dependencies: yocto-queue: 1.2.2 + p-limit@6.2.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@6.0.0: dependencies: p-limit: 4.0.0 @@ -5790,7 +5901,9 @@ snapshots: - typescript - utf-8-validate - qs@6.14.2: + pure-rand@8.4.0: {} + + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -6029,6 +6142,11 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 + string-width@8.1.0: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -6237,7 +6355,7 @@ snapshots: url@0.11.4: dependencies: punycode: 1.4.1 - qs: 6.14.2 + qs: 6.15.2 use-sync-external-store@1.5.0(react@19.1.1): dependencies: diff --git a/scripts/makefile-audit.test.mjs b/scripts/makefile-audit.test.mjs new file mode 100644 index 00000000..ec290820 --- /dev/null +++ b/scripts/makefile-audit.test.mjs @@ -0,0 +1,87 @@ +/** @file Functional dry-run tests for the Makefile audit target contracts. */ + +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +const execFileAsync = promisify(execFile); +const repositoryRoot = new URL('../', import.meta.url); + +/** + * Ask Make to print a target's execution plan without running the recipes. + * @param {string} target Make target to dry-run. + * @returns {Promise} Commands Make would execute for the target. + */ +async function dryRunMake(target) { + const { stdout } = await execFileAsync('make', ['--dry-run', '--always-make', target], { + cwd: repositoryRoot, + }); + return stdout; +} + +describe('Makefile audit targets', () => { + it('executes the aggregate audit target through node and Rust audits', async () => { + const stdout = await dryRunMake('audit'); + + expect(stdout).toContain('pnpm -r --if-present run audit'); + expect(stdout).toContain('pnpm run audit:validate'); + expect(stdout).toContain('cargo audit --file Cargo.lock --ignore RUSTSEC-2023-0071'); + }); + + it('does not reinstall node dependencies inside audit-node', async () => { + const stdout = await dryRunMake('audit-node'); + + expect(stdout).toContain('pnpm -r --if-present run audit'); + expect(stdout).toContain('pnpm run audit:validate'); + expect(stdout).not.toContain('pnpm -r install'); + }); + + it('checks cargo-audit availability before running the Rust audit', async () => { + const stdout = await dryRunMake('rust-audit'); + + expect(stdout).toContain('command -v cargo-audit'); + expect(stdout).toContain('printf "Error: \'%s\' is required, but not installed\\n" "cargo-audit"'); + expect(stdout).toContain('cargo-audit@0.22.1'); + }); + + it('runs cargo audit against Cargo.lock with configured ignores', async () => { + const stdout = await dryRunMake('rust-audit'); + + expect(stdout).toContain('cargo audit --file Cargo.lock --ignore RUSTSEC-2023-0071'); + }); + + it('executes rust-audit after checking cargo-audit availability', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'wildside-make-audit-')); + const commandLogDir = await mkdtemp(join(tmpdir(), 'wildside-audit-log-')); + const commandLog = join(commandLogDir, 'commands.log'); + const cargoPath = join(tempDir, 'cargo'); + const cargoAuditPath = join(tempDir, 'cargo-audit'); + const cargoAuditShim = '#!/usr/bin/env bash\nexit 0\n'; + const cargoShim = `#!/usr/bin/env bash +printf 'cargo %s\\n' "$*" >> "${commandLog}" +`; + + await writeFile(cargoAuditPath, cargoAuditShim, { mode: 0o755 }); + await writeFile(cargoPath, cargoShim, { mode: 0o755 }); + + try { + await execFileAsync('make', [`CARGO=${cargoPath}`, 'rust-audit'], { + cwd: repositoryRoot, + env: { + ...process.env, + PATH: `${tempDir}:${process.env.PATH}`, + }, + }); + + await expect(readFile(commandLog, 'utf8')).resolves.toBe( + 'cargo audit --file Cargo.lock --ignore RUSTSEC-2023-0071\n', + ); + } finally { + await rm(tempDir, { force: true, recursive: true }); + await rm(commandLogDir, { force: true, recursive: true }); + } + }); +}); diff --git a/scripts/security-audit-helpers.test.mjs b/scripts/security-audit-helpers.test.mjs new file mode 100644 index 00000000..a74494ad --- /dev/null +++ b/scripts/security-audit-helpers.test.mjs @@ -0,0 +1,463 @@ +/** @file Unit and property tests for shared security audit helper modules. */ + +import fc from 'fast-check'; +import { describe, expect, it, vi } from 'vitest'; +import { + buildVersionMap, + collectInstalledPackageVersions, + loadPackageTrees, + normalizeBulkAdvisories, + parseJsonOutput, +} from '../security/audit-package-data.js'; +import { runAuditJson } from '../security/audit-utils.js'; + +function createCompletedResult(stdout = '[]') { + return { error: undefined, signal: null, status: 0, stdout }; +} + +function assertCompletedProcess(result) { + return result.status; +} + +function mapToSortedObject(versionMap) { + return Object.fromEntries( + [...versionMap.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([packageName, versions]) => [packageName, [...versions].sort()]), + ); +} + +describe('parseJsonOutput', () => { + it('parses trimmed object and array payloads', () => { + expect(parseJsonOutput(' {"advisories":{}} ', 'pnpm audit')).toEqual({ + advisories: {}, + }); + expect(parseJsonOutput('\n[{"name":"frontend-pwa"}]\n', 'pnpm ls')).toEqual([ + { name: 'frontend-pwa' }, + ]); + }); + + it('returns an empty object for optional blank payloads', () => { + expect(parseJsonOutput(' ', 'pnpm audit')).toEqual({}); + expect(parseJsonOutput(undefined, 'pnpm audit')).toEqual({}); + }); + + it('throws labelled errors for required blank or malformed payloads', () => { + expect(() => + parseJsonOutput('', 'bulk advisory audit', { requireNonEmpty: true }), + ).toThrow('Failed to parse bulk advisory audit JSON: response body was empty.'); + expect(() => parseJsonOutput('{', 'pnpm audit')).toThrow( + /^Failed to parse pnpm audit JSON:/, + ); + }); + + it.each([ + ['literal null', 'null', null], + ['literal false', 'false', false], + ['numeric zero', '0', 0], + ])('preserves valid JSON edge case %#: %s', (_label, payload, expected) => { + expect(parseJsonOutput(payload, 'pnpm audit')).toBe(expected); + }); +}); + +describe('buildVersionMap', () => { + it('walks dependency sections recursively and skips workspace-local versions', () => { + const versions = buildVersionMap([ + { + dependencies: { + '@app/types': { version: 'link:../packages/types' }, + validator: { + version: '13.15.23', + dependencies: { + nanoid: { version: '3.3.11' }, + }, + }, + }, + devDependencies: { + vitest: { version: '3.2.4' }, + }, + optionalDependencies: { + fsevents: { version: '2.3.3' }, + }, + }, + ]); + + expect(mapToSortedObject(versions)).toEqual({ + fsevents: ['2.3.3'], + nanoid: ['3.3.11'], + validator: ['13.15.23'], + vitest: ['3.2.4'], + }); + }); + + it('accepts a single null-prototype package tree', () => { + const tree = Object.create(null); + tree.dependencies = { + validator: { version: '13.15.23' }, + }; + + expect(mapToSortedObject(buildVersionMap(tree))).toEqual({ + validator: ['13.15.23'], + }); + }); + + it.each([null, new Map(), new Date()])( + 'rejects invalid dependency tree payload %#', + (payload) => { + expect(() => buildVersionMap(payload)).toThrow( + 'pnpm ls returned an invalid dependency tree payload.', + ); + }, + ); + + it('only records non-local string versions from generated dependency trees', () => { + fc.assert( + fc.property( + fc.dictionary( + fc.string({ minLength: 1 }).filter((name) => !name.includes('\0')), + fc.oneof( + fc.constantFrom('file:../pkg', 'link:../pkg', 'workspace:*', ''), + fc.string({ minLength: 1 }).filter((version) => + !['file:', 'link:', 'workspace:'].some((prefix) => version.startsWith(prefix)), + ), + ), + { maxKeys: 20 }, + ), + (versionsByName) => { + const tree = { + dependencies: Object.fromEntries( + Object.entries(versionsByName).map(([name, version]) => [ + name, + { version }, + ]), + ), + }; + + const actual = mapToSortedObject(buildVersionMap(tree)); + const expected = Object.fromEntries( + Object.entries(versionsByName) + .filter(([, version]) => + version && + !['file:', 'link:', 'workspace:'].some((prefix) => version.startsWith(prefix)), + ) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, version]) => [name, [version]]), + ); + + expect(actual).toEqual(expected); + }, + ), + ); + }); +}); + +describe('loadPackageTrees and collectInstalledPackageVersions', () => { + it('runs pnpm ls and returns parsed dependency trees', () => { + const auditIo = { + spawnSync: vi.fn(() => + createCompletedResult('[{"dependencies":{"validator":{"version":"13.15.23"}}}]'), + ), + }; + + const trees = loadPackageTrees(auditIo, assertCompletedProcess); + + expect(trees).toEqual([ + { dependencies: { validator: { version: '13.15.23' } } }, + ]); + expect(auditIo.spawnSync).toHaveBeenCalledWith( + 'pnpm', + ['ls', '--json', '--depth', 'Infinity'], + expect.objectContaining({ encoding: 'utf8' }), + ); + }); + + it('throws when pnpm ls exits non-zero or returns no tree', () => { + expect(() => + loadPackageTrees( + { spawnSync: () => createCompletedResult('[]') }, + () => 1, + ), + ).toThrow('pnpm ls failed without producing a dependency tree (exit status 1).'); + expect(() => + loadPackageTrees( + { spawnSync: () => createCompletedResult(' ') }, + assertCompletedProcess, + ), + ).toThrow('pnpm ls failed without producing a dependency tree.'); + }); + + it('serializes collected versions for the bulk advisory endpoint', () => { + const auditIo = { + spawnSync: () => + createCompletedResult( + JSON.stringify([ + { + dependencies: { + validator: { version: '13.15.23' }, + nanoid: { version: '3.3.11' }, + }, + }, + ]), + ), + }; + + expect(collectInstalledPackageVersions(auditIo, assertCompletedProcess)).toEqual({ + nanoid: ['3.3.11'], + validator: ['13.15.23'], + }); + }); +}); + +describe('normalizeBulkAdvisories', () => { + it('normalizes GHSA IDs, package names, and fallback keys', () => { + expect( + normalizeBulkAdvisories({ + validator: [ + { + id: 100000, + title: 'Validator SSRF', + url: 'https://github.com/advisories/GHSA-Vghf-HV5Q-vC2G', + }, + { + id: 100001, + title: 'Registry advisory', + url: 'https://example.test/advisories/100001', + }, + ], + }), + ).toEqual({ + 'GHSA-vghf-hv5q-vc2g': { + github_advisory_id: 'GHSA-vghf-hv5q-vc2g', + id: 100000, + package_name: 'validator', + title: 'Validator SSRF', + url: 'https://github.com/advisories/GHSA-Vghf-HV5Q-vC2G', + }, + 'validator:100001': { + id: 100001, + package_name: 'validator', + title: 'Registry advisory', + url: 'https://example.test/advisories/100001', + }, + }); + }); + + it('snapshots a normalized bulk advisory transformation', () => { + expect( + normalizeBulkAdvisories({ + ws: [ + { + id: 110001, + severity: 'moderate', + title: 'Uninitialized memory disclosure', + url: 'https://github.com/advisories/GHSA-58QX-3VCG-4XPX', + }, + ], + }), + ).toMatchInlineSnapshot(` + { + "GHSA-58qx-3vcg-4xpx": { + "github_advisory_id": "GHSA-58qx-3vcg-4xpx", + "id": 110001, + "package_name": "ws", + "severity": "moderate", + "title": "Uninitialized memory disclosure", + "url": "https://github.com/advisories/GHSA-58QX-3VCG-4XPX", + }, + } + `); + }); + + it('rejects malformed bulk advisory payloads', () => { + expect(() => normalizeBulkAdvisories(null)).toThrow( + 'Invalid bulk advisory payload: expected an object keyed by package name.', + ); + expect(() => normalizeBulkAdvisories({ validator: {} })).toThrow( + 'Invalid bulk advisory entry for package validator: expected array', + ); + expect(() => normalizeBulkAdvisories({ validator: [null] })).toThrow( + 'Invalid advisory for package validator at index 0: expected object', + ); + }); + + it('deduplicates generated advisories by canonical GHSA key', () => { + fc.assert( + fc.property( + fc.array( + fc.record({ + packageName: fc.string({ minLength: 1 }), + id: fc.integer({ min: 1, max: 1_000_000 }), + ghsa: fc.constantFrom( + 'GHSA-vghf-hv5q-vc2g', + 'GHSA-58qx-3vcg-4xpx', + 'GHSA-abcd-efgh-1234', + ), + }), + { minLength: 1, maxLength: 30 }, + ), + (entries) => { + const payload = Object.create(null); + for (const { packageName, id, ghsa } of entries) { + payload[packageName] ??= []; + payload[packageName].push({ + id, + title: `Advisory ${id}`, + url: `https://github.com/advisories/${ghsa.toUpperCase()}`, + }); + } + + const normalized = normalizeBulkAdvisories(payload); + const expectedKeys = new Set( + entries.map(({ ghsa }) => `GHSA-${ghsa.slice('GHSA-'.length).toLowerCase()}`), + ); + + expect(new Set(Object.keys(normalized))).toEqual(expectedKeys); + for (const key of expectedKeys) { + expect(normalized[key].github_advisory_id).toBe(key); + } + }, + ), + ); + }); +}); + +describe('runAuditJson audit IO boundary', () => { + it('reads npm registry settings through auditIo.getEnv before pnpm config', async () => { + const auditIo = { + clearTimeout: vi.fn(), + execFileSync: vi.fn(() => { + throw new Error('pnpm config should not be read'); + }), + fetch: vi.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => '{}', + })), + getEnv: vi.fn((name) => + name === 'npm_config_registry' ? 'https://registry.example.test/custom' : undefined, + ), + setTimeout: vi.fn(() => 1), + spawnSync: vi + .fn() + .mockReturnValueOnce( + createCompletedResult( + JSON.stringify({ + error: { + code: 'ERR_PNPM_AUDIT_BAD_RESPONSE', + message: + 'The audit endpoint responded with 410: {"error":"This endpoint is being retired. Use the bulk advisory endpoint instead."}', + }, + }), + ), + ) + .mockReturnValueOnce(createCompletedResult('[{"dependencies":{}}]')), + }; + + await expect(runAuditJson(auditIo)).resolves.toEqual({ + json: { advisories: {} }, + status: 0, + }); + + expect(auditIo.getEnv).toHaveBeenCalledWith('npm_config_registry'); + expect(auditIo.execFileSync).not.toHaveBeenCalled(); + expect(String(auditIo.fetch.mock.calls[0][0])).toBe( + 'https://registry.example.test/custom/-/npm/v1/security/advisories/bulk', + ); + }); +}); + +describe('fetchBulkAdvisories timeout and abort handling', () => { + /** Build an auditIo that triggers the retired-endpoint code path. + * @param {{ fetchStub: Function, timeoutStub?: Function, clearTimeoutStub?: Function }} options Test doubles for bulk advisory IO. + * @returns {object} Audit IO adapter. + */ + function retiredEndpointAuditIo({ + fetchStub, + timeoutStub = vi.fn(() => 99), + clearTimeoutStub = vi.fn(), + }) { + return { + clearTimeout: clearTimeoutStub, + execFileSync: vi.fn(() => { + throw new Error('pnpm config should not be called'); + }), + fetch: fetchStub, + getEnv: vi.fn((name) => + name === 'npm_config_registry' ? 'https://registry.example.test/' : undefined, + ), + setTimeout: timeoutStub, + spawnSync: vi + .fn() + .mockReturnValueOnce( + createCompletedResult( + JSON.stringify({ + error: { + code: 'ERR_PNPM_AUDIT_BAD_RESPONSE', + message: + 'The audit endpoint responded with 410: {"error":"This endpoint is being retired. Use the bulk advisory endpoint instead."}', + }, + }), + ), + ) + .mockReturnValueOnce(createCompletedResult('[{"dependencies":{}}]')), + }; + } + + it('throws a timeout message when the fetch is aborted via AbortController', async () => { + const fetchStub = vi.fn(async () => { + throw new DOMException('The operation was aborted.', 'AbortError'); + }); + + const auditIo = retiredEndpointAuditIo({ fetchStub }); + + await expect(runAuditJson(auditIo)).rejects.toThrow( + /Bulk advisory audit timed out after \d+ms at/, + ); + expect(auditIo.clearTimeout).toHaveBeenCalledWith(99); + }); + + it('calls clearTimeout even when response.text() throws an AbortError', async () => { + const fetchStub = vi.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => { + throw new DOMException('The operation was aborted.', 'AbortError'); + }, + })); + + const auditIo = retiredEndpointAuditIo({ fetchStub }); + + await expect(runAuditJson(auditIo)).rejects.toThrow( + /Bulk advisory audit timed out after \d+ms at/, + ); + expect(auditIo.clearTimeout).toHaveBeenCalledWith(99); + }); + + it('always calls clearTimeout regardless of fetch outcome', async () => { + await fc.assert( + fc.asyncProperty(fc.boolean(), async (shouldAbort) => { + const clearTimeoutStub = vi.fn(); + const fetchStub = vi.fn(async () => { + if (shouldAbort) { + throw new DOMException('The operation was aborted.', 'AbortError'); + } + return { ok: true, status: 200, statusText: 'OK', text: async () => '{}' }; + }); + + const auditIo = retiredEndpointAuditIo({ fetchStub, clearTimeoutStub }); + + if (shouldAbort) { + await expect(runAuditJson(auditIo)).rejects.toThrow( + /Bulk advisory audit timed out after \d+ms at/, + ); + } else { + await expect(runAuditJson(auditIo)).resolves.toMatchObject({ status: 0 }); + } + + expect(clearTimeoutStub).toHaveBeenCalledWith(99); + }), + ); + }); +}); diff --git a/scripts/security-audit-reporting.test.mjs b/scripts/security-audit-reporting.test.mjs new file mode 100644 index 00000000..f2c44fbf --- /dev/null +++ b/scripts/security-audit-reporting.test.mjs @@ -0,0 +1,171 @@ +/** @file Unit and property tests for audit reporting and exception policy. */ + +import fc from 'fast-check'; +import { describe, expect, it, vi } from 'vitest'; +import { assertNoExpired } from '../security/audit-exception-policy.js'; +import { + partitionAdvisoriesById as partitionAdvisoriesByIdFromUtils, + reportUnexpectedAdvisories as reportUnexpectedAdvisoriesFromUtils, +} from '../security/audit-utils.js'; +import { + partitionAdvisoriesById, + reportUnexpectedAdvisories, +} from '../security/audit-reporting.js'; + +function advisory(id, title = `Advisory ${id}`) { + return { github_advisory_id: id, title }; +} + +function exceptionEntry({ addedAt, expiresAt, id = 'exception-1' }) { + return { + addedAt, + advisory: 'GHSA-vghf-hv5q-vc2g', + expiresAt, + id, + package: 'validator', + reason: 'Regression test fixture', + }; +} + +function throwingPolicyIo() { + return { + error: vi.fn(), + exit: vi.fn((code) => { + throw new Error(`exit ${code}`); + }), + }; +} + +describe('partitionAdvisoriesById', () => { + it('is re-exported from the shared audit utility surface', () => { + expect(partitionAdvisoriesByIdFromUtils).toBe(partitionAdvisoriesById); + }); + + it('partitions advisories without reordering either group', () => { + const first = advisory('GHSA-1111-2222-3333'); + const second = advisory('GHSA-4444-5555-6666'); + const third = { title: 'Missing GHSA' }; + + expect(partitionAdvisoriesById([first, second, third], [second.github_advisory_id])).toEqual({ + expected: [second], + unexpected: [first, third], + }); + }); + + it('keeps every generated advisory in exactly one partition', () => { + fc.assert( + fc.property( + fc.uniqueArray(fc.uuid(), { minLength: 1, maxLength: 30 }), + fc.array(fc.boolean(), { minLength: 1, maxLength: 30 }), + (ids, flags) => { + const advisories = ids.map((id) => advisory(id)); + const allowedIds = ids.filter((_, index) => flags[index % flags.length]); + const { expected, unexpected } = partitionAdvisoriesById(advisories, allowedIds); + + expect(expected).toHaveLength(new Set(allowedIds).size); + expect([...expected, ...unexpected].sort((left, right) => + left.github_advisory_id.localeCompare(right.github_advisory_id), + )).toEqual([...advisories].sort((left, right) => + left.github_advisory_id.localeCompare(right.github_advisory_id), + )); + }, + ), + ); + }); +}); + +describe('reportUnexpectedAdvisories', () => { + it('is re-exported from the shared audit utility surface', () => { + expect(reportUnexpectedAdvisoriesFromUtils).toBe(reportUnexpectedAdvisories); + }); + + it('returns false and writes nothing for an empty report', () => { + const reportingIo = { error: vi.fn() }; + + expect(reportUnexpectedAdvisories([], 'Unexpected advisories:', reportingIo)).toBe(false); + expect(reportingIo.error).not.toHaveBeenCalled(); + }); + + it('reports unexpected advisories to the injected reportingIo adapter', () => { + const errorLines = []; + const reportingIo = { error: (...args) => errorLines.push(args.join(' ')) }; + + expect( + reportUnexpectedAdvisories( + [advisory('GHSA-1', 'Example')], + 'Unexpected advisories:', + reportingIo, + ), + ).toBe(true); + + expect(errorLines).toMatchInlineSnapshot(` + [ + "Unexpected advisories:", + "- GHSA-1: Example", + ] + `); + }); +}); + +describe('assertNoExpired', () => { + it('uses the injected current date instead of wall-clock time', () => { + const policyIo = throwingPolicyIo(); + + assertNoExpired( + [exceptionEntry({ addedAt: '2024-01-01', expiresAt: '2024-01-31' })], + new Date('2024-01-30T00:00:00.000Z'), + policyIo, + ); + + expect(policyIo.exit).not.toHaveBeenCalled(); + expect(policyIo.error).not.toHaveBeenCalled(); + }); + + it('allows exceptions expiring on or after the current date', () => { + fc.assert( + fc.property(fc.date({ min: new Date('2024-01-01'), max: new Date('2030-12-31') }), (date) => { + const today = date.toISOString().slice(0, 10); + const policyIo = throwingPolicyIo(); + + assertNoExpired( + [ + exceptionEntry({ + addedAt: '2024-01-01', + expiresAt: today, + }), + ], + date, + policyIo, + ); + + expect(policyIo.exit).not.toHaveBeenCalled(); + }), + ); + }); + + it.each([ + [ + 'expires before the current date', + exceptionEntry({ addedAt: '2024-01-01', expiresAt: '2024-01-31' }), + new Date('2024-02-01T00:00:00.000Z'), + [ + 'Audit exceptions have expired:', + '- exception-1 (validator) expired on 2024-01-31', + ], + ], + [ + 'has an inverted date range', + exceptionEntry({ addedAt: '2024-02-01', expiresAt: '2024-01-31' }), + new Date('2024-01-15T00:00:00.000Z'), + [ + 'Audit exceptions have invalid date ranges (addedAt > expiresAt):', + '- exception-1 (validator) addedAt 2024-02-01 > expiresAt 2024-01-31', + ], + ], + ])('exits when an exception %s', (_description, entry, currentDate, expectedErrors) => { + const policyIo = throwingPolicyIo(); + + expect(() => assertNoExpired([entry], currentDate, policyIo)).toThrow('exit 1'); + expect(policyIo.error.mock.calls.map(([line]) => line)).toEqual(expectedErrors); + }); +}); diff --git a/security/audit-exception-policy.js b/security/audit-exception-policy.js new file mode 100644 index 00000000..eb9dd52b --- /dev/null +++ b/security/audit-exception-policy.js @@ -0,0 +1,53 @@ +/** + * @file Audit exception ledger policy: validates time-bound dependency-audit + * exception entries and enforces expiry at validation time. + * + * Owns the `assertNoExpired` export, which reads an array of exception entries + * (each carrying `id`, `package`, `addedAt`, and `expiresAt` ISO-date fields), + * computes today's date relative to an injected `currentDate`, and exits with + * status 1 when any entry has expired or carries an invalid date range + * (`addedAt` later than `expiresAt`). + * + * Relationships: + * - `security/validate-audit.js` imports `assertNoExpired` and invokes it after + * loading `security/audit-exceptions.json` through AJV schema validation. + * - Test suites (`scripts/security-audit-reporting.test.mjs`) import + * `assertNoExpired` directly with injected `policyIo` adapters to exercise + * expiry and inverted-range paths without calling `process.exit`. + * + * IO effects are isolated behind the `policyIo` adapter (`error`, `exit`); the + * `defaultPolicyIo` implementation delegates to `console.error` and + * `process.exit`. + */ + +const defaultPolicyIo = { + error: (...args) => console.error(...args), + exit: (code) => process.exit(code), +}; + +/** Exit with error if any audit exceptions are past their expiry date. + * @param {Array<{ id: string, package: string, addedAt: string, expiresAt: string }>} entries Entries to inspect. + * @param {Date} [currentDate=new Date()] Current date for deterministic validation. + * @param {{ error: (...args: unknown[]) => void, exit: (code: number) => never }} [policyIo=defaultPolicyIo] Policy IO adapter. + * @returns {void} + * @example assertNoExpired([{ id: '1', package: 'pkg', addedAt: '2024-01-01', expiresAt: '2099-01-01' }]); + */ +export function assertNoExpired(entries, currentDate = new Date(), policyIo = defaultPolicyIo) { + const today = currentDate.toISOString().slice(0, 10); + const expired = entries.filter((e) => e.expiresAt < today); + const inverted = entries.filter((e) => e.addedAt > e.expiresAt); + if (expired.length > 0) { + policyIo.error('Audit exceptions have expired:'); + for (const { id, package: pkg, expiresAt } of expired) { + policyIo.error(`- ${id} (${pkg}) expired on ${expiresAt}`); + } + policyIo.exit(1); + } + if (inverted.length > 0) { + policyIo.error('Audit exceptions have invalid date ranges (addedAt > expiresAt):'); + for (const { id, package: pkg, addedAt, expiresAt } of inverted) { + policyIo.error(`- ${id} (${pkg}) addedAt ${addedAt} > expiresAt ${expiresAt}`); + } + policyIo.exit(1); + } +} diff --git a/security/audit-package-data.js b/security/audit-package-data.js new file mode 100644 index 00000000..a79cd2b5 --- /dev/null +++ b/security/audit-package-data.js @@ -0,0 +1,306 @@ +/** + * @file Package-tree and advisory-normalization helpers for audit utilities. + * + * Owns `pnpm ls` serialization, installed-version map construction, and npm + * bulk-advisory response normalization. Callers provide parsed JSON-shaped + * objects for pure helpers or an `auditIo` adapter for command execution. + */ + +const LIST_ARGS = ['ls', '--json', '--depth', 'Infinity']; +const COMMAND_MAX_BUFFER = 64 * 1024 * 1024; +const DEPENDENCY_SECTION_NAMES = ['dependencies', 'devDependencies', 'optionalDependencies']; + +/** Parse command JSON and optionally reject blank responses. + * @param {string | undefined | null} payloadText Raw command output. @param {string} commandLabel Label used in parse errors. @param {{ requireNonEmpty?: boolean }} [options={}] Parsing options. + * @returns {Record | unknown[]} Parsed JSON value, or `{}` for optional blank output. @example parseJsonOutput('{"advisories":{}}', 'pnpm audit'); // { advisories: {} } + */ +export function parseJsonOutput(payloadText, commandLabel, options = {}) { + const { requireNonEmpty = false } = options; + const text = payloadText?.trim?.() ?? ''; + if (!text) { + if (requireNonEmpty) { + throw new Error(`Failed to parse ${commandLabel} JSON: response body was empty.`); + } + return {}; + } + try { + return JSON.parse(text); + } catch (error) { + error.message = `Failed to parse ${commandLabel} JSON: ${error.message}`; + throw error; + } +} + +/** Check whether a version points at a local workspace dependency. + * @param {string} version Package version or workspace reference. + * @returns {boolean} `true` when the version should be ignored for registry audits. @example isLocalWorkspaceVersion('workspace:*'); // true + */ +function isLocalWorkspaceVersion(version) { + return ( + version.startsWith('file:') || + version.startsWith('link:') || + version.startsWith('workspace:')); +} + +/** Record an installed package version unless it is missing or workspace-local. + * @param {Map>} versionsByPackage Installed versions keyed by package name. @param {string} packageName Package name from `pnpm ls`. @param {string} version Installed package version. + * @returns {void} @example const versions = new Map(); addPackageVersion(versions, 'validator', '13.15.23'); console.log([...versions.get('validator')]); // ['13.15.23'] + */ +function addPackageVersion(versionsByPackage, packageName, version) { + const isMissing = !packageName || !version; + if (isMissing || isLocalWorkspaceVersion(version)) { + return; + } + const knownVersions = versionsByPackage.get(packageName) ?? new Set(); + knownVersions.add(version); + versionsByPackage.set(packageName, knownVersions); +} + +/** Process a single entry from a pnpm ls dependency section. + * @param {string} packageName Package name. + * @param {unknown} dependency Raw dependency value from the section. + * @param {Map>} versionsByPackage Collected versions keyed by package name. + * @returns {void} + */ +function processDependencyEntry(packageName, dependency, versionsByPackage) { + if (!dependency || typeof dependency !== 'object') { + return; + } + if (typeof dependency.version === 'string') { + addPackageVersion(versionsByPackage, packageName, dependency.version); + } + walkDependencies(dependency, versionsByPackage); +} + +/** Walk one dependency section from `pnpm ls` and record installed versions. + * @param {Record | undefined} section Dependency section keyed by package name. @param {Map>} versionsByPackage Collected versions keyed by package name. + * @returns {void} @example const versions = new Map(); walkDependencySection({ validator: { version: '13.15.23' } }, versions); console.log(versions.get('validator').has('13.15.23')); // true + */ +function walkDependencySection(section, versionsByPackage) { + if (!section || typeof section !== 'object') { + return; + } + for (const [packageName, dependency] of Object.entries(section)) { + processDependencyEntry(packageName, dependency, versionsByPackage); + } +} + +/** Walk a `pnpm ls` tree and record every installed package version. + * @param {Record | undefined} node Dependency tree node from `pnpm ls`. @param {Map>} versionsByPackage Collected versions keyed by package name. + * @returns {void} @example const versions = new Map(); walkDependencies({ dependencies: { validator: { version: '13.15.23' } } }, versions); console.log([...versions.get('validator')]); // ['13.15.23'] + */ +function walkDependencies(node, versionsByPackage) { + if (!node || typeof node !== 'object') { + return; + } + for (const sectionName of DEPENDENCY_SECTION_NAMES) { + walkDependencySection(node[sectionName], versionsByPackage); + } +} + +/** + * Return `true` when `value` is a non-null object (any kind). + * @param {unknown} value Value to test. + * @returns {boolean} + */ +function isNonNullObject(value) { + return typeof value === 'object' && value !== null; +} + +/** + * Return `true` when `value` is a non-null, non-array object. + * @param {unknown} value Value to test. + * @returns {boolean} + * @example isPlainObject({ id: 1 }); // true + * @example isPlainObject(null); // false + * @example isPlainObject([]); // false + */ +function isPlainObject(value) { + return isNonNullObject(value) && !Array.isArray(value); +} + +/** + * Return `true` when `value`'s prototype is `Object.prototype` or `null` + * (i.e. a plain object literal or `Object.create(null)`). + * @param {object} value Non-null object to test. + * @returns {boolean} + */ +function hasPlainObjectPrototype(value) { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** Return `true` when a value is a valid `pnpm ls` dependency tree node + * (a non-null, non-array plain object). + * @param {unknown} value Value to test. + * @returns {boolean} + * @example isValidTreeNode({ dependencies: {} }); // true + * @example isValidTreeNode(null); // false + * @example isValidTreeNode([]); // false + */ +function isValidTreeNode(value) { + return isPlainObject(value) && hasPlainObjectPrototype(value); +} + +/** Build the installed package-version map from parsed `pnpm ls` output. + * @param {Record | Record[] | undefined} packageTrees Parsed `pnpm ls` output as one tree or many. @returns {Map>} Installed versions keyed by package name. @example const versions = buildVersionMap([{ dependencies: { validator: { version: '13.15.23' } } }]); console.log(versions.get('validator').has('13.15.23')); // true + */ +export function buildVersionMap(packageTrees) { + const versionsByPackage = new Map(); + const trees = Array.isArray(packageTrees) ? packageTrees : [packageTrees]; + for (const tree of trees) { + if (!isValidTreeNode(tree)) { + throw new TypeError('pnpm ls returned an invalid dependency tree payload.'); + } + walkDependencies(tree, versionsByPackage); + } + return versionsByPackage; +} + +/** Load parsed package trees from `pnpm ls`. + * @param {object} auditIo Audit IO adapter. + * @param {(result: { error?: Error, signal?: string | null, status?: number | null }, commandLabel: string) => number} assertCompletedProcess Process completion validator. + * @returns {Record | unknown[]} Parsed package-tree payload. + * @example loadPackageTrees(auditIo, assertCompletedProcess); // [{ dependencies: {} }] + */ +export function loadPackageTrees(auditIo, assertCompletedProcess) { + const result = auditIo.spawnSync('pnpm', LIST_ARGS, { + encoding: 'utf8', + maxBuffer: COMMAND_MAX_BUFFER, + stdio: ['ignore', 'pipe', 'inherit'], + }); + const status = assertCompletedProcess(result, 'pnpm ls'); + if (status !== 0) { + throw new Error(`pnpm ls failed without producing a dependency tree (exit status ${status}).`); + } + const stdout = result.stdout?.trim(); + if (!stdout) { + throw new Error('pnpm ls failed without producing a dependency tree.'); + } + return parseJsonOutput(stdout, 'pnpm ls'); +} + +/** Convert a version map to the sorted object expected by bulk advisory lookups. + * @param {Map>} versionsByPackage Installed versions keyed by package name. + * @returns {Record} Sorted installed versions keyed by package name. + * @example serializeVersionMap(new Map([['validator', new Set(['13.15.23'])]])); // { validator: ['13.15.23'] } + */ +function serializeVersionMap(versionsByPackage) { + return Object.fromEntries( + [...versionsByPackage.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([packageName, versions]) => [packageName, [...versions].sort()]), + ); +} + +/** Collect installed package versions from `pnpm ls` for bulk advisory lookups. + * Throws when `pnpm ls` fails, is signalled, or returns invalid JSON. + * @param {object} auditIo Audit IO adapter. + * @param {(result: { error?: Error, signal?: string | null, status?: number | null }, commandLabel: string) => number} assertCompletedProcess Process completion validator. + * @returns {Record} Sorted installed versions keyed by package name. + * @example collectInstalledPackageVersions(auditIo, assertCompletedProcess); // { validator: ['13.15.23'] } + */ +export function collectInstalledPackageVersions(auditIo, assertCompletedProcess) { + return serializeVersionMap(buildVersionMap(loadPackageTrees(auditIo, assertCompletedProcess))); +} + +/** Extract a GitHub advisory identifier from an advisory URL. + * @param {unknown} advisoryUrl Advisory URL from pnpm or npm audit output. + * @returns {string | undefined} Matching GHSA identifier when one is present. @example extractGithubAdvisoryId('https://github.com/advisories/GHSA-vghf-hv5q-vc2g'); // 'GHSA-vghf-hv5q-vc2g' + */ +function extractGithubAdvisoryId(advisoryUrl) { + if (typeof advisoryUrl !== 'string') { + return undefined; + } + const match = advisoryUrl.match(/GHSA-([0-9a-z]{4})-([0-9a-z]{4})-([0-9a-z]{4})/i); + if (!match) { + return undefined; + } + const [, first, second, third] = match; + return `GHSA-${first.toLowerCase()}-${second.toLowerCase()}-${third.toLowerCase()}`; +} + +/** Derive the advisory key used to deduplicate bulk advisory responses. + * @param {string} packageName Advisory package name. @param {{ id?: unknown, url?: unknown }} advisory Raw advisory object. + * @returns {{ key: string, githubAdvisoryId: string | undefined }} Stable advisory key and extracted GHSA identifier. @example deriveAdvisoryKey('validator', { id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g' }); // { key: 'GHSA-vghf-hv5q-vc2g', githubAdvisoryId: 'GHSA-vghf-hv5q-vc2g' } + */ +function deriveAdvisoryKey(packageName, advisory) { + const githubAdvisoryId = extractGithubAdvisoryId(advisory?.url); + const key = githubAdvisoryId ?? `${packageName}:${String(advisory?.id ?? 'unknown')}`; + return { key, githubAdvisoryId }; +} + +/** Return `true` when a value is a plain (non-array, non-null) object with a plain + * prototype (`Object.prototype` or `null`). + * @param {unknown} value Value to test. @returns {boolean} + * @example isPlainAdvisoryObject({ id: 1 }); // true + * @example isPlainAdvisoryObject(new Map()); // false + */ +function isPlainAdvisoryObject(value) { + return isPlainObject(value) && hasPlainObjectPrototype(value); +} + +/** Validate and merge one advisory into the shared accumulator. + * @param {string} packageName Package name from the bulk advisory payload. + * @param {unknown} advisory Raw advisory object; must be a plain object. + * @param {number} index Position within the package advisory array. + * @param {Record} advisories Accumulator mutated in place. + * @returns {void} + */ +function mergeOneAdvisory(packageName, advisory, index, advisories) { + if (!isPlainAdvisoryObject(advisory)) { + throw new Error(`Invalid advisory for package ${packageName} at index ${index}: expected object`); + } + const { key, githubAdvisoryId } = deriveAdvisoryKey(packageName, advisory); + if (Object.hasOwn(advisories, key)) { + return; + } + advisories[key] = { + ...advisory, + package_name: packageName, + }; + if (githubAdvisoryId != null) { + advisories[key].github_advisory_id = githubAdvisoryId; + } +} + +/** Merge advisories for one package into the shared accumulator. + * @param {string} packageName Package name from the bulk advisory payload. @param {unknown[]} packageAdvisories Validated array of raw advisory objects. @param {Record} advisories Accumulator mutated in place. + * @returns {void} @example const advisories = {}; addPackageAdvisories('validator', [{ id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g', title: 'Validator SSRF' }], advisories); console.log(advisories['GHSA-vghf-hv5q-vc2g'].package_name); // 'validator' + */ +function addPackageAdvisories(packageName, packageAdvisories, advisories) { + for (const [index, advisory] of packageAdvisories.entries()) { + mergeOneAdvisory(packageName, advisory, index, advisories); + } +} + +/** Validate and merge one package's advisory array into the accumulator. + * @param {string} packageName Package name from the bulk advisory payload. + * @param {unknown} packageAdvisories Raw value for this package; must be an array. + * @param {Record} advisories Accumulator mutated in place. + * @returns {void} + */ +function mergePackageAdvisories(packageName, packageAdvisories, advisories) { + if (!Array.isArray(packageAdvisories)) { + throw new TypeError( + `Invalid bulk advisory entry for package ${packageName}: expected array, received ${JSON.stringify(packageAdvisories)}`, + ); + } + addPackageAdvisories(packageName, packageAdvisories, advisories); +} + +/** Normalize bulk advisory responses into the shared advisory object shape. + * @param {Record | undefined} bulkPayload Bulk advisory payload keyed by package name. + * @returns {Record} Deduplicated advisories keyed by GHSA identifier or package fallback. @example normalizeBulkAdvisories({ validator: [{ id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g' }] }); // { 'GHSA-vghf-hv5q-vc2g': { github_advisory_id: 'GHSA-vghf-hv5q-vc2g', package_name: 'validator', id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g' } } + */ +export function normalizeBulkAdvisories(bulkPayload) { + if (!isPlainAdvisoryObject(bulkPayload)) { + throw new TypeError('Invalid bulk advisory payload: expected an object keyed by package name.'); + } + const advisories = {}; + for (const [packageName, packageAdvisories] of Object.entries(bulkPayload)) { + mergePackageAdvisories(packageName, packageAdvisories, advisories); + } + + return advisories; +} diff --git a/security/audit-reporting.js b/security/audit-reporting.js new file mode 100644 index 00000000..1833f171 --- /dev/null +++ b/security/audit-reporting.js @@ -0,0 +1,69 @@ +/** + * @file Owns advisory partitioning and stderr formatting for audit validation. + * + * Exports `partitionAdvisoriesById` for splitting expected and unexpected + * advisories, and `reportUnexpectedAdvisories` for writing validation failures + * to stderr. `security/audit-utils.js` re-exports these helpers so callers can + * use one audit utility surface while the reporting step stays isolated in the + * dependency-auditing pipeline. + */ + +/** Return `true` when an advisory's GHSA ID is present in the allow-set. + * @param {{ github_advisory_id?: string }} advisory Advisory to check. @param {Set} allowed Set of permitted advisory IDs. @returns {boolean} + * @example isExpectedAdvisory({ github_advisory_id: 'GHSA-vghf-hv5q-vc2g' }, new Set(['GHSA-vghf-hv5q-vc2g'])); // true + */ +function isExpectedAdvisory(advisory, allowed) { + const id = advisory.github_advisory_id; + return Boolean(id) && allowed.has(id); +} + +/** Split advisories into allowed and unexpected groups. + * @param {Array<{ github_advisory_id?: string }>} advisories Advisories to partition. @param {Iterable} allowedIds Advisory IDs the caller expects. + * @returns {{ expected: typeof advisories, unexpected: typeof advisories }} Partitioned advisories. + * @example const { expected, unexpected } = partitionAdvisoriesById([{ github_advisory_id: 'GHSA-1' }, { github_advisory_id: 'GHSA-2' }], ['GHSA-2']); console.log(expected.length); // 1 + * @example console.log(unexpected.length); // 1 + */ +export function partitionAdvisoriesById(advisories, allowedIds) { + const allowed = new Set(allowedIds); + const expected = []; + const unexpected = []; + for (const advisory of advisories) { + if (isExpectedAdvisory(advisory, allowed)) { + expected.push(advisory); + } else { + unexpected.push(advisory); + } + } + + return { expected, unexpected }; +} + +/** Format one advisory as a report line. + * @param {{ github_advisory_id?: string, title?: string }} advisory Advisory to print. @returns {string} Human-readable bullet line for the advisory. @example formatAdvisoryLine({ github_advisory_id: 'GHSA-1', title: 'Example' }); // "- GHSA-1: Example" + */ +function formatAdvisoryLine(advisory) { + const id = advisory.github_advisory_id ?? 'UNKNOWN'; + const suffix = advisory.title ? `: ${advisory.title}` : ''; + return `- ${id}${suffix}`; +} + +const defaultReportingIo = { + error: (...args) => console.error(...args), +}; + +/** Report unexpected advisories to stderr. + * @param {Array<{ github_advisory_id?: string, title?: string }>} unexpected Advisories that were not permitted. @param {string} heading Descriptive heading for the error output. + * @param {{ error: (...args: unknown[]) => void }} [reportingIo=defaultReportingIo] IO adapter for stderr output. + * @returns {boolean} Whether any advisories were reported. @example const hadUnexpected = reportUnexpectedAdvisories([{ github_advisory_id: 'GHSA-1', title: 'Example' }], 'Unexpected advisories:'); console.log(hadUnexpected); // true + */ +export function reportUnexpectedAdvisories(unexpected, heading, reportingIo = defaultReportingIo) { + if (unexpected.length === 0) { + return false; + } + + reportingIo.error(heading); + for (const advisory of unexpected) { + reportingIo.error(formatAdvisoryLine(advisory)); + } + return true; +} diff --git a/security/audit-utils.js b/security/audit-utils.js index 21e891cc..465ddf31 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -1,36 +1,51 @@ -/** @file Shared helpers for dependency audits and advisory filtering. */ +/** + * @file Shared helpers for dependency audits and advisory filtering. + * + * Provides the audit pipeline's JSON parsing, PNPM command execution, installed + * package-version collection, bulk-advisory fallback, and advisory flattening + * utilities. Pure helpers accept parsed JSON-shaped objects and return normalised + * maps or arrays; effectful helpers cross the IO boundary through `auditIo`, + * whose default implementation wraps filesystem, CLI, timer, and fetch effects. + * `audit-reporting.js` owns advisory partitioning and stderr formatting, while + * `validate-audit.js` applies policy to these normalised audit results. Callers + * can assume exported helpers either return parsed audit data in the documented + * shapes or throw explicit errors for failed, signalled, malformed, or + * unavailable audit inputs. + */ import { execFileSync, spawnSync } from 'node:child_process'; +import { + collectInstalledPackageVersions, + normalizeBulkAdvisories, + parseJsonOutput, +} from './audit-package-data.js'; + +export { + buildVersionMap, + collectInstalledPackageVersions, + loadPackageTrees, + normalizeBulkAdvisories, + parseJsonOutput, +} from './audit-package-data.js'; +export { + partitionAdvisoriesById, + reportUnexpectedAdvisories, +} from './audit-reporting.js'; const AUDIT_ARGS = ['audit', '--json']; -const LIST_ARGS = ['ls', '--json', '--depth', 'Infinity']; const BULK_ADVISORY_PATH = '-/npm/v1/security/advisories/bulk'; const BULK_AUDIT_TIMEOUT_MS = 30_000; const DEFAULT_REGISTRY = 'https://registry.npmjs.org/'; const COMMAND_MAX_BUFFER = 64 * 1024 * 1024; -const DEPENDENCY_SECTION_NAMES = ['dependencies', 'devDependencies', 'optionalDependencies']; const RETIRED_AUDIT_ENDPOINT_MESSAGE = 'This endpoint is being retired. Use the bulk advisory endpoint instead.'; - -/** Parse command JSON and optionally reject blank responses. - * @param {string | undefined | null} payloadText Raw command output. @param {string} commandLabel Label used in parse errors. @param {{ requireNonEmpty?: boolean }} [options={}] Parsing options. - * @returns {Record | unknown[]} Parsed JSON value, or `{}` for optional blank output. @example parseJsonOutput('{"advisories":{}}', 'pnpm audit'); // { advisories: {} } - */ -function parseJsonOutput(payloadText, commandLabel, options = {}) { - const { requireNonEmpty = false } = options; - const text = payloadText?.trim?.() ?? ''; - if (!text) { - if (requireNonEmpty) { - throw new Error(`Failed to parse ${commandLabel} JSON: response body was empty.`); - } - return {}; - } - try { - return JSON.parse(text); - } catch (error) { - error.message = `Failed to parse ${commandLabel} JSON: ${error.message}`; - throw error; - } -} +const defaultAuditIo = { + execFileSync, + fetch: (...args) => fetch(...args), + getEnv: (name) => process.env[name], + setTimeout, + clearTimeout, + spawnSync, +}; /** Detect whether pnpm reported the retired audit endpoint. * @param {unknown} payload Parsed `pnpm audit --json` payload. @@ -43,111 +58,23 @@ function isRetiredAuditEndpoint(payload) { payload.error.message.includes(RETIRED_AUDIT_ENDPOINT_MESSAGE)); } -/** Check whether a version points at a local workspace dependency. - * @param {string} version Package version or workspace reference. - * @returns {boolean} `true` when the version should be ignored for registry audits. @example isLocalWorkspaceVersion('workspace:*'); // true - */ -function isLocalWorkspaceVersion(version) { - return ( - version.startsWith('file:') || - version.startsWith('link:') || - version.startsWith('workspace:')); -} - -/** Record an installed package version unless it is missing or workspace-local. - * @param {Map>} versionsByPackage Installed versions keyed by package name. @param {string} packageName Package name from `pnpm ls`. @param {string} version Installed package version. - * @returns {void} @example const versions = new Map(); addPackageVersion(versions, 'validator', '13.15.23'); console.log([...versions.get('validator')]); // ['13.15.23'] - */ -function addPackageVersion(versionsByPackage, packageName, version) { - const isMissing = !packageName || !version; - if (isMissing || isLocalWorkspaceVersion(version)) { - return; - } - const knownVersions = versionsByPackage.get(packageName) ?? new Set(); - knownVersions.add(version); - versionsByPackage.set(packageName, knownVersions); -} - -/** Walk one dependency section from `pnpm ls` and record installed versions. - * @param {Record | undefined} section Dependency section keyed by package name. @param {Map>} versionsByPackage Collected versions keyed by package name. - * @returns {void} @example const versions = new Map(); walkDependencySection({ validator: { version: '13.15.23' } }, versions); console.log(versions.get('validator').has('13.15.23')); // true - */ -function walkDependencySection(section, versionsByPackage) { - if (!section || typeof section !== 'object') { - return; - } - for (const [packageName, dependency] of Object.entries(section)) { - if (!dependency || typeof dependency !== 'object') { - continue; - } - if (typeof dependency.version === 'string') { - addPackageVersion(versionsByPackage, packageName, dependency.version); - } - walkDependencies(dependency, versionsByPackage); - } -} - -/** Walk a `pnpm ls` tree and record every installed package version. - * @param {Record | undefined} node Dependency tree node from `pnpm ls`. @param {Map>} versionsByPackage Collected versions keyed by package name. - * @returns {void} @example const versions = new Map(); walkDependencies({ dependencies: { validator: { version: '13.15.23' } } }, versions); console.log([...versions.get('validator')]); // ['13.15.23'] - */ -function walkDependencies(node, versionsByPackage) { - if (!node || typeof node !== 'object') { - return; - } - for (const sectionName of DEPENDENCY_SECTION_NAMES) { - walkDependencySection(node[sectionName], versionsByPackage); - } -} - -/** Check whether `pnpm ls` returned a dependency tree object. - * @param {unknown} value Parsed `pnpm ls` payload value. - * @returns {boolean} `true` when the value can be walked as one dependency tree. @example isDependencyTreeNode({ dependencies: {} }); // true - */ -function isDependencyTreeNode(value) { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -/** Build the installed package-version map from parsed `pnpm ls` output. - * @param {Record | Record[] | undefined} packageTrees Parsed `pnpm ls` output as one tree or many. @returns {Map>} Installed versions keyed by package name. @example const versions = buildVersionMap([{ dependencies: { validator: { version: '13.15.23' } } }]); console.log(versions.get('validator').has('13.15.23')); // true +/** Ensure a child-process result exited normally. + * @param {{ error?: Error, signal?: string | null, status?: number | null }} result Spawn result from an audit command. + * @param {string} commandLabel Label used in thrown errors. + * @returns {number} Process exit status. + * @example assertCompletedProcess({ status: 0, signal: null }, 'pnpm audit'); // 0 */ -function buildVersionMap(packageTrees) { - const versionsByPackage = new Map(); - const trees = Array.isArray(packageTrees) ? packageTrees : [packageTrees]; - for (const tree of trees) { - if (!isDependencyTreeNode(tree)) { - throw new TypeError('pnpm ls returned an invalid dependency tree payload.'); - } - walkDependencies(tree, versionsByPackage); - } - return versionsByPackage; -} - -/** Collect installed package versions from `pnpm ls` for bulk advisory lookups. - * @returns {Record} Sorted installed versions keyed by package name. @example // With `pnpm ls` returning one installed validator version: collectInstalledPackageVersions(); // { validator: ['13.15.23'] } - */ -function collectInstalledPackageVersions() { - const result = spawnSync('pnpm', LIST_ARGS, { encoding: 'utf8', maxBuffer: COMMAND_MAX_BUFFER, stdio: ['ignore', 'pipe', 'inherit'] }); +function assertCompletedProcess(result, commandLabel) { if (result.error) { throw result.error; } - const status = result.status ?? 0; - if (status !== 0) { - throw new Error(`pnpm ls failed without producing a dependency tree (exit status ${status}).`); + if (result.signal) { + throw new Error(`${commandLabel} was terminated by signal ${result.signal}.`); } - const stdout = result.stdout?.trim(); - if (!stdout) { - throw new Error('pnpm ls failed without producing a dependency tree.'); + if (result.status === null) { + throw new Error(`${commandLabel} was terminated before reporting an exit status.`); } - - const packageTrees = parseJsonOutput(stdout, 'pnpm ls'); - const versionsByPackage = buildVersionMap(packageTrees); - - return Object.fromEntries( - [...versionsByPackage.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([packageName, versions]) => [packageName, [...versions].sort()]), - ); + return result.status; } /** Return `true` when a raw registry string is a real URL and not a placeholder. @@ -167,16 +94,19 @@ function normalizeRegistryUrl(rawRegistry) { } /** Read the npm registry URL from the environment or pnpm config. - * @returns {string} Normalised registry URL, or the npm default when lookup fails. @example // With `npm_config_registry=https://registry.npmjs.org`: readRegistryUrl(); // 'https://registry.npmjs.org/' + * @param {object} [auditIo=defaultAuditIo] Audit IO adapter; `defaultAuditIo` is used when omitted. + * @returns {string} Normalised registry URL, or the npm default when lookup fails. + * @example // With `npm_config_registry=https://registry.npmjs.org`: readRegistryUrl(); // 'https://registry.npmjs.org/' + * @example const auditIo = { ...defaultAuditIo, getEnv: () => undefined, execFileSync: () => 'https://registry.npmjs.org\n' }; readRegistryUrl(auditIo); // 'https://registry.npmjs.org/' */ -function readRegistryUrl() { - const envRegistry = process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY; +function readRegistryUrl(auditIo = defaultAuditIo) { + const envRegistry = auditIo.getEnv?.('npm_config_registry') ?? auditIo.getEnv?.('NPM_CONFIG_REGISTRY'); if (envRegistry) { return normalizeRegistryUrl(envRegistry); } try { return normalizeRegistryUrl( - execFileSync('pnpm', ['config', 'get', 'registry'], { + auditIo.execFileSync('pnpm', ['config', 'get', 'registry'], { encoding: 'utf8', }), ); @@ -185,88 +115,18 @@ function readRegistryUrl() { } } -/** Extract a GitHub advisory identifier from an advisory URL. - * @param {unknown} advisoryUrl Advisory URL from pnpm or npm audit output. - * @returns {string | undefined} Matching GHSA identifier when one is present. @example extractGithubAdvisoryId('https://github.com/advisories/GHSA-vghf-hv5q-vc2g'); // 'GHSA-vghf-hv5q-vc2g' - */ -function extractGithubAdvisoryId(advisoryUrl) { - if (typeof advisoryUrl !== 'string') { - return undefined; - } - const match = advisoryUrl.match(/GHSA-([0-9a-z]{4})-([0-9a-z]{4})-([0-9a-z]{4})/i); - if (!match) { - return undefined; - } - const [, first, second, third] = match; - return `GHSA-${first.toLowerCase()}-${second.toLowerCase()}-${third.toLowerCase()}`; -} - -/** Derive the advisory key used to deduplicate bulk advisory responses. - * @param {string} packageName Advisory package name. @param {{ id?: unknown, url?: unknown }} advisory Raw advisory object. - * @returns {{ key: string, githubAdvisoryId: string | undefined }} Stable advisory key and extracted GHSA identifier. @example deriveAdvisoryKey('validator', { id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g' }); // { key: 'GHSA-vghf-hv5q-vc2g', githubAdvisoryId: 'GHSA-vghf-hv5q-vc2g' } - */ -function deriveAdvisoryKey(packageName, advisory) { - const githubAdvisoryId = extractGithubAdvisoryId(advisory?.url); - const key = githubAdvisoryId ?? `${packageName}:${String(advisory?.id ?? 'unknown')}`; - return { key, githubAdvisoryId }; -} - -/** Return `true` when a value is a plain (non-array, non-null) object. - * @param {unknown} value Value to test. @returns {boolean} - * @example isPlainAdvisoryObject({ id: 1 }); // true - */ -function isPlainAdvisoryObject(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } - -/** Merge advisories for one package into the shared accumulator. - * @param {string} packageName Package name from the bulk advisory payload. @param {unknown[]} packageAdvisories Validated array of raw advisory objects. @param {Record} advisories Accumulator mutated in place. - * @returns {void} @example const advisories = {}; addPackageAdvisories('validator', [{ id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g', title: 'Validator SSRF' }], advisories); console.log(advisories['GHSA-vghf-hv5q-vc2g'].package_name); // 'validator' - */ -function addPackageAdvisories(packageName, packageAdvisories, advisories) { - for (const [index, advisory] of packageAdvisories.entries()) { - if (!isPlainAdvisoryObject(advisory)) { - throw new Error(`Invalid advisory for package ${packageName} at index ${index}: expected object`); - } - const { key, githubAdvisoryId } = deriveAdvisoryKey(packageName, advisory); - if (Object.hasOwn(advisories, key)) { - continue; - } - advisories[key] = { - ...advisory, - github_advisory_id: githubAdvisoryId, - package_name: packageName, - }; - } -} - -/** Normalize bulk advisory responses into the shared advisory object shape. - * @param {Record | undefined} bulkPayload Bulk advisory payload keyed by package name. - * @returns {Record} Deduplicated advisories keyed by GHSA identifier or package fallback. @example normalizeBulkAdvisories({ validator: [{ id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g' }] }); // { 'GHSA-vghf-hv5q-vc2g': { github_advisory_id: 'GHSA-vghf-hv5q-vc2g', package_name: 'validator', id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g' } } - */ -function normalizeBulkAdvisories(bulkPayload) { - if (!isPlainAdvisoryObject(bulkPayload)) { - throw new TypeError('Invalid bulk advisory payload: expected an object keyed by package name.'); - } - const advisories = {}; - for (const [packageName, packageAdvisories] of Object.entries(bulkPayload)) { - if (!Array.isArray(packageAdvisories)) { - throw new TypeError(`Invalid bulk advisory entry for package ${packageName}: expected array, received ${JSON.stringify(packageAdvisories)}`); - } - addPackageAdvisories(packageName, packageAdvisories, advisories); - } - - return advisories; -} - /** Post package versions to the npm bulk advisory endpoint and return the raw response. * @param {URL} endpoint Bulk advisory endpoint URL. @param {Record} packageVersions Installed package versions keyed by package name. + * @param {object} [auditIo=defaultAuditIo] Audit IO adapter; `defaultAuditIo` is used when omitted. * @returns {Promise<{ response: Response, responseText: string }>} HTTP response and response body text. * @example const { responseText } = await fetchBulkAdvisories(new URL('https://registry.npmjs.org/-/npm/v1/security/advisories/bulk'), { validator: ['13.15.23'] }); console.log(responseText); // '{}' + * @example const auditIo = { ...defaultAuditIo, fetch: async () => ({ text: async () => '{}' }), setTimeout: () => 1, clearTimeout: () => undefined }; await fetchBulkAdvisories(new URL('https://registry.npmjs.org/-/npm/v1/security/advisories/bulk'), {}, auditIo); // { response: ..., responseText: '{}' } */ -async function fetchBulkAdvisories(endpoint, packageVersions) { +async function fetchBulkAdvisories(endpoint, packageVersions, auditIo = defaultAuditIo) { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), BULK_AUDIT_TIMEOUT_MS); + const timeoutId = auditIo.setTimeout(() => controller.abort(), BULK_AUDIT_TIMEOUT_MS); try { - const response = await fetch(endpoint, { + const response = await auditIo.fetch(endpoint, { method: 'POST', headers: { accept: 'application/json', @@ -283,7 +143,7 @@ async function fetchBulkAdvisories(endpoint, packageVersions) { } throw error; } finally { - clearTimeout(timeoutId); + auditIo.clearTimeout(timeoutId); } } @@ -296,14 +156,19 @@ function toAdvisoryResult(advisories) { } /** Query the npm bulk advisory endpoint using the installed PNPM dependency tree. - * @returns {Promise<{ json: { advisories: Record }, status: number }>} Bulk advisory payload and derived exit status. @example // With a successful bulk advisory response containing one advisory: await runBulkAdvisoryAudit(); // { json: { advisories: { 'GHSA-vghf-hv5q-vc2g': { ... } } }, status: 1 } - */ -async function runBulkAdvisoryAudit() { - const registryUrl = readRegistryUrl(); + * @param {object} [auditIo=defaultAuditIo] Audit IO adapter; `defaultAuditIo` is used when omitted. + * @returns {Promise<{ json: { advisories: Record }, status: number }>} Bulk advisory payload and derived exit status. + * @example // With a successful bulk advisory response containing one advisory: + * await runBulkAdvisoryAudit(); // { json: { advisories: { 'GHSA-vghf-hv5q-vc2g': { ... } } }, status: 1 } + * @example const auditIo = { ...defaultAuditIo, spawnSync: () => ({ status: 0, stdout: '[{"dependencies":{}}]' }), fetch: async () => ({ ok: true, text: async () => '{}' }) }; await runBulkAdvisoryAudit(auditIo); // { json: { advisories: {} }, status: 0 } + */ +async function runBulkAdvisoryAudit(auditIo = defaultAuditIo) { + const registryUrl = readRegistryUrl(auditIo); const endpoint = new URL(BULK_ADVISORY_PATH, registryUrl); const { response, responseText } = await fetchBulkAdvisories( endpoint, - collectInstalledPackageVersions(), + collectInstalledPackageVersions(auditIo, assertCompletedProcess), + auditIo, ); if (!response.ok) { @@ -318,18 +183,19 @@ async function runBulkAdvisoryAudit() { } /** Run `pnpm audit --json`, falling back to the bulk advisory endpoint when needed. - * @returns {Promise<{ json: { advisories?: Record }, status: number }>} Parsed audit output and pnpm exit status. @example const { json, status } = await runAuditJson(); console.log(status, Object.keys(json.advisories ?? {})); - */ -export async function runAuditJson() { - const result = spawnSync('pnpm', AUDIT_ARGS, { + * Throws when `pnpm audit` fails to start or is signalled. + * @param {object} [auditIo=defaultAuditIo] Audit IO adapter; `defaultAuditIo` is used when omitted. + * @returns {Promise<{ json: { advisories?: Record }, status: number }>} Parsed audit output and pnpm exit status. + * @example const { json, status } = await runAuditJson(); console.log(status, Object.keys(json.advisories ?? {})); + * @example const auditIo = { ...defaultAuditIo, spawnSync: () => ({ status: 0, stdout: '{"advisories":{}}' }) }; await runAuditJson(auditIo); // { json: { advisories: {} }, status: 0 } + */ +export async function runAuditJson(auditIo = defaultAuditIo) { + const result = auditIo.spawnSync('pnpm', AUDIT_ARGS, { encoding: 'utf8', maxBuffer: COMMAND_MAX_BUFFER, stdio: ['ignore', 'pipe', 'inherit'], }); - if (result.error) { - throw result.error; - } - const status = result.status ?? 0; + const status = assertCompletedProcess(result, 'pnpm audit'); const stdout = result.stdout ? result.stdout.trim() : ''; if (!stdout) { return { json: { advisories: {} }, status }; @@ -337,7 +203,7 @@ export async function runAuditJson() { const json = parseJsonOutput(stdout, 'pnpm audit'); if (isRetiredAuditEndpoint(json)) { - return runBulkAdvisoryAudit(); + return await runBulkAdvisoryAudit(auditIo); } return { json, status }; @@ -350,58 +216,3 @@ export async function runAuditJson() { export function collectAdvisories(auditJson) { return Object.values(auditJson.advisories ?? {}); } - -/** Return `true` when an advisory's GHSA ID is present in the allow-set. - * @param {{ github_advisory_id?: string }} advisory Advisory to check. @param {Set} allowed Set of permitted advisory IDs. @returns {boolean} - * @example isExpectedAdvisory({ github_advisory_id: 'GHSA-vghf-hv5q-vc2g' }, new Set(['GHSA-vghf-hv5q-vc2g'])); // true - */ -function isExpectedAdvisory(advisory, allowed) { - const id = advisory.github_advisory_id; - return Boolean(id) && allowed.has(id); -} - -/** Split advisories into allowed and unexpected groups. - * @param {Array<{ github_advisory_id?: string }>} advisories Advisories to partition. @param {Iterable} allowedIds Advisory IDs the caller expects. - * @returns {{ expected: typeof advisories, unexpected: typeof advisories }} Partitioned advisories. - * @example const { expected, unexpected } = partitionAdvisoriesById([{ github_advisory_id: 'GHSA-1' }, { github_advisory_id: 'GHSA-2' }], ['GHSA-2']); console.log(expected.length); // 1 - * @example console.log(unexpected.length); // 1 - */ -export function partitionAdvisoriesById(advisories, allowedIds) { - const allowed = new Set(allowedIds); - const expected = []; - const unexpected = []; - for (const advisory of advisories) { - if (isExpectedAdvisory(advisory, allowed)) { - expected.push(advisory); - } else { - unexpected.push(advisory); - } - } - - return { expected, unexpected }; -} - -/** Format one advisory as a report line. - * @param {{ github_advisory_id?: string, title?: string }} advisory Advisory to print. @returns {string} Human-readable bullet line for the advisory. @example formatAdvisoryLine({ github_advisory_id: 'GHSA-1', title: 'Example' }); // "- GHSA-1: Example" - */ -function formatAdvisoryLine(advisory) { - const id = advisory.github_advisory_id ?? 'UNKNOWN'; - const suffix = advisory.title ? `: ${advisory.title}` : ''; - return `- ${id}${suffix}`; -} - -/** Report unexpected advisories to stderr. - * @param {Array<{ github_advisory_id?: string, title?: string }>} unexpected Advisories that were not permitted. @param {string} heading Descriptive heading for the error output. - * @returns {boolean} Whether any advisories were reported. @example const hadUnexpected = reportUnexpectedAdvisories([{ github_advisory_id: 'GHSA-1', title: 'Example' }], 'Unexpected advisories:'); console.log(hadUnexpected); // true - */ -export function reportUnexpectedAdvisories(unexpected, heading) { - if (unexpected.length === 0) { - return false; - } - - console.error(heading); - for (const advisory of unexpected) { - console.error(formatAdvisoryLine(advisory)); - } - return true; -} diff --git a/security/validate-audit.js b/security/validate-audit.js index 064e64fe..9775c165 100644 --- a/security/validate-audit.js +++ b/security/validate-audit.js @@ -5,6 +5,7 @@ import Ajv from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; import { VALIDATOR_ADVISORY_ID, VALIDATOR_MIN_SAFE_VERSION } from './constants.js'; import { isValidatorPatched } from './validator-patch.js'; +import { assertNoExpired } from './audit-exception-policy.js'; import { collectAdvisories, partitionAdvisoriesById, @@ -58,42 +59,6 @@ function assertValidSchema(entries) { } } -/** - * Exit with error if any audit exceptions are past their expiry date. - * - * @param {typeof data} entries Entries to inspect. - * @example - * assertNoExpired([ - * { - * id: "1", - * package: "pkg", - * advisory: "ADV-1", - * reason: "Justified", - * addedAt: "2024-01-01", - * expiresAt: "2099-01-01", - * }, - * ]); - */ -function assertNoExpired(entries) { - const today = new Date().toISOString().slice(0, 10); - const expired = entries.filter((e) => e.expiresAt < today); - const inverted = entries.filter((e) => e.addedAt > e.expiresAt); - if (expired.length > 0) { - console.error('Audit exceptions have expired:'); - for (const { id, package: pkg, expiresAt } of expired) { - console.error(`- ${id} (${pkg}) expired on ${expiresAt}`); - } - process.exit(1); - } - if (inverted.length > 0) { - console.error('Audit exceptions have invalid date ranges (addedAt > expiresAt):'); - for (const { id, package: pkg, addedAt, expiresAt } of inverted) { - console.error(`- ${id} (${pkg}) addedAt ${addedAt} > expiresAt ${expiresAt}`); - } - process.exit(1); - } -} - /** * Validate that current advisories align with the configured exceptions. *