diff --git a/.claude/commands/pr-submit.md b/.claude/commands/pr-submit.md new file mode 100644 index 00000000..07274c6f --- /dev/null +++ b/.claude/commands/pr-submit.md @@ -0,0 +1,21 @@ +Create a PR for the existing branch. + +Keep the PR title and body concise. Use the following title prefixes: + +- `fix:` for production code bug fixes +- `perf:` for production code performance improvements +- `feat:` for new features +- `docs:` for documentation improvements (including README.md and CLAUDE.md) +- `ci:` for CI improvements + +based on what best describes the changes. + +Avoid extraneous emphasis and emojis in the PR body. It should be factual and neutral. + +Consider at least: + +- Background (if any) +- If fixing bugs, reproduction steps +- Changes to the UX surface area +- Testing approach +- Acknowledged but intentionally ignored issues diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 00000000..5d8d50e9 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,49 @@ +name: Benchmarks + +on: + workflow_dispatch: + pull_request: + branches: [main] + paths: + - 'src/rules/**' + - 'benches/**' + - '.github/workflows/benchmark.yml' + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + benchmark: + runs-on: ubuntu-latest-8-cores + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: ${{ runner.os }} + # Share cache with other Linux workflows + cache-on-failure: true + # Cache benchmark-specific dependencies + cache-targets: true + cache-all-crates: true + + - name: Build benchmarks first (populates cache) + run: cargo build --release --bench rule_engines + + - name: Run benchmarks + run: cargo bench --bench rule_engines + + - name: Upload benchmark results + uses: actions/upload-artifact@v4 + with: + name: benchmark-results + path: target/criterion/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index c8e287a8..3f2ebbd7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ CLAUDE.local.md artifacts/ book test-build -mermaid*.js \ No newline at end of file +mermaid*.js +*.pb.gz diff --git a/Cargo.lock b/Cargo.lock index 5f013e54..ae781168 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,6 +53,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.20" @@ -148,7 +154,7 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" dependencies = [ - "hermit-abi", + "hermit-abi 0.1.19", "libc", "winapi", ] @@ -209,10 +215,10 @@ version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ - "bitflags", + "bitflags 2.9.2", "cexpr", "clang-sys", - "itertools", + "itertools 0.12.1", "lazy_static", "lazycell", "log", @@ -226,6 +232,12 @@ dependencies = [ "which 4.4.2", ] +[[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.2" @@ -261,6 +273,12 @@ version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d07aa9a93b00c76f71bc35d598bed923f6d4f3a9ca5c24b7737ae1a292841c0" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.33" @@ -307,6 +325,33 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -399,6 +444,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpp_demangle" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96e58d342ad113c2b878f16d5d034c03be492ae460cdbc02b7f0f2284d310c7d" +dependencies = [ + "cfg-if", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -408,6 +462,75 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "futures", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "tokio", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "ctor" version = "0.2.9" @@ -425,10 +548,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "881c5d0a13b2f1498e2306e82cbada78390e152d4b1378fb28a84f4dcd0dc4f3" dependencies = [ "dispatch", - "nix", + "nix 0.30.1", "windows-sys 0.61.0", ] +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + [[package]] name = "deranged" version = "0.4.0" @@ -534,6 +666,18 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "findshlibs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" +dependencies = [ + "cc", + "lazy_static", + "libc", + "winapi", +] + [[package]] name = "float-cmp" version = "0.10.0" @@ -720,6 +864,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +dependencies = [ + "cfg-if", + "crunchy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -746,6 +900,12 @@ dependencies = [ "libc", ] +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "home" version = "0.5.11" @@ -813,6 +973,7 @@ dependencies = [ "camino", "chrono", "clap", + "criterion", "ctor", "ctrlc", "dirs", @@ -823,6 +984,7 @@ dependencies = [ "hyper-util", "libc", "lru", + "pprof", "predicates", "rand", "rcgen", @@ -1057,7 +1219,7 @@ version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" dependencies = [ - "bitflags", + "bitflags 2.9.2", "cfg-if", "libc", ] @@ -1068,12 +1230,32 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "is-terminal" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +dependencies = [ + "hermit-abi 0.5.2", + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -1143,7 +1325,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ - "bitflags", + "bitflags 2.9.2", "libc", "redox_syscall", ] @@ -1206,6 +1388,15 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "memmap2" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843a98750cd611cc2965a8213b53b43e715f13c37a9e096c6408e69990961db7" +dependencies = [ + "libc", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1241,13 +1432,24 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + [[package]] name = "nix" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags", + "bitflags 2.9.2", "cfg-if", "cfg_aliases", "libc", @@ -1358,6 +1560,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "openssl-probe" version = "0.1.6" @@ -1465,6 +1673,34 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "potential_utf" version = "0.1.3" @@ -1480,6 +1716,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "pprof" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef5c97c51bd34c7e742402e216abdeb44d415fbe6ae41d56b114723e953711cb" +dependencies = [ + "backtrace", + "cfg-if", + "criterion", + "findshlibs", + "libc", + "log", + "nix 0.26.4", + "once_cell", + "parking_lot", + "protobuf", + "protobuf-codegen-pure", + "smallvec", + "symbolic-demangle", + "tempfile", + "thiserror 1.0.69", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1547,6 +1806,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "protobuf" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" + +[[package]] +name = "protobuf-codegen" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "033460afb75cf755fcfc16dfaed20b86468082a2ea24e05ac35ab4a099a017d6" +dependencies = [ + "protobuf", +] + +[[package]] +name = "protobuf-codegen-pure" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a29399fc94bcd3eeaa951c715f7bea69409b2445356b00519740bcd6ddd865" +dependencies = [ + "protobuf", + "protobuf-codegen", +] + [[package]] name = "quote" version = "1.0.40" @@ -1592,6 +1876,26 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "rcgen" version = "0.13.2" @@ -1611,7 +1915,7 @@ version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags", + "bitflags 2.9.2", ] [[package]] @@ -1622,7 +1926,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror", + "thiserror 2.0.16", ] [[package]] @@ -1695,7 +1999,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.9.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -1708,7 +2012,7 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags", + "bitflags 2.9.2", "errno", "libc", "linux-raw-sys 0.9.4", @@ -1775,6 +2079,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scc" version = "2.4.0" @@ -1811,7 +2124,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80fb1d92c5028aa318b4b8bd7302a5bfcf48be96a37fc6fc790f806b0004ee0c" dependencies = [ - "bitflags", + "bitflags 2.9.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -1915,7 +2228,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c80e565e7dcc4f1ef247e2f395550d4cf7d777746d5988e7e4e3156b71077fc" dependencies = [ - "bitflags", + "bitflags 2.9.2", ] [[package]] @@ -1974,6 +2287,29 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symbolic-common" +version = "12.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d03f433c9befeea460a01d750e698aa86caf86dcfbd77d552885cd6c89d52f50" +dependencies = [ + "debugid", + "memmap2", + "stable_deref_trait", + "uuid", +] + +[[package]] +name = "symbolic-demangle" +version = "12.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d359ef6192db1760a34321ec4f089245ede4342c27e59be99642f12a859de8" +dependencies = [ + "cpp_demangle", + "rustc-demangle", + "symbolic-common", +] + [[package]] name = "syn" version = "1.0.109" @@ -2013,7 +2349,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags", + "bitflags 2.9.2", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -2047,13 +2383,33 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.16", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] @@ -2105,6 +2461,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tls-parser" version = "0.12.2" @@ -2300,6 +2666,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "v8" version = "129.0.0" @@ -2307,7 +2683,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f276b42044c07ee34aaa7cdc640185148787a78de761c42e8ae0a12af9a9dc6" dependencies = [ "bindgen", - "bitflags", + "bitflags 2.9.2", "fslock", "gzip-header", "home", @@ -2332,6 +2708,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2414,6 +2800,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2472,6 +2868,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2740,7 +3145,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags", + "bitflags 2.9.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ef0dcfd0..86a8c783 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ v8 = "129" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" simple-dns = "0.7" +tempfile = "3.8" [target.'cfg(target_os = "macos")'.dependencies] libc = "0.2" @@ -56,6 +57,8 @@ assert_cmd = "2.0" predicates = "3.0" serial_test = "3.0" ctor = "0.2" +criterion = { version = "0.5", features = ["async_tokio"] } +pprof = { version = "0.13", features = ["protobuf-codec", "criterion"] } [package.metadata.cargo-udeps.ignore] dev = ["serial_test"] @@ -65,3 +68,11 @@ inherits = "release" opt-level = 1 lto = false codegen-units = 16 + +[profile.bench] +inherits = "release" +debug = true + +[[bench]] +name = "rule_engines" +harness = false diff --git a/README.md b/README.md index f376f5ad..fc86ca25 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Table of Contents: - [Platform Support](https://coder.github.io/httpjail/guide/platform-support.html) - [Request Logging](https://coder.github.io/httpjail/guide/request-logging.html) - [TLS Interception](https://coder.github.io/httpjail/advanced/tls-interception.html) -- [DNS Exfiltration](https://coder.github.io/httpjail/advanced/dns-protection.html) +- [DNS Exfiltration](https://coder.github.io/httpjail/advanced/dns-exfiltration.html) - [Server Mode](https://coder.github.io/httpjail/advanced/server-mode.html) ## License diff --git a/benches/rule_engines.rs b/benches/rule_engines.rs new file mode 100644 index 00000000..2892685e --- /dev/null +++ b/benches/rule_engines.rs @@ -0,0 +1,70 @@ +use criterion::{Criterion, criterion_group, criterion_main}; +use httpjail::rules::{ + RuleEngine, proc::ProcRuleEngine, shell::ShellRuleEngine, v8_js::V8JsRuleEngine, +}; +use httpjail::test_utils::create_program_file; +use hyper::Method; +use std::time::Duration; + +fn bench_v8_js_engine(c: &mut Criterion) { + let runtime = tokio::runtime::Runtime::new().unwrap(); + let engine = V8JsRuleEngine::new("true".to_string()).unwrap(); + let engine = RuleEngine::from_trait(Box::new(engine), None); + + c.bench_function("v8_js_engine", |b| { + b.to_async(&runtime) + .iter(|| async { engine.evaluate(Method::GET, "https://example.com").await }); + }); +} + +fn bench_shell_engine(c: &mut Criterion) { + let runtime = tokio::runtime::Runtime::new().unwrap(); + let engine = ShellRuleEngine::new("true".to_string()); + let engine = RuleEngine::from_trait(Box::new(engine), None); + + c.bench_function("shell_engine", |b| { + b.to_async(&runtime) + .iter(|| async { engine.evaluate(Method::GET, "https://example.com").await }); + }); +} + +fn bench_proc_engine(c: &mut Criterion) { + let runtime = tokio::runtime::Runtime::new().unwrap(); + + // Create a simple interactive program that stays alive and responds quickly + let program = r#"#!/bin/sh +while IFS= read -r line; do + echo "true" +done +"#; + + let program_path = create_program_file(program); + let engine = ProcRuleEngine::new(program_path.to_str().unwrap().to_string()); + let engine = RuleEngine::from_trait(Box::new(engine), None); + + c.bench_function("proc_engine", |b| { + b.to_async(&runtime) + .iter(|| async { engine.evaluate(Method::GET, "https://example.com").await }); + }); +} + +fn create_criterion() -> Criterion { + let mut criterion = Criterion::default().measurement_time(Duration::from_secs(10)); + + // Enable profiling if --profile flag is passed + // Usage: cargo bench -- --profile-time 10 + if std::env::args().any(|arg| arg.contains("--profile-time")) { + use pprof::criterion::{Output, PProfProfiler}; + criterion = criterion.with_profiler(PProfProfiler::new(100, Output::Protobuf)); + } + + criterion +} + +criterion_group! { + name = benches; + config = create_criterion(); + targets = bench_v8_js_engine, bench_shell_engine, bench_proc_engine +} + +criterion_main!(benches); diff --git a/docs/guide/rule-engines/index.md b/docs/guide/rule-engines/index.md index c521216a..c0b8ddae 100644 --- a/docs/guide/rule-engines/index.md +++ b/docs/guide/rule-engines/index.md @@ -7,7 +7,7 @@ httpjail provides three different rule engines for evaluating HTTP requests. Eac | Feature | JavaScript (V8) | Shell Script | Line Processor | | ---------------------- | --------------- | ------------ | -------------- | | **Performance** | | | | -| Per-Request Overhead | ~100µs | ~1-3ms | ~100µs | +| Per-Request Latency | 550µs-1.3ms | 700µs-1.6ms | 70-90µs | | **Capabilities** | | | | | Stateful Processing | ❌ | ✅ | ✅ | | External Tool Access | ❌ | ✅ | ✅ | @@ -15,6 +15,8 @@ httpjail provides three different rule engines for evaluating HTTP requests. Eac | Sandboxed Execution | ✅ | ❌ | Depends | | Development Complexity | Easy | Easy | Moderate | +> **Performance Note**: Latency measurements are from benchmarks on modern hardware. JavaScript (V8) creates a new isolate per request for safety. Line processor maintains a persistent process, providing the best performance for high-throughput scenarios. + ## Examples ### Simple Host Filtering @@ -56,7 +58,7 @@ const allowed = ["api.example.com", "cdn.example.com"]; allowed.includes(r.host) && r.method === "GET" && r.path.startsWith("/v1/"); ``` -**Shell Script** - Can use any tool but slow: +**Shell Script** - Can use any tool but slower: ```bash #!/bin/bash diff --git a/src/lib.rs b/src/lib.rs index 9b2f1687..d340b25f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,3 +8,5 @@ pub mod proxy_tls; pub mod rules; pub mod sys_resource; pub mod tls; + +pub mod test_utils; diff --git a/src/rules/v8_js.rs b/src/rules/v8_js.rs index 4b7163f7..30f1d237 100644 --- a/src/rules/v8_js.rs +++ b/src/rules/v8_js.rs @@ -71,7 +71,6 @@ impl V8JsRuleEngine { /// Convert a V8 value to a response string that can be parsed by RuleResponse fn value_to_response_string( - &self, context_scope: &mut v8::ContextScope, global: v8::Local, value: v8::Local, @@ -116,12 +115,12 @@ impl V8JsRuleEngine { } } - fn create_and_execute( - &self, + fn execute_with_isolate( + isolate: &mut v8::OwnedIsolate, + js_code: &str, request_info: &RequestInfo, ) -> Result<(bool, Option), Box> { - let mut isolate = v8::Isolate::new(v8::CreateParams::default()); - let handle_scope = &mut v8::HandleScope::new(&mut isolate); + let handle_scope = &mut v8::HandleScope::new(isolate); let context = v8::Context::new(handle_scope, Default::default()); let context_scope = &mut v8::ContextScope::new(handle_scope, context); @@ -160,8 +159,7 @@ impl V8JsRuleEngine { global.set(context_scope, r_key.into(), r_obj); // Execute the JavaScript expression - let source = - v8::String::new(context_scope, &self.js_code).ok_or("Failed to create V8 string")?; + let source = v8::String::new(context_scope, js_code).ok_or("Failed to create V8 string")?; let script = v8::Script::compile(context_scope, source, None) .ok_or("Failed to compile JavaScript expression")?; @@ -173,7 +171,7 @@ impl V8JsRuleEngine { // Convert the V8 result to a JSON string for consistent parsing // This ensures perfect parity with the proc engine response handling - let response_str = self.value_to_response_string(context_scope, global, result)?; + let response_str = Self::value_to_response_string(context_scope, global, result)?; // Use the common RuleResponse parser - exact same logic as proc engine let rule_response = RuleResponse::from_string(&response_str); @@ -192,6 +190,15 @@ impl V8JsRuleEngine { Ok((allowed, message)) } + + fn create_and_execute( + &self, + request_info: &RequestInfo, + ) -> Result<(bool, Option), Box> { + // Create a new isolate for each execution (simpler approach) + let mut isolate = v8::Isolate::new(v8::CreateParams::default()); + Self::execute_with_isolate(&mut isolate, &self.js_code, request_info) + } } #[async_trait] @@ -199,14 +206,18 @@ impl RuleEngineTrait for V8JsRuleEngine { async fn evaluate(&self, method: Method, url: &str, requester_ip: &str) -> EvaluationResult { // Run the JavaScript evaluation in a blocking task to avoid // issues with V8's single-threaded nature - let js_code = self.js_code.clone(); let method_clone = method.clone(); let url_clone = url.to_string(); let ip_clone = requester_ip.to_string(); + // Clone self to move into the closure + let self_clone = Self { + js_code: self.js_code.clone(), + runtime: self.runtime.clone(), + }; + let (allowed, context) = tokio::task::spawn_blocking(move || { - let engine = V8JsRuleEngine::new(js_code).unwrap(); - engine.execute(&method_clone, &url_clone, &ip_clone) + self_clone.execute(&method_clone, &url_clone, &ip_clone) }) .await .unwrap_or_else(|e| { @@ -320,4 +331,180 @@ mod tests { assert!(matches!(result.action, crate::rules::Action::Deny)); assert_eq!(result.context, Some("Shorthand denial".to_string())); } + + #[tokio::test] + async fn test_request_field_access() { + use crate::rules::Action; + // Test accessing various fields of the request + let test_cases = vec![ + ( + "r.method === 'GET'", + Method::GET, + "https://example.com", + true, + ), + ( + "r.method === 'POST'", + Method::GET, + "https://example.com", + false, + ), + ( + "r.host === 'example.com'", + Method::GET, + "https://example.com/test", + true, + ), + ( + "r.host === 'other.com'", + Method::GET, + "https://example.com/test", + false, + ), + ( + "r.path === '/test'", + Method::GET, + "https://example.com/test", + true, + ), + ( + "r.path.startsWith('/api')", + Method::GET, + "https://example.com/api/v1", + true, + ), + ( + "r.path.startsWith('/api')", + Method::GET, + "https://example.com/v1/api", + false, + ), + ]; + + for (js_code, method, url, expected_allow) in test_cases { + let engine = V8JsRuleEngine::new(js_code.to_string()).unwrap(); + let result = engine.evaluate(method, url, "127.0.0.1").await; + + assert_eq!( + matches!(result.action, Action::Allow), + expected_allow, + "Expression '{}' should {} request to {}", + js_code, + if expected_allow { "allow" } else { "deny" }, + url + ); + } + } + + #[tokio::test] + async fn test_object_response() { + use crate::rules::Action; + // Test returning an object with allow/deny and message + let js_code = r#" + if (r.host === 'blocked.com') { + ({ allow: false, deny_message: `Host ${r.host} is blocked` }) + } else { + ({ allow: true }) + } + "#; + + let engine = V8JsRuleEngine::new(js_code.to_string()).unwrap(); + + // Test allowed request + let result = engine + .evaluate(Method::GET, "https://example.com/test", "127.0.0.1") + .await; + assert!(matches!(result.action, Action::Allow)); + assert_eq!(result.context, None); + + // Test denied request with message + let result = engine + .evaluate(Method::GET, "https://blocked.com/test", "127.0.0.1") + .await; + assert!(matches!(result.action, Action::Deny)); + assert_eq!( + result.context, + Some("Host blocked.com is blocked".to_string()) + ); + } + + #[tokio::test] + async fn test_complex_logic() { + use crate::rules::Action; + let js_code = r#" + // Allow GitHub and GitLab + const allowed_hosts = ['github.com', 'gitlab.com']; + + // Block certain paths + const blocked_paths = ['/admin', '/config']; + + if (blocked_paths.some(p => r.path.startsWith(p))) { + ({ deny_message: 'Access to administrative paths denied' }) + } else if (allowed_hosts.includes(r.host)) { + true + } else { + false + } + "#; + + let engine = V8JsRuleEngine::new(js_code.to_string()).unwrap(); + + // Test allowed hosts + let result = engine + .evaluate(Method::GET, "https://github.com/repo", "127.0.0.1") + .await; + assert!(matches!(result.action, Action::Allow)); + + let result = engine + .evaluate(Method::GET, "https://gitlab.com/project", "127.0.0.1") + .await; + assert!(matches!(result.action, Action::Allow)); + + // Test blocked paths + let result = engine + .evaluate(Method::GET, "https://github.com/admin", "127.0.0.1") + .await; + assert!(matches!(result.action, Action::Deny)); + assert_eq!( + result.context, + Some("Access to administrative paths denied".to_string()) + ); + + // Test non-allowed host + let result = engine + .evaluate(Method::GET, "https://example.com/test", "127.0.0.1") + .await; + assert!(matches!(result.action, Action::Deny)); + } + + #[tokio::test] + async fn test_concurrent_evaluation() { + use crate::rules::Action; + use std::sync::Arc; + // Test that multiple evaluations can run concurrently + let engine = Arc::new(V8JsRuleEngine::new("r.host === 'example.com'".to_string()).unwrap()); + + let mut tasks = vec![]; + for i in 0..10 { + let engine_clone = engine.clone(); + let host = if i % 2 == 0 { + "example.com" + } else { + "other.com" + }; + let should_allow = i % 2 == 0; + + tasks.push(tokio::spawn(async move { + let result = engine_clone + .evaluate(Method::GET, &format!("https://{}/path", host), "127.0.0.1") + .await; + (should_allow, matches!(result.action, Action::Allow)) + })); + } + + for task in tasks { + let (should_allow, did_allow) = task.await.unwrap(); + assert_eq!(should_allow, did_allow); + } + } } diff --git a/src/test_utils.rs b/src/test_utils.rs new file mode 100644 index 00000000..0c50955d --- /dev/null +++ b/src/test_utils.rs @@ -0,0 +1,22 @@ +use std::fs; +use std::io::Write; +use tempfile::{NamedTempFile, TempPath}; + +/// Helper function to create an executable program file with the given content +pub fn create_program_file(content: &str) -> TempPath { + let mut program_file = NamedTempFile::new().unwrap(); + program_file.write_all(content.as_bytes()).unwrap(); + program_file.flush().unwrap(); + + let program_path = program_file.into_temp_path(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&program_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&program_path, perms).unwrap(); + } + + program_path +}