diff --git a/.github/workflows/fuzz-nightly.yml b/.github/workflows/fuzz-nightly.yml new file mode 100644 index 0000000..dc92aca --- /dev/null +++ b/.github/workflows/fuzz-nightly.yml @@ -0,0 +1,47 @@ +name: Nightly fuzz + +on: + schedule: + # 03:00 UTC every day + - cron: "0 3 * * *" + workflow_dispatch: + inputs: + duration: + description: Seconds to run each target + default: "300" + +jobs: + fuzz: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: + - callouts + steps: + - uses: actions/checkout@v4 + + - name: Install Rust (nightly) + uses: dtolnay/rust-toolchain@nightly + + - name: Install cargo-fuzz + uses: taiki-e/install-action@v2 + with: + tool: cargo-fuzz + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: driver/fuzz + + - name: Fuzz ${{ matrix.target }} + working-directory: driver/fuzz + run: cargo fuzz run ${{ matrix.target }} -- -max_total_time=${{ github.event.inputs.duration || '300' }} + + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-artifacts-${{ matrix.target }} + path: driver/fuzz/artifacts/ + if-no-files-found: ignore diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..ee7d23c --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,72 @@ +name: PR checks + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust (stable, MSVC) + uses: dtolnay/rust-toolchain@stable + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Install just + uses: taiki-e/install-action@v2 + with: + tool: just + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + # `just test` runs: + # - kext-interface-gen (regenerate Go command fixture) + # - test-protocol (Rust protocol parsers + bounds regression tests) + # - test-kext-interface (Go <-> Rust differential on the .bin fixtures) + # - test-driver (host property tests against mock_wdk) + # None of these need the WDK / a kernel. + - name: Run tests + run: just test + + # End-to-end simulation on Linux: the real driver over mock_wdk in the zf-sim + # daemon, driven by the real Go agent over a Unix domain socket. The agent + # spawns the daemon and exits with its pass/fail code. mixed_v4v6_parallel has + # no `dns` op, so the run stays offline and deterministic. + sim-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@stable + + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Install just + uses: taiki-e/install-action@v2 + with: + tool: just + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: sim + + - name: Sim unit tests + run: just sim-test + + - name: Replay scenario end-to-end + run: | + just sim-emit-samples + timeout 300 just sim-scenario ./sim/scenarios/mixed_v4v6_parallel.cbor diff --git a/.gitignore b/.gitignore index 153f19c..d956160 100644 --- a/.gitignore +++ b/.gitignore @@ -1,409 +1,413 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -*/**/[Rr]elease/ -*/**/[Rr]eleases/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc -*.exe - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 auto-generated project file (contains which files were open etc.) -*.vbp - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# Local History for Visual Studio Code -.history/ - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp - -# JetBrains Rider -*.sln.iml -pm_kext/RCa04400 -pm_kext/RCa05788 -pm_kext/RCa06452 -target -kext.sys -release/build -DriverCertificate.cer -release/config.json - -# Generated cross-language protocol test fixtures. These are (re)generated by -# `just kext-interface-gen` / `cargo test` and in CI before the tests that -# consume them run, so they must not be committed (a stale commit desyncs the -# parsers — see protocol-test.yml). -kext_interface/go_command_test.bin -protocol/rust_info_test.bin +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +*/**/[Rr]elease/ +*/**/[Rr]eleases/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc +*.exe + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +pm_kext/RCa04400 +pm_kext/RCa05788 +pm_kext/RCa06452 +target +kext.sys +release/build +DriverCertificate.cer +release/config.json +driver/fuzz/corpus + +# Generated cross-language protocol test fixtures. These are (re)generated by +# `just kext-interface-gen` / `cargo test` and in CI before the tests that +# consume them run, so they must not be committed (a stale commit desyncs the +# parsers — see protocol-test.yml). +kext_interface/go_command_test.bin +protocol/rust_info_test.bin + +# Unix build of the Go agent (`just sim-build`); the Windows one is covered by *.exe. +/kext_tester/kext_tester diff --git a/CLAUDE.md b/CLAUDE.md index 5127a21..342b86f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +> **Before exploring the source, read the relevant generated reference in `docs/LLMs/`** (every type, signature and doc comment; bodies stripped): +> driver → `RUSTDOC-driver.md` · protocol → `RUSTDOC-protocol.md` · `wdk/` → `RUSTDOC-wdk.md` · `mock_wdk/` / fuzzing → `RUSTDOC-mock_wdk.md` · `sim/` + `release/` → `RUSTDOC-tools.md` · Go (`kext_interface`, `kext_tester`) → `GODOC.md`. +> See `docs/LLMs/README.md` for sizes and when to load which. Regenerate with `just gen-llm-docs` after significant changes. + ## Project Overview ZenithFence is a Windows kernel network filtering driver (`.sys`) written in Rust, with Go bindings for user-space communication. It hooks into the Windows Filtering Platform (WFP) to intercept, inspect, and make allow/block decisions on network traffic at the kernel level. Target architecture: **amd64 only**. @@ -27,6 +31,9 @@ just test-protocol # Rust serialization tests (protocol/) just test-kext-interface # Go parsing tests (kext_interface/) just kext-interface-gen # Regenerate binary test fixtures +# Regenerate the LLM reference files in docs/LLMs/ +just gen-llm-docs + # One-time: generate test signing certificate just generate-cert ``` diff --git a/Justfile b/Justfile index dffe615..641c88b 100644 --- a/Justfile +++ b/Justfile @@ -28,10 +28,209 @@ test-protocol: [working-directory: './kext_interface'] test-kext-interface: - go test -run TestRustInfoFile + go test -run 'TestRustInfoFile|TestInfoDiffFile' [working-directory: './kext_interface'] kext-interface-gen: - go test -run TestGenerateCommandFile + go test -run 'TestGenerateCommandFile|TestGenerateCommandDiff' -test: kext-interface-gen test-protocol test-kext-interface +# Host property/regression tests for the driver, built against the mock WDK +# (see mock_wdk/). Runs natively on the existing MSVC toolchain -- no kernel. +[working-directory: './driver'] +test-driver: + cargo test --no-default-features --features mock + +# Alias kept for discoverability; same as `test-driver`. +proptest: test-driver + +# Coverage-guided fuzzing. Requires a nightly toolchain + `cargo install cargo-fuzz`, +# and is most reliable on Linux/WSL (libFuzzer + AddressSanitizer). `target` is one +# of the names under driver/fuzz/fuzz_targets/. Example: `just fuzz callouts 120`. +[working-directory: './driver/fuzz'] +fuzz target time="60": + cargo +nightly fuzz run {{target}} -- -max_total_time={{time}} + +# Coverage-guided fuzzing under ThreadSanitizer -- the deep data-race detector +# for the multithreaded `callouts_mt` target (stresses one shared device from +# many threads). Linux/WSL + nightly only. Example: `just fuzz-tsan callouts_mt 120`. +[working-directory: './driver/fuzz'] +fuzz-tsan target="callouts_mt" time="60": + cargo +nightly fuzz run {{target}} --sanitizer thread -- -max_total_time={{time}} + +# Smoke-build every fuzz target so the harnesses don't bit-rot. +[working-directory: './driver/fuzz'] +fuzz-build: + cargo +nightly fuzz build + +# Windows-runnable: random-input smoke loop, or replay specific input files. +# Coverage-guided fuzzing needs Linux (`just fuzz`); this runs anywhere. +# just fuzz-replay callouts # random smoke loop (env ITERS, SEED) +# just fuzz-replay callouts crash-abc # replay a crash/corpus file +[working-directory: './driver/fuzz'] +fuzz-replay target *files: + cargo run --bin replay -- {{target}} {{files}} + +test: kext-interface-gen test-protocol test-kext-interface test-driver + +# Regenerate the LLM reference files under docs/LLMs/ (see docs/LLMs/README.md). +gen-llm-docs: + go run ./tools/godoc-gen . + cargo run -q --manifest-path tools/rustdoc-gen/Cargo.toml -- . + +# --------------------------------------------------------------------------- +# Real-world userspace simulation (see sim/). +# +# The fake-driver daemon (Rust, runs the real driver over mock_wdk) exposes the +# driver on a Windows named pipe (a Unix domain socket on Linux/macOS); the +# *real* Go agent (kext_tester) drives it +# exactly as it would the kernel device. By default the agent *loads and starts +# the mock driver itself* -- it spawns the daemon (ZF_SIM_DAEMON), mirroring how +# production loads and starts the kernel driver -- so a single command runs the +# whole thing. Fake OS network events are injected by the daemon. In random- +# traffic mode the daemon serves agents one after another: each disconnect +# unloads the driver and the daemon waits for the next agent. Each recipe +# below that differs by OS has a [windows] (pwsh) and a [unix] (bash) variant. +# --------------------------------------------------------------------------- + +# Build the fake-driver daemon and the Go agent. The repo-root go.work makes the +# agent build against the local kext_interface (incl. the sim transports). +[windows] +sim-build: + cargo build --manifest-path ./sim/Cargo.toml + go build -C ./kext_tester -o ../kext_tester.exe + +# (Unix: the agent can't sit at ./kext_tester, which is its source directory.) +[unix] +sim-build: + cargo build --manifest-path ./sim/Cargo.toml + go build -C ./kext_tester -o kext_tester + +# Run the fake driver daemon STANDALONE (no agent): generate random network +# traffic forever, exiting non-zero the moment a driver invariant breaks. Rarely +# needed now that `just sim-run` starts the daemon itself; use it to run the +# daemon under a debugger, then attach the agent with `just sim-attach`. +[windows] +mock-run: sim-build + #!pwsh.exe -File + ./sim/target/debug/zf-sim.exe + +[unix] +mock-run: sim-build + ./sim/target/debug/zf-sim + +# Serve the mock driver to the REAL agent (nexufend, built with +# `just agent build-mock-kext`): random traffic, any verdict the agent decides is +# accepted (--any-verdict), and after the agent disconnects the driver is +# reloaded and the daemon waits for the next agent. Start this before the agent; +# `pipe` must match the path the agent was built with. Ctrl+C to stop. +[windows] +mock-serve pipe='\\.\pipe\ZenithFence': sim-build + #!pwsh.exe -File + $env:ZF_SIM_PIPE = '{{pipe}}' + ./sim/target/debug/zf-sim.exe --any-verdict + +[unix] +mock-serve pipe='ZenithFence': sim-build + ZF_SIM_PIPE='{{pipe}}' ./sim/target/debug/zf-sim --any-verdict + +# Run the whole simulation with ONE command: the agent loads and starts the mock +# driver (spawns the daemon), then reads events and answers verdicts. The daemon +# generates random traffic forever, exiting non-zero the moment a driver +# invariant breaks; the seed is fixed, so any failure reproduces. Ctrl+C stops +# both. +[windows] +sim-run: sim-build + #!pwsh.exe -File + $env:ZF_SIM_PIPE = "ZenithFence" + $env:ZF_SIM_DAEMON = (Resolve-Path ./sim/target/debug/zf-sim.exe).Path + & "./kext_tester.exe" + +[unix] +sim-run: sim-build + #!/usr/bin/env bash + set -euo pipefail + export ZF_SIM_PIPE=ZenithFence + export ZF_SIM_DAEMON="$(realpath ./sim/target/debug/zf-sim)" + exec ./kext_tester/kext_tester + +# Attach the agent to a daemon started separately (`just mock-run`), leaving +# ZF_SIM_DAEMON unset so the agent does NOT start one. For debugging the daemon. +[windows] +sim-attach: sim-build + #!pwsh.exe -File + $env:ZF_SIM_PIPE = "ZenithFence" + & "./kext_tester.exe" + +[unix] +sim-attach: sim-build + ZF_SIM_PIPE=ZenithFence ./kext_tester/kext_tester + +# Regenerate the reference CBOR scenario files under sim/scenarios/. Writes the +# fixtures only -- no driver load. Run after changing the sample builders. +[windows] +sim-emit-samples: sim-build + #!pwsh.exe -File + ./sim/target/debug/zf-sim.exe --emit-samples ./sim/scenarios + +[unix] +sim-emit-samples: sim-build + ./sim/target/debug/zf-sim --emit-samples ./sim/scenarios + +# Replay an authored CBOR scenario file (deterministic, multi-threaded) instead +# of random traffic, in ONE command: the agent loads and starts the mock driver, +# which replays the scenario and exits with a pass/fail code the agent +# propagates. The scenario path rides to the daemon via inherited environment. +# just sim-scenario ./sim/scenarios/mixed_v4v6_parallel.cbor +# ZF_SIM_THREADS sets the worker-pool size (default 4). +[windows] +sim-scenario file: sim-build + #!pwsh.exe -File + $env:ZF_SIM_PIPE = "ZenithFence" + $env:ZF_SIM_DAEMON = (Resolve-Path ./sim/target/debug/zf-sim.exe).Path + # just args are positional; tolerate a stray `file=` prefix either way. + $env:ZF_SIM_SCENARIO = ("{{file}}" -replace '^file=', '') + & "./kext_tester.exe" + +[unix] +sim-scenario file: sim-build + #!/usr/bin/env bash + set -euo pipefail + export ZF_SIM_PIPE=ZenithFence + export ZF_SIM_DAEMON="$(realpath ./sim/target/debug/zf-sim)" + # just args are positional; tolerate a stray `file=` prefix either way. + file='{{file}}' + export ZF_SIM_SCENARIO="${file#file=}" + exec ./kext_tester/kext_tester + +# Like `sim-scenario`, but loop the file forever: when the daemon reaches the end +# it restarts from the top and keeps going until you Ctrl+C (which stops both the +# agent and the daemon). A soak/stress replay -- the driver's global state (the +# connection/packet caches) persists across iterations. Same args as +# `sim-scenario`; ZF_SIM_THREADS sets the worker-pool size. +# just sim-scenario-loop ./sim/scenarios/dns_and_connect.cbor +[windows] +sim-scenario-loop file: sim-build + #!pwsh.exe -File + $env:ZF_SIM_PIPE = "ZenithFence" + $env:ZF_SIM_DAEMON = (Resolve-Path ./sim/target/debug/zf-sim.exe).Path + # just args are positional; tolerate a stray `file=` prefix either way. + $env:ZF_SIM_SCENARIO = ("{{file}}" -replace '^file=', '') + $env:ZF_SIM_LOOP = "1" + & "./kext_tester.exe" + +[unix] +sim-scenario-loop file: sim-build + #!/usr/bin/env bash + set -euo pipefail + export ZF_SIM_PIPE=ZenithFence + export ZF_SIM_DAEMON="$(realpath ./sim/target/debug/zf-sim)" + # just args are positional; tolerate a stray `file=` prefix either way. + file='{{file}}' + export ZF_SIM_SCENARIO="${file#file=}" + export ZF_SIM_LOOP=1 + exec ./kext_tester/kext_tester + +# Host unit tests for the sim crate (scenario format round-trip, delta logic, +# tuple validation). No driver device required. +sim-test: + cargo test --manifest-path ./sim/Cargo.toml diff --git a/docs/LLMs/GODOC.md b/docs/LLMs/GODOC.md new file mode 100644 index 0000000..681f20f --- /dev/null +++ b/docs/LLMs/GODOC.md @@ -0,0 +1,644 @@ +# ZenithFence — Go Package Reference + +> Auto-generated by `go run ./tools/godoc-gen .` (`just gen-llm-docs`) — do not edit by hand. +> Excludes generated files (`Code generated` header), test files, and non-Go directories. +> Library packages: exported symbols only. `package main`: all declarations. Function bodies are stripped. + +--- + +## `github.com/vlabo/zenithfence/kext_interface` + +Files: `command.go`, `info.go`, `ioctl.go`, `kext.go`, `kext_file.go`, `mock_service.go`, `pipe_connection.go`, `sim_conn.go`, `sim_conn_unix.go`, `sim_conn_windows.go`, `version.go` + +### Variables + +```go +var ( + IOCTL_VERSION = ctlCode(SIOCTL_TYPE, 0x800, METHOD_BUFFERED, windows.FILE_READ_DATA|windows.FILE_WRITE_DATA) + IOCTL_SHUTDOWN_REQUEST = ctlCode(SIOCTL_TYPE, 0x801, METHOD_BUFFERED, windows.FILE_READ_DATA|windows.FILE_WRITE_DATA) +) +``` + +```go +var ErrorUnknownInfoType = errors.New("unknown info type") +``` + +```go +var ( + + // 4 byte version of the Kext interface + InterfaceVersion = func() (v [4]byte) { + + s := strings.TrimSpace(versionTxt) + s = strings.TrimPrefix(s, "[") + s = strings.TrimSuffix(s, "]") + str_ver := strings.Split(s, ",") + for i := range v { + n, err := strconv.Atoi(strings.TrimSpace(str_ver[i])) + if err != nil { + panic(err) + } + v[i] = byte(n) + } + return + }() +) +``` + +### Constants + +```go +const ( + CommandShutdown = 0 + CommandVerdict = 1 + CommandUpdateV4 = 2 + CommandUpdateV6 = 3 + CommandClearCache = 4 + CommandGetConnectionsUpdate = 5 + CommandGetLogs = 6 + CommandPrintMemoryStats = 7 + CommandCleanEndedConnections = 8 +) +``` + +```go +const ( + SeverityTrace byte = 1 + SeverityDebug byte = 2 + SeverityInfo byte = 3 + SeverityWarn byte = 4 + SeverityError byte = 5 + SeverityFatal byte = 6 +) +``` + +```go +const ( + METHOD_BUFFERED = 0 + METHOD_IN_DIRECT = 1 + METHOD_OUT_DIRECT = 2 + METHOD_NEITHER = 3 + + SIOCTL_TYPE = 40000 +) +``` + +```go +const INFO_ONLY_PACKET_ID = math.MaxUint64 +``` + +### Types + +#### `ConnectionEndV4` + +```go +type ConnectionEndV4 struct { + ProcessId uint64 + Direction byte + Protocol byte + LocalIp [4]byte + RemoteIp [4]byte + LocalPort uint16 + RemotePort uint16 + RxBytes uint64 + RxPackets uint64 + TxBytes uint64 + TxPackets uint64 +} +``` + +#### `ConnectionEndV6` + +```go +type ConnectionEndV6 struct { + ProcessId uint64 + Direction byte + Protocol byte + LocalIp [16]byte + RemoteIp [16]byte + LocalPort uint16 + RemotePort uint16 + RxBytes uint64 + RxPackets uint64 + TxBytes uint64 + TxPackets uint64 +} +``` + +#### `ConnectionUpdateEnd` + +```go +type ConnectionUpdateEnd struct{} +``` + +#### `ConnectionUpdateV4` + +```go +type ConnectionUpdateV4 struct { + Protocol byte + LocalIp [4]byte + RemoteIp [4]byte + LocalPort uint16 + RemotePort uint16 + RxBytes uint64 + RxPackets uint64 + TxBytes uint64 + TxPackets uint64 +} +``` + +#### `ConnectionUpdateV6` + +```go +type ConnectionUpdateV6 struct { + Protocol byte + LocalIp [16]byte + RemoteIp [16]byte + LocalPort uint16 + RemotePort uint16 + RxBytes uint64 + RxPackets uint64 + TxBytes uint64 + TxPackets uint64 +} +``` + +#### `ConnectionV4` + +```go +type ConnectionV4 struct { + Payload []byte + // contains filtered or unexported fields +} +``` + +```go +func (c *ConnectionV4) Compare(other *ConnectionV4) bool +``` + +```go +func (c *ConnectionV4) IsInfoOnly() bool +``` + +#### `ConnectionV6` + +```go +type ConnectionV6 struct { + Payload []byte + // contains filtered or unexported fields +} +``` + +```go +func (c ConnectionV6) Compare(other *ConnectionV6) bool +``` + +```go +func (c *ConnectionV6) IsInfoOnly() bool +``` + +#### `ConnectionsUpdate` + +```go +type ConnectionsUpdate struct { + // contains filtered or unexported fields +} +``` + +#### `Info` + +```go +type Info any +``` + +```go +func RecvInfo(reader io.Reader) (Info, error) +``` + +#### `KextFile` + +```go +type KextFile struct { + // contains filtered or unexported fields +} +``` + +OpenPipe connects to a userspace fake-driver named pipe and returns a +*KextFile that is interchangeable with the one KextService.OpenFile returns +for the kernel device. pipeName is either a full pipe path (\\.\pipe\...) or a +bare name, which maps to \\.\pipe\ (see SimEndpoint). This is the +"different loading" path for the simulation harness; production uses +KextService.OpenFile instead. + +The handle is opened with FILE_FLAG_OVERLAPPED, exactly like the device, so a +pending Read on one goroutine does not block Writes from others and Close can +cancel the pending Read. The client's default read mode is byte-stream, which +is what the event parser (RecvInfo) expects, so message boundaries on the +command direction don't affect event reassembly. CreateFile is retried while +the server is still coming up, so daemon-first ordering is not required. + +```go +func OpenPipe(pipeName string, readBufferSize int) (*KextFile, error) +``` + +Cancel aborts any overlapped operations currently in flight on the file +without closing it. It is safe to call from a different goroutine than the +one that issued the I/O: CancelIoEx cancels requests issued by any thread. + +```go +func (f *KextFile) Cancel() error +``` + +```go +func (f *KextFile) Close() error +``` + +```go +func (f *KextFile) GetHandle() windows.Handle +``` + +Read reads the next chunk of data from the kext. +IMPORTANT: method is not thread safe. + +```go +func (f *KextFile) Read(buffer []byte) (int, error) +``` + +```go +func (f *KextFile) Write(buffer []byte) (int, error) +``` + +#### `KextService` + +```go +type KextService struct { + // contains filtered or unexported fields +} +``` + +```go +func CreateKextService(driverName string, driverPath string) (*KextService, error) +``` + +```go +func (s *KextService) Delete() error +``` + +```go +func (s *KextService) GetHandle() windows.Handle +``` + +```go +func (s *KextService) OpenFile(readBufferSize int) (*KextFile, error) +``` + +```go +func (s *KextService) Start(wait bool) error +``` + +```go +func (s *KextService) Stop(wait bool) error +``` + +```go +func (s *KextService) WaitUntilDeleted(serviceManager windows.Handle) error +``` + +#### `KextVerdict` + +```go +type KextVerdict uint8 +``` + +Make sure this is in sync with the Rust version. + +```go +const ( + // VerdictUndecided is the default status of new connections. + VerdictUndecided KextVerdict = 0 + VerdictUndeterminable KextVerdict = 1 + VerdictAccept KextVerdict = 2 + VerdictPermanentAccept KextVerdict = 3 + VerdictBlock KextVerdict = 4 + VerdictPermanentBlock KextVerdict = 5 + VerdictDrop KextVerdict = 6 + VerdictPermanentDrop KextVerdict = 7 + VerdictRerouteToNameserver KextVerdict = 8 + VerdictRerouteToTunnel KextVerdict = 9 + VerdictFailed KextVerdict = 10 +) +``` + +#### `LogLine` + +```go +type LogLine struct { + Severity byte + Line string +} +``` + +#### `MockKextService` + +MockKextService is a drop-in stand-in for KextService that is backed by the +userspace `zf-sim` daemon instead of a kernel driver service. The daemon loads +the *real* driver over the host WDK mock (mock_wdk) and runs its actual +driver_entry, then exposes it on a named pipe (a Unix socket on Unix; see +SimConn). + +It mirrors the KextService lifecycle so the agent drives the mock exactly as it +drives a kernel driver: + + - CreateMockKextService -> CreateKextService (register the driver image) + - Start -> StartService (kernel loads driver.sys, DriverEntry runs) + - OpenFile -> OpenFile (open the device / endpoint) + - Stop / Delete -> Stop / Delete (unload / deregister the driver) + +The mock "load and start" is spawning the daemon process; the OS reclaims the +driver, its I/O pumps and the endpoint when that process exits, so Stop/Delete are +a hard process termination rather than a graceful service stop. + +```go +type MockKextService struct { + // contains filtered or unexported fields +} +``` + +CreateMockKextService prepares (but does not start) a simulated kext service +backed by the `zf-sim` daemon at daemonPath, reachable on SimEndpoint(pipeName). +Any extra args are passed to the daemon; the daemon also inherits this +process's environment (ZF_SIM_SCENARIO, ZF_SIM_SEED, ...). Mirrors +CreateKextService: it validates the daemon image exists, analogous to the SCM +checking the driver path, but starts nothing until Start is called. + +```go +func CreateMockKextService(pipeName string, daemonPath string, args ...string) (*MockKextService, error) +``` + +Delete ensures the daemon is gone -- the mock analog of deleting the service. +It is idempotent (delegates to Stop), so deferring both Stop and Delete is safe. + +```go +func (s *MockKextService) Delete() error +``` + +OpenFile connects to the daemon's endpoint and returns a SimConn whose +Read/Write behave as they do against the kernel device (on Windows it is the +*KextFile from OpenPipe; use OpenPipe directly where a *KextFile is needed, +e.g. for ReadVersion). It retries while the +daemon finishes coming up, so Start need not block until the endpoint is ready. + +```go +func (s *MockKextService) OpenFile(readBufferSize int) (SimConn, error) +``` + +Start spawns the daemon, which loads the driver over mock_wdk and runs its +driver_entry -- the mock analog of StartService loading the kernel driver. The +daemon's stdout/stderr are inherited so its logs interleave with the agent's. +When wait is set, Start fails fast if the daemon dies during startup; otherwise +it returns as soon as the process is launched. + +```go +func (s *MockKextService) Start(wait bool) error +``` + +Stop terminates the daemon if it is still running -- the mock analog of +stopping the service. The producer runs forever, so a hard kill +(TerminateProcess / SIGKILL) is how the agent stops it; the OS reclaims the +driver, pumps and endpoint with the process (the daemon replaces a stale Unix +socket file on its next start). Idempotent, and a no-op once the daemon has already exited. With wait +set it blocks until the process is fully reaped. + +```go +func (s *MockKextService) Stop(wait bool) error +``` + +Wait blocks until the daemon exits on its own (e.g. a scenario finished +replaying) and returns its process exit code, so a caller can propagate the +mock driver's pass/fail result. Safe to call after Stop/Delete (returns the +recorded code). + +```go +func (s *MockKextService) Wait() (int, error) +``` + +#### `RedirectV4` + +```go +type RedirectV4 struct { + Id uint64 + RemoteAddress [4]byte + RemotePort uint16 + // contains filtered or unexported fields +} +``` + +#### `RedirectV6` + +```go +type RedirectV6 struct { + Id uint64 + RemoteAddress [16]byte + RemotePort uint16 + // contains filtered or unexported fields +} +``` + +#### `SimConn` + +SimConn is a connection to the userspace fake-driver daemon (`zf-sim`, see +sim/). Read/Write behave like a *KextFile against the kernel device: Read +fills the whole buffer from the event stream, and each Write delivers exactly +one command to the driver. + +The daemon's endpoint is addressed by a logical name (ZF_SIM_PIPE), mapped +per OS by SimEndpoint. The rule must match `endpoint` in sim/src/pipe/mod.rs: + - Windows: itself if it is a full \\.\pipe\ path, else the named + pipe \\.\pipe\ + - Unix: itself if it contains '/', else $XDG_RUNTIME_DIR/.sock, + falling back to /tmp/.sock + +```go +type SimConn = io.ReadWriteCloser +``` + +OpenSimConn connects to the daemon endpoint for name, retrying while the +daemon is still coming up, so daemon-first ordering is not required. + +```go +func OpenSimConn(name string, readBufferSize int) (SimConn, error) +``` + +#### `UpdateV4` + +```go +type UpdateV4 struct { + Protocol uint8 + LocalAddress [4]byte + LocalPort uint16 + RemoteAddress [4]byte + RemotePort uint16 + Verdict uint8 + // contains filtered or unexported fields +} +``` + +#### `UpdateV6` + +```go +type UpdateV6 struct { + Protocol uint8 + LocalAddress [16]byte + LocalPort uint16 + RemoteAddress [16]byte + RemotePort uint16 + Verdict uint8 + // contains filtered or unexported fields +} +``` + +#### `Verdict` + +```go +type Verdict struct { + Id uint64 + Verdict uint8 + // contains filtered or unexported fields +} +``` + +### Functions + +ReadVersion queries the driver's version. A simulation pipe (OpenPipe) has no +IOCTL channel; the daemon runs the driver built from this same tree, so the +interface version is reported instead. + +```go +func ReadVersion(file *KextFile) ([]uint8, error) +``` + +```go +func SendCleanEndedConnectionsCommand(writer io.Writer) error +``` + +```go +func SendClearCacheCommand(writer io.Writer) error +``` + +```go +func SendGetConnectionsUpdateCommand(writer io.Writer, timestamp uint64) error +``` + +```go +func SendGetLogsCommand(writer io.Writer) error +``` + +```go +func SendPrintMemoryStatsCommand(writer io.Writer) error +``` + +```go +func SendShutdownCommand(writer io.Writer) error +``` + +```go +func SendUpdateV4Command(writer io.Writer, update UpdateV4) error +``` + +```go +func SendUpdateV6Command(writer io.Writer, update UpdateV6) error +``` + +```go +func SendVerdictCommand(writer io.Writer, verdict Verdict) error +``` + +SimEndpoint returns the Unix socket path for the logical endpoint name. + +```go +func SimEndpoint(name string) string +``` + +--- + +## `kext_tester` + +Files: `main_unix.go`, `main_windows.go`, `sim.go` + +### Variables + +```go +var protocols = map[int]string{ + 1: "icmp", + 2: "igmp", + 6: "tcp", + 17: "udp", + 58: "ipv6-icmp", +} +``` + +### Types + +#### `Verdict` + +```go +type Verdict int8 +``` + +### Functions + +handleInfo applies the agent's verdict policy to one event. Shared by the +production and simulation paths so the decision logic is identical. + +```go +func handleInfo(file io.ReadWriter, info kext_interface.Info) +``` + +installInterrupt runs cleanup on the first Ctrl+C, then exits. Producer mode +never ends on its own, so without this the daemon would be orphaned. + +```go +func installInterrupt(cleanup func()) +``` + +```go +func logInterfaceVersion() +``` + +Off Windows there is no kernel driver to load, so the agent only runs in +simulation mode, against the userspace fake driver (see sim.go). + +```go +func main() +``` + +pumpEvents runs the read/verdict loop shared by every simulation mode: read one +event, apply the verdict policy, repeat until the connection closes. + +```go +func pumpEvents(file io.ReadWriter) +``` + +runSimulation drives the same read/verdict loop as production against a +userspace fake driver. When ZF_SIM_DAEMON names the `zf-sim` binary the agent +*loads and starts the mock driver itself* -- it spawns the daemon (which runs +the real driver over mock_wdk via driver_entry), opens its endpoint, and stops it +on exit -- mirroring how production loads and starts the kernel driver before +opening the device. With ZF_SIM_DAEMON unset it attaches to a daemon started +separately. Returns the process exit code. + +```go +func runSimulation(pipeName string) int +``` + +runSimulationAttached connects to an already-running fake-driver daemon without +starting one (ZF_SIM_DAEMON unset, e.g. the daemon launched separately under a +debugger). The read/verdict loop is identical; only the driver's lifecycle is +out of the agent's hands. + +```go +func runSimulationAttached(pipeName string) int +``` + +--- + diff --git a/docs/LLMs/README.md b/docs/LLMs/README.md new file mode 100644 index 0000000..7b55c63 --- /dev/null +++ b/docs/LLMs/README.md @@ -0,0 +1,80 @@ +# docs/LLMs — LLM Reference Files + +This directory contains auto-generated reference files for LLMs (AI assistants) +working on the ZenithFence codebase. They give a compact, structured overview of +every type, function signature and doc comment, so an assistant can find the +right code without reading the whole source tree. + +Regenerate all files after significant code changes: + +```bash +just gen-llm-docs +``` + +The generators live in `tools/godoc-gen` (Go) and `tools/rustdoc-gen` (Rust, uses `syn`). +Do not edit the generated files by hand. + +--- + +## Files + +| File | Size | ~Tokens | Contents | +|---|---|---|---| +| `RUSTDOC-driver.md` | ~67 KB | ~19k | `driver/src` (callouts, device, caches, queues) and the `driver/fuzz` harnesses | +| `RUSTDOC-protocol.md` | ~7 KB | ~2k | `protocol/src` — command parsing (user→kernel) and info serialization (kernel→user) | +| `RUSTDOC-wdk.md` | ~96 KB | ~28k | `wdk/src` — unsafe wrappers over WFP, NDIS, IRP, IOQueue, locks, allocator, FFI | +| `RUSTDOC-mock_wdk.md` | ~60 KB | ~17k | `mock_wdk/src` — host test double of `wdk` used by `--features mock` builds | +| `RUSTDOC-tools.md` | ~40 KB | ~11k | `sim/src` (fake-driver daemon, scenarios) and `release/src` (packaging) | +| `GODOC.md` | ~13 KB | ~4k | Go user space: `kext_interface` (exported API) and `kext_tester` (all decls) | + +Token estimates assume ~3.5 characters per token (typical for mixed code and prose). + +--- + +## When to load which file + +Load only the file(s) relevant to the current task to conserve context: + +- **Driver logic** (callouts, verdicts, caches, device I/O) → `RUSTDOC-driver.md` +- **Protocol changes** (new command or info event) → `RUSTDOC-protocol.md` + `GODOC.md` — both sides must change together +- **Kernel API wrappers** (`wdk/`, unsafe) → `RUSTDOC-wdk.md` +- **Fuzzing / host tests** → `RUSTDOC-driver.md` + `RUSTDOC-mock_wdk.md` +- **Simulation harness** (`sim/`, `just sim-*`) → `RUSTDOC-tools.md` + `GODOC.md` +- **Go user-space library or tester** → `GODOC.md` + +`RUSTDOC-wdk.md` and `RUSTDOC-mock_wdk.md` both define the WFP layer field enums; +load only the one the task builds against. + +--- + +## What each file contains + +**RUSTDOC files** — one section per source file (headed by its repo-relative path), each containing: +- Module doc comment (`//!`) +- `pub use` re-exports +- Constants and statics (long initializers elided as `...`) +- `macro_rules!` names with their docs +- Full type definitions — structs, enums, unions, aliases, traits — with field/variant docs, + followed by the type's inherent `impl` blocks as method signatures with their docs +- Trait implementations as one-line headers (e.g. `unsafe impl Sync for ArrayHolder`) +- Free function signatures with their docs +- `extern` blocks (FFI declarations) +- Inline submodules as their own sections + +Private items are included: most of the driver's logic lives in crate-private functions. + +**GODOC.md** — one section per Go package, each containing: +- Import path, source file list and package doc comment +- Variables, constants, types (full struct/interface body) with methods grouped under their type +- Function signatures (no bodies) +- Library packages show exported symbols only; `package main` shows all declarations + +--- + +## What is excluded + +- Function and method bodies +- Test code: Rust `#[test]` / `#[cfg(test)]` items, `tests/` directories; Go `_test.go` files +- Generated Go files (`Code generated` header) +- `build.rs`, `target/`, `release/build/`, `c_helper/`, `libs/`, `npcap/`, and the doc tools themselves +- Non-code docs — read `PacketDoc.md` for the packet path and `CLAUDE.md` for the architecture overview diff --git a/docs/LLMs/RUSTDOC-driver.md b/docs/LLMs/RUSTDOC-driver.md new file mode 100644 index 0000000..b54f014 --- /dev/null +++ b/docs/LLMs/RUSTDOC-driver.md @@ -0,0 +1,2703 @@ +# ZenithFence — Rust Item Reference: Driver + +> Auto-generated by `cargo run --manifest-path tools/rustdoc-gen/Cargo.toml -- .` (`just gen-llm-docs`) — do not edit by hand. +> One section per source file: module docs, constants, macros, full type definitions, impl blocks, functions and extern blocks. +> Includes private items. Function bodies, long initializers and test code are stripped. + +--- + +## `driver/src/ale_callouts.rs` + +### Types + +#### `AleLayerData` + +```rust +#[derive(Debug)] +struct AleLayerData { + is_ipv6: bool, + reauthorize: bool, + process_id: u64, + protocol: IpProtocol, + direction: Direction, + local_ip: IpAddress, + local_port: u16, + remote_ip: IpAddress, + remote_port: u16, + interface_index: u32, + sub_interface_index: u32, +} +``` + +```rust +impl AleLayerData { + fn as_key(&self) -> Key; + fn is_loopback(&self) -> bool; +} +``` + +### Functions + +```rust +fn get_protocol(data: &CalloutData, index: usize) -> IpProtocol; +``` + +```rust +fn get_ipv4_address(data: &CalloutData, index: usize) -> IpAddress; +``` + +```rust +fn get_ipv6_address(data: &CalloutData, index: usize) -> IpAddress; +``` + +```rust +pub fn ale_layer_connect_v4(data: CalloutData); +``` + +```rust +pub fn ale_layer_accept_v4(data: CalloutData); +``` + +```rust +pub fn ale_layer_connect_v6(data: CalloutData); +``` + +```rust +pub fn ale_layer_accept_v6(data: CalloutData); +``` + +```rust +fn ale_layer_auth_outbound(mut data: CalloutData, ale_data: AleLayerData); +``` + +```rust +fn ale_layer_auth_inbound(mut data: CalloutData, ale_data: AleLayerData); +``` + +```rust +fn add_connection( + device: &Device, + key: &Key, + ale_data: &AleLayerData, + verdict: Option, +); +``` + +```rust +fn save_packet_outbound( + device: &Device, + callout_data: &mut CalloutData, + ale_data: &AleLayerData, +) -> Result; +``` + +```rust +fn save_packet_inbound( + device: &Device, + callout_data: &mut CalloutData, + ale_data: &AleLayerData, + pend: bool, +) -> Result; +``` + +```rust +fn create_packet_list( + device: &Device, + callout_data: &mut CalloutData, + ale_data: &AleLayerData, +) -> Option; +``` + +```rust +pub fn endpoint_closure_v4(data: CalloutData); +``` + +```rust +pub fn endpoint_closure_v6(data: CalloutData); +``` + +```rust +pub fn ale_resource_monitor(data: CalloutData); +``` + +--- + +## `driver/src/ale_callouts.rs` — `mod tests` + +### Functions + +```rust +fn install_device(); +``` + +```rust +fn uninstall_device(); +``` + +```rust +fn ale_recv_v4_values( + proto: u8, + local_ip: [u8; 4], + local_port: u16, + remote_ip: [u8; 4], + remote_port: u16, +) -> Vec; +``` + +```rust +fn inbound_ippacket_v4_values() -> Vec; +``` + +```rust +fn ipv4_tcp_packet(src: [u8; 4], dst: [u8; 4], src_port: u16, dst_port: u16) -> Vec; +``` + +```rust +fn ale_key() -> Key; +``` + +```rust +fn mt_worker(tid: u64, iters: u64); +``` + +--- + +## `driver/src/array_holder.rs` + +### Types + +#### `ArrayHolder` + +```rust +pub struct ArrayHolder(RefCell>>); +``` + +```rust +impl ArrayHolder { + pub const fn default() -> Self; + pub fn save(&self, data: &[u8]); + pub fn load(&self) -> Option>; +} +``` + +### Trait implementations + +```rust +unsafe impl Sync for ArrayHolder +``` + +--- + +## `driver/src/callouts.rs` + +### Functions + +```rust +pub fn get_callout_vec() -> Vec; +``` + +--- + +## `driver/src/common.rs` + +### Constants & statics + +```rust +pub const ICMPV4_CODE_DESTINATION_UNREACHABLE: u32 = 3; +``` + +```rust +pub const ICMPV4_CODE_DU_PORT_UNREACHABLE: u32 = 3; +``` + +```rust +pub const ICMPV4_CODE_DU_ADMINISTRATIVELY_PROHIBITED: u32 = 13; +``` + +```rust +pub const ICMPV6_CODE_DESTINATION_UNREACHABLE: u32 = 1; +``` + +```rust +pub const ICMPV6_CODE_DU_PORT_UNREACHABLE: u32 = 4; +``` + +```rust +const SIOCTL_TYPE: u32 = 40000; +``` + +```rust +pub const METHOD_BUFFERED: u32 = 0; +``` + +```rust +pub const METHOD_IN_DIRECT: u32 = 1; +``` + +```rust +pub const METHOD_OUT_DIRECT: u32 = 2; +``` + +```rust +pub const METHOD_NEITHER: u32 = 3; +``` + +```rust +pub const FILE_READ_DATA: u32 = 0x0001; +``` + +```rust +pub const FILE_WRITE_DATA: u32 = 0x0002; +``` + +### Macros + +```rust +macro_rules! ctl_code { /* ... */ } +``` + +### Types + +#### `Direction` + +```rust +enum Direction { + Outbound = 0, + Inbound = 1, +} +``` + +#### `ControlCode` + +```rust +#[repr(u32)] +#[derive(FromPrimitive, ToPrimitive)] +pub enum ControlCode { + Version = ctl_code!( + SIOCTL_TYPE, 0x800, METHOD_BUFFERED, FILE_READ_DATA | FILE_WRITE_DATA + ), + ShutdownRequest = ctl_code!( + SIOCTL_TYPE, 0x801, METHOD_BUFFERED, FILE_READ_DATA | FILE_WRITE_DATA + ), +} +``` + +### Trait implementations + +```rust +impl Display for ControlCode +``` + +--- + +## `driver/src/connection.rs` + +### Constants & statics + +```rust +pub static PM_DNS_PORT: u16 = 53; +``` + +```rust +pub static PM_SPN_PORT: u16 = 717; +``` + +### Types + +#### `Verdict` + +```rust +#[derive(Copy, Clone, FromPrimitive)] +#[repr(u8)] +pub enum Verdict { + Undecided = 0, + Undeterminable = 1, + Accept = 2, + PermanentAccept = 3, + Block = 4, + PermanentBlock = 5, + Drop = 6, + PermanentDrop = 7, + RedirectNameServer = 8, + RedirectTunnel = 9, + Failed = 10, +} +``` + +```rust +impl Verdict { + /// Returns true if the verdict is a redirect. + pub fn is_redirect(&self) -> bool; + /// Returns true if the verdict is a permanent verdict. + pub fn is_permanent(&self) -> bool; +} +``` + +#### `Direction` + +Direction of the connection. + +```rust +#[derive(Copy, Clone, FromPrimitive)] +#[repr(u8)] +pub enum Direction { + Outbound = 0, + Inbound = 1, +} +``` + +#### `Connection` + +```rust +pub trait Connection { + fn redirect_info(&self) -> Option; + /// Builds the [`Key`] that identifies this connection (protocol, local and + /// remote address/port). Relies on the address/port accessors, so it works + /// for both IPv4 and IPv6 connections without per-type duplication. + fn get_key(&self) -> Key; + /// Returns true if the connection is equal to the given key. + fn equals(&self, key: &Key) -> bool; + /// Returns true if the connection is equal to the given key for redirecting. The key is considered equal if the remote port and address are equal. + fn redirect_equals(&self, key: &Key) -> bool; + /// Returns the protocol of the connection. + fn get_protocol(&self) -> IpProtocol; + /// Returns the verdict of the connection. + fn get_verdict(&self) -> Verdict; + /// Returns the local address of the connection. + fn get_local_address(&self) -> IpAddress; + /// Returns the local port of the connection. + fn get_local_port(&self) -> u16; + /// Returns the remote address of the connection. + fn get_remote_address(&self) -> IpAddress; + /// Returns the remote port of the connection. + fn get_remote_port(&self) -> u16; + /// Returns true if the connection is an IPv6 connection. + fn is_ipv6(&self) -> bool; + /// Returns the direction of the connection. + fn get_direction(&self) -> Direction; + fn get_process_id(&self) -> u64; + /// Ends the connection. + fn end(&self, timestamp: u64); + /// Returns true if the connection has ended. + fn has_ended(&self) -> bool; + /// Returns the timestamp when the connection ended. + fn get_end_time(&self) -> u64; + /// Returns the timestamp when the connection was last accessed. + fn get_last_accessed_time(&self) -> u64; + /// Sets the timestamp when the connection was last accessed. + fn set_last_accessed_time(&self, timestamp: u64); + fn set_verdict(&self, verdict: Verdict); + fn get_bandwidth_usage(&self) -> &BandwidthUsage; + fn update_bandwidth_data(&self, bytes: u64, direction: Direction); +} +``` + +#### `BandwidthUsage` + +```rust +pub struct BandwidthUsage { + pub rx_bytes: AtomicU64, + pub rx_packets: AtomicU64, + pub tx_bytes: AtomicU64, + pub tx_packets: AtomicU64, +} +``` + +```rust +impl BandwidthUsage { + /// Accumulates another counter set into this one. Used when merging a + /// duplicate connection so its traffic accounting is not lost. + pub(crate) fn add_from(&self, other: &BandwidthUsage); +} +``` + +#### `ConnectionV4` + +```rust +pub struct ConnectionV4 { + pub(crate) protocol: IpProtocol, + pub(crate) local_address: Ipv4Address, + pub(crate) local_port: u16, + pub(crate) remote_address: Ipv4Address, + pub(crate) remote_port: u16, + pub(crate) verdict: AtomicU8, + pub(crate) last_accessed_timestamp: AtomicU64, + pub(crate) bandwidth_usage: BandwidthUsage, + pub(crate) process_id: u64, + pub(crate) end_timestamp: AtomicU64, + pub(crate) direction: Direction, +} +``` + +```rust +impl ConnectionV4 { + /// Creates a new ipv4 connection from the given key. + pub fn from_key( + key: &Key, + process_id: u64, + direction: Direction, + ) -> Result; +} +``` + +#### `ConnectionV6` + +```rust +pub struct ConnectionV6 { + pub(crate) protocol: IpProtocol, + pub(crate) local_address: Ipv6Address, + pub(crate) local_port: u16, + pub(crate) remote_address: Ipv6Address, + pub(crate) remote_port: u16, + pub(crate) verdict: AtomicU8, + pub(crate) last_accessed_timestamp: AtomicU64, + pub(crate) bandwidth_usage: BandwidthUsage, + pub(crate) process_id: u64, + pub(crate) end_timestamp: AtomicU64, + pub(crate) direction: Direction, +} +``` + +```rust +impl ConnectionV6 { + /// Creates a new ipv6 connection from the given key. + pub fn from_key( + key: &Key, + process_id: u64, + direction: Direction, + ) -> Result; +} +``` + +#### `RedirectInfo` + +```rust +#[derive(Debug)] +pub struct RedirectInfo { + pub(crate) local_address: IpAddress, + pub(crate) remote_address: IpAddress, + pub(crate) remote_port: u16, + pub(crate) redirect_port: u16, + pub(crate) unify: bool, + pub(crate) redirect_address: IpAddress, +} +``` + +#### `Key` + +```rust +#[derive(Clone, Copy, PartialEq, PartialOrd, Eq, Ord)] +pub struct Key { + pub(crate) protocol: IpProtocol, + pub(crate) local_address: IpAddress, + pub(crate) local_port: u16, + pub(crate) remote_address: IpAddress, + pub(crate) remote_port: u16, +} +``` + +```rust +impl Key { + pub fn is_ipv6(&self) -> bool; + pub fn is_loopback(&self) -> bool; + pub fn reverse(&self) -> Key; +} +``` + +### Trait implementations + +```rust +impl Display for Verdict + +impl Display for Direction + +impl Debug for Direction + +impl Clone for BandwidthUsage + +impl Connection for ConnectionV4 + +impl Clone for ConnectionV4 + +impl Connection for ConnectionV6 + +impl Clone for ConnectionV6 + +impl core::fmt::Display for Key +``` + +--- + +## `driver/src/connection_cache.rs` + +### Constants & statics + +```rust +const PORT_COUT: usize = u16::MAX as usize + 1; +``` + +### Types + +#### `PortArray` + +```rust +type PortArray = [RCUPort; PORT_COUT]; +``` + +#### `ConnectionCache` + +```rust +pub struct ConnectionCache { + tcp_v4: Box>, + udp_v4: Box>, + tcp_v6: Box>, + udp_v6: Box>, + unlinked_ports_v4: MpscQueue>, + unlinked_ports_v6: MpscQueue>, +} +``` + +```rust +impl ConnectionCache { + pub fn new() -> Self; + pub fn add_v4(&self, new: ConnectionV4); + pub fn add_v6(&self, new: ConnectionV6); + pub fn end_v4(&self, key: Key) -> Option>; + pub fn end_v6(&self, key: Key) -> Option>; + pub fn end_all_on_port_v4( + &self, + key: (IpProtocol, u16), + ) -> Option>>; + pub fn end_all_on_port_v6( + &self, + key: (IpProtocol, u16), + ) -> Option>>; + pub fn update_connection(&self, key: Key, verdict: Verdict) -> Option; + pub fn clean_ended_connections( + &self, + ) -> (Vec>, Vec>); + pub fn get_verdict(&self, key: &Key) -> Option; + pub fn walk_over_connections_v4(&self, iter: F); + pub fn walk_over_connections_v6(&self, iter: F); + pub fn get_unlinked_queue_counts(&self) -> (usize, usize); + pub fn get_entries_count(&self) -> (usize, usize); + pub fn get_connection_v4(&self, key: &Key) -> Option>; + pub fn get_connection_v6(&self, key: &Key) -> Option>; + pub fn clear(&self); +} +``` + +### Trait implementations + +```rust +impl Drop for ConnectionCache +``` + +### Functions + +```rust +fn get_port<'a, T: Connection>( + tcp: &'a PortArray, + udp: &'a PortArray, + protocol: IpProtocol, + local_port: u16, +) -> Option<&'a RCUPort>; +``` + +```rust +fn alloc_port_array() -> Box>; +``` + +```rust +fn ports_clear( + tcp: &PortArray, + udp: &PortArray, + queue: &MpscQueue>, +); +``` + +```rust +fn get_connection( + tcp: &PortArray, + udp: &PortArray, + key: &Key, +) -> Option>; +``` + +```rust +fn add_connection( + tcp: &PortArray, + udp: &PortArray, + queue: &MpscQueue>, + new: T, +); +``` + +```rust +fn end_connection( + tcp: &PortArray, + udp: &PortArray, + key: &Key, +) -> Option>; +``` + +```rust +fn end_all_on_port( + tcp: &PortArray, + udp: &PortArray, + protocol: IpProtocol, + local_port: u16, +) -> Option>>; +``` + +```rust +fn set_connection_verdict( + tcp: &PortArray, + udp: &PortArray, + key: &Key, + verdict: Verdict, +) -> Option; +``` + +```rust +fn find_verdict( + tcp: &PortArray, + udp: &PortArray, + key: &Key, +) -> Option; +``` + +```rust +fn ports_walk( + tcp: &PortArray, + udp: &PortArray, + mut iter: F, +); +``` + +```rust +fn ports_clean_ended( + tcp: &PortArray, + udp: &PortArray, + removed_connections: &mut Vec>, + queue: &MpscQueue>, +); +``` + +--- + +## `driver/src/connection_cache.rs` — `mod tests` + +### Constants & statics + +```rust +const POOL: usize = 6; +``` + +### Types + +#### `ConnModel` + +```rust +#[derive(Clone, Copy, Debug)] +struct ConnModel { + verdict: u8, + ended: bool, + end_time: u64, + last_access: u64, +} +``` + +#### `Op` + +```rust +#[derive(Clone, Debug)] +enum Op { + Add(usize), + Update(usize, u8), + End(usize), + EndAllOnPort(usize), + Clean, + Clear, + Advance(u64), + CheckVerdict(usize), +} +``` + +### Functions + +```rust +fn v6(last: u8) -> Ipv6Address; +``` + +```rust +fn key(i: usize) -> Key; +``` + +```rust +fn verdict_from(v: u8) -> Verdict; +``` + +```rust +fn op_strategy() -> impl Strategy; +``` + +--- + +## `driver/src/device.rs` + +### Types + +#### `Packet` + +```rust +pub enum Packet { + PacketLayer(NetBufferList, InjectInfo), + AleLayer(ClassifyDefer), +} +``` + +#### `FilterEngineCell` + +Gives `&self` access to the `FilterEngine`, which needs `&mut` for its WFP transactions. +It cannot sit behind the `rw_spin_lock::Mutex` like the rest of the shared state: acquiring +that raises the IRQL to DISPATCH_LEVEL, while the filter engine calls (`FwpmTransaction*`, +filter and callout (un)registration) must run at PASSIVE_LEVEL, and the reset and teardown +paths wait between attempts (`wdk::utils::sleep_ms` refuses to above APC_LEVEL). +In the kernel build the mutable access is serialized by the driver instead: a filter reset +only ever runs on the thread holding the `filter_reset_queue` drain claim, and the teardown +runs from `shutdown`. WFP itself rejects overlapping transactions with +`STATUS_FWP_TXN_IN_PROGRESS`, which both paths retry on. Under the host mock a std mutex +guards it instead, so the multi-threaded harnesses stay free of data races. + +```rust +pub(crate) struct FilterEngineCell { + #[cfg(not(feature = "mock"))] + inner: core::cell::UnsafeCell, + #[cfg(feature = "mock")] + inner: std::sync::Mutex, +} +``` + +```rust +impl FilterEngineCell { + fn new(filter_engine: FilterEngine) -> Self; + /// Runs `f` with mutable access to the filter engine. Must only be called at PASSIVE_LEVEL, + /// from the paths described on the type, and never re-entered from within `f`. + pub(crate) fn with(&self, f: impl FnOnce(&mut FilterEngine) -> R) -> R; +} +``` + +#### `Device` + +```rust +pub struct Device { + pub(crate) filter_engine: FilterEngineCell, + pub(crate) read_leftover: ArrayHolder, + pub(crate) event_queue: IOQueue, + pub(crate) packet_cache: IdCache, + pub(crate) connection_cache: ConnectionCache, + pub(crate) injector: Injector, + pub(crate) network_allocator: NetworkAllocator, + /// Connections that were deferred without a completion handle and are waiting for the filter + /// reset that releases them. See `reset_filters_and_inject`. + filter_reset_queue: FilterResetQueue, + /// Set once the teardown has begun. Callouts check it and stop pending operations, because + /// from that point on nothing is left to answer a pend. + shutdown_started: AtomicBool, +} +``` + +```rust +impl Device { + /// Initialize all members of the device. Memory is handled by windows. + /// Make sure everything is initialized here. + /// + /// The event queue is non-blocking: this is the constructor the synchronous + /// fuzz harness uses, where reads are drained on the same thread that drives + /// the callouts (a blocking read there would hang). The real driver and the + /// full-driver simulation use [`Device::new_with_blocking_reads`] instead, so + /// this is only reachable from the host (mock) builds. + pub fn new(driver: &Driver) -> Result; + /// Like [`Device::new`] but selects whether `Device::read` blocks when the + /// event queue is empty. The real kernel queue always blocks, so under the + /// real `wdk` `blocking_reads` makes no difference; under the host mock it + /// chooses between the blocking and non-blocking `IOQueue`. + pub fn new_with_blocking_reads( + driver: &Driver, + blocking_reads: bool, + ) -> Result; + /// Reports whether the teardown has started. Callouts must not pend a new operation once this + /// is set: the packet cache is being drained and the user space handle is on its way out, so + /// a pend made now would never be completed. + pub fn is_shutting_down(&self) -> bool; + /// Cleanup is called just before drop. + fn write_buffer(&self, read_request: &mut ReadRequest, info: Info); + /// Called when handle. Read is called from user-space. + pub fn read(&self, read_request: &mut ReadRequest); + pub fn write(&self, write_request: &mut WriteRequest); + /// Tears the device down, in the only order that leaves nothing behind for the unload: + /// remove the filters, complete everything that is still pended, then remove the callouts. + /// + /// Reachable from the shutdown command (write and device-control) and from the unload path, + /// and safe to call more than once: the one-time part runs once, the rest runs on every call + /// so anything that raced in afterwards is still resolved. + pub fn shutdown(&self); + /// Completes every operation still held in the packet cache. A pended ALE operation keeps its + /// IRP alive inside the callout: if the driver unloads while one is still outstanding, that + /// IRP can never be completed, the thread that owns it never leaves kernel mode, and its + /// process can no longer be terminated. Everything is resolved with a verdict here so the + /// callouts can be unregistered cleanly. + fn complete_pending_packets(&self); + pub fn inject_packet(&self, packet: Packet, blocked: bool) -> Result<(), String>; + /// Resets the filters to let go of the connections that are still held by one, and injects the + /// packets that were captured with them. + /// + /// This is the only place the filters are reset while the driver is running, and the reset only + /// ever happens on the thread holding the drain claim, so two of them can never run at once. + /// That matters because a reset opens a WFP write transaction and only one transaction can be + /// open on the filter engine session at a time. + /// + /// The connection is therefore always queued first, never reset for on the spot. Whether the + /// calling thread goes on to do the work depends only on whether another thread is already doing + /// it: + /// - The claim is free: this thread takes it and works the queue until it runs empty, so its + /// own packet is injected before this returns. + /// - The claim is taken: this returns right away. The holder's next reset releases this + /// connection too — it was deferred before that reset, which is all it takes. + /// + /// Nothing is lost either way: the entry is queued before the claim is even looked at, and the + /// claim is only given back while the queue is empty (see `filter_reset_queue`). + /// + /// A reset that still loses the race for the transaction — against the teardown removing the + /// filters, or the initial commit registering them — is retried every + /// `FILTER_RESET_RETRY_INTERVAL_MS`, up to `FILTER_RESET_MAX_ATTEMPTS` times, with everything + /// that piled up in the meantime released by the same reset. Waiting means this is only usable + /// at IRQL <= APC_LEVEL; every caller reaches it from a user space write or from the teardown, + /// so that holds. + /// + /// Reports failures through the log rather than to the caller: a reset serves a whole batch, so + /// a failed one is not attributable to the connection that happened to trigger it. + fn reset_filters_and_inject(&self, pending: PendingReset); + /// Injects a packet captured with a deferred connection, if there was one to capture. + fn inject_pending(&self, pending: PendingReset) -> Result<(), String>; +} +``` + +### Trait implementations + +```rust +#[cfg(not(feature = "mock"))] +unsafe impl Sync for FilterEngineCell + +impl Drop for Device +``` + +--- + +## `driver/src/device.rs` — `mod tests` + +### Functions + +```rust +fn mock_device() -> Device; +``` + +```rust +fn write(device: &mut Device, bytes: &[u8]); +``` + +--- + +## `driver/src/entry.rs` + +### Constants & statics + +```rust +static mut DEVICE: *mut device::Device = core::ptr::null_mut(); +``` + +Serializes tests that install a `Device` into the global slot, which is a +process-wide singleton. Any test (in any module) that calls `driver_entry`, +`set_device`, or otherwise touches the slot must hold this for its duration. + +```rust +#[cfg(any(test, feature = "mock"))] +pub static DEVICE_TEST_LOCK: std::sync::Mutex<()> = ...; +``` + +```rust +static VERSION: [u8; 4] = include!("../../kext_interface/version.txt"); +``` + +### Functions + +```rust +pub fn get_device() -> Option<&'static device::Device>; +``` + +Test/fuzz helper: install a device pointer as the global device. The caller +owns the allocation (typically `Box::into_raw`) and must clear it with +`clear_device` afterwards. Not compiled into production. + +```rust +#[cfg(any(test, feature = "mock"))] +pub fn set_device(device: *mut device::Device); +``` + +Test/fuzz helper: detach and return the current global device pointer. + +```rust +#[cfg(any(test, feature = "mock"))] +pub fn clear_device() -> *mut device::Device; +``` + +```rust +pub extern "system" fn driver_entry( + driver_object: *mut DRIVER_OBJECT, + registry_path: *mut UNICODE_STRING, +) -> NTSTATUS; +``` + +```rust +pub(crate) unsafe extern "system" fn driver_unload(_object: *const DRIVER_OBJECT); +``` + +```rust +unsafe extern "system" fn driver_read( + _device_object: &mut DEVICE_OBJECT, + irp: &mut IRP, +) -> NTSTATUS; +``` + +driver_write event triggered from user-space on file.Write. + +```rust +unsafe extern "system" fn driver_write( + _device_object: &mut DEVICE_OBJECT, + irp: &mut IRP, +) -> NTSTATUS; +``` + +device_control event triggered from user-space on file.deviceIOControl. + +```rust +unsafe extern "system" fn device_control( + _device_object: &mut DEVICE_OBJECT, + irp: &mut IRP, +) -> NTSTATUS; +``` + +--- + +## `driver/src/filter_reset_queue.rs` + +The queue of connections waiting for a filter reset. +A connection that is classified with `FWP_CONDITION_FLAG_IS_REAUTHORIZE` has no completion +handle, so it cannot be pended. It is deferred by capturing the packet and blocking it, and it +stays held by the filter that blocked it until all resettable filters are re-registered +(`FilterEngine::reset_all_filters`), which makes WFP reauthorize it against the fresh filters. +That reset opens a WFP write transaction and only one transaction can be open on the filter +engine session at a time, so the resets cannot all run when they are asked for. This queue holds +them until they can. It only stores and hands back entries; when the reset is attempted, how long +to wait and what to do with a batch that could not be released are decisions of `Device` (see +`reset_filters_and_inject`). +One thread at a time works the queue. That thread is said to hold the *drain claim*, taken with +[`FilterResetQueue::push_and_claim`] and given back by +[`FilterResetQueue::take_all_or_release`]. Both the entries and the claim are guarded by the same +lock, which keeps two guarantees: + - Two threads can never both hold the claim, so whatever the holder does with an entry is done + by one thread at a time. + - A non-empty queue always has a holder. The queue only becomes non-empty through + `push_and_claim`, which never returns without leaving a holder behind, and the claim is only + given back while the queue is empty. So nothing is ever left queued with nobody to come back + for it. + +### Types + +#### `PendingReset` + +One entry of the queue: a connection held by a filter, and the packet captured with it. `None` +carries no packet, only the need for the reset itself (an outbound connect has no packet data +yet, and the ClearCache command has no connection of its own). + +```rust +pub type PendingReset = Option; +``` + +#### `State` + +```rust +struct State { + /// Entries waiting for a reset, oldest first. + entries: VecDeque, + /// Set while a thread holds the drain claim. + claimed: bool, +} +``` + +#### `FilterResetQueue` + +```rust +pub struct FilterResetQueue { + state: Mutex, +} +``` + +```rust +impl FilterResetQueue { + pub fn new() -> Self; + /// Pushes an entry and takes the drain claim for the calling thread. Returns false when another + /// thread already holds it. + /// + /// The entry is queued either way, so it is never lost when the claim is refused. + pub fn push_and_claim(&self, entry: PendingReset) -> bool; + /// Takes every entry queued so far, or gives the drain claim back when there is none left. Only + /// the thread holding the claim may call this. + /// + /// `None` means the queue was empty and the claim has been given back, so the caller no longer + /// holds it. + pub fn take_all_or_release(&self) -> Option>; + /// Pushes entries back in front of everything queued in the meantime, keeping the order they + /// were taken in. The caller keeps the drain claim. + pub fn push_front_all(&self, mut entries: VecDeque); + pub fn get_entries_count(&self) -> usize; +} +``` + +--- + +## `driver/src/fuzz_api.rs` + +Public test/fuzz surface for the `driver` crate. +Compiled only under the `mock` feature, so it is never part of the +production driver's public API. It re-exports the packet-handling functions +(some of which are `pub(crate)`) and provides small constructors that build +mock inputs, so external fuzz targets (in the `fuzz/` crate) can drive the +driver without knowing the internal module layout. + +### Re-exports + +```rust +pub use wdk::filter_engine::layer::{self, Layer}; +pub use wdk::filter_engine::packet::InjectedPacket; +pub use crate::connection::{Direction, Key, Verdict}; +pub use crate::device::{Device, Packet}; +pub use crate::entry::{clear_device, get_device, set_device}; +pub use crate::packet_util::{ + get_key_from_nb_v4, get_key_from_nb_v6, recalc_header_checksums, +}; +pub use wdk::driver::Driver; +pub use wdk::filter_engine::net_buffer::{NetBuffer, NetBufferList, NET_BUFFER_LIST}; +pub use wdk::filter_engine::classify::{FWP_ACTION_BLOCK, FWP_ACTION_PERMIT}; +``` + +### Constants & statics + +Number of distinct connection slots the fuzzer can address. The slot index +(op byte `conn` mod `POOL_SIZE`) selects a *stable* 5-tuple so the +ALE-pend -> verdict -> packet-layer pipeline links up across ops (fully +random tuples would never collide). The pool is deliberately diverse and +spans the whole address space (see `pool_v4`/`pool_v6`) so coverage is wide +while collisions stay common. The last slot is always a loopback connection +(special-cased by ALE). Fully fuzzer-controlled, arbitrary-range packets are +exercised separately via the `raw` packet path (the bytes become the packet +verbatim, so `get_key_from_nb` parses arbitrary addresses/ports/protocols). + +```rust +pub const POOL_SIZE: u8 = 64; +``` + +```rust +const IP4_HDR: u32 = 20; +``` + +```rust +const IP6_HDR: u32 = 40; +``` + +```rust +const TP_HDR: u32 = 20; +``` + +### Types + +#### `CalloutResult` + +Outcome of one callout invocation, as observed on the `ClassifyOut`. + +```rust +#[derive(Clone, Copy)] +pub struct CalloutResult { + /// The action the callout set (`0` if it left none, e.g. on a parse error). + pub action: u32, + /// Whether the callout set the ABSORB flag. + pub absorb: bool, +} +``` + +#### `TupleV4` + +```rust +#[derive(Clone, Copy)] +struct TupleV4 { + proto: IpProtocol, + local: Ipv4Address, + lport: u16, + remote: Ipv4Address, + rport: u16, +} +``` + +```rust +impl TupleV4 { + fn from_spec(spec: &TupleSpecV4) -> Self; +} +``` + +#### `TupleV6` + +```rust +#[derive(Clone, Copy)] +struct TupleV6 { + proto: IpProtocol, + local: Ipv6Address, + lport: u16, + remote: Ipv6Address, + rport: u16, +} +``` + +```rust +impl TupleV6 { + fn from_spec(spec: &TupleSpecV6) -> Self; +} +``` + +#### `TupleSpecV4` + +An explicit IPv4 5-tuple for the `*_tuple` entry points, so harnesses (the +sim traffic producer) are not limited to the fixed fuzz pool. `protocol` is +the raw IP protocol number (6 TCP, 17 UDP, 1 ICMP, ...). + +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TupleSpecV4 { + pub protocol: u8, + pub local: [u8; 4], + pub local_port: u16, + pub remote: [u8; 4], + pub remote_port: u16, +} +``` + +#### `TupleSpecV6` + +An explicit IPv6 5-tuple, see [`TupleSpecV4`]. + +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TupleSpecV6 { + pub protocol: u8, + pub local: [u8; 16], + pub local_port: u16, + pub remote: [u8; 16], + pub remote_port: u16, +} +``` + +#### `FilterEngineSnapshot` + +Snapshot of the filter engine's registration state, for asserting the +driver loaded like the kernel would see it (sublayer committed, expected +callouts on expected layers, live filter ids). + +```rust +pub struct FilterEngineSnapshot { + pub committed: bool, + /// `(name, layer, filter_id)` per registered callout, in registration order. + pub callouts: Vec<(String, Layer, u64)>, +} +``` + +### Functions + +Construct a mock `Device` for end-to-end harnesses (command channel / callouts). + +```rust +pub fn new_mock_device() -> Result; +``` + +Deterministic 32-bit mix (splitmix-style), used to spread slot indices over +the entire address/port space. + +```rust +fn mix32(x: u32) -> u32; +``` + +```rust +fn proto_from_sel(sel: u8) -> IpProtocol; +``` + +```rust +fn ports_for(proto: IpProtocol, lport: u16, rport: u16) -> (u16, u16); +``` + +Whether a `proto_sel` op byte maps to a connection-tracked protocol +(TCP/UDP). Kept here so the callout fuzz oracle doesn't hard-code the private +`proto_from_sel` mapping: a *structured* TCP/UDP packet always parses, so the +packet callout must set an action. + +```rust +pub fn proto_is_connectable(proto_sel: u8) -> bool; +``` + +```rust +fn v6_prefix(p0: u8, p1: u8, last: u8) -> Ipv6Address; +``` + +```rust +fn v6_from_seed(seed: u32) -> Ipv6Address; +``` + +```rust +fn pool_v4(conn: u8, proto_sel: u8) -> TupleV4; +``` + +```rust +fn pool_v6(conn: u8, proto_sel: u8) -> TupleV6; +``` + +```rust +fn key_v4(t: &TupleV4) -> Key; +``` + +```rust +fn key_v6(t: &TupleV6) -> Key; +``` + +```rust +fn reauth_flag(reauthorize: bool) -> u32; +``` + +```rust +fn build_packet_v4( + t: &TupleV4, + direction: Direction, + payload: &[u8], + raw: bool, +) -> Vec; +``` + +```rust +fn build_packet_v6( + t: &TupleV6, + direction: Direction, + payload: &[u8], + raw: bool, +) -> Vec; +``` + +```rust +fn drive( + layer: Layer, + values: Vec, + process_id: Option, + packet: Vec, + data_offset: usize, + ip_header_size: u32, + transport_header_size: u32, + callout: F, +) -> CalloutResult +where + F: FnOnce(CalloutData),; +``` + +```rust +fn drive_nbl( + layer: Layer, + values: Vec, + process_id: Option, + mut nbl: Box, + ip_header_size: u32, + transport_header_size: u32, + callout: F, +) -> CalloutResult +where + F: FnOnce(CalloutData),; +``` + +Install a fresh mock `Device` in the global slot if none is present. The +callout fuzz target keeps one device for the whole process (persistent +stateful fuzzing), so this is a no-op after the first call. + +```rust +pub fn ensure_device(); +``` + +Number of packets currently pending in the id/packet cache. + +```rust +pub fn packet_cache_len() -> usize; +``` + +Number of active (not-yet-ended) connections in the connection cache. + +```rust +pub fn active_conn_count() -> usize; +``` + +Snapshot of packet ids currently pending, for issuing matching verdicts. + +```rust +pub fn live_ids() -> Vec; +``` + +The verdict the connection cache currently holds for pool connection +`(conn, proto_sel)`, if any. Used as the cross-layer (Tier-3) oracle: the +packet layer's action for this flow must agree with this verdict. + +```rust +pub fn verdict_for_v4(conn: u8, proto_sel: u8) -> Option; +``` + +```rust +pub fn verdict_for_v6(conn: u8, proto_sel: u8) -> Option; +``` + +The connection cache's verdict for an explicit tuple, if any. This is how a +harness observes the agent's (asynchronous) verdict landing, like the OS +observes the ALE re-authorization outcome. + +```rust +pub fn verdict_for_key_v4(spec: TupleSpecV4) -> Option; +``` + +See [`verdict_for_key_v4`]; v6 flavor. + +```rust +pub fn verdict_for_key_v6(spec: TupleSpecV6) -> Option; +``` + +Take every packet the driver injected since the last drain (the mock +injector records instead of injecting). A harness feeds these back through +the packet-layer callouts to model the OS re-presenting injected packets. + +```rust +pub fn drain_injected() -> Vec; +``` + +Number of injected packets not yet drained. + +```rust +pub fn injected_len() -> usize; +``` + +Reset the device's transient per-flow state to a clean slate: drop every +tracked connection and any still-pended packet, and discard recorded injects. +An independent replay then sees every connection as new again instead of +inheriting the previous run's cached verdicts, and a long soak does not grow +the injector without bound. Callout registration and the filter engine are +left intact -- only transient state resets. No-op if no device is installed. + +```rust +pub fn reset_device_state(); +``` + +```rust +pub fn filter_engine_snapshot() -> Option; +``` + +```rust +pub fn run_ale_connect_v4( + conn: u8, + proto_sel: u8, + reauthorize: bool, + process_id: u64, + payload: &[u8], + raw: bool, +) -> CalloutResult; +``` + +ALE outbound-connect against an explicit tuple (see [`TupleSpecV4`]). + +```rust +pub fn run_ale_connect_v4_tuple( + spec: TupleSpecV4, + reauthorize: bool, + process_id: u64, + payload: &[u8], +) -> CalloutResult; +``` + +```rust +fn ale_connect_v4_with( + t: TupleV4, + reauthorize: bool, + process_id: u64, + payload: &[u8], + raw: bool, +) -> CalloutResult; +``` + +```rust +pub fn run_ale_accept_v4( + conn: u8, + proto_sel: u8, + reauthorize: bool, + process_id: u64, + payload: &[u8], + raw: bool, +) -> CalloutResult; +``` + +ALE inbound-accept against an explicit tuple (see [`TupleSpecV4`]). + +```rust +pub fn run_ale_accept_v4_tuple( + spec: TupleSpecV4, + reauthorize: bool, + process_id: u64, + payload: &[u8], +) -> CalloutResult; +``` + +```rust +fn ale_accept_v4_with( + t: TupleV4, + reauthorize: bool, + process_id: u64, + payload: &[u8], + raw: bool, +) -> CalloutResult; +``` + +```rust +pub fn run_ale_connect_v6( + conn: u8, + proto_sel: u8, + reauthorize: bool, + process_id: u64, + payload: &[u8], + raw: bool, +) -> CalloutResult; +``` + +ALE outbound-connect against an explicit tuple (see [`TupleSpecV6`]). + +```rust +pub fn run_ale_connect_v6_tuple( + spec: TupleSpecV6, + reauthorize: bool, + process_id: u64, + payload: &[u8], +) -> CalloutResult; +``` + +```rust +fn ale_connect_v6_with( + t: TupleV6, + reauthorize: bool, + process_id: u64, + payload: &[u8], + raw: bool, +) -> CalloutResult; +``` + +```rust +pub fn run_ale_accept_v6( + conn: u8, + proto_sel: u8, + reauthorize: bool, + process_id: u64, + payload: &[u8], + raw: bool, +) -> CalloutResult; +``` + +ALE inbound-accept against an explicit tuple (see [`TupleSpecV6`]). + +```rust +pub fn run_ale_accept_v6_tuple( + spec: TupleSpecV6, + reauthorize: bool, + process_id: u64, + payload: &[u8], +) -> CalloutResult; +``` + +```rust +fn ale_accept_v6_with( + t: TupleV6, + reauthorize: bool, + process_id: u64, + payload: &[u8], + raw: bool, +) -> CalloutResult; +``` + +```rust +pub fn run_packet_in_v4( + conn: u8, + proto_sel: u8, + payload: &[u8], + raw: bool, +) -> CalloutResult; +``` + +```rust +fn packet_in_v4_with(t: TupleV4, payload: &[u8], raw: bool) -> CalloutResult; +``` + +```rust +pub fn run_packet_out_v4( + conn: u8, + proto_sel: u8, + payload: &[u8], + raw: bool, +) -> CalloutResult; +``` + +Packet-layer classify for an explicit tuple, in either direction, with a +structured packet built around `payload`. + +```rust +pub fn run_packet_v4_tuple( + spec: TupleSpecV4, + direction: Direction, + payload: &[u8], +) -> CalloutResult; +``` + +```rust +fn packet_out_v4_with(t: TupleV4, payload: &[u8], raw: bool) -> CalloutResult; +``` + +```rust +pub fn run_packet_in_v6( + conn: u8, + proto_sel: u8, + payload: &[u8], + raw: bool, +) -> CalloutResult; +``` + +```rust +fn packet_in_v6_with(t: TupleV6, payload: &[u8], raw: bool) -> CalloutResult; +``` + +```rust +pub fn run_packet_out_v6( + conn: u8, + proto_sel: u8, + payload: &[u8], + raw: bool, +) -> CalloutResult; +``` + +Packet-layer classify for an explicit tuple, see [`run_packet_v4_tuple`]. + +```rust +pub fn run_packet_v6_tuple( + spec: TupleSpecV6, + direction: Direction, + payload: &[u8], +) -> CalloutResult; +``` + +Run the v4 packet-layer callout on an already-built packet buffer, as the +OS re-presents a packet: `injected_by_self` marks it as network-injected by +the driver (the FWPS injection state), so the callout's self-inject permit +branch runs. `bytes` must start at the IP header. + +```rust +pub fn run_packet_bytes_v4( + bytes: &[u8], + direction: Direction, + injected_by_self: bool, +) -> CalloutResult; +``` + +See [`run_packet_bytes_v4`]; v6 flavor. + +```rust +pub fn run_packet_bytes_v6( + bytes: &[u8], + direction: Direction, + injected_by_self: bool, +) -> CalloutResult; +``` + +```rust +fn packet_out_v6_with(t: TupleV6, payload: &[u8], raw: bool) -> CalloutResult; +``` + +```rust +pub fn run_endpoint_close_v4(conn: u8, proto_sel: u8, process_id: u64); +``` + +Endpoint closure for an explicit tuple (see [`TupleSpecV4`]). + +```rust +pub fn run_endpoint_close_v4_tuple(spec: TupleSpecV4, process_id: u64); +``` + +```rust +fn endpoint_close_v4_with(t: TupleV4, process_id: u64); +``` + +```rust +pub fn run_endpoint_close_v6(conn: u8, proto_sel: u8, process_id: u64); +``` + +Endpoint closure for an explicit tuple (see [`TupleSpecV6`]). + +```rust +pub fn run_endpoint_close_v6_tuple(spec: TupleSpecV6, process_id: u64); +``` + +```rust +fn endpoint_close_v6_with(t: TupleV6, process_id: u64); +``` + +Resource assignment-discard (`kind` even) or resource release (`kind` odd). + +```rust +pub fn run_resource_v4(kind: u8, proto_sel: u8, port: u16); +``` + +```rust +pub fn run_resource_v6(kind: u8, proto_sel: u8, port: u16); +``` + +Resource release for a raw protocol number and port (unlike +[`run_resource_v4`], which maps through the fuzz `proto_sel` distribution). + +```rust +pub fn run_resource_release_v4_port(protocol: u8, port: u16); +``` + +See [`run_resource_release_v4_port`]; v6 flavor. + +```rust +pub fn run_resource_release_v6_port(protocol: u8, port: u16); +``` + +```rust +fn write_global(bytes: &[u8]); +``` + +Deliver a verdict for a pended packet id (mirrors a user-space `Verdict` +command). `id` should usually come from [`live_ids`]. + +```rust +pub fn device_write_verdict(id: u64, verdict: u8); +``` + +```rust +pub fn device_write_update_v4(conn: u8, proto_sel: u8, verdict: u8); +``` + +```rust +pub fn device_write_update_v6(conn: u8, proto_sel: u8, verdict: u8); +``` + +```rust +pub fn device_clear_cache(); +``` + +```rust +pub fn device_shutdown(); +``` + +Drain up to `max` events from the device (mirrors user-space `Read` calls). +`wait_and_pop` is non-blocking in the mock, so an empty queue just ends. + +```rust +pub fn drain_events(max: u8); +``` + +--- + +## `driver/src/fuzz_api.rs` — `mod tests` + +### Functions + +```rust +fn with_fresh_device(test: impl FnOnce()); +``` + +--- + +## `driver/src/id_cache.rs` + +### Constants & statics + +```rust +pub const PACKET_MISSING_ID: u64 = u64::MAX; +``` + +### Types + +#### `Entry` + +```rust +pub struct Entry { + pub value: T, + id: u64, +} +``` + +#### `IdCache` + +```rust +pub struct IdCache { + values: Mutex>>, + next_id: AtomicU64, +} +``` + +```rust +impl IdCache { + pub fn new() -> Self; + pub fn push( + &self, + value: (Key, Packet), + process_id: u64, + direction: Direction, + ale_layer: bool, + ) -> Option; + pub fn pop_id(&self, id: u64) -> Option<(Key, Packet)>; + pub fn get_entries_count(&self) -> usize; + /// Fuzz helper: snapshot the ids currently pending in the cache. The callout + /// fuzz harness uses this to issue `Verdict` commands that reference a real + /// pended packet (instead of an id that would never match), so the full + /// ALE-pend -> user-verdict -> packet-layer pipeline actually links up. + #[cfg(feature = "mock")] + pub fn fuzz_live_ids(&self) -> alloc::vec::Vec; + pub fn pop_all(&self) -> VecDeque>; +} +``` + +### Functions + +```rust +fn get_payload<'a>(packet: &'a Packet) -> Option<&'a [u8]>; +``` + +Builds an informational-only event that carries no packet to reinject (it uses +[`PACKET_MISSING_ID`]). Used when the ALE layer only needs to tell user space about a +connection and its process id, leaving the real packet to be sent and reinjected by the +packet layer. This is the case for inbound loopback and for reauthorized outbound +connections (which cannot be pended). + +```rust +pub fn build_info_only( + key: &Key, + process_id: u64, + direction: Direction, +) -> Option; +``` + +```rust +pub fn build_info( + key: &Key, + packet_id: u64, + process_id: u64, + direction: Direction, + packet: &Packet, + ale_layer: bool, +) -> Option; +``` + +--- + +## `driver/src/id_cache.rs` — `mod tests` + +### Functions + +```rust +fn test_key() -> Key; +``` + +```rust +fn mk_packet() -> Packet; +``` + +--- + +## `driver/src/lib.rs` + +### Constants & statics + +```rust +#[cfg(not(any(test, feature = "mock")))] +static HEAP: WindowsAllocator = ...; +``` + +### Functions + +```rust +#[cfg(not(any(test, feature = "mock")))] +#[no_mangle] +pub extern "system" fn _DllMainCRTStartup(); +``` + +```rust +#[cfg(not(any(test, feature = "mock")))] +fn panic(info: &PanicInfo) -> !; +``` + +--- + +## `driver/src/logger.rs` + +### Constants & statics + +```rust +pub const LOG_LEVEL: u8 = Severity::Error as u8; +``` + +```rust +pub const MAX_LOG_LINE_SIZE: usize = 150; +``` + +```rust +const SIZE_OF_LOG_LINE_BUFFER: usize = 1024; +``` + +```rust +static LOG_LINES: [AtomicPtr; SIZE_OF_LOG_LINE_BUFFER] = ...; +``` + +```rust +static START_INDEX: AtomicUsize = AtomicUsize::new(0); +``` + +```rust +static END_INDEX: AtomicUsize = AtomicUsize::new(0); +``` + +### Macros + +```rust +macro_rules! log_internal { /* ... */ } +``` + +```rust +macro_rules! crit { /* ... */ } +``` + +```rust +macro_rules! err { /* ... */ } +``` + +```rust +macro_rules! warn { /* ... */ } +``` + +```rust +macro_rules! dbg { /* ... */ } +``` + +```rust +macro_rules! info { /* ... */ } +``` + +### Functions + +```rust +pub fn add_line(log_line: Info); +``` + +```rust +pub fn flush() -> Vec; +``` + +--- + +## `driver/src/mpsc_queue.rs` + +### Types + +#### `Node` + +```rust +struct Node { + /// Raw pointer to the caller-owned element. + /// Null on the sentinel node; non-null (though possibly a null payload) on + /// every real node. + data: *mut T, + /// Link to the next node closer to the front of the queue. + /// Written exactly once by the producer that owns the previous tail slot, + /// then never modified again. + next: AtomicPtr>, +} +``` + +#### `MpscQueue` + +Lock-free MPSC FIFO queue storing raw `*mut T` pointers. + +```rust +pub struct MpscQueue { + /// Always points to the sentinel node. Only the consumer advances this + /// pointer (via `store`). Producers never read or write `head`. + head: AtomicPtr>, + /// Always points to the last node in the chain (sentinel when empty). + /// Producers advance this with a single atomic `swap`; the consumer never + /// touches it. + tail: AtomicPtr>, +} +``` + +```rust +impl MpscQueue { + /// Creates an empty queue. + /// + /// Allocates the sentinel node. Both `head` and `tail` start pointing to + /// it; `tail` moves forward on every push while `head` moves forward on + /// every pop. + pub fn new() -> Self; + /// Appends `ptr` to the back of the queue. + /// + /// Uses a single atomic `swap` — no CAS loop, no retries. + /// + /// `prev` returned by the swap is always a valid node (at minimum the + /// sentinel) so the `prev.is_null()` branch that would be needed in a + /// sentinel-free design does not exist here. + /// + /// Safe to call from any number of concurrent threads. + pub fn push(&self, ptr: *mut T); + /// Returns the `*mut T` at the front of the queue without removing it. + /// + /// Returns `null` if the queue is empty or if a push is mid-flight (see + /// module-level note on transient emptiness). + pub fn peek(&self) -> Option<&T>; + /// Removes and returns the `*mut T` at the front of the queue. + /// Returns `null` if the queue is empty or transiently appears so. + pub fn pop(&self) -> *mut T; + /// Returns `true` if no elements are currently visible to the consumer. + /// May return `true` spuriously during a push (see module-level note). + pub fn is_empty(&self) -> bool; + /// Returns the approximate number of elements visible to the consumer. + /// May under-count by items that are mid-push (see module-level note on + /// transient emptiness). Must only be called from the single consumer thread. + pub fn count(&self) -> usize; +} +``` + +### Trait implementations + +```rust +unsafe impl Send for MpscQueue + +unsafe impl Sync for MpscQueue + +impl Drop for MpscQueue +``` + +--- + +## `driver/src/packet_callouts.rs` + +### Types + +#### `IpVersion` + +```rust +trait IpVersion: Connection + Sized { + const HEADER_LEN: u32; + const IS_IPV6: bool; + fn get_key_from_nb(nb: &NetBuffer, direction: Direction) -> Result; + fn get_connection(cache: &ConnectionCache, key: &Key) -> Option>; +} +``` + +### Trait implementations + +```rust +impl IpVersion for ConnectionV4 + +impl IpVersion for ConnectionV6 +``` + +### Functions + +```rust +pub fn ip_packet_layer_outbound_v4(data: CalloutData); +``` + +```rust +pub fn ip_packet_layer_inbound_v4(data: CalloutData); +``` + +```rust +pub fn ip_packet_layer_outbound_v6(data: CalloutData); +``` + +```rust +pub fn ip_packet_layer_inbound_v6(data: CalloutData); +``` + +```rust +fn ip_packet_layer( + mut data: CalloutData, + direction: Direction, + interface_index: u32, + sub_interface_index: u32, + compartment_id: i32, +); +``` + +```rust +fn clone_packet( + device: &Device, + nb: &NetBuffer, + direction: Direction, + ipv6: bool, + loopback: bool, + interface_index: u32, + sub_interface_index: u32, + compartment_id: i32, +) -> Result; +``` + +--- + +## `driver/src/packet_util.rs` + +### Constants & statics + +Upper bound on an IPv4 header: the fixed 20 bytes plus the 40 bytes of +options that the 4-bit IHL field can express. + +```rust +const MAX_IPV4_HEADER_LEN: usize = 60; +``` + +```rust +const IPPROTO_HOPOPTS: u8 = 0; +``` + +```rust +const IPPROTO_ROUTING: u8 = 43; +``` + +```rust +const IPPROTO_FRAGMENT: u8 = 44; +``` + +```rust +const IPPROTO_DSTOPT: u8 = 60; +``` + +Bounds the extension-header walk. This is the chain depth (headers stacked in +one packet), not the number of header types; a real-world packet almost never +stacks more than 2-3. + +```rust +const MAX_IPV6_EXT_HEADERS: usize = 4; +``` + +Upper bound on a single extension header's length; anything larger is treated +as malformed. + +```rust +const MAX_IPV6_EXT_HEADER_LEN: usize = 256; +``` + +### Types + +#### `Redirect` + +`Redirect` is a trait that defines a method for redirecting network packets. +This trait is used to implement different strategies for redirecting packets, +depending on the specific requirements of the application. + +```rust +pub trait Redirect { + /// Redirects a network packet based on the provided `RedirectInfo`. + /// + /// # Arguments + /// + /// * `redirect_info` - A struct containing information about how to redirect the packet. + /// + /// # Returns + /// + /// * `Ok(())` if the packet was successfully redirected. + /// * `Err(String)` if there was an error redirecting the packet. + fn redirect(&mut self, redirect_info: RedirectInfo) -> Result<(), String>; +} +``` + +### Trait implementations + +```rust +impl Redirect for Packet +``` + +### Functions + +Redirects an outbound packet to a specified remote address and port. +# Arguments +* `packet` - A mutable reference to the packet data. +* `remote_address` - The IP address to redirect the packet to. +* `remote_port` - The port to redirect the packet to. +* `unify` - If true, the source and destination addresses of the packet will be set to the same value. +This function modifies the packet in-place to change its destination address and port. +It also updates the checksums for the IP and transport layer headers. +If the `unify` parameter is true, it sets the source and destination addresses to be the same. +If the remote address is a loopback address, it sets the source address to the loopback address. + +```rust +pub(crate) fn redirect_outbound_packet( + packet: &mut [u8], + remote_address: IpAddress, + remote_port: u16, + unify: bool, +); +``` + +Redirects an inbound packet to a local address. +This function takes a mutable reference to a packet and modifies it in place. +It changes the destination address to the provided local address and the source address +to the original remote address. It also sets the source port to the original remote port. +The function handles both IPv4 and IPv6 addresses. +# Arguments +* `packet` - A mutable reference to the packet data. +* `local_address` - The local IP address to redirect the packet to. +* `original_remote_address` - The original remote IP address of the packet. +* `original_remote_port` - The original remote port of the packet. + +```rust +pub(crate) fn redirect_inbound_packet( + packet: &mut [u8], + local_address: IpAddress, + original_remote_address: IpAddress, + original_remote_port: u16, +); +``` + +```rust +pub fn recalc_header_checksums(packet: &mut [u8], ipv6: bool); +``` + +```rust +fn print_packet(packet: &[u8]); +``` + +This function extracts a key from a given IPv4 net buffer. +The key contains the protocol, local and remote addresses and ports. +# Arguments +* `nb` - A reference to the net buffer from which the key will be extracted. +* `direction` - The direction of the packet (Inbound or Outbound). +# Returns +* `Ok(Key)` - A key containing the protocol, local and remote addresses and ports. +* `Err(String)` - An error message if the function fails to get net_buffer data. + +```rust +pub(crate) fn get_ports( + packet: &[u8], + protocol: smoltcp::wire::IpProtocol, +) -> (u16, u16); +``` + +Reads the first `len` bytes of the net buffer into `buffer[..len]`. +`NetBuffer::read_bytes` always reads from the start of the buffer and +fails if the requested length exceeds the packet, so anything past the fixed +header (IPv4 options, IPv6 extension headers, the transport header) is +reached by reading successively larger prefixes and indexing into them. + +```rust +fn read_prefix(nb: &NetBuffer, buffer: &mut [u8], len: usize) -> Result<(), ()>; +``` + +```rust +pub fn get_key_from_nb_v4(nb: &NetBuffer, direction: Direction) -> Result; +``` + +This function extracts a key from a given IPv6 net buffer. +The key contains the protocol, local and remote addresses and ports. +IPv6 packets may carry a chain of extension headers (Hop-by-Hop, Routing, +Fragment, Destination Options) between the fixed header and the transport +header. The chain is walked so the key reflects the real transport protocol +and ports rather than the first extension header. +# Arguments +* `nb` - A reference to the net buffer from which the key will be extracted. +* `direction` - The direction of the packet (Inbound or Outbound). +# Returns +* `Ok(Key)` - A key containing the protocol, local and remote addresses and ports. +* `Err(String)` - An error message if the function fails to get net_buffer data + or the packet carries a malformed extension-header chain. + +```rust +pub fn get_key_from_nb_v6(nb: &NetBuffer, direction: Direction) -> Result; +``` + +--- + +## `driver/src/packet_util.rs` — `mod tests` + +### Functions + +```rust +fn v6(last: u8) -> Ipv6Address; +``` + +--- + +## `driver/src/rcu_port.rs` + +### Types + +#### `ConnectionArray` + +```rust +pub(crate) struct ConnectionArray { + array: Box<[Arc]>, + pub(crate) readers: AtomicU16, + pub(crate) unlinked_timestamp: AtomicU64, +} +``` + +#### `RCUPort` + +```rust +pub(crate) struct RCUPort { + connections: AtomicPtr>, + write_mutex: Mutex<()>, +} +``` + +```rust +impl RCUPort { + pub(crate) fn is_empty(&self) -> bool; + pub(crate) fn read(&self) -> RCUPortReadGuard<'_, T>; + pub(crate) fn lock(&self) -> RCUPortWriteGuard<'_, T>; +} +``` + +#### `RCUPortReadGuard` + +```rust +pub(crate) struct RCUPortReadGuard<'a, T: Connection> { + _port: &'a RCUPort, + snapshot: *const ConnectionArray, +} +``` + +```rust +impl RCUPortReadGuard<'_, T> { + pub(crate) fn get(&self) -> Option<&[Arc]>; +} +``` + +#### `RCUPortWriteGuard` + +```rust +pub(crate) struct RCUPortWriteGuard<'a, T: Connection> { + port: &'a RCUPort, + _guard: MutexWriteGuard<'a, ()>, +} +``` + +```rust +impl RCUPortWriteGuard<'_, T> { + pub(crate) fn snapshot(&self) -> Option<&[Arc]>; + pub(crate) fn publish( + &self, + new: Option]>>, + queue: &MpscQueue>, + ); +} +``` + +### Trait implementations + +```rust +impl Drop for RCUPort + +impl Drop for RCUPortReadGuard<'_, T> +``` + +--- + +## `driver/src/sim.rs` + +Full-driver simulation harness (host / mock only). +Loads the *real* driver via its `DriverEntry` (over the mocked WDK), then lets +a user-space side talk to it through the same channel a real agent uses +([`wdk::connection::DriverConnection`] = `ReadFile`/`WriteFile`/`DeviceIoControl`). +Callouts are driven on one thread (the "kernel" producer) while a separate +user-space consumer thread blocks on `read`, parses the event stream, and +replies with verdicts — exercising the asynchronous pend -> event -> verdict +-> inject pipeline, the blocking event queue, and the IRP dispatch, with real +OS threads (so ThreadSanitizer sees genuine concurrency). +Teardown order is load-bearing: `shutdown` runs down the event queue so a +blocked reader wakes with end-of-file; join the consumer; only then drop the +`Simulation`, which unloads the driver (frees the device, clears the slot). + +### Types + +#### `Simulation` + +A loaded driver instance. Owns the `DRIVER_OBJECT` (which must outlive every +connection and worker thread) and holds the global-device test lock for its +lifetime so concurrent tests don't fight over the singleton slot. + +```rust +pub struct Simulation { + _guard: std::sync::MutexGuard<'static, ()>, + driver_object: Box, + torn_down: bool, +} +``` + +```rust +impl Simulation { + /// Load the driver: run the real `DriverEntry` against a fresh + /// `DRIVER_OBJECT`. The installed `Device` uses blocking reads, so a consumer + /// thread blocks on `read` like a real user-space agent. + pub fn start() -> Result; + /// Open a fresh user-space connection to the driver. Each thread should use + /// its own (the dispatch handlers take `&mut` device/IRP per call). + pub fn connect(&self) -> DriverConnection; + /// Request shutdown: resolve pending packets and run down the event queue so + /// any blocked reader wakes with end-of-file. Idempotent; safe to call from + /// the producer once the op stream is done. + pub fn shutdown(&self); + fn teardown(&mut self); +} +``` + +#### `Event` + +One decoded event frame from the driver's read stream +(`[InfoType:u8, size:u32 LE, payload]`). + +```rust +pub struct Event { + pub info_type: u8, + pub payload: Vec, +} +``` + +```rust +impl Event { + pub fn packet_id(&self) -> Option; +} +``` + +#### `EventParser` + +Streaming parser for the driver's event byte stream. A single `read` may +return several whole frames, and the driver may split a frame across reads +(via its read-leftover buffer), so bytes are accumulated and whole frames are +pulled off the front. + +```rust +pub struct EventParser { + buf: Vec, +} +``` + +```rust +impl EventParser { + pub fn new() -> Self; + pub fn feed(&mut self, bytes: &[u8]); + /// Pull the next complete frame, or `None` if a full frame isn't buffered yet. + pub fn next_event(&mut self) -> Option; +} +``` + +#### `ConsumerStats` + +What a finished consumer thread observed. + +```rust +pub struct ConsumerStats { + pub events: u64, + pub replies: u64, +} +``` + +#### `ConsumerHandle` + +Handle to a running user-space consumer thread. + +```rust +pub struct ConsumerHandle { + join: JoinHandle, +} +``` + +```rust +impl ConsumerHandle { + /// Wait for the consumer to exit (it exits when the channel reaches + /// end-of-file, i.e. after `Simulation::shutdown`). Re-raises a consumer panic. + pub fn join(self) -> ConsumerStats; + /// Like [`join`](Self::join) but gives up after `dur`, returning `None` (used + /// by the no-hang test to fail fast if shutdown didn't release the reader). + pub fn join_timeout(self, dur: Duration) -> Option; +} +``` + +### Trait implementations + +```rust +impl Drop for Simulation + +impl Default for EventParser +``` + +### Functions + +Serialize a `Verdict` command (the wire form a user-space agent writes back +to resolve a pended packet). + +```rust +pub fn verdict_command(id: u64, verdict: u8) -> Vec; +``` + +Spawn a user-space consumer thread: it blocks on `conn.read`, parses events, +and for each event asks `verdict_policy` for a verdict to write back. If +`processed` is set, the packet id of each replied-to event is sent on it (so a +test can synchronize on "the verdict has been applied"). The thread exits when +the channel reaches end-of-file (after shutdown). + +```rust +pub fn spawn_consumer( + mut conn: DriverConnection, + mut verdict_policy: F, + processed: Option>, +) -> ConsumerHandle +where + F: FnMut(&Event) -> Option + Send + 'static,; +``` + +--- + +## `driver/fuzz/src/bin/replay.rs` + +Windows-runnable driver for the fuzz targets. +Unlike the libFuzzer targets in `fuzz_targets/` (which only link under +`cargo fuzz` on Linux), this is an ordinary binary with a real `main`, so it +builds and runs on Windows with plain `cargo run`. +Usage: + cargo run --bin replay -- [file ...] + with files : run the target on each file's bytes (replay a corpus/crash input) + no files : run a random-input smoke loop (env: ITERS, SEED) +Targets: callouts, callouts_mt, simulation +Note: this is NOT coverage-guided -- it is random/replay testing. Use it to +reproduce a Linux fuzzer crash on Windows or for a quick "does anything +panic" sweep. The coverage-guided runs happen via `cargo fuzz run` on Linux. + +### Functions + +```rust +fn next(state: &mut u64) -> u64; +``` + +```rust +fn main(); +``` + +--- + +## `driver/fuzz/src/lib.rs` + +Shared fuzz-target bodies for the ZenithFence driver. +Each `targets::(data: &[u8])` contains the actual logic + oracles for +one fuzz target. The libFuzzer harnesses in `fuzz_targets/` and the +Windows-runnable `replay` binary both call these, so there is a single +source of truth and the same code is exercised on both platforms. + +--- + +## `driver/fuzz/src/targets.rs` + +Fuzz-target bodies. Each takes a raw `&[u8]` (what libFuzzer / the replay +runner provide) and runs the code under test plus its oracles. A violated +oracle panics, which libFuzzer reports as a crash and the replay runner +surfaces as a process abort. + +### Constants & statics + +The set of valid target names (kept in sync with the functions below). + +```rust +pub const NAMES: &[&str] = &["callouts", "callouts_mt", "simulation"]; +``` + +Number of op variants the byte decoder recognizes. + +```rust +const N_OPS: u8 = 18; +``` + +```rust +const PACKET_CACHE_LIMIT: usize = 8192; +``` + +```rust +const CONN_CACHE_LIMIT: usize = 8192; +``` + +```rust +const INJECTED_LIMIT: usize = 8192; +``` + +Number of producer (event-generating) op variants for the simulation target. + +```rust +const N_PRODUCER_OPS: u8 = 12; +``` + +### Types + +#### `Stream` + +Minimal byte-stream reader for the op grammar. Reads past the end yield +`0`/empty; the loop stops once the cursor passes the end, so a truncated +final op simply runs with zeroed fields (still fully deterministic, which +keeps hand-authored corpus seeds stable). + +```rust +struct Stream<'a> { + data: &'a [u8], + pos: usize, +} +``` + +```rust +impl<'a> Stream<'a> { + fn new(data: &'a [u8]) -> Self; + fn done(&self) -> bool; + fn u8(&mut self) -> u8; + fn u16(&mut self) -> u16; + fn take(&mut self, n: usize) -> &'a [u8]; +} +``` + +### Functions + +Dispatch by name. Returns `false` for an unknown target. + +```rust +pub fn run(name: &str, data: &[u8]) -> bool; +``` + +```rust +fn payload_len(b: u8) -> usize; +``` + +Stateful callout pipeline -- the main reason this harness exists. Decodes the +input into a sequence of operations replayed against one persistent mock +`Device`: ALE callouts pend flows, `Verdict`/`Update` commands resolve them, +and packet-layer callouts then read the resulting verdict. Oracles: + * Tier 1: no panic / no OOB / no UB (libFuzzer + ASAN). + * Tier 2: ALE + structured packet callouts always set an action; after a + `Shutdown` op the packet cache is empty. + * Tier 3: for a structured TCP/UDP flow carrying a permanent verdict, the + packet-layer action matches that verdict. + +```rust +pub fn callouts(data: &[u8]); +``` + +Multithreaded callout stress -- one shared persistent `Device`, many worker +threads driving the callouts at the same time, in all combinations. +Layout of `data`: byte 0 selects the worker count (2..=8); the rest is one +op stream. Every worker runs the *whole* op stream, phase-shifted by a +distinct rotation, so all workers hammer overlapping connection-pool slots +concurrently with varied op orders -- maximizing contention on the same RCU +ports / packet cache. Workers are released together by a barrier. +Oracles under concurrency are interleaving-independent: + * Tier 1: no panic / no ASAN / no ThreadSanitizer report (the main one). + * Tier 2 (per call): ALE + structured packet callouts always set an action. + * Post-join quiesce: with no worker running, a `Shutdown` must fully drain + the packet cache. +The Tier-3 cross-layer verdict mapping and the mid-stream "Shutdown -> empty" +assert are dropped here: another thread can change a verdict or pend a packet +between the observation and the check, so they are not race-stable. + +```rust +pub fn callouts_mt(data: &[u8]); +``` + +Full-driver simulation -- the highest-fidelity target. Loads the *real* +driver via `DriverEntry` (over the mocked WDK), then runs the asynchronous +channel pipeline with real OS threads: + * a "kernel" producer (this thread) drives event-producing callouts from + the op stream -- ALE/packet callouts pend flows and push events, endpoint + closures and resource events fire; + * a user-space consumer thread blocks on `DriverConnection::read`, parses + the event stream, and writes `Verdict` commands back over the channel + (the verdict bytes come from the fuzz input, so all verdict kinds -- + accept/block/drop/redirect and the permanent variants -- get explored). +This exercises the full lifecycle (load -> dispatch -> unload), the blocking +event queue, the IRP read/write path, and the pend -> event -> verdict -> +inject loop under genuine producer/consumer concurrency (so ThreadSanitizer +sees real races if any exist). +Oracles (interleaving-independent): + * Tier 1: no panic / no ASAN / no ThreadSanitizer report. + * Tier 2 (per call): ALE + structured packet callouts always set an action. + * Post-shutdown quiesce: once the producer is done and the driver is shut + down, the packet cache is empty (every pended flow was resolved or drained). + +```rust +pub fn simulation(data: &[u8]); +``` + +Decode and run only the event-*producing* callouts against the installed +device. Unlike [`drive_ops`], this issues no `Verdict`/`Update`/`Shutdown` +commands and never reads the event queue -- in the simulation those are the +user-space consumer's job, over the channel -- so it never blocks on the +(now blocking) event queue. + +```rust +fn drive_producer(s: &mut Stream); +``` + +Decode and execute the op stream against the (already installed) global +device. `concurrent` selects the oracle set: when other threads may be +mutating the same device, race-sensitive checks (Tier-3 verdict mapping and +the mid-stream shutdown-empties-cache assert) are skipped; everything else +(no panic, per-call "an action was set") still holds. + +```rust +fn drive_ops(s: &mut Stream, concurrent: bool); +``` + +```rust +fn check_perm_verdict(op: u8, verdict: u8, r: driver::fuzz_api::CalloutResult); +``` + +--- + +## `driver/fuzz/fuzz_targets/callouts.rs` + +Stateful ALE + packet-layer callout pipeline. See driver_fuzz::targets::callouts. + +--- + +## `driver/fuzz/fuzz_targets/callouts_mt.rs` + +Multithreaded ALE + packet-layer callout stress: many worker threads drive +the callouts against one shared device at the same time. See +driver_fuzz::targets::callouts_mt. Best run with ThreadSanitizer +(`just fuzz-tsan callouts_mt`). + +--- + +## `driver/fuzz/fuzz_targets/simulation.rs` + +Full-driver simulation: load the real driver over the mocked WDK and drive it +through the user-space channel with a real producer thread (callouts) and a +real consumer thread (reads events, writes verdicts). See +driver_fuzz::targets::simulation. Run with ThreadSanitizer for race coverage +(`just fuzz-tsan simulation`) or ASAN (`just fuzz simulation`). + +--- + diff --git a/docs/LLMs/RUSTDOC-mock_wdk.md b/docs/LLMs/RUSTDOC-mock_wdk.md new file mode 100644 index 0000000..822172e --- /dev/null +++ b/docs/LLMs/RUSTDOC-mock_wdk.md @@ -0,0 +1,2627 @@ +# ZenithFence — Rust Item Reference: Mock WDK (host test double for wdk) + +> Auto-generated by `cargo run --manifest-path tools/rustdoc-gen/Cargo.toml -- .` (`just gen-llm-docs`) — do not edit by hand. +> One section per source file: module docs, constants, macros, full type definitions, impl blocks, functions and extern blocks. +> Includes private items. Function bodies, long initializers and test code are stripped. + +--- + +## `mock_wdk/src/connection.rs` + +Host-only user-space client for the mocked driver. +`DriverConnection` mirrors the Win32 device-handle API a real user-space +agent uses (`ReadFile` / `WriteFile` / `DeviceIoControl`): each call +synthesizes a mock `IRP` and invokes the dispatch handler the driver +registered in its `DRIVER_OBJECT`, exactly like the kernel I/O manager. This +is what lets the full-driver simulation talk to the driver "like a real +driver" instead of poking `Device` methods directly. +Each connection owns its own `DEVICE_OBJECT`, and the dispatch methods take +`&mut self`, so the `&mut DEVICE_OBJECT`/`&mut IRP` a handler receives are +never shared across threads — every thread (producer, consumer) opens its own +connection from the same `DRIVER_OBJECT`. + +### Constants & statics + +```rust +const STATUS_NO_HANDLER: NTSTATUS = 0xC000_0002u32 as i32; +``` + +### Types + +#### `DriverConnection` + +```rust +pub struct DriverConnection { + device_object: DEVICE_OBJECT, + read_fn: MjFnType, + write_fn: MjFnType, + control_fn: Option, +} +``` + +```rust +impl DriverConnection { + /// Open a client against a driver that `driver_entry` has populated. Returns + /// `None` if the read/write dispatch handlers are absent (driver not loaded). + pub fn open(driver_object: &DRIVER_OBJECT) -> Option; + /// `WriteFile` equivalent: hand `bytes` to the driver's write dispatch + /// (the command channel). Returns the completion status. + pub fn write(&mut self, bytes: &[u8]) -> NTSTATUS; + /// `ReadFile` equivalent: drain pending events into `out`. Returns + /// `(bytes_written, status)`. If the driver was loaded with blocking reads + /// and the event queue is empty, this blocks until an event arrives or the + /// queue is run down (shutdown), which returns end-of-file. + pub fn read(&mut self, out: &mut [u8]) -> (usize, NTSTATUS); + /// `DeviceIoControl` equivalent (buffered method: input and output share one + /// system buffer). Returns `(bytes_written_to_out, status)`. + pub fn device_io_control( + &mut self, + control_code: u32, + input: &[u8], + out: &mut [u8], + ) -> (usize, NTSTATUS); +} +``` + +### Trait implementations + +```rust +unsafe impl Send for DriverConnection +``` + +--- + +## `mock_wdk/src/consts.rs` + +### Constants & statics + +```rust +pub const FWP_ACTION_FLAG_TERMINATING: u32 = 0x00001000; +``` + +```rust +pub const FWP_ACTION_FLAG_NON_TERMINATING: u32 = 0x00002000; +``` + +```rust +pub const FWP_ACTION_FLAG_CALLOUT: u32 = 0x00004000; +``` + +```rust +pub const FWP_ACTION_BLOCK: u32 = 0x00000001 | FWP_ACTION_FLAG_TERMINATING; +``` + +```rust +pub const FWP_ACTION_PERMIT: u32 = 0x00000002 | FWP_ACTION_FLAG_TERMINATING; +``` + +```rust +pub const FWP_ACTION_CALLOUT_TERMINATING: u32 = ...; +``` + +```rust +pub const FWP_ACTION_CALLOUT_INSPECTION: u32 = ...; +``` + +```rust +pub const FWP_ACTION_CALLOUT_UNKNOWN: u32 = 0x00000005 | FWP_ACTION_FLAG_CALLOUT; +``` + +```rust +pub const FWP_ACTION_CONTINUE: u32 = 0x00000006 | FWP_ACTION_FLAG_NON_TERMINATING; +``` + +```rust +pub const FWP_ACTION_NONE: u32 = 0x00000007; +``` + +```rust +pub const FWP_ACTION_NONE_NO_MATCH: u32 = 0x00000008; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_LOOPBACK: u32 = 0x00000001; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_IPSEC_SECURED: u32 = 0x00000002; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_REAUTHORIZE: u32 = 0x00000004; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_WILDCARD_BIND: u32 = 0x00000008; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_RAW_ENDPOINT: u32 = 0x00000010; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_FRAGMENT: u32 = 0x00000020; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_FRAGMENT_GROUP: u32 = 0x00000040; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_IPSEC_NATT_RECLASSIFY: u32 = 0x00000080; +``` + +```rust +pub const FWP_CONDITION_FLAG_REQUIRES_ALE_CLASSIFY: u32 = 0x00000100; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_IMPLICIT_BIND: u32 = 0x00000200; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_REASSEMBLED: u32 = 0x00000400; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_NAME_APP_SPECIFIED: u32 = 0x00004000; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_PROMISCUOUS: u32 = 0x00008000; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_AUTH_FW: u32 = 0x00010000; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_RECLASSIFY: u32 = 0x00020000; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_OUTBOUND_PASS_THRU: u32 = 0x00040000; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_INBOUND_PASS_THRU: u32 = 0x00080000; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_CONNECTION_REDIRECTED: u32 = 0x00100000; +``` + +```rust +pub const METHOD_BUFFERED: u32 = 0; +``` + +```rust +pub const METHOD_IN_DIRECT: u32 = 1; +``` + +```rust +pub const METHOD_OUT_DIRECT: u32 = 2; +``` + +```rust +pub const METHOD_NEITHER: u32 = 3; +``` + +```rust +pub const SIOCTL_TYPE: u32 = 40000; +``` + +```rust +pub const FILE_READ_DATA: u32 = 0x00000001; +``` + +```rust +pub const FILE_READ_ATTRIBUTES: u32 = 0x00000080; +``` + +```rust +pub const FILE_READ_EA: u32 = 0x00000008; +``` + +```rust +pub const FILE_WRITE_DATA: u32 = 0x00000002; +``` + +```rust +pub const FILE_WRITE_ATTRIBUTES: u32 = 0x00000100; +``` + +```rust +pub const FILE_WRITE_EA: u32 = 0x00000010; +``` + +```rust +pub const FILE_APPEND_DATA: u32 = 0x00000004; +``` + +```rust +pub const FILE_EXECUTE: u32 = 0x00000020; +``` + +--- + +## `mock_wdk/src/driver.rs` + +Host mock of `wdk::driver::Driver`. +In production this wraps the WDM `DRIVER_OBJECT`/`DEVICE_OBJECT` and registers +IRP dispatch handlers by writing function pointers into +`DRIVER_OBJECT.MajorFunction[IRP_MJ_*]`. The mock mirrors that exactly so the +real `DriverEntry` runs unchanged and a `DriverConnection` can read the +handlers back from the `DRIVER_OBJECT` and dispatch like the I/O manager. +`Driver::mock()` (carrying a null driver object) keeps the older +`Device::new(&Driver::mock())` fuzz/test path working: with no driver object, +the `set_*_fn` registrations are no-ops, which is fine because that path never +dispatches through the registered handlers. It does carry a (static, zero +sized) device object, because the mock filter engine — like the kernel's +`FwpsCalloutRegister` — refuses callout registration without one. + +### Types + +#### `Driver` + +```rust +pub struct Driver { + driver_object: *mut DRIVER_OBJECT, + device_object: *mut DEVICE_OBJECT, +} +``` + +```rust +impl Driver { + /// Construct a driver with no backing driver object, for tests / fuzz + /// harnesses that build a `Device` directly without going through + /// `DriverEntry`. The device object is a process-wide stateless ZST so the + /// filter engine's registration check passes. + pub fn mock() -> Self; + /// Bind a driver to a caller-owned `DRIVER_OBJECT` (used by + /// `interface::init_driver_object`). Handler registration writes into it; + /// the device object is the one driver init created in it, if any. + pub(crate) fn from_driver_object(driver_object: *mut DRIVER_OBJECT) -> Self; + pub fn get_device_object(&self) -> *mut DEVICE_OBJECT; + pub fn get_device_object_ref(&self) -> Option<&mut DEVICE_OBJECT>; + pub fn set_driver_unload(&mut self, driver_unload: UnloadFnType); + pub fn set_read_fn(&mut self, mj_fn: MjFnType); + pub fn set_write_fn(&mut self, mj_fn: MjFnType); + pub fn set_create_fn(&mut self, mj_fn: MjFnType); + pub fn set_device_control_fn(&mut self, mj_fn: MjFnType); + pub fn set_close_fn(&mut self, mj_fn: MjFnType); + pub fn set_cleanup_fn(&mut self, mj_fn: MjFnType); + fn set_major_fn(&mut self, fn_index: usize, mj_fn: MjFnType); +} +``` + +### Trait implementations + +```rust +unsafe impl Sync for Driver + +unsafe impl Send for Driver +``` + +--- + +## `mock_wdk/src/filter_engine/callout.rs` + +### Types + +#### `FilterType` + +```rust +pub enum FilterType { + Resettable, + NonResettable, +} +``` + +#### `Callout` + +Host mock of `wdk::filter_engine::callout::Callout`. Stores the same fields +the driver passes to `Callout::new` (in particular the `callout_fn` pointer, +whose type must match so `callouts::get_callout_vec` type-checks). +Registration is a no-op handled by the mock `FilterEngine`. + +```rust +pub struct Callout { + pub name: String, + pub description: String, + pub guid: u128, + pub layer: Layer, + pub action: u32, + pub filter_type: FilterType, + pub callout_fn: fn(CalloutData), +} +``` + +```rust +impl Callout { + pub fn new( + name: &str, + description: &str, + guid: u128, + layer: Layer, + action: u32, + filter_type: FilterType, + callout_fn: fn(CalloutData), + ) -> Self; +} +``` + +--- + +## `mock_wdk/src/filter_engine/callout_data.rs` + +### Constants & statics + +```rust +static ZERO16: [u8; 16] = [0u8; 16]; +``` + +### Types + +#### `Value` + +One classify "fixed value". The real type is a tagged C union; the mock uses +a safe enum the harness can build directly. A callout reads it through the +`get_value_*` accessors below. + +```rust +#[derive(Clone, Debug)] +pub enum Value { + Empty, + U8(u8), + U16(u16), + U32(u32), + Bytes16([u8; 16]), +} +``` + +#### `ClassifyDefer` + +Host mock of `wdk::filter_engine::callout_data::ClassifyDefer`. +The first payload of `Initial` mirrors the real `HANDLE` (isize); the driver +only ever binds it as `_`. + +```rust +pub enum ClassifyDefer { + Initial(isize, Option), + Reauthorization(usize, Option), +} +``` + +```rust +impl ClassifyDefer { + /// Mirrors the real `resolve`: completing an `Initial` pend has no host-side + /// effect, a `Reauthorization` is reported as still held by its filter. + pub fn resolve(self) -> DeferResolution; +} +``` + +#### `DeferResolution` + +Host mock of `wdk::filter_engine::callout_data::DeferResolution`. + +```rust +pub enum DeferResolution { + /// The pended operation has been completed. + Completed(Option), + /// There was no completion handle; the connection stays held by the filter + /// until the filters are reset. + HeldByFilter(Option), +} +``` + +#### `CalloutData` + +Host mock of `wdk::filter_engine::callout_data::CalloutData`. +Construct one with [`CalloutData::mock`]. `classify_out` and `layer_data` +are raw pointers to harness-owned storage (a `ClassifyOut` and an NBL chain +respectively), matching the real layout; either may be null in harnesses +that don't exercise that path (the accessors null-check). + +```rust +pub struct CalloutData<'a> { + pub layer: Layer, + callout_id: usize, + values: Vec, + process_id: Option, + classify_out: *mut ClassifyOut, + layer_data: *mut NET_BUFFER_LIST, + ip_header_size: u32, + transport_header_size: u32, + _marker: PhantomData<&'a ()>, +} +``` + +```rust +impl<'a> CalloutData<'a> { + pub fn mock( + layer: Layer, + values: Vec, + process_id: Option, + classify_out: *mut ClassifyOut, + layer_data: *mut NET_BUFFER_LIST, + ip_header_size: u32, + transport_header_size: u32, + ) -> Self; + pub fn get_value_type(&self, index: usize) -> ValueType; + pub fn get_value_u8(&self, index: usize) -> u8; + pub fn get_value_u16(&self, index: usize) -> u16; + pub fn get_value_u32(&self, index: usize) -> u32; + pub fn get_value_byte_array16(&self, index: usize) -> &[u8; 16]; + pub fn get_process_id(&self) -> Option; + /// Mirrors the real accessor, which returns `None` when the layer does not + /// set the metadata field. The mock models an absent field as 0. + pub fn get_ip_header_size(&self) -> Option; + pub fn get_transport_header_size(&self) -> u32; + pub fn get_layer_data(&self) -> *mut c_void; + pub fn pend_operation( + &mut self, + packet_list: Option, + ) -> Result; + pub fn pend_filter_rest( + &mut self, + packet_list: Option, + ) -> ClassifyDefer; + pub fn action_permit(&mut self); + pub fn action_continue(&mut self); + pub fn action_block(&mut self); + pub fn action_none(&mut self); + pub fn block_and_absorb(&mut self); + pub fn parmit_and_absorb(&mut self); + pub fn clear_write_flag(&mut self); + pub fn is_reauthorize(&self, flags_index: usize) -> bool; + pub fn get_callout_id(&self) -> usize; +} +``` + +--- + +## `mock_wdk/src/filter_engine/classify.rs` + +### Constants & statics + +```rust +const FWP_ACTION_FLAG_TERMINATING: u32 = 0x00001000; +``` + +```rust +const FWP_ACTION_FLAG_NON_TERMINATING: u32 = 0x00002000; +``` + +```rust +const FWP_ACTION_FLAG_CALLOUT: u32 = 0x00004000; +``` + +```rust +pub const FWP_ACTION_BLOCK: u32 = 0x00000001 | FWP_ACTION_FLAG_TERMINATING; +``` + +```rust +pub const FWP_ACTION_PERMIT: u32 = 0x00000002 | FWP_ACTION_FLAG_TERMINATING; +``` + +```rust +pub const FWP_ACTION_CONTINUE: u32 = 0x00000006 | FWP_ACTION_FLAG_NON_TERMINATING; +``` + +```rust +pub const FWP_ACTION_NONE: u32 = 0x00000007; +``` + +```rust +const FWPS_RIGHT_ACTION_WRITE: u32 = 0x00000001; +``` + +```rust +const FWPS_CLASSIFY_OUT_FLAG_ABSORB: u32 = 0x00000001; +``` + +### Types + +#### `ClassifyOut` + +```rust +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ClassifyOut { + action_type: u32, + _out_context: u64, + _filter_id: u64, + rights: u32, + flags: u32, + reserved: u32, +} +``` + +```rust +impl ClassifyOut { + /// Construct a fresh `ClassifyOut` as the kernel would hand one to a + /// callout: action unset, write right granted. + pub fn new() -> Self; + pub fn can_set_action(&self) -> bool; + pub fn action_block(&mut self); + pub fn action_permit(&mut self); + pub fn action_continue(&mut self); + pub fn set_none(&mut self); + pub fn set_absorb(&mut self); + pub fn clear_write_flag(&mut self); + /// The action the callout set (0 if none). + pub fn action(&self) -> u32; + /// The current flags word (e.g. ABSORB). + pub fn flags(&self) -> u32; + pub fn is_absorb(&self) -> bool; +} +``` + +### Trait implementations + +```rust +impl Default for ClassifyOut +``` + +--- + +## `mock_wdk/src/filter_engine/layer.rs` + +### Types + +#### `ValueType` + +```rust +#[repr(C)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ValueType { + FwpEmpty = 0, + FwpUint8 = 1, + FwpUint16 = 2, + FwpUint32 = 3, + FwpUint64 = 4, + FwpInt8 = 5, + FwpInt16 = 6, + FwpInt32 = 7, + FwpInt64 = 8, + FwpFloat = 9, + FwpDouble = 10, + FwpByteArray16Type = 11, + FwpByteBlobType = 12, + FwpSid = 13, + FwpSecurityDescriptorType = 14, + FwpTokenInformationType = 15, + FwpTokenAccessInformationType = 16, + FwpUnicodeStringType = 17, + FwpByteArray6Type = 18, + FwpSingleDataTypeMax = 0xff, + FwpV4AddrMask = 0xff + 1, + FwpV6AddrMask = 0xff + 2, + FwpRangeType = 0xff + 3, + FwpDataTypeMax = 0xff + 4, +} +``` + +#### `Layer` + +```rust +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Layer { + InboundIppacketV4, + InboundIppacketV4Discard, + InboundIppacketV6, + InboundIppacketV6Discard, + OutboundIppacketV4, + OutboundIppacketV4Discard, + OutboundIppacketV6, + OutboundIppacketV6Discard, + IpforwardV4, + IpforwardV4Discard, + IpforwardV6, + IpforwardV6Discard, + InboundTransportV4, + InboundTransportV4Discard, + InboundTransportV6, + InboundTransportV6Discard, + OutboundTransportV4, + OutboundTransportV4Discard, + OutboundTransportV6, + OutboundTransportV6Discard, + StreamV4, + StreamV4Discard, + StreamV6, + StreamV6Discard, + DatagramDataV4, + DatagramDataV4Discard, + DatagramDataV6, + DatagramDataV6Discard, + InboundIcmpErrorV4, + InboundIcmpErrorV4Discard, + InboundIcmpErrorV6, + InboundIcmpErrorV6Discard, + OutboundIcmpErrorV4, + OutboundIcmpErrorV4Discard, + OutboundIcmpErrorV6, + OutboundIcmpErrorV6Discard, + AleResourceAssignmentV4, + AleResourceAssignmentV4Discard, + AleResourceAssignmentV6, + AleResourceAssignmentV6Discard, + AleAuthListenV4, + AleAuthListenV4Discard, + AleAuthListenV6, + AleAuthListenV6Discard, + AleAuthRecvAcceptV4, + AleAuthRecvAcceptV4Discard, + AleAuthRecvAcceptV6, + AleAuthRecvAcceptV6Discard, + AleAuthConnectV4, + AleAuthConnectV4Discard, + AleAuthConnectV6, + AleAuthConnectV6Discard, + AleFlowEstablishedV4, + AleFlowEstablishedV4Discard, + AleFlowEstablishedV6, + AleFlowEstablishedV6Discard, + AleConnectRedirectV4, + AleConnectRedirectV6, + AleBindRedirectV4, + AleBindRedirectV6, + AleResourceReleaseV4, + AleResourceReleaseV6, + AleEndpointClosureV4, + AleEndpointClosureV6, +} +``` + +#### `FieldsInboundIppacketV4` + +```rust +#[repr(usize)] +pub enum FieldsInboundIppacketV4 { + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} +``` + +#### `FieldsInboundIppacketV6` + +```rust +#[repr(usize)] +pub enum FieldsInboundIppacketV6 { + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} +``` + +#### `FieldsOutboundIppacketV4` + +```rust +#[repr(usize)] +pub enum FieldsOutboundIppacketV4 { + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} +``` + +#### `FieldsOutboundIppacketV6` + +```rust +#[repr(usize)] +pub enum FieldsOutboundIppacketV6 { + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} +``` + +#### `FieldsIpforwardV4` + +```rust +#[repr(usize)] +pub enum FieldsIpforwardV4 { + IpSourceAddress, + IpDestinationAddress, + IpDestinationAddressType, + IpLocalInterface, + IpForwardInterface, + SourceInterfaceIndex, + SourceSubInterfaceIndex, + DestinationInterfaceIndex, + DestinationSubInterfaceIndex, + Flags, + IpPhysicalArrivalInterface, + ArrivalInterfaceProfileId, + IpPhysicalNexthopInterface, + NexthopInterfaceProfileId, + CompartmentId, + Max, +} +``` + +#### `FieldsIpforwardV6` + +```rust +#[repr(usize)] +pub enum FieldsIpforwardV6 { + IpSourceAddress, + IpDestinationAddress, + IpDestinationAddressType, + IpLocalInterface, + IpForwardInterface, + SourceInterfaceIndex, + SourceSubInterfaceIndex, + DestinationInterfaceIndex, + DestinationSubInterfaceIndex, + Flags, + IpPhysicalArrivalInterface, + ArrivalInterfaceProfileId, + IpPhysicalNexthopInterface, + NexthopInterfaceProfileId, + CompartmentId, + Max, +} +``` + +#### `FieldsInboundTransportV4` + +```rust +#[repr(usize)] +pub enum FieldsInboundTransportV4 { + IpProtocol, + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + ProfileId, + IpsecSecurityRealmId, + CompartmentId, + Max, +} +``` + +#### `FieldsInboundTransportFas` + +```rust +#[repr(usize)] +pub enum FieldsInboundTransportFas { + FieldInboundTransportFastMax, +} +``` + +#### `FieldsOutboundTransportFas` + +```rust +#[repr(usize)] +pub enum FieldsOutboundTransportFas { + FieldOutboundTransportFastMax, +} +``` + +#### `FieldsInboundTransportV6` + +```rust +#[repr(usize)] +pub enum FieldsInboundTransportV6 { + IpProtocol, + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + ProfileId, + IpsecSecurityRealmId, + CompartmentId, + Max, +} +``` + +#### `FieldsOutboundTransportV4` + +```rust +#[repr(usize)] +pub enum FieldsOutboundTransportV4 { + IpProtocol, + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + IpDestinationAddressType, + Flags, + InterfaceType, + TunnelType, + ProfileId, + IpsecSecurityRealmId, + CompartmentId, + Max, +} +``` + +#### `FieldsOutboundTransportV6` + +```rust +#[repr(usize)] +pub enum FieldsOutboundTransportV6 { + IpProtocol, + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + IpDestinationAddressType, + Flags, + InterfaceType, + TunnelType, + ProfileId, + IpsecSecurityRealmId, + CompartmentId, + Max, +} +``` + +#### `FieldsStreamV4` + +```rust +#[repr(usize)] +pub enum FieldsStreamV4 { + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + Direction, + Flags, + CompartmentId, + Max, +} +``` + +#### `FieldsStreamV6` + +```rust +#[repr(usize)] +pub enum FieldsStreamV6 { + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + Direction, + Flags, + CompartmentId, + Max, +} +``` + +#### `FieldsDatagramDataV4` + +```rust +#[repr(usize)] +pub enum FieldsDatagramDataV4 { + IpProtocol, + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Direction, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} +``` + +#### `FieldsDatagramDataV6` + +```rust +#[repr(usize)] +pub enum FieldsDatagramDataV6 { + IpProtocol, + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Direction, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} +``` + +#### `FieldsStreamPacketV4` + +```rust +#[repr(usize)] +pub enum FieldsStreamPacketV4 { + IpLocalAddress, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Direction, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} +``` + +#### `FieldsStreamPacketV6` + +```rust +#[repr(usize)] +pub enum FieldsStreamPacketV6 { + IpLocalAddress, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Direction, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} +``` + +#### `FieldsInboundIcmpErrorV4` + +```rust +#[repr(usize)] +pub enum FieldsInboundIcmpErrorV4 { + EmbeddedProtocol, + IpLocalAddress, + IpRemoteAddress, + EmbeddedRemoteAddress, + EmbeddedLocalAddressType, + EmbeddedLocalPort, + EmbeddedRemotePort, + IpLocalInterface, + IcmpType, + IcmpCode, + InterfaceIndex, + SubInterfaceIndex, + InterfaceType, + TunnelType, + IpArrivalInterface, + ArrivalInterfaceIndex, + ArrivalInterfaceType, + ArrivalTunnelType, + Flags, + ArrivalInterfaceProfileId, + InterfaceQuarantineEpoch, + CompartmentId, + Max, +} +``` + +#### `FieldsInboundIcmpErrorV6` + +```rust +#[repr(usize)] +pub enum FieldsInboundIcmpErrorV6 { + EmbeddedProtocol, + IpLocalAddress, + IpRemoteAddress, + EmbeddedRemoteAddress, + EmbeddedLocalAddressType, + EmbeddedLocalPort, + EmbeddedRemotePort, + IpLocalInterface, + IcmpType, + IcmpCode, + InterfaceIndex, + SubInterfaceIndex, + InterfaceType, + TunnelType, + IpArrivalInterface, + ArrivalInterfaceIndex, + ArrivalInterfaceType, + ArrivalTunnelType, + Flags, + ArrivalInterfaceProfileId, + InterfaceQuarantineEpoch, + CompartmentId, + Max, +} +``` + +#### `FieldsOutboundIcmpErrorV4` + +```rust +#[repr(usize)] +pub enum FieldsOutboundIcmpErrorV4 { + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalInterface, + IcmpType, + IcmpCode, + InterfaceIndex, + SubInterfaceIndex, + InterfaceType, + TunnelType, + Flags, + NexthopInterfaceProfileId, + InterfaceQuarantineEpoch, + CompartmentId, + Max, +} +``` + +#### `FieldsOutboundIcmpErrorV6` + +```rust +#[repr(usize)] +pub enum FieldsOutboundIcmpErrorV6 { + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalInterface, + IpLocalPort, + IpRemotePort, + InterfaceIndex, + SubInterfaceIndex, + InterfaceType, + TunnelType, + Flags, + NexthopInterfaceProfileId, + InterfaceQuarantineEpoch, + CompartmentId, + Max, +} +``` + +#### `FieldsAleResourceAssignmentV4` + +```rust +#[repr(usize)] +pub enum FieldsAleResourceAssignmentV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + AlePromiscuousMode, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + LocalInterfaceProfileId, + SioFirewallSocketProperty, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Max, +} +``` + +#### `FieldsAleResourceAssignmentV6` + +```rust +#[repr(usize)] +pub enum FieldsAleResourceAssignmentV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + AlePromiscuousMode, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + LocalInterfaceProfileId, + SioFirewallSocketProperty, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Max, +} +``` + +#### `FieldsAleResourceReleaseV4` + +```rust +#[repr(usize)] +pub enum FieldsAleResourceReleaseV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpLocalInterface, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleResourceReleaseV6` + +```rust +#[repr(usize)] +pub enum FieldsAleResourceReleaseV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpLocalInterface, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleEndpointClosureV4` + +```rust +#[repr(usize)] +pub enum FieldsAleEndpointClosureV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + IpLocalInterface, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleEndpointClosureV6` + +```rust +#[repr(usize)] +pub enum FieldsAleEndpointClosureV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + IpLocalInterface, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleAuthListenV4` + +```rust +#[repr(usize)] +pub enum FieldsAleAuthListenV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + LocalInterfaceProfileId, + SioFirewallSocketProperty, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleAuthListenV6` + +```rust +#[repr(usize)] +pub enum FieldsAleAuthListenV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + LocalInterfaceProfileId, + SioFirewallSocketProperty, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleAuthRecvAcceptV4` + +```rust +#[repr(usize)] +pub enum FieldsAleAuthRecvAcceptV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpLocalInterface, + Flags, + SioFirewallSystemPort, + NapContext, + InterfaceType, + TunnelType, + InterfaceIndex, + SubInterfaceIndex, + IpArrivalInterface, + ArrivalInterfaceType, + ArrivalTunnelType, + ArrivalInterfaceIndex, + NexthopSubInterfaceIndex, + IpNexthopInterface, + NexthopInterfaceType, + NexthopTunnelType, + NexthopInterfaceIndex, + OriginalProfileId, + CurrentProfileId, + ReauthorizeReason, + OriginalIcmpType, + InterfaceQuarantineEpoch, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} +``` + +#### `FieldsAleAuthRecvAcceptV6` + +```rust +#[repr(usize)] +pub enum FieldsAleAuthRecvAcceptV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpLocalInterface, + Flags, + SioFirewallSystemPort, + NapContext, + InterfaceType, + TunnelType, + InterfaceIndex, + SubInterfaceIndex, + IpArrivalInterface, + ArrivalInterfaceType, + ArrivalTunnelType, + ArrivalInterfaceIndex, + NexthopSubInterfaceIndex, + IpNexthopInterface, + NexthopInterfaceType, + NexthopTunnelType, + NexthopInterfaceIndex, + OriginalProfileId, + CurrentProfileId, + ReauthorizeReason, + OriginalIcmpType, + InterfaceQuarantineEpoch, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} +``` + +#### `FieldsAleBindRedirectV4` + +```rust +#[repr(usize)] +pub enum FieldsAleBindRedirectV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleBindRedirectV6` + +```rust +#[repr(usize)] +pub enum FieldsAleBindRedirectV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleConnectRedirectV4` + +```rust +#[repr(usize)] +pub enum FieldsAleConnectRedirectV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpDestinationAddressType, + IpRemotePort, + Flags, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleConnectRedirectV6` + +```rust +#[repr(usize)] +pub enum FieldsAleConnectRedirectV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpDestinationAddressType, + IpRemotePort, + Flags, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleAuthConnectV4` + +```rust +#[repr(usize)] +pub enum FieldsAleAuthConnectV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpDestinationAddressType, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + InterfaceIndex, + SubInterfaceIndex, + IpArrivalInterface, + ArrivalInterfaceType, + ArrivalTunnelType, + ArrivalInterfaceIndex, + NexthopSubInterfaceIndex, + IpNexthopInterface, + NexthopInterfaceType, + NexthopTunnelType, + NexthopInterfaceIndex, + OriginalProfileId, + CurrentProfileId, + ReauthorizeReason, + PeerName, + OriginalIcmpType, + InterfaceQuarantineEpoch, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + AleEffectiveName, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} +``` + +#### `FieldsAleAuthConnectV6` + +```rust +#[repr(usize)] +pub enum FieldsAleAuthConnectV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpDestinationAddressType, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + InterfaceIndex, + SubInterfaceIndex, + IpArrivalInterface, + ArrivalInterfaceType, + ArrivalTunnelType, + ArrivalInterfaceIndex, + NexthopSubInterfaceIndex, + IpNexthopInterface, + NexthopInterfaceType, + NexthopTunnelType, + NexthopInterfaceIndex, + OriginalProfileId, + CurrentProfileId, + ReauthorizeReason, + PeerName, + OriginalIcmpType, + InterfaceQuarantineEpoch, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + AleEffectiveName, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} +``` + +#### `FieldsAleFlowEstablishedV4` + +```rust +#[repr(usize)] +pub enum FieldsAleFlowEstablishedV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpDestinationAddressType, + IpLocalInterface, + Direction, + InterfaceType, + TunnelType, + Flags, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} +``` + +#### `FieldsAleFlowEstablishedV6` + +```rust +#[repr(usize)] +pub enum FieldsAleFlowEstablishedV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpDestinationAddressType, + IpLocalInterface, + Direction, + InterfaceType, + TunnelType, + Flags, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} +``` + +--- + +## `mock_wdk/src/filter_engine/mod.rs` + +### Types + +#### `ResetError` + +Host mock of `wdk::filter_engine::ResetError`. + +```rust +pub enum ResetError { + /// Another transaction is in progress on the filter engine session. The + /// mock has no concurrent transactions, so it never produces this, but the + /// driver matches on it. + TransactionInProgress, + Failed(String), +} +``` + +#### `FilterRegistration` + +One registered callout+filter pair, as the kernel would hold it after +`FwpsCalloutRegister` + `FwpmFilterAdd`. Exposed so harnesses can assert the +driver registered exactly the callouts it should, on the layers it should. + +```rust +pub struct FilterRegistration { + pub name: String, + pub guid: u128, + pub layer: Layer, + pub callout_id: u32, + /// 0 once the filter has been removed (`unregister_filters`). + pub filter_id: u64, + pub resettable: bool, + /// Cleared by `unregister_callouts`. + pub callout_registered: bool, +} +``` + +#### `FilterEngine` + +Host mock of `wdk::filter_engine::FilterEngine`. +Models the real registration sequence instead of no-op'ing it: `commit` is a +write transaction that registers the sublayer once, then every callout and +its filter — all-or-nothing, like the kernel transaction. Failure modes the +kernel would produce are reproduced: a second commit fails (sublayer +ALREADY_EXISTS), registration without a device object fails +(`FwpsCalloutRegister` needs one), and a duplicate callout GUID fails and +leaves no partial state behind. + +```rust +pub struct FilterEngine { + device_object: *mut DEVICE_OBJECT, + sublayer_guid: u128, + committed: bool, + _callouts: Vec, + registrations: Vec, + next_filter_id: u64, + next_callout_id: u32, +} +``` + +```rust +impl FilterEngine { + pub fn new(driver: &Driver, layer_guid: u128) -> Result; + pub fn commit(&mut self, callouts: Vec) -> Result<(), String>; + fn has_registered_filters(&self) -> bool; + /// Mirrors the real `unregister_filters`: removes every filter, leaving the + /// callouts registered. A no-op once the filters are gone. + pub fn unregister_filters(&mut self) -> Result<(), String>; + /// Mirrors the real `unregister_callouts`: unregisters every callout that is + /// still registered. A no-op for the ones already gone. + pub fn unregister_callouts(&mut self) -> Result<(), String>; + pub fn reset_all_filters(&mut self) -> Result<(), ResetError>; + /// Whether the sublayer/callouts/filters transaction has been committed. + pub fn is_committed(&self) -> bool; + /// The registered callout+filter pairs, in registration order. + pub fn registrations(&self) -> &[FilterRegistration]; +} +``` + +### Trait implementations + +```rust +impl From for String + +unsafe impl Send for FilterEngine + +unsafe impl Sync for FilterEngine +``` + +--- + +## `mock_wdk/src/filter_engine/net_buffer.rs` + +### Types + +#### `NET_BUFFER_LIST` + +Host stand-in for the kernel `NET_BUFFER_LIST`. Owns the packet bytes plus a +`data_offset` cursor that models where the current data view starts (the +kernel moves this with retreat/advance). `next` chains buffers so the driver +can iterate, exactly like the real NBL chain. + +```rust +pub struct NET_BUFFER_LIST { + pub data: Vec, + pub data_offset: usize, + pub next: *mut NET_BUFFER_LIST, + /// Mock of the FWPS per-NBL injection state: `Some(ipv6)` marks the packet + /// as injected by the driver through the network handle of that family. + /// The kernel tracks this per injection handle; the family here models the + /// v4-vs-v6 handle split (see `Injector::was_network_packet_injected_by_self`). + pub injected_by_self: Option, +} +``` + +```rust +impl NET_BUFFER_LIST { + /// Allocate a single NBL node on the heap. The caller owns the `Box` and is + /// responsible for keeping it alive while any `NetBufferList` view of it + /// exists. For inbound packets, set `data_offset` to the size of the headers + /// the kernel has already advanced past (so `retreat` brings it back). + pub fn new_box(data: Vec, data_offset: usize) -> Box; + /// Like [`new_box`](Self::new_box), but carrying the injected-by-self mark + /// for the given family — a packet re-entering the stack after the driver + /// injected it through the v4 (`ipv6 = false`) or v6 network handle. + pub fn new_box_injected( + data: Vec, + data_offset: usize, + ipv6: bool, + ) -> Box; +} +``` + +#### `NetBuffer` + +Host mock of `wdk::filter_engine::net_buffer::NetBuffer`. +The real list carries a chain of NET_BUFFERs; the mock models every +`NET_BUFFER_LIST` node as holding exactly one net buffer, so a `NetBuffer` +views the node's bytes and its `data_offset` cursor directly (retreat and +advance move that cursor, like `NdisRetreat/AdvanceNetBufferDataStart`). +[`NetBuffer::owned_from_bytes`] builds a standalone one for tests. + +```rust +pub struct NetBuffer { + nb: *mut NET_BUFFER_LIST, + data: Option>, + advance_on_drop: Option, +} +``` + +```rust +impl NetBuffer { + pub fn new(nb: *mut NET_BUFFER_LIST) -> NetBuffer; + /// Test helper: a net buffer over `bytes`, not backed by any list. + pub fn owned_from_bytes(bytes: Vec) -> NetBuffer; + fn view(&self) -> &[u8]; + pub fn get_data_length(&self) -> u32; + /// Reads `buffer.len()` bytes from the current data start. Fails if the net + /// buffer is empty or holds fewer bytes than requested. + pub fn read_bytes(&self, buffer: &mut [u8]) -> Result<(), ()>; + /// Copies this net buffer into a new, owned single-buffer list. + pub fn clone_as_nbl( + &self, + _net_allocator: &NetworkAllocator, + ) -> Result; + /// Retreats the data start by `size`. Like the real call (which passes no + /// allocation routine), it fails when there are not `size` bytes in front + /// of the current offset, leaving the offset unchanged. + pub fn retreat(&mut self, size: u32, auto_advance: bool) -> Result<(), String>; + /// Advances the data start by `size`, undoing a retreat. + pub fn advance(&self, size: u32); +} +``` + +#### `NetBufferIter` + +Host mock of `NetBufferIter`. Yields the single net buffer of a mock list. + +```rust +pub struct NetBufferIter(*mut NET_BUFFER_LIST); +``` + +```rust +impl NetBufferIter { + pub fn new(nb: *mut NET_BUFFER_LIST) -> Self; +} +``` + +#### `NetBufferList` + +Host mock of `wdk::filter_engine::net_buffer::NetBufferList`. +Two modes, mirroring the real type: +* *borrowed* -- `nbl` points at an externally owned `NET_BUFFER_LIST` + (from a callout's layer data or the iterator). `get_data*` return `None`. +* *owned* -- produced by `clone()`; `data` holds a copy and `get_data*` + return `Some`. This is the only mode `packet_util::redirect` mutates. + +```rust +pub struct NetBufferList { + pub(crate) nbl: *mut NET_BUFFER_LIST, + data: Option>, +} +``` + +```rust +impl NetBufferList { + pub fn new(nbl: *mut NET_BUFFER_LIST) -> NetBufferList; + /// Test helper: build an *owned* NBL directly from bytes (so `get_data` / + /// `get_data_mut` return `Some`). Useful for exercising redirect/checksum. + pub fn owned_from_bytes(bytes: Vec) -> NetBufferList; + pub fn iter(&self) -> NetBufferListIter; + /// Iterates over every net buffer in this list (one, in the mock). + pub fn iter_net_buffers(&self) -> NetBufferIter; + fn view_len(&self) -> usize; + pub fn read_bytes(&self, buffer: &mut [u8]) -> Result<(), ()>; + pub fn clone( + &self, + _net_allocator: &NetworkAllocator, + ) -> Result; + pub fn get_data_mut(&mut self) -> Option<&mut [u8]>; + pub fn get_data(&self) -> Option<&[u8]>; + pub fn get_data_length(&self) -> u64; +} +``` + +#### `NetBufferListIter` + +```rust +pub struct NetBufferListIter(*mut NET_BUFFER_LIST); +``` + +```rust +impl NetBufferListIter { + pub fn new(nbl: *mut NET_BUFFER_LIST) -> Self; +} +``` + +#### `NetworkAllocator` + +Host mock of `NetworkAllocator`. No pool to manage; all methods are no-ops. + +```rust +pub struct NetworkAllocator; +``` + +```rust +impl NetworkAllocator { + pub fn new() -> Self; + pub fn free_net_buffer(_nbl: *mut NET_BUFFER_LIST); +} +``` + +### Trait implementations + +```rust +impl Drop for NetBuffer + +impl Iterator for NetBufferIter + +impl Iterator for NetBufferListIter + +impl Default for NetworkAllocator +``` + +### Functions + +```rust +pub fn as_layer_data(nbl: *mut NET_BUFFER_LIST) -> *mut c_void; +``` + +--- + +## `mock_wdk/src/filter_engine/packet.rs` + +### Types + +#### `InjectInfo` + +Host mock of `wdk::filter_engine::packet::InjectInfo`. +`compartment_id` is `i32` to match the real `COMPARTMENT_ID` type alias. + +```rust +pub struct InjectInfo { + pub ipv6: bool, + pub inbound: bool, + pub loopback: bool, + pub interface_index: u32, + pub sub_interface_index: u32, + pub compartment_id: i32, +} +``` + +#### `TransportPacketList` + +Host mock of `wdk::filter_engine::packet::TransportPacketList`. +`net_buffer_list_queue` is public because `id_cache::get_payload` reads it. + +```rust +pub struct TransportPacketList { + pub net_buffer_list_queue: NetBufferList, + pub(crate) ipv6: bool, + pub(crate) inbound: bool, +} +``` + +#### `InjectedPacket` + +One packet the driver handed to the injector, as a harness can observe it. +`transport` distinguishes the ALE/transport-layer inject (a pended flow's +first packet being released) from the network/packet-layer inject (a cloned +or redirected IP packet). `loopback` is only meaningful for network injects. + +```rust +#[derive(Clone)] +pub struct InjectedPacket { + pub ipv6: bool, + pub inbound: bool, + pub loopback: bool, + pub transport: bool, + pub data: Vec, +} +``` + +#### `Injector` + +Host mock of `wdk::filter_engine::packet::Injector`. +The kernel version hands packets back to the network stack; the mock records +them instead, so a harness can drain the queue and — mimicking the OS — feed +them back through the packet-layer callouts (see the sim's reinjector). + +```rust +pub struct Injector { + injected: Mutex>, +} +``` + +```rust +impl Injector { + pub fn new() -> Self; + pub fn was_network_packet_injected_by_self( + &self, + nbl: *const NET_BUFFER_LIST, + ipv6: bool, + ) -> bool; + pub fn was_network_packet_injected_by_self_ale( + &self, + _nbl: *const NET_BUFFER_LIST, + ) -> bool; + pub fn inject_net_buffer_list( + &self, + net_buffer_list: NetBufferList, + inject_info: InjectInfo, + ) -> Result<(), String>; + pub fn inject_packet_list_transport( + &self, + packet_list: TransportPacketList, + ) -> Result<(), String>; + /// Take every packet injected since the last drain, in injection order. + pub fn drain_injected(&self) -> Vec; + /// Number of injected packets not yet drained. + pub fn injected_len(&self) -> usize; + fn push(&self, packet: InjectedPacket); + pub fn from_ale_callout( + ipv6: bool, + _callout_data: &CalloutData, + net_buffer_list: NetBufferList, + _remote_ip_slice: &[u8], + inbound: bool, + _interface_index: u32, + _sub_interface_index: u32, + ) -> TransportPacketList; +} +``` + +### Trait implementations + +```rust +impl Default for Injector +``` + +--- + +## `mock_wdk/src/interface.rs` + +Host mock of `wdk::interface`. Just enough to let the driver's real +`DriverEntry` run: bind a [`Driver`] to the caller-provided `DRIVER_OBJECT` +so handler registration writes into it. + +### Types + +#### `WdfObjectAttributes` + +Mock of `wdk::ffi::WdfObjectAttributes`. The driver only ever passes a null +pointer for it, so the mock is an opaque stand-in to type the argument. + +```rust +pub struct WdfObjectAttributes; +``` + +### Functions + +Mock of `wdk::interface::init_driver_object`. +The real version creates the WDF driver/device objects via FFI and returns a +`Driver` wrapping them. The mock has no WDF: it binds the `Driver` to the +caller-owned `DRIVER_OBJECT` (so the subsequent `set_*_fn` calls land in +`MajorFunction`) and creates the device object (the IoCreateDevice step), +which the filter engine requires for callout registration. + +```rust +pub fn init_driver_object( + driver_object: *mut DRIVER_OBJECT, + _registry_path: *mut UNICODE_STRING, + _driver_name: &str, + _object_attributes: *mut WdfObjectAttributes, +) -> Result; +``` + +--- + +## `mock_wdk/src/ioqueue.rs` + +### Constants & statics + +```rust +const BLOCKING_POLL: Duration = Duration::from_millis(50); +``` + +### Types + +#### `Status` + +```rust +#[derive(Debug)] +pub enum Status { + Uninitialized, + Timeout, + UserAPC, + Abandoned, +} +``` + +#### `IOQueue` + +Host mock of the kernel `IOQueue`, backed by a `VecDeque` guarded by a +`Mutex` + `Condvar`. +It has two modes, chosen at construction, because the host harness has two +very different needs: +* **non-blocking** (`new`) — `wait_and_pop` returns `Timeout` immediately when + the queue is empty. The synchronous fuzz targets drain the event queue on + the same thread that drives the callouts, so a blocking read there would + simply hang the fuzzer. This matches the real `pop()` (zero timeout). +* **blocking** (`new_blocking`) — `wait_and_pop` blocks until an entry is + pushed or the queue is run down, mirroring the kernel `KeRemoveQueue` with a + NULL timeout. The full-driver simulation uses this so a user-space reader + thread blocks on `Device::read` exactly like the real driver, and `rundown` + (shutdown / unload) wakes it with `Abandoned`, which `Device::read` turns + into a clean end-of-file. + +```rust +pub struct IOQueue { + inner: Mutex>, + cond: Condvar, + blocking: bool, +} +``` + +```rust +impl IOQueue { + /// Non-blocking queue (see type docs). The default for fuzz targets. + pub fn new() -> Self; + /// Blocking queue (see type docs). Used by the full-driver simulation so the + /// user-space reader thread blocks on `Device::read` like the real driver. + pub fn new_blocking() -> Self; + fn with_blocking(blocking: bool) -> Self; + pub fn push(&self, entry: T) -> Result<(), Status>; + /// Non-blocking pop. Returns `Timeout` when empty (mirrors the real `pop()` + /// with a zero timeout). + pub fn pop(&self) -> Result; + pub fn wait_and_pop(&self) -> Result; + /// Blocks up to `timeout` milliseconds for an entry. Returns `Timeout` if the + /// deadline passes with the queue still empty, or `Abandoned` after rundown. + pub fn pop_timeout(&self, timeout: i64) -> Result; + /// Closes the queue: drops all pending entries and wakes every blocked + /// waiter with `Abandoned`. The queue must not be used afterwards (mirrors + /// `KeRundownQueue`). + pub fn rundown(&self); +} +``` + +#### `Inner` + +```rust +struct Inner { + queue: VecDeque, + live: bool, +} +``` + +### Trait implementations + +```rust +impl Display for Status + +unsafe impl Sync for IOQueue + +impl Default for IOQueue +``` + +--- + +## `mock_wdk/src/irp_helpers.rs` + +### Types + +#### `ReadRequest` + +```rust +pub struct ReadRequest<'a> { + irp: Option<&'a mut IRP>, + buffer: &'a mut [u8], + fill_index: usize, + status: i32, +} +``` + +```rust +impl<'a> ReadRequest<'a> { + /// Build a read request over a caller-owned output buffer (no IRP). + pub fn from_buffer(buffer: &'a mut [u8]) -> ReadRequest<'a>; + /// Build a read request from a mock IRP (the dispatch path). The output + /// buffer is the IRP's system buffer, sized to `output_len`. + pub fn new(irp: &'a mut IRP) -> ReadRequest<'a>; + pub fn free_space(&self) -> usize; + pub fn complete(&mut self); + pub fn end_of_file(&mut self); + pub fn timeout(&mut self); + fn set_status(&mut self, status: i32); + pub fn get_status(&self) -> i32; + pub fn write(&mut self, bytes: &[u8]) -> usize; + /// Test helper: the bytes written so far. + pub fn written(&self) -> &[u8]; +} +``` + +#### `WriteRequest` + +```rust +pub struct WriteRequest<'a> { + irp: Option<&'a mut IRP>, + buffer: &'a [u8], + status: i32, +} +``` + +```rust +impl<'a> WriteRequest<'a> { + /// Build a write request over a caller-supplied input buffer (no IRP). This + /// is the entry point a fuzz target uses to feed arbitrary bytes into + /// `Device::write`. + pub fn from_bytes(buffer: &'a [u8]) -> WriteRequest<'a>; + /// Build a write request from a mock IRP (the dispatch path). The input + /// buffer is the IRP's system buffer, sized to `input_len`. + pub fn new(irp: &'a mut IRP) -> WriteRequest<'a>; + pub fn get_buffer(&self) -> &[u8]; + pub fn mark_all_as_read(&mut self); + pub fn complete(&mut self); + pub fn get_status(&self) -> i32; +} +``` + +#### `DeviceControlRequest` + +```rust +pub struct DeviceControlRequest<'a> { + irp: Option<&'a mut IRP>, + buffer: &'a mut [u8], + fill_index: usize, + control_code: u32, + status: i32, +} +``` + +```rust +impl<'a> DeviceControlRequest<'a> { + pub fn from_parts( + buffer: &'a mut [u8], + control_code: u32, + ) -> DeviceControlRequest<'a>; + /// Build a device-control request from a mock IRP (the dispatch path). The + /// output buffer is the IRP's system buffer, sized to `output_len`. + pub fn new(irp: &'a mut IRP) -> DeviceControlRequest<'a>; + pub fn get_buffer(&self) -> &[u8]; + pub fn write(&mut self, bytes: &[u8]) -> usize; + pub fn complete(&mut self); + pub fn not_implemented(&mut self); + pub fn get_status(&self) -> i32; + pub fn get_control_code(&self) -> u32; + pub fn free_space(&self) -> usize; +} +``` + +--- + +## `mock_wdk/src/kernel_types.rs` + +Host mocks of the kernel / Win32 types the driver's entry plumbing names. +These mirror `wdk::kernel_types` (which on the real build re-exports the +`windows_sys` types) closely enough that the driver's `entry.rs` — +`DriverEntry` and the IRP dispatch handlers — compiles and runs unchanged +against the mock. They are deliberately minimal: only the fields the driver +actually touches exist. + +### Constants & statics + +```rust +pub const STATUS_SUCCESS: NTSTATUS = 0; +``` + +```rust +pub const STATUS_FAILED_DRIVER_ENTRY: NTSTATUS = 0xC000_0001u32 as i32; +``` + +```rust +pub const STATUS_END_OF_FILE: NTSTATUS = 0xC000_0011u32 as i32; +``` + +```rust +pub const STATUS_TIMEOUT: NTSTATUS = 0x0000_0102; +``` + +```rust +pub const STATUS_NOT_IMPLEMENTED: NTSTATUS = 0xC000_0002u32 as i32; +``` + +```rust +pub const IRP_MJ_CREATE: usize = 0x00; +``` + +```rust +pub const IRP_MJ_CLOSE: usize = 0x02; +``` + +```rust +pub const IRP_MJ_READ: usize = 0x03; +``` + +```rust +pub const IRP_MJ_WRITE: usize = 0x04; +``` + +```rust +pub const IRP_MJ_DEVICE_CONTROL: usize = 0x0e; +``` + +```rust +pub const IRP_MJ_CLEANUP: usize = 0x12; +``` + +```rust +pub const IRP_MJ_MAXIMUM_FUNCTION: usize = 0x1b; +``` + +### Types + +#### `NTSTATUS` + +Mirrors `windows_sys` `NTSTATUS` (an `i32`). + +```rust +pub type NTSTATUS = i32; +``` + +#### `UNICODE_STRING` + +The registry path handed to `DriverEntry`. The driver never dereferences it +(it is passed straight through to `init_driver_object`), so the mock is an +opaque zero-sized stand-in. + +```rust +pub struct UNICODE_STRING; +``` + +#### `DEVICE_OBJECT` + +WDM device object. The driver's handlers take `&mut DEVICE_OBJECT` but ignore +it (they reach the device through the global slot), so the mock carries no +state. Each `DriverConnection` owns its own instance to avoid cross-thread +aliasing of a shared `&mut DEVICE_OBJECT`. + +```rust +#[derive(Default)] +pub struct DEVICE_OBJECT; +``` + +```rust +impl DEVICE_OBJECT { + pub fn new() -> Self; +} +``` + +#### `MjFnType` + +IRP major function dispatch handler. Same shape as the real `MjFnType`, but +over the mock `DEVICE_OBJECT`/`IRP`/`NTSTATUS`. + +```rust +pub type MjFnType = unsafe extern "system" fn(&mut DEVICE_OBJECT, &mut IRP) -> NTSTATUS; +``` + +#### `UnloadFnType` + +Driver unload callback. Same shape as the real `UnloadFnType`. + +```rust +pub type UnloadFnType = unsafe extern "system" fn(*const DRIVER_OBJECT); +``` + +#### `DRIVER_OBJECT` + +WDM driver object. The real `Driver` registers handlers by writing function +pointers into `MajorFunction[IRP_MJ_*]` / `DriverUnload`; the mock `Driver` +does the same so a `DriverConnection` can read them back and dispatch exactly +like the kernel I/O manager. +`DeviceObject` models the WDM field of the same name: null until driver init +creates a device (`interface::init_driver_object` here, IoCreateDevice in the +kernel). The mock filter engine refuses callout registration without one, +like `FwpsCalloutRegister` does. Owned: freed on drop. + +```rust +pub struct DRIVER_OBJECT { + pub MajorFunction: [Option; IRP_MJ_MAXIMUM_FUNCTION + 1], + pub DriverUnload: Option, + pub DeviceObject: *mut DEVICE_OBJECT, +} +``` + +```rust +impl DRIVER_OBJECT { + pub fn new() -> Self; +} +``` + +#### `IRP` + +Host stand-in for the kernel IRP. Models a single buffered request: a system +buffer (the `AssociatedIrp.SystemBuffer` equivalent, shared by input and +output for buffered I/O) plus the lengths and the completion fields a handler +writes back (`information` = bytes transferred, `status` = NTSTATUS). +`system_buffer` points at caller-owned storage (a `DriverConnection`'s +buffer); the IRP and its request wrapper must not outlive it. + +```rust +pub struct IRP { + pub system_buffer: *mut u8, + pub input_len: usize, + pub output_len: usize, + pub control_code: u32, + pub information: usize, + pub status: NTSTATUS, +} +``` + +```rust +impl IRP { + /// IRP for a read (output-only): the handler fills up to `output_len` bytes. + pub fn for_read(system_buffer: *mut u8, output_len: usize) -> Self; + /// IRP for a write (input-only): the handler reads `input_len` bytes. + pub fn for_write(system_buffer: *mut u8, input_len: usize) -> Self; + /// IRP for a device-io-control request (buffered method): input and output + /// share `system_buffer`. + pub fn for_ioctl( + system_buffer: *mut u8, + input_len: usize, + output_len: usize, + control_code: u32, + ) -> Self; +} +``` + +### Trait implementations + +```rust +unsafe impl Send for DRIVER_OBJECT + +unsafe impl Sync for DRIVER_OBJECT + +impl Drop for DRIVER_OBJECT + +impl Default for DRIVER_OBJECT +``` + +--- + +## `mock_wdk/src/lib.rs` + +Host-side mock of the `wdk` crate. +Mirrors the module tree and the exact public method signatures the `driver` +crate consumes, backed by ordinary host data structures (Vec, UnsafeCell, +VecDeque) instead of kernel objects. No `windows_sys`, no FFI, no `#[link]`. + +### Macros + +```rust +macro_rules! log { /* ... */ } +``` + +```rust +macro_rules! err { /* ... */ } +``` + +```rust +macro_rules! dbg { /* ... */ } +``` + +```rust +macro_rules! info { /* ... */ } +``` + +```rust +macro_rules! warn { /* ... */ } +``` + +--- + +## `mock_wdk/src/rw_spin_lock.rs` + +### Types + +#### `Mutex` + +Host mock of the kernel reader-writer spin lock based `Mutex`. +This is a REAL reader-writer spinlock (not a no-op), so the multithreaded +host fuzz/stress harness exercises the same mutual exclusion the kernel +spinlock provides. Multiple readers may hold it at once; a writer is +exclusive. +IMPORTANT: the real type is valid when zero-initialized, and the driver +relies on that -- `connection_cache::alloc_port_array` allocates arrays of +`RCUPort` (which contain `Mutex<()>`) with `alloc_zeroed`. So this mock +must also be valid when its bytes are all zero. We back the lock word with an +`AtomicIsize` that is `0` (unlocked) when zeroed: + * `0` -> unlocked + * `-1` -> write-locked (exclusive) + * `>0` -> number of active readers (shared) +The data lives in an `UnsafeCell`; the lock word is what makes concurrent +access sound. No lock-ordering cycles exist in the driver (each operation +holds at most one port lock at a time), so this spinlock cannot deadlock. + +```rust +pub struct Mutex { + lock: AtomicIsize, + data: UnsafeCell, +} +``` + +```rust +impl Mutex { + pub const fn new(data: T) -> Self; + /// Acquire a shared (reader) lock, spinning until no writer holds it. + pub fn read_lock(&self) -> MutexReadGuard<'_, T>; + /// Acquire the exclusive (writer) lock, spinning until fully unlocked. + pub fn write_lock(&self) -> MutexWriteGuard<'_, T>; +} +``` + +#### `MutexWriteGuard` + +```rust +pub struct MutexWriteGuard<'a, T> { + lock: &'a Mutex, +} +``` + +#### `MutexReadGuard` + +```rust +pub struct MutexReadGuard<'a, T> { + lock: &'a Mutex, +} +``` + +### Trait implementations + +```rust +unsafe impl Sync for Mutex + +unsafe impl Send for Mutex + +impl<'a, T> Deref for MutexWriteGuard<'a, T> + +impl<'a, T> DerefMut for MutexWriteGuard<'a, T> + +impl<'a, T> Drop for MutexWriteGuard<'a, T> + +impl<'a, T> Deref for MutexReadGuard<'a, T> + +impl<'a, T> Drop for MutexReadGuard<'a, T> +``` + +### Functions + +```rust +fn spin(); +``` + +--- + +## `mock_wdk/src/utils.rs` + +### Constants & statics + +```rust +static MOCK_TIME_MS: AtomicU64 = AtomicU64::new(0); +``` + +### Functions + +Test helper: pin the mock clock to a fixed millisecond value. +Pass 0 to fall back to the real host clock. + +```rust +pub fn set_mock_time_ms(value: u64); +``` + +Test helper: advance the pinned mock clock by `delta` ms. Only meaningful +after `set_mock_time_ms` has pinned the clock to a non-zero value. + +```rust +pub fn advance_mock_time_ms(delta: u64); +``` + +```rust +pub fn get_system_timestamp_ms() -> u64; +``` + +```rust +pub fn get_startup_time_ns() -> u64; +``` + +Mock of `wdk::utils::sleep_ms`: the kernel version waits non-alertably and +fails above APC_LEVEL. The host has no IRQL, so this just sleeps the thread. + +```rust +pub fn sleep_ms(milliseconds: u32) -> Result<(), String>; +``` + +--- + diff --git a/docs/LLMs/RUSTDOC-protocol.md b/docs/LLMs/RUSTDOC-protocol.md new file mode 100644 index 0000000..d9e60ae --- /dev/null +++ b/docs/LLMs/RUSTDOC-protocol.md @@ -0,0 +1,376 @@ +# ZenithFence — Rust Item Reference: Protocol + +> Auto-generated by `cargo run --manifest-path tools/rustdoc-gen/Cargo.toml -- .` (`just gen-llm-docs`) — do not edit by hand. +> One section per source file: module docs, constants, macros, full type definitions, impl blocks, functions and extern blocks. +> Includes private items. Function bodies, long initializers and test code are stripped. + +--- + +## `protocol/src/command.rs` + +### Types + +#### `CommandType` + +```rust +#[repr(u8)] +#[derive(Clone, Copy, FromPrimitive)] +pub enum CommandType { + Shutdown = 0, + Verdict = 1, + UpdateV4 = 2, + UpdateV6 = 3, + ClearCache = 4, + GetConnectionsUpdate = 5, + GetLogs = 6, + PrintMemoryStats = 7, + CleanEndedConnections = 8, +} +``` + +#### `Command` + +```rust +#[repr(C, packed)] +pub struct Command { + pub command_type: CommandType, + value: [u8; 0], +} +``` + +#### `Verdict` + +```rust +#[repr(C, packed)] +#[derive(Debug, PartialEq, Eq)] +pub struct Verdict { + pub id: u64, + pub verdict: u8, +} +``` + +#### `UpdateV4` + +```rust +#[repr(C, packed)] +#[derive(Debug, PartialEq, Eq)] +pub struct UpdateV4 { + pub protocol: u8, + pub local_address: [u8; 4], + pub local_port: u16, + pub remote_address: [u8; 4], + pub remote_port: u16, + pub verdict: u8, +} +``` + +#### `UpdateV6` + +```rust +#[repr(C, packed)] +#[derive(Debug, PartialEq, Eq)] +pub struct UpdateV6 { + pub protocol: u8, + pub local_address: [u8; 16], + pub local_port: u16, + pub remote_address: [u8; 16], + pub remote_port: u16, + pub verdict: u8, +} +``` + +#### `ConnectionsUpdate` + +```rust +#[repr(C, packed)] +#[derive(Debug, PartialEq, Eq)] +pub struct ConnectionsUpdate { + pub timestamp: u64, +} +``` + +### Functions + +```rust +pub fn parse_type(bytes: &[u8]) -> Option; +``` + +```rust +pub fn parse_verdict(bytes: &[u8]) -> Option<&Verdict>; +``` + +```rust +pub fn parse_update_v4(bytes: &[u8]) -> Option<&UpdateV4>; +``` + +```rust +pub fn parse_update_v6(bytes: &[u8]) -> Option<&UpdateV6>; +``` + +```rust +pub fn parse_update_info(bytes: &[u8]) -> Option<&ConnectionsUpdate>; +``` + +Reinterpret the start of `bytes` as a `T`. +Returns `None` unless the buffer holds at least `size_of::()` bytes, so a +short/truncated command from user space can never cause an out-of-bounds +read. The command structs are `#[repr(C, packed)]` (alignment 1), so the +cast is always correctly aligned. + +```rust +fn as_type(bytes: &[u8]) -> Option<&T>; +``` + +--- + +## `protocol/src/difftest.rs` + +Deterministic Rust <-> Go differential tests of the user/kernel wire format. +Every record's field values are derived from its index by [`diff_mix`] (a +splitmix64-style hash implemented identically here and in +`kext_interface/diff_test.go`). No shared RNG is needed: both languages +compute the same expected values from the record position, so any divergence +in encoding/decoding (endianness, field order, type width, struct size) +surfaces immediately as a mismatch. +Two files cross the language boundary: + * commands Go writes `go_command_diff.bin` -> Rust reads (`test_go_command_diff`) + * info Rust writes `info_diff.bin` -> Go reads (`TestInfoDiffFile`) +KEEP THE FORMULAS BELOW IN SYNC WITH kext_interface/diff_test.go. + +### Constants & statics + +```rust +const COMMAND_DIFF_FILE: &str = "../kext_interface/go_command_diff.bin"; +``` + +```rust +const INFO_DIFF_FILE: &str = "info_diff.bin"; +``` + +```rust +const INFO_RECORDS: u32 = 6000; +``` + +### Functions + +splitmix64-style per-(record, field) value. MUST match `diffMix` in +kext_interface/diff_test.go. All arithmetic wraps (matches Go's uint64). + +```rust +pub(crate) fn diff_mix(i: u32, f: u32) -> u64; +``` + +```rust +fn b4(i: u32, f: u32) -> [u8; 4]; +``` + +```rust +fn b16(i: u32, f: u32) -> [u8; 16]; +``` + +```rust +fn verdict_val(i: u32, f: u32) -> u8; +``` + +```rust +fn diff_payload(i: u32) -> Vec; +``` + +--- + +## `protocol/src/info.rs` + +### Macros + +```rust +macro_rules! push_bytes { /* ... */ } +``` + +```rust +macro_rules! get_combined_size { /* ... */ } +``` + +### Types + +#### `InfoType` + +```rust +#[repr(u8)] +#[derive(Clone, Copy)] +enum InfoType { + LogLine = 0, + ConnectionIpv4 = 1, + ConnectionIpv6 = 2, + ConnectionEndEventV4 = 3, + ConnectionEndEventV6 = 4, + ConnectionUpdateEventV4 = 5, + ConnectionUpdateEventV6 = 6, + ConnectionUpdateEnd = 7, +} +``` + +#### `PushBytes` + +```rust +trait PushBytes { + fn push(self, vec: &mut Vec); +} +``` + +#### `Info` + +```rust +pub struct Info(Vec); +``` + +```rust +impl Info { + fn new(info_type: InfoType, size: usize) -> Self; + fn with_capacity(info_type: InfoType, capacity: usize) -> Self; + fn update_size(&mut self); + pub fn as_bytes(&self) -> &[u8]; +} +``` + +#### `Severity` + +```rust +#[repr(u8)] +#[derive(Clone, Copy)] +pub enum Severity { + Trace = 1, + Debug = 2, + Info = 3, + Warning = 4, + Error = 5, + Critical = 6, + Disabled = 7, +} +``` + +### Trait implementations + +```rust +impl PushBytes for u8 + +impl PushBytes for InfoType + +impl PushBytes for u16 + +impl PushBytes for u32 + +impl PushBytes for u64 + +impl PushBytes for usize + +impl PushBytes for [u8; 4] + +impl PushBytes for [u8; 16] + +impl PushBytes for &[u8] + +impl core::fmt::Write for Info +``` + +### Functions + +```rust +pub fn connection_info_v4( + id: u64, + process_id: u64, + direction: u8, + protocol: u8, + local_ip: [u8; 4], + remote_ip: [u8; 4], + local_port: u16, + remote_port: u16, + payload_layer: u8, + payload: &[u8], +) -> Info; +``` + +```rust +pub fn connection_info_v6( + id: u64, + process_id: u64, + direction: u8, + protocol: u8, + local_ip: [u8; 16], + remote_ip: [u8; 16], + local_port: u16, + remote_port: u16, + payload_layer: u8, + payload: &[u8], +) -> Info; +``` + +```rust +pub fn connection_end_event_v4_info( + process_id: u64, + direction: u8, + protocol: u8, + local_ip: [u8; 4], + remote_ip: [u8; 4], + local_port: u16, + remote_port: u16, + rx_bytes: u64, + rx_packets: u64, + tx_bytes: u64, + tx_packets: u64, +) -> Info; +``` + +```rust +pub fn connection_end_event_v6_info( + process_id: u64, + direction: u8, + protocol: u8, + local_ip: [u8; 16], + remote_ip: [u8; 16], + local_port: u16, + remote_port: u16, + rx_bytes: u64, + rx_packets: u64, + tx_bytes: u64, + tx_packets: u64, +) -> Info; +``` + +```rust +pub fn connection_update_event_v4_info( + protocol: u8, + local_ip: [u8; 4], + remote_ip: [u8; 4], + local_port: u16, + remote_port: u16, + rx_bytes: u64, + rx_packets: u64, + tx_bytes: u64, + tx_packets: u64, +) -> Info; +``` + +```rust +pub fn connection_update_event_v6_info( + protocol: u8, + local_ip: [u8; 16], + remote_ip: [u8; 16], + local_port: u16, + remote_port: u16, + rx_bytes: u64, + rx_packets: u64, + tx_bytes: u64, + tx_packets: u64, +) -> Info; +``` + +```rust +pub fn connection_update_end_info() -> Info; +``` + +```rust +pub fn log_line(severity: Severity, capacity: usize) -> Info; +``` + +--- + diff --git a/docs/LLMs/RUSTDOC-tools.md b/docs/LLMs/RUSTDOC-tools.md new file mode 100644 index 0000000..df80826 --- /dev/null +++ b/docs/LLMs/RUSTDOC-tools.md @@ -0,0 +1,1662 @@ +# ZenithFence — Rust Item Reference: Simulation & Release Tools + +> Auto-generated by `cargo run --manifest-path tools/rustdoc-gen/Cargo.toml -- .` (`just gen-llm-docs`) — do not edit by hand. +> One section per source file: module docs, constants, macros, full type definitions, impl blocks, functions and extern blocks. +> Includes private items. Function bodies, long initializers and test code are stripped. + +--- + +## `sim/src/dns.rs` + +Live DNS resolution for the high-level `dns` scenario op. +Unlike the rest of the harness -- which fabricates packets so a run stays +offline and reproducible -- this module performs a REAL DNS query against the +machine's default resolver (or an explicit one) and hands back the actual +query and response datagrams that crossed the socket. The `dns` op feeds those +real bytes to the driver's callouts, so a domain-aware policy sees genuine DNS +traffic. Touching the network makes `dns` records non-deterministic and +dependent on connectivity; a failed lookup is reported, never silently faked. +The OS default resolver is read via `GetNetworkParams` (iphlpapi) on Windows -- +raw FFI in the same spirit as the named-pipe server, pulling in no +`windows-sys` -- and from `/etc/resolv.conf` on Unix. + +### Constants & statics + +Per-attempt timeout waiting for the resolver to answer. + +```rust +const RECV_TIMEOUT: Duration = Duration::from_secs(2); +``` + +How many times to (re)send the query before giving up. + +```rust +const ATTEMPTS: usize = 2; +``` + +```rust +#[cfg(windows)] +const ERROR_SUCCESS: u32 = ...; +``` + +```rust +#[cfg(windows)] +const ERROR_BUFFER_OVERFLOW: u32 = ...; +``` + +### Types + +#### `Resolution` + +A completed lookup: the server actually queried, the first address of the +requested family (if the reply carried one), and the raw query/response +datagrams exactly as they went over the wire. + +```rust +pub struct Resolution { + pub resolver: IpAddr, + pub resolved: Option, + pub query: Vec, + pub response: Vec, +} +``` + +#### `IpAddrString` + +```rust +#[cfg(windows)] +#[repr(C)] +struct IpAddrString { + next: *mut IpAddrString, + ip_address: [u8; 16], + ip_mask: [u8; 16], + context: u32, +} +``` + +#### `FixedInfo` + +```rust +#[cfg(windows)] +#[repr(C)] +struct FixedInfo { + host_name: [u8; 132], + domain_name: [u8; 132], + current_dns_server: *mut IpAddrString, + dns_server_list: IpAddrString, + node_type: u32, + scope_id: [u8; 260], + enable_routing: u32, + enable_proxy: u32, + enable_dns: u32, +} +``` + +### Functions + +Resolve `domain` for real. `want_v6` selects the record type (AAAA vs A), and +hence the family of `resolved`; the query itself travels to `resolver` (the +explicit override, else the OS default) over whatever family that server is. +Returns the wire bytes on success, or a human-readable reason on failure. + +```rust +pub fn resolve( + domain: &str, + want_v6: bool, + resolver: Option, +) -> Result; +``` + +Parse a 4- or 16-byte address into an [`IpAddr`]; `None` for any other width. + +```rust +pub fn ip_from_bytes(bytes: &[u8]) -> Option; +``` + +A DNS transaction id derived from the name (FNV-1a), so a query is stable for +a given domain without needing a random source. + +```rust +fn query_id(domain: &str) -> u16; +``` + +The machine's primary configured DNS server, or `None` if none can be read. +Walks `DnsServerList`, skipping any unspecified (0.0.0.0) placeholder entry. + +```rust +#[cfg(windows)] +fn os_default_resolver() -> Option; +``` + +The first usable `nameserver` in `/etc/resolv.conf`, or `None` if there is +none. A zone-scoped IPv6 entry (`fe80::1%eth0`) doesn't parse as `IpAddr` and +is skipped. + +```rust +#[cfg(unix)] +fn os_default_resolver() -> Option; +``` + +Parse a NUL-terminated ASCII address string (as `GetNetworkParams` fills in). + +```rust +fn parse_ascii_ip(bytes: &[u8]) -> Option; +``` + +### Extern blocks + +```rust +#[cfg(windows)] +#[link(name = "iphlpapi")] +extern "system" { + fn GetNetworkParams(fixed_info: *mut FixedInfo, out_buf_len: *mut u32) -> u32; +} +``` + +--- + +## `sim/src/main.rs` + +Real-world userspace simulation: a *fake driver* the real Go agent connects +to over a Windows named pipe or, on Unix, a Unix domain socket (see [`pipe`]). +Loads the real driver over `mock_wdk` ([`driver::sim::Simulation`]), exposes +it on the endpoint named by `ZF_SIM_PIPE` (default `ZenithFence`, i.e. +`\\.\pipe\ZenithFence` on Windows; a full `\\.\pipe\...` path is used as is), +waits for an agent to connect, and bridges the pipe to the driver's +user-space channel ([`DriverConnection`] read/write). A producer injects fake +OS network events through the driver's callout entry points; the agent reads +the resulting events and writes verdicts back, exactly as it does against the +kernel device. +Threads (genuine concurrency, all funnelling to the one global `Device`): + - producer ("kernel"): runs callouts that pend packets and emit events + - event pump: blocks on `DriverConnection::read`, ships bytes to the pipe + - command pump: reads command messages from the pipe, applies them +With no scenario file the daemon serves agents one after another until the +user stops it (Ctrl+C): each agent session gets a freshly loaded driver and +seeded-random traffic, and when the agent disconnects the driver is unloaded +and the daemon waits for the next agent -- the way the agent's Start/Stop load +and unload the kernel driver. A broken invariant ends the process via the +monitor thread's non-zero `process::exit`. `--any-verdict` / +`ZF_SIM_ANY_VERDICT` lets a real agent decide verdicts by its own policy (see +[`producer::VerdictPolicy`]). Given a scenario file (positional arg or +`ZF_SIM_SCENARIO`, see [`scenario`]) the daemon instead serves one agent, +replays that file on a worker pool and exits when it finishes. Either way the +OS reclaims the driver, the pumps, and the pipe. + +### Constants & statics + +Logical endpoint name when `ZF_SIM_PIPE` is unset; mapped per OS by [`pipe::endpoint`]. + +```rust +const DEFAULT_PIPE_NAME: &str = "ZenithFence"; +``` + +### Types + +#### `Pumps` + +The two threads bridging the pipe and the driver's user-space channel. Both +set `stop` once the agent is gone. + +```rust +struct Pumps { + events: JoinHandle<()>, + commands: JoinHandle<()>, +} +``` + +```rust +impl Pumps { + fn spawn(sim: &Simulation, server: &Arc, stop: Arc) -> Self; + fn join(self); +} +``` + +### Functions + +```rust +fn main() -> ExitCode; +``` + +Random-traffic mode: serve agents one after another until the process is +stopped. Each session mirrors the agent's Start/Stop of the kernel driver: the +producer runs while the agent is connected; once it disconnects the driver is +shut down and unloaded, and a fresh one is loaded for the next agent. + +```rust +fn serve_agents( + mut sim: Simulation, + server: &Arc, + policy: VerdictPolicy, +) -> ExitCode; +``` + +Load the real driver over mock_wdk and check it registered like a kernel +load would. + +```rust +fn load_driver() -> Result; +``` + +```rust +fn wait_for_agent(server: &PipeServer) -> Result<(), ExitCode>; +``` + +Assert the driver's load registered exactly the production callout set, on +the expected layers, inside a committed filter-engine transaction. + +```rust +fn check_registration() -> Result<(), String>; +``` + +--- + +## `sim/src/pipe/mod.rs` + +The daemon's agent-facing endpoint, one implementation per OS behind the same +`PipeServer` API (`create`, `wait_for_client`, `write_all`, `read_message`, +`disconnect`). One client is served at a time; after `disconnect` the server +can `wait_for_client` again for the next one: +* Windows: an overlapped, message-mode named pipe (`\\.\pipe\`). +* Unix: a stream Unix domain socket. Stream sockets don't preserve message + boundaries, so on this transport the agent prefixes each command with a + `u32` little-endian length, which restores "one command message = one + `Device::write`". The event direction stays an unframed byte stream on both. +[`endpoint`] maps the logical name (`ZF_SIM_PIPE`) to the OS address. The Go +side (`kext_interface/sim_conn*.go`) applies the same rule. + +### Re-exports + +```rust +#[cfg(unix)] +pub use unix::PipeServer; +#[cfg(windows)] +pub use windows::PipeServer; +``` + +### Functions + +The OS address for the logical endpoint `name`: `name` itself if it is a +full pipe path (`\\.\pipe\...`), else `\\.\pipe\`. + +```rust +#[cfg(windows)] +pub fn endpoint(name: &str) -> String; +``` + +The OS address for the logical endpoint `name`: `name` itself if it is a +path (contains `/`), else `$XDG_RUNTIME_DIR/.sock`, falling back to +`/tmp/.sock`. + +```rust +#[cfg(unix)] +pub fn endpoint(name: &str) -> String; +``` + +--- + +## `sim/src/pipe/unix.rs` + +Unix domain socket server: the Unix counterpart of the Windows named pipe. +A `SOCK_STREAM` socket from `std` (no extra dependencies). Reads and writes +run on separate threads over separate fds (`try_clone`), the same concurrency +the overlapped pipe provides on Windows. +Stream sockets don't keep message boundaries, but the driver needs one command +per `Device::write` (one IRP per `WriteFile` in the kernel). So on this +transport each agent command arrives as a frame: a `u32` little-endian length, +then that many bytes. `SOCK_SEQPACKET` would keep the boundaries without +framing, but it truncates any message larger than the reader's buffer, and the +agent deliberately reads events through a small (1 KiB) buffer. Events are +therefore sent as a plain byte stream, exactly like the pipe's byte-mode reads. + +### Types + +#### `Slot` + +The current client's stream, shared with the pump blocked on it. + +```rust +type Slot = Mutex>>; +``` + +#### `PipeServer` + +```rust +pub struct PipeServer { + path: String, + listener: UnixListener, + reader: Slot, + writer: Slot, +} +``` + +```rust +impl PipeServer { + /// Bind the socket at `path`, replacing a stale socket file left by a + /// daemon that was killed before it could clean up. + pub fn create(path: &str) -> io::Result; + /// Block until a client (the Go agent) connects, replacing the previous + /// client's streams. + pub fn wait_for_client(&self) -> io::Result<()>; + /// Write all `bytes` to the socket (event stream → agent). + pub fn write_all(&self, bytes: &[u8]) -> io::Result<()>; + /// Read one length-prefixed command frame into `buf` (agent → command + /// channel). Returns the byte count, or `Err` once the client disconnects or + /// sends a frame that doesn't fit `buf`. + pub fn read_message(&self, buf: &mut [u8]) -> io::Result; + /// Force the connection closed, ending both pumps, and forget the client so + /// the next [`wait_for_client`](Self::wait_for_client) starts clean. + pub fn disconnect(&self); +} +``` + +### Trait implementations + +```rust +impl Drop for PipeServer +``` + +### Functions + +```rust +fn lock(slot: &Slot) -> MutexGuard<'_, Option>>; +``` + +```rust +fn not_connected() -> io::Error; +``` + +--- + +## `sim/src/pipe/windows.rs` + +Minimal Windows named-pipe server with overlapped (asynchronous) I/O. +Raw `kernel32` FFI (no `windows-sys`/`windows` dependency, so it is immune to +the version churn in those crates and pulls nothing extra into the build). +The handle **must** be overlapped: the daemon reads commands on one thread and +writes events on another, and on a *synchronous* pipe handle Windows +serializes all I/O — a blocked `ReadFile` (waiting for a command) would block +every concurrent `WriteFile` (sending an event), which deadlocks the pipeline +(no events out -> no verdicts in -> read never unblocks). With overlapped I/O +each operation carries its own OVERLAPPED + event and completes independently, +so reads and writes run concurrently. +The pipe is **message** type so each agent `WriteFile` (one per command) +arrives as a single message on our read side — one message maps to one +`Device::write`, exactly like one IRP per `WriteFile` in the kernel. The agent +reads events in byte mode (its default), so the byte-oriented event parser is +unaffected by message boundaries. + +### Constants & statics + +```rust +const FILE_FLAG_OVERLAPPED: Dword = 0x4000_0000; +``` + +```rust +const PIPE_ACCESS_DUPLEX: Dword = 0x0000_0003; +``` + +```rust +const PIPE_TYPE_MESSAGE: Dword = 0x0000_0004; +``` + +```rust +const PIPE_READMODE_MESSAGE: Dword = 0x0000_0002; +``` + +```rust +const PIPE_WAIT: Dword = 0x0000_0000; +``` + +```rust +const PIPE_UNLIMITED_INSTANCES: Dword = 255; +``` + +```rust +const INVALID_HANDLE_VALUE: isize = -1; +``` + +```rust +const ERROR_IO_PENDING: Dword = 997; +``` + +```rust +const ERROR_PIPE_CONNECTED: Dword = 535; +``` + +### Types + +#### `Handle` + +```rust +type Handle = *mut c_void; +``` + +#### `Bool` + +```rust +type Bool = i32; +``` + +#### `Dword` + +```rust +type Dword = u32; +``` + +#### `Overlapped` + +```rust +#[repr(C)] +struct Overlapped { + internal: usize, + internal_high: usize, + offset: u32, + offset_high: u32, + h_event: Handle, +} +``` + +#### `PipeServer` + +```rust +pub struct PipeServer { + handle: Handle, +} +``` + +```rust +impl PipeServer { + /// Create the overlapped, message-mode pipe server (e.g. `\\.\pipe\ZenithFence`). + pub fn create(name: &str) -> io::Result; + /// Block until a client (the Go agent) connects. + pub fn wait_for_client(&self) -> io::Result<()>; + /// Write all `bytes` to the pipe (event stream → agent). + pub fn write_all(&self, bytes: &[u8]) -> io::Result<()>; + /// Read one command message (agent → command channel). Returns the byte + /// count, or `Err` once the client disconnects. + pub fn read_message(&self, buf: &mut [u8]) -> io::Result; + /// Force the connection closed: completes both a pending server `ReadFile` + /// and the agent's `ReadFile` with an error, so each side's loop ends. Also + /// readies the instance for the next [`wait_for_client`](Self::wait_for_client). + pub fn disconnect(&self); + /// Run an overlapped op (started by `start`) to completion and return the + /// number of bytes transferred. + fn overlapped( + &self, + start: impl FnOnce(*mut Overlapped) -> Bool, + ) -> io::Result; + /// Block on an overlapped op's completion; returns bytes transferred. + fn wait(&self, ov: &mut Overlapped) -> io::Result; +} +``` + +### Trait implementations + +```rust +unsafe impl Send for PipeServer + +unsafe impl Sync for PipeServer + +impl Drop for PipeServer +``` + +### Functions + +```rust +fn new_event() -> io::Result; +``` + +```rust +fn zeroed_overlapped(event: Handle) -> Overlapped; +``` + +### Extern blocks + +```rust +#[link(name = "kernel32")] +extern "system" { + fn CreateNamedPipeW( + name: *const u16, + open_mode: Dword, + pipe_mode: Dword, + max_instances: Dword, + out_buffer_size: Dword, + in_buffer_size: Dword, + default_timeout: Dword, + security_attributes: *mut c_void, + ) -> Handle; + fn ConnectNamedPipe(pipe: Handle, overlapped: *mut Overlapped) -> Bool; + fn DisconnectNamedPipe(pipe: Handle) -> Bool; + fn ReadFile( + file: Handle, + buffer: *mut u8, + to_read: Dword, + read: *mut Dword, + overlapped: *mut Overlapped, + ) -> Bool; + fn WriteFile( + file: Handle, + buffer: *const u8, + to_write: Dword, + written: *mut Dword, + overlapped: *mut Overlapped, + ) -> Bool; + fn GetOverlappedResult( + file: Handle, + overlapped: *mut Overlapped, + transferred: *mut Dword, + wait: Bool, + ) -> Bool; + fn CreateEventW( + attributes: *mut c_void, + manual_reset: Bool, + initial_state: Bool, + name: *const u16, + ) -> Handle; + fn CloseHandle(handle: Handle) -> Bool; + fn GetLastError() -> Dword; +} +``` + +--- + +## `sim/src/producer.rs` + +Fake OS-side network traffic. +Drives semi-realistic flows through the real driver callout entry points, +the way the OS does: ALE authorization first (which pends and emits an event +the agent answers over the pipe), then data packets through the IP packet +layer, then endpoint closure and resource release. The default run generates +seeded random flows ([`crate::scenarios::random_flow`]) until the agent +disconnects. +Concurrency mirrors the kernel: several producer workers classify flows at +the same time (WFP classify callbacks fire on many threads), and one +reinjector thread models the OS re-presenting driver-injected packets to the +packet-layer callouts (network injects re-enter marked injected-by-self; +outbound transport injects descend the stack unmarked; inbound transport +injects re-indicate upward and never pass the network layer again). +Everything is reproducible from the fixed [`SEED`]: just rerun. +Verdicts are judged per [`VerdictPolicy`]: against kext_tester's fixed rules +by default, or -- for a real agent with its own policy -- accepting any +verdict and checking only that the driver enforces the one it got. + +### Constants & statics + +```rust +const VERDICT_POLL: Duration = Duration::from_millis(5); +``` + +```rust +const REINJECT_POLL: Duration = Duration::from_millis(2); +``` + +How often the monitor checks whether the run was stopped. + +```rust +const STOP_POLL: Duration = Duration::from_millis(50); +``` + +Fixed run seed. The traffic is fully reproducible across runs -- rerun to +reproduce a failure. The RNG stream is infinite, so one seed still yields +endless distinct flows. + +```rust +const SEED: u64 = 0x0BAD_C0DE_5EED_1234; +``` + +Producer worker threads (WFP classify fires on many threads at once). Kept +small so each worker's disjoint local-port window -- 2048 ports starting at +`20000 + worker * 2048` -- stays inside a u16. + +```rust +const THREADS: usize = 4; +``` + +Per-flow budget for the agent's verdict and any mid-burst upgrades before the +flow is counted as a timeout/failure. + +```rust +const FLOW_TIMEOUT: Duration = Duration::from_secs(15); +``` + +How often the monitor thread prints a liveness line and checks invariants. + +```rust +const HEARTBEAT: Duration = Duration::from_secs(5); +``` + +### Types + +#### `Summary` + +```rust +#[derive(Default)] +pub struct Summary { + pub flows_ok: AtomicU64, + pub packets: AtomicU64, + pub bytes: AtomicU64, + /// Network-injected packets that re-entered the packet layer marked + /// injected-by-self and were permitted (the driver's self-inject branch). + pub loopback_permits: AtomicU64, + /// Outbound transport injects replayed down the stack and permitted. + pub transport_replays: AtomicU64, + pub verdict_timeouts: AtomicU64, + pub by_scenario: Mutex>, + pub failures: Mutex>, +} +``` + +```rust +impl Summary { + fn fail(&self, msg: String); + fn count_scenario(&self, name: &'static str); + /// No failures recorded. Verdict timeouts are recorded as failures only + /// under [`VerdictPolicy::Expected`] (see [`Run::verdict_timeout`]). + pub fn is_ok(&self) -> bool; + /// Compact one-line liveness snapshot for the periodic monitor. + pub fn heartbeat(&self); + pub fn print(&self); +} +``` + +#### `VerdictPolicy` + +How the producer judges the verdicts the agent hands out. + +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VerdictPolicy { + /// kext_tester's fixed rules ([`FlowPlan::expect`]): a different verdict, or + /// none within [`FLOW_TIMEOUT`], is a failure. + Expected, + /// A real agent deciding by its own policy: any verdict is legal and + /// [`FlowPlan::expect`] is only a hint. Checks that the driver enforces the + /// verdict it got remain fatal; a verdict that never comes is only counted. + Any, +} +``` + +#### `Run` + +Shared state of one [`drive`] run. + +```rust +struct Run { + summary: Summary, + policy: VerdictPolicy, + stop: Arc, +} +``` + +```rust +impl Run { + fn any_verdict(&self) -> bool; + fn stopped(&self) -> bool; + fn verdict_timeout(&self, msg: String); +} +``` + +### Functions + +Generate random flows across [`THREADS`] workers until `stop` is set, then +join every producer thread and return. A broken invariant ends the process via +the monitor's non-zero `process::exit`. Each worker owns a disjoint IP/port +window and a private RNG stream, and runs one flow fully -- ALE authorization, +the agent's verdict, data bursts, closure, release -- before starting the +next, so a worker can never outpace the agent it waits on. + +```rust +pub fn drive(policy: VerdictPolicy, stop: Arc); +``` + +Print a liveness line every [`HEARTBEAT`]; the moment an invariant breaks, +dump the full summary and exit non-zero. The seed is fixed, so a failing run +reproduces exactly on the next launch. + +```rust +fn monitor_loop(run: &Run); +``` + +```rust +fn run_flow(plan: &FlowPlan, run: &Run, deadline: Instant); +``` + +Data exchange for an authorized flow. A permanent accept permits packets +outright; a temporary Accept (inbound loopback's initial state) pends each +packet until the agent upgrades the verdict, so both outcomes are legal -- +what's illegal is a hard block or a missing action. + +```rust +fn run_bursts(plan: &FlowPlan, run: &Run, deadline: Instant) -> bool; +``` + +Data exchange under [`VerdictPolicy::Any`]: whatever verdict the agent gave, +each packet must get the action the driver owes that verdict. Temporary +verdicts pend the packet for a per-packet answer, so nothing waits for an +upgrade; a pended packet is resolved by the agent and the reinjector. + +```rust +fn run_bursts_any_verdict(plan: &FlowPlan, run: &Run) -> bool; +``` + +```rust +fn reauthorize(plan: &FlowPlan, summary: &Summary) -> bool; +``` + +```rust +fn run_ale(plan: &FlowPlan, reauthorize: bool) -> CalloutResult; +``` + +```rust +fn run_packet(plan: &FlowPlan, direction: Direction, payload: &[u8]) -> CalloutResult; +``` + +```rust +fn close_flow(plan: &FlowPlan); +``` + +```rust +fn release_port(plan: &FlowPlan); +``` + +```rust +fn current_verdict(plan: &FlowPlan) -> Option; +``` + +```rust +fn wait_verdict(plan: &FlowPlan, run: &Run, deadline: Instant) -> Option; +``` + +Poll `cond` until it yields `Some`, the deadline passes, or the run is +stopped (check [`Run::stopped`] to tell the last apart from a timeout). + +```rust +fn wait_for( + run: &Run, + deadline: Instant, + mut cond: impl FnMut() -> Option, +) -> Option; +``` + +```rust +fn count_packet(summary: &Summary, payload: &[u8]); +``` + +```rust +fn reinject_loop(run: &Run); +``` + +Best-effort one-line identity for a drained injected packet: parses the IP +header for the 5-tuple, falling back to flags + length when the bytes are not +a parsable IP packet (e.g. a raw transport segment). + +```rust +fn describe_injected(packet: &api::InjectedPacket) -> String; +``` + +Pull "PROTO src=addr:port dst=addr:port" out of a raw IP packet, or `None` if +the header is too short or its version nibble does not match `ipv6`. + +```rust +fn describe_ip_packet(data: &[u8], ipv6: bool) -> Option; +``` + +The source/destination ports at `offset` for TCP/UDP, if the bytes are there. + +```rust +fn ports_at(data: &[u8], offset: usize, proto: u8) -> Option<(u16, u16)>; +``` + +--- + +## `sim/src/rng.rs` + +Tiny deterministic PRNG (SplitMix64) so the traffic generator is fully +reproducible from one printed seed, with no external dependencies. + +### Types + +#### `SplitMix64` + +```rust +pub struct SplitMix64 { + state: u64, +} +``` + +```rust +impl SplitMix64 { + pub fn new(seed: u64) -> Self; + pub fn next_u64(&mut self) -> u64; + /// Uniform-ish value in `[lo, hi)`. `hi` must be greater than `lo`. + pub fn range(&mut self, lo: u64, hi: u64) -> u64; + /// True with probability `percent`/100. + pub fn chance(&mut self, percent: u64) -> bool; +} +``` + +### Functions + +The SplitMix64 finalizer, also used standalone to derive per-worker seeds +from the run seed. + +```rust +pub fn mix(x: u64) -> u64; +``` + +--- + +## `sim/src/scenario.rs` + +CBOR scenario-file executor. +Replays an authored scenario file against the driver instead of the seeded +random [`crate::producer`]. The file is either **CBOR** (`.cbor`: a sequence +of record maps per RFC 8742, compact) or **JSON Lines** (`.json`: one record +object per line / NDJSON, human-readable for debugging) -- chosen by extension. +Both are decoded as a LAZY STREAM: exactly one record is parsed at a time and +dispatched before the next is read, so the whole file is never buffered and a +scenario can be arbitrarily long. Each record carries an absolute timestamp +(`ts`, microseconds since scenario start) and one command. +Execution is *global-timeline dispatch*: a single scheduler thread reads the +file in order, sleeps the delta between consecutive records' timestamps, then +hands each command to the next free worker in a pool. Workers run the matching +[`driver::fuzz_api`] callout entry point immediately, so records that share a +timestamp (delta 0) are injected in parallel -- genuine OS threads hammering +the one global device, which is what surfaces concurrency bugs. +Verdicts are out of scope here: the real Go agent still answers them over the +pipe (see [`crate::main`]); this module only *injects* the scenario. +Each record is a flat map/object so it is trivial to author from any language +-- Python CBOR: `cbor2.dump({"ts": 0, "op": "ale_connect", ...}, f)` per record; +or just hand-write one JSON object per line. Only the fields relevant to `op` +need be present; the rest default. `ts` and `op` are always required. Byte +fields (`local`/`remote`/`payload`/`bytes`/`resolver`) are CBOR byte strings, +and arrays of byte values in JSON. +## High-level `dns` op +Beyond the one-callout-per-record ops above, `dns` is a *live* macro op: one +record names a `domain` and, at replay time, performs a REAL DNS lookup +against the machine's default resolver (the OS-configured server, auto-detected; +override with `resolver`), then expands into the callout sequence that lookup +is made of -- ALE connect (UDP, carrying the real query so a domain-aware policy +sees it on the pended event), the real query datagram outbound, the real +response datagram inbound, then endpoint close + resource release. Set +`tcp_connect` to append a TCP session (`tcp_port`, default 443) to the address +the response actually resolved to. `v6` asks for AAAA and connects over IPv6; +the query itself reaches the resolver over whatever family that server uses. +The sub-steps run through [`execute`] like hand-authored records, so they +validate and tally identically. Unlike every other op this one touches the +network: it is non-deterministic, needs connectivity, and a failed lookup is +recorded as a failure rather than faked. Because the lookup blocks on the +network (tens of ms) on its worker, space `dns` records by more than that in +`ts` (milliseconds) if you want them paced distinctly -- tighter deltas just +overlap on the worker pool. See [`run_dns`]. + +### Constants & statics + +```rust +const TCP: u8 = 6; +``` + +```rust +const UDP: u8 = 17; +``` + +The running total across every loop pass, published for [`ctrl_handler`]. + +```rust +static LOOP_TOTAL: OnceLock> = OnceLock::new(); +``` + +Passes started so far, so the Ctrl+C summary can report the count. + +```rust +static LOOP_ITERS: AtomicU64 = AtomicU64::new(0); +``` + +### Types + +#### `Record` + +One scenario command ("line"). See the module docs for the field vocabulary. + +```rust +#[derive(Debug, Default, PartialEq, Serialize, Deserialize)] +struct Record { + /// Miliseconds since scenario start (absolute point on the timeline). + ts: u64, + /// Command selector: `ale_connect` | `ale_accept` | `packet` | + /// `packet_bytes` | `endpoint_close` | `resource_release` | `dns`. + op: String, + /// Address family: `false` => IPv4, `true` => IPv6. For `dns`, selects the + /// record type asked for (AAAA vs A) and the family of the `tcp_connect`. + v6: bool, + /// IP protocol number (6 TCP, 17 UDP, 1 ICMP, ...). + proto: u8, + /// Local address, as a byte string (4 bytes for v4, 16 for v6). + local: Vec, + lport: u16, + /// Remote address, as a byte string (4 bytes for v4, 16 for v6). + remote: Vec, + rport: u16, + /// Process id carried into `ale_*` / `endpoint_close` callouts. + pid: u64, + /// `ale_*`: reauthorization flag. + reauth: bool, + /// `packet` / `packet_bytes`: direction, 0 = Outbound, 1 = Inbound. + dir: u8, + /// `ale_*` / `packet`: upper-layer payload a structured packet is built around. + payload: Vec, + /// `packet_bytes`: full raw packet bytes, starting at the IP header. + bytes: Vec, + /// `packet_bytes`: mark the NBL as network-injected by the driver itself. + injected_by_self: bool, + /// `resource_release`: the local port to release. + port: u16, + /// `dns`: the domain to look up for real. A non-empty `domain` is all a `dns` + /// record needs; every other field below has a default. It is resolved live + /// against the default resolver, and the address that comes back is what + /// `tcp_connect` then dials -- see [`run_dns`]. + domain: String, + /// `dns`: the DNS server to query, as a byte string (4 bytes for v4, 16 for + /// v6). Defaults to the machine's OS-configured default resolver. + resolver: Vec, + /// `dns`: after the lookup, also open a TCP session to the address the + /// response resolved to. + tcp_connect: bool, + /// `dns`: destination port for `tcp_connect` (defaults to 443). + tcp_port: u16, +} +``` + +#### `Tuple` + +An explicit 5-tuple resolved from a record, ready for the `*_tuple` runners. + +```rust +enum Tuple { + V4(TupleSpecV4), + V6(TupleSpecV6), +} +``` + +#### `ScenarioSummary` + +Counters gathered across the run, plus any failures (validation errors, parse +errors, worker panics). Shared behind an `Arc` across the worker pool. + +```rust +#[derive(Default)] +pub struct ScenarioSummary { + dispatched: AtomicU64, + executed: AtomicU64, + pended: AtomicU64, + permitted: AtomicU64, + blocked: AtomicU64, + /// High-level `dns` records expanded, and TCP sessions appended to them. + dns_lookups: AtomicU64, + tcp_sessions: AtomicU64, + by_op: Mutex>, + failures: Mutex>, +} +``` + +```rust +impl ScenarioSummary { + fn tally(&self, op: &str, result: Option); + fn fail(&self, msg: String); + /// True if the scenario replayed with no validation errors or worker panics. + pub fn ok(&self) -> bool; + pub fn print(&self); +} +``` + +#### `RecordStream` + +A lazy stream of records; each `next()` parses exactly one record from the +underlying reader, so the file is never fully buffered. + +```rust +type RecordStream = Box>>; +``` + +### Functions + +`skip_serializing_if` predicate: omit a field that still holds its zero value, +so emitted sample files only carry the fields that matter for each `op`. + +```rust +fn is_default(v: &T) -> bool; +``` + +Build the 5-tuple from a record, validating the address widths for the family. + +```rust +fn tuple(rec: &Record) -> Result; +``` + +```rust +fn direction(dir: u8) -> Direction; +``` + +Delta between the previous timestamp and this one, in microseconds. The first +record (no previous) is 0; a non-monotonic timestamp clamps to 0 via +`saturating_sub`, so a mis-ordered file never panics or sleeps backwards. + +```rust +fn delta_milis(prev: Option, ts: u64) -> u64; +``` + +Run one command against the driver via the real callout entry points. + +```rust +fn execute(rec: &Record, sum: &ScenarioSummary); +``` + +Resolve a `dns` record's domain live, build the callout sequence from the real +query/response bytes, run each sub-step through [`execute`], then tally the +lookup (and any TCP session). A bad record, an unreachable resolver, or a +domain with no address of the requested family is recorded as a failure -- +this op depends on the network, so those are real, reportable outcomes. + +```rust +fn run_dns(rec: &Record, sum: &ScenarioSummary); +``` + +Build the low-level records a completed lookup expands into, around the real +wire bytes in `res` -- ALE connect (carrying the actual query so the agent sees +the domain on the pended event), the query datagram outbound, the response +datagram inbound, then endpoint close + resource release -- plus, when +`tcp_connect` is set, a follow-on TCP session to the address the response +resolved to. Each sub-flow's family follows the address it actually uses (the +resolver for the lookup, the resolved host for the connect), so a v6 record can +legitimately query a v4 resolver and then connect over IPv6. The local port is +a per-domain ephemeral so distinct domains stay distinct connection keys. +Pure and device-free (it only shapes records from `res`), so it is unit tested +directly; [`run_dns`] is the part that does the live lookup and injection. + +```rust +fn build_dns_records( + rec: &Record, + res: &crate::dns::Resolution, +) -> Result, String>; +``` + +One low-level tuple-bearing record for the `dns` expansion; caller then fills +in `dir`/`payload`/`pid` as the sub-step needs. + +```rust +fn sub_record( + op: &str, + v6: bool, + proto: u8, + local: &[u8], + lport: u16, + remote: &[u8], + rport: u16, +) -> Record; +``` + +A `resource_release` record (keys on protocol + local port, no tuple). + +```rust +fn release_record(v6: bool, proto: u8, port: u16) -> Record; +``` + +The local client address for a sub-flow of the given family: the record's +`local` override when it is set to a matching-width address, else a stable +default. A `dns` record's ephemeral local *port* is what keeps distinct +domains on distinct connection keys, so a shared local address is fine. + +```rust +fn local_for(provided: &[u8], v6: bool) -> Vec; +``` + +The default local client address for the family (documentation ranges). + +```rust +fn default_local(v6: bool) -> Vec; +``` + +An [`IpAddr`](std::net::IpAddr) as the 4- or 16-byte string a record carries. + +```rust +fn ip_to_bytes(ip: std::net::IpAddr) -> Vec; +``` + +Fold a domain (salted by the record timestamp, so repeats of the same name +stay distinct) into a well-mixed 64-bit value seeding the DNS transaction id, +the local port, and any synthesized address -- all deterministic, so a +scenario replays identically. + +```rust +fn mix_domain(domain: &str, ts: u64) -> u64; +``` + +An ephemeral local port (49152-65151) derived from the domain hash. + +```rust +fn derived_port(h: u64) -> u16; +``` + +Replay a CBOR scenario file. Never panics on I/O -- file and parse errors are +recorded as failures on the returned summary. Worker panics (a driver +invariant blowing up, which is exactly what we hunt) are caught at join and +recorded too. Worker count comes from `ZF_SIM_THREADS` (default 4). + +```rust +pub fn run(path: &str) -> Arc; +``` + +The heart of both [`run`] and [`run_loop`]: stream `path` and dispatch every +record into `summary` on a worker pool, so a caller can accumulate across +runs. `announce` prints the one-line "streaming ..." banner (the loop +suppresses it, keeping per-iteration output quiet). Never panics on I/O. + +```rust +fn run_into(path: &str, summary: &Arc, announce: bool); +``` + +Replay `path` on repeat until the process is stopped. Diverges: on Ctrl+C the +[`ctrl_handler`] prints the accumulated totals once and exits. Between passes +the driver's per-connection state is wiped (see [`api::reset_device_state`]), +so an iteration re-pends everything instead of inheriting the last pass's +verdicts -- each pass is the same scenario run afresh, not a continuation. + +```rust +pub fn run_loop(path: &str) -> !; +``` + +Ctrl+C: print the loop's running totals once, then exit. Reached from the +OS-specific hook installed by [`install_ctrl_handler`]. + +```rust +fn report_and_exit() -> !; +``` + +Windows: `SetConsoleCtrlHandler` (raw FFI, like the pipe server). The handler +runs on its own thread, so it can report and exit directly. + +```rust +#[cfg(windows)] +fn install_ctrl_handler(); +``` + +Unix: a `SIGINT` handler. Printing and exiting aren't async-signal-safe, so +the handler only raises a flag and a watcher thread does the reporting. + +```rust +#[cfg(unix)] +fn install_ctrl_handler(); +``` + +Open a scenario file as a [`RecordStream`], choosing the codec by extension: +* `.json` => **JSON Lines** (one record object per line / whitespace-separated + JSON values), decoded with serde_json's streaming `StreamDeserializer`. + NOTE: this is NDJSON, deliberately NOT a single JSON array (arrays can't be + streamed a value at a time). +* anything else (e.g. `.cbor`) => a CBOR sequence (RFC 8742) of record maps, + read one top-level item at a time until EOF. + +```rust +fn open_records(path: &str) -> Result; +``` + +Write the reference sample scenarios into `dir`, creating it if needed. They +double as living documentation of the wire format and as round-trip fixtures. + +```rust +pub fn emit_samples(dir: &str) -> io::Result<()>; +``` + +Write records as a CBOR sequence (compact; for real runs and Python authoring). + +```rust +fn write_cbor(path: &str, records: &[Record]) -> io::Result<()>; +``` + +Write records as JSON Lines (one compact record object per line; NDJSON) -- +streamable and diff/grep-friendly. Byte fields render as arrays of byte values. + +```rust +fn write_json(path: &str, records: &[Record]) -> io::Result<()>; +``` + +Base record with just the timeline point and the op set; the rest defaults. + +```rust +fn at(ts: u64, op: &str) -> Record; +``` + +One outbound TCP flow to 1.1.1.1:443: authorize, two data packets, close, release. + +```rust +fn basic_v4() -> Vec; +``` + +Three connections authorized at the same instant (delta 0 => injected in +parallel on different workers), staggered data, then simultaneous closes. + +```rust +fn mixed_v4v6_parallel() -> Vec; +``` + +A burst of 32 ICMP packets all stamped ts 0, so they are dispatched +back-to-back and pend concurrently across the pool -- a small concurrency probe. + +```rust +fn icmp_burst() -> Vec; +``` + +High-level `dns` records: resolve a real domain, optionally opening a TCP +session to the resolved address. Each performs a live lookup at replay time +and expands into a full ALE/packet/close/release callout sequence (see +[`run_dns`]) -- the "set a domain and go" authoring path. Being live, these +need connectivity; the domains below are chosen to actually resolve. + +```rust +fn dns_flows() -> Vec; +``` + +--- + +## `sim/src/scenarios.rs` + +Traffic scenarios: what the fake OS "does" on the network. +A [`FlowPlan`] is one connection's whole life, mirroring how the OS drives +WFP: resource assignment -> ALE authorization (pends, the agent decides) -> +data packets through the IP packet layer -> endpoint closure -> resource +release. +The default run generates seeded random flows from weighted templates +([`random_flow`]) and never stops. The hand-authored deterministic scenarios +(DNS, TLS, blocked remote, loopback, v6, ICMP, reauth, port reuse) each live +as their own function in the [`deterministic`] module, currently unwired -- +kept for when we decide how to schedule them alongside the random loop. +Payload realism: first packets carry real-looking application bytes (DNS +query/response, TLS ClientHello, HTTP GET). Payloads that reach the agent +(the pended first packet) are kept small (<= 256 bytes) because the agent +reads events through a 1 KiB pipe buffer. + +### Constants & statics + +```rust +const TCP: u8 = 6; +``` + +```rust +const UDP: u8 = 17; +``` + +### Types + +#### `Expect` + +What the agent's policy (kext_tester `handleInfo`) is expected to decide. + +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Expect { + /// Verdict lands asynchronously as Accept/PermanentAccept. + Accepted, + /// Outbound to 1.1.1.1: the agent replies PermanentBlock. + Blocked, + /// Inbound loopback: ALE itself sets Accept and permits, no pend. + ImmediateAccept, + /// Not connection-tracked (ICMP): every packet pends on its own, no + /// connection verdict ever lands in the cache. + NoVerdict, +} +``` + +#### `Tuple` + +```rust +#[derive(Clone, Copy, Debug)] +pub enum Tuple { + V4(TupleSpecV4), + V6(TupleSpecV6), +} +``` + +```rust +impl Tuple { + pub fn protocol(&self) -> u8; + pub fn local_port(&self) -> u16; + /// "PROTO local=addr:port remote=addr:port" for diagnostics (v6 bracketed). + pub fn endpoints(&self) -> String; +} +``` + +#### `Burst` + +One data packet after authorization. + +```rust +#[derive(Clone, Debug)] +pub struct Burst { + pub direction: Direction, + pub payload: Vec, +} +``` + +#### `FlowPlan` + +One connection's planned life. + +```rust +#[derive(Clone, Debug)] +pub struct FlowPlan { + /// Scenario label for the summary (template name for random flows). + pub name: &'static str, + pub tuple: Tuple, + /// Direction of the authorization: Outbound = ALE connect, Inbound = ALE accept. + pub direction: Direction, + pub process_id: u64, + /// Payload carried by the ALE authorization packet (reaches the agent). + pub first_payload: Vec, + /// Data packets exchanged once the flow is authorized. + pub bursts: Vec, + pub expect: Expect, + /// Fire a reauthorization ALE classify after this many bursts. + pub reauth_after: Option, + /// Report the local port released to the resource-release callout at the end. + pub release_port: bool, + /// After closing, open a follow-up flow reusing the local port against a + /// different remote (same-key reuse would hit the ended-connection cache + /// entry; see the connection-cache stale-lookup semantic). + pub follow_up_reuse: bool, +} +``` + +```rust +impl FlowPlan { + /// One-line identity for failure messages: scenario, 5-tuple, direction, pid. + pub fn describe(&self) -> String; +} +``` + +### Functions + +A short human label for an IP protocol number, for diagnostics. + +```rust +pub fn proto_label(protocol: u8) -> String; +``` + +```rust +fn out(payload: Vec) -> Burst; +``` + +```rust +fn inb(payload: Vec) -> Burst; +``` + +```rust +fn v4(protocol: u8, local: [u8; 4], lport: u16, remote: [u8; 4], rport: u16) -> Tuple; +``` + +```rust +fn v6(protocol: u8, local16: u8, lport: u16, remote16: u8, rport: u16) -> Tuple; +``` + +Encode a domain as a DNS QNAME: each dot-separated label prefixed by its +length byte, terminated by a zero (root) byte. Labels are clamped to the +63-byte label limit and empty labels (e.g. a trailing dot) are dropped. + +```rust +fn encode_qname(domain: &str) -> Vec; +``` + +A real-shaped DNS query for `domain`: a standard recursion-desired query with +one question, type A (`v6` false) or AAAA (`v6` true) in class IN. + +```rust +pub fn dns_query_for(id: u16, domain: &str, v6: bool) -> Vec; +``` + +A matching DNS response for `domain`: the question echoed back plus one +answer carrying `ip` -- a 4-byte A record or a 16-byte AAAA record (the +record type follows `ip`'s width). + +```rust +pub fn dns_response_for(id: u16, domain: &str, ip: &[u8]) -> Vec; +``` + +A real-shaped DNS query: header + QD for `example.com` A IN. + +```rust +pub fn dns_query(id: u16) -> Vec; +``` + +A matching DNS response: query echo + one A answer (93.184.216.34). + +```rust +pub fn dns_response(id: u16) -> Vec; +``` + +Read the first answer of the requested family out of a DNS response: the A +record (`want_v6 == false`, 4 bytes) or AAAA (`want_v6 == true`, 16 bytes). +Walks the real message -- header counts, the question section, then each +answer -- following/skipping name compression, so it copes with CNAME chains +and multi-record replies from a live resolver, not just our own fabricated +ones. Returns `None` if no answer of that family is present or the buffer is +malformed. This is how a resolved address flows from the response into a +follow-on connection, the way a client reads the answer and dials it. + +```rust +pub fn dns_answer_ip(response: &[u8], want_v6: bool) -> Option>; +``` + +Advance past a DNS name starting at `i`, returning the index just after it. +Handles the three label forms: the zero root label ends the name, a `0xC0` +compression pointer is two bytes and also ends it, and a normal label is a +length byte plus that many octets. `None` on a truncated or reserved name. + +```rust +fn skip_name(msg: &[u8], mut i: usize) -> Option; +``` + +The first bytes of a TLS 1.2/1.3 ClientHello record, truncated to fit the +agent's pipe read buffer when it rides a pended packet. + +```rust +pub fn tls_client_hello() -> Vec; +``` + +```rust +pub fn http_get() -> Vec; +``` + +```rust +pub fn http_response() -> Vec; +``` + +The follow-up flow for `follow_up_reuse`: same local port, different remote +(a fresh key, like a new socket bound to the just-released port). + +```rust +pub fn reuse_follow_up(plan: &FlowPlan) -> Option; +``` + +A seeded random flow for `worker`. Cross-worker keys stay disjoint via the +per-worker local-port window (`20000 + worker*2048 + seq`, `seq < 2048`). +Within a worker that window is reused every 2048 flows, so a flow's *remote* +address must carry enough entropy that the reused (port, local) pair still +yields a fresh key -- otherwise the driver treats the repeat as an existing +connection and permits it without pending. The v4 remote is 3 random octets; +the v6 remote is a random 64-bit interface id for the same reason. Templates +are weighted toward the common real-world mix; the costly/special paths +(blocked, loopback, ICMP) stay in the deterministic set. + +```rust +pub fn random_flow(rng: &mut SplitMix64, worker: usize, seq: u16) -> FlowPlan; +``` + +--- + +## `sim/src/scenarios.rs` — `mod deterministic` + +### Constants & statics + +```rust +const ICMP: u8 = 1; +``` + +### Functions + +```rust +fn icmp_echo(seq: u16) -> Vec; +``` + +DNS query over UDP to a public resolver; the verdict lands asynchronously. + +```rust +pub fn dns_udp() -> FlowPlan; +``` + +Outbound HTTPS: TLS handshake, then application data both ways. + +```rust +pub fn https_tcp() -> FlowPlan; +``` + +Plain HTTP GET / 200 OK. + +```rust +pub fn http_get_flow() -> FlowPlan; +``` + +Outbound to 1.1.1.1: the agent replies PermanentBlock; the packet layer +must hard-block. + +```rust +pub fn blocked_cloudflare() -> FlowPlan; +``` + +Loopback client half. + +```rust +pub fn loopback_out() -> FlowPlan; +``` + +Loopback server half: ALE accepts inbound loopback immediately. + +```rust +pub fn loopback_in() -> FlowPlan; +``` + +IPv6 HTTPS. + +```rust +pub fn https_v6() -> FlowPlan; +``` + +IPv6 DNS. + +```rust +pub fn dns_v6() -> FlowPlan; +``` + +ICMP echo: not connection-tracked, so every packet pends on its own. + +```rust +pub fn icmp_echo_flow() -> FlowPlan; +``` + +Mid-flow reauthorization: an accepted connection is re-classified by ALE. + +```rust +pub fn reauth_mid_flow() -> FlowPlan; +``` + +Port reuse: release the local port, then reopen it against a new remote +(see [`super::reuse_follow_up`]). + +```rust +pub fn port_reuse() -> FlowPlan; +``` + +Inbound TCP to a local listener (non-loopback). + +```rust +pub fn inbound_tcp() -> FlowPlan; +``` + +--- + +## `release/src/main.rs` + +### Constants & statics + +```rust +static VERSION: [u8; 4] = include!("../../kext_interface/version.txt"); +``` + +```rust +static LIB_PATH: &'static str = "./build/x86_64-pc-windows-msvc/release/driver.lib"; +``` + +```rust +static CONFIG: OnceLock = OnceLock::new(); +``` + +### Types + +#### `Config` + +```rust +#[derive(Debug, Deserialize)] +struct Config { + sys_basename: String, + driver_name: String, + company_name: String, +} +``` + +### Functions + +```rust +fn main(); +``` + +```rust +fn version_str() -> String; +``` + +```rust +fn load_config() -> &'static Config; +``` + +```rust +fn build_driver(); +``` + +```rust +fn get_inf_content() -> String; +``` + +```rust +fn get_ddf_content() -> String; +``` + +```rust +fn get_build_cab_script_content() -> String; +``` + +```rust +fn create_release_zip(zip_path: &Path); +``` + +```rust +fn create_release_dir(dir_path: &Path, with_bat: bool); +``` + +```rust +fn write_to_zip(zip: &mut ZipWriter, filename: &str, content: String); +``` + +```rust +fn write_lib_file_zip(zip: &mut ZipWriter); +``` + +--- + diff --git a/docs/LLMs/RUSTDOC-wdk.md b/docs/LLMs/RUSTDOC-wdk.md new file mode 100644 index 0000000..096b8e0 --- /dev/null +++ b/docs/LLMs/RUSTDOC-wdk.md @@ -0,0 +1,4303 @@ +# ZenithFence — Rust Item Reference: WDK Bindings + +> Auto-generated by `cargo run --manifest-path tools/rustdoc-gen/Cargo.toml -- .` (`just gen-llm-docs`) — do not edit by hand. +> One section per source file: module docs, constants, macros, full type definitions, impl blocks, functions and extern blocks. +> Includes private items. Function bodies, long initializers and test code are stripped. + +--- + +## `wdk/src/allocator.rs` + +### Constants & statics + +```rust +pub(crate) const POOL_TAG: u32 = u32::from_ne_bytes(*b"znfc"); +``` + +### Types + +#### `PoolType` + +```rust +#[repr(u64)] +enum PoolType { + RequiredStartUseQuota = 0x0000000000000001, + Uninitialized = 0x0000000000000002, + Session = 0x0000000000000004, + CacheAligned = 0x0000000000000008, + RaiseOnFailure = 0x0000000000000020, + NonPaged = 0x0000000000000040, + NonPagedExecute = 0x0000000000000080, + Paged = 0x0000000000000100, + RequiredEnd = 0x0000000080000000, + OptionalStart = 0x0000000100000000, + OptionalEnd = 0x8000000000000000, +} +``` + +#### `WindowsAllocator` + +```rust +pub struct WindowsAllocator {} +``` + +### Trait implementations + +```rust +unsafe impl Sync for WindowsAllocator + +unsafe impl GlobalAlloc for WindowsAllocator +``` + +--- + +## `wdk/src/consts.rs` + +### Constants & statics + +```rust +pub const FWP_ACTION_FLAG_TERMINATING: u32 = 0x00001000; +``` + +```rust +pub const FWP_ACTION_FLAG_NON_TERMINATING: u32 = 0x00002000; +``` + +```rust +pub const FWP_ACTION_FLAG_CALLOUT: u32 = 0x00004000; +``` + +```rust +pub const FWP_ACTION_BLOCK: u32 = 0x00000001 | FWP_ACTION_FLAG_TERMINATING; +``` + +```rust +pub const FWP_ACTION_PERMIT: u32 = 0x00000002 | FWP_ACTION_FLAG_TERMINATING; +``` + +```rust +pub const FWP_ACTION_CALLOUT_TERMINATING: u32 = ...; +``` + +```rust +pub const FWP_ACTION_CALLOUT_INSPECTION: u32 = ...; +``` + +```rust +pub const FWP_ACTION_CALLOUT_UNKNOWN: u32 = 0x00000005 | FWP_ACTION_FLAG_CALLOUT; +``` + +```rust +pub const FWP_ACTION_CONTINUE: u32 = 0x00000006 | FWP_ACTION_FLAG_NON_TERMINATING; +``` + +```rust +pub const FWP_ACTION_NONE: u32 = 0x00000007; +``` + +```rust +pub const FWP_ACTION_NONE_NO_MATCH: u32 = 0x00000008; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_LOOPBACK: u32 = 0x00000001; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_IPSEC_SECURED: u32 = 0x00000002; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_REAUTHORIZE: u32 = 0x00000004; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_WILDCARD_BIND: u32 = 0x00000008; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_RAW_ENDPOINT: u32 = 0x00000010; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_FRAGMENT: u32 = 0x00000020; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_FRAGMENT_GROUP: u32 = 0x00000040; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_IPSEC_NATT_RECLASSIFY: u32 = 0x00000080; +``` + +```rust +pub const FWP_CONDITION_FLAG_REQUIRES_ALE_CLASSIFY: u32 = 0x00000100; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_IMPLICIT_BIND: u32 = 0x00000200; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_REASSEMBLED: u32 = 0x00000400; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_NAME_APP_SPECIFIED: u32 = 0x00004000; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_PROMISCUOUS: u32 = 0x00008000; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_AUTH_FW: u32 = 0x00010000; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_RECLASSIFY: u32 = 0x00020000; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_OUTBOUND_PASS_THRU: u32 = 0x00040000; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_INBOUND_PASS_THRU: u32 = 0x00080000; +``` + +```rust +pub const FWP_CONDITION_FLAG_IS_CONNECTION_REDIRECTED: u32 = 0x00100000; +``` + +Returned by `FwpmTransactionBegin0` when a transaction is already in progress on the same +filter engine session. Nothing was changed, so the call can simply be repeated later. + +```rust +pub const STATUS_FWP_TXN_IN_PROGRESS: i32 = 0xC022000Eu32 as i32; +``` + +```rust +pub const KERNEL_MODE: i8 = 0; +``` + +```rust +pub const PASSIVE_LEVEL: u8 = 0; +``` + +```rust +pub const APC_LEVEL: u8 = 1; +``` + +```rust +pub const METHOD_BUFFERED: u32 = 0; +``` + +```rust +pub const METHOD_IN_DIRECT: u32 = 1; +``` + +```rust +pub const METHOD_OUT_DIRECT: u32 = 2; +``` + +```rust +pub const METHOD_NEITHER: u32 = 3; +``` + +```rust +pub const SIOCTL_TYPE: u32 = 40000; +``` + +```rust +pub const FILE_READ_DATA: u32 = 0x00000001; +``` + +```rust +pub const FILE_READ_ATTRIBUTES: u32 = 0x00000080; +``` + +```rust +pub const FILE_READ_EA: u32 = 0x00000008; +``` + +```rust +pub const FILE_WRITE_DATA: u32 = 0x00000002; +``` + +```rust +pub const FILE_WRITE_ATTRIBUTES: u32 = 0x00000100; +``` + +```rust +pub const FILE_WRITE_EA: u32 = 0x00000010; +``` + +```rust +pub const FILE_APPEND_DATA: u32 = 0x00000004; +``` + +```rust +pub const FILE_EXECUTE: u32 = 0x00000020; +``` + +--- + +## `wdk/src/debug.rs` + +### Macros + +```rust +macro_rules! log { /* ... */ } +``` + +```rust +macro_rules! log { /* ... */ } +``` + +```rust +macro_rules! err { /* ... */ } +``` + +```rust +macro_rules! dbg { /* ... */ } +``` + +```rust +macro_rules! info { /* ... */ } +``` + +--- + +## `wdk/src/driver.rs` + +### Types + +#### `Device` + +```rust +pub trait Device { + fn new(driver: &Driver) -> Self; + fn cleanup(&mut self); + fn read(&mut self, read_request: &mut ReadRequest); + fn write(&mut self, write_request: &mut WriteRequest); + fn shutdown(&mut self); +} +``` + +#### `Driver` + +```rust +pub struct Driver { + _device_handle: HANDLE, + driver_object: *mut DRIVER_OBJECT, + device_object: *mut DEVICE_OBJECT, +} +``` + +```rust +impl Driver { + pub(crate) fn new( + driver_object: *mut DRIVER_OBJECT, + _driver_handle: HANDLE, + device_handle: HANDLE, + ) -> Driver; + pub fn get_device_object(&self) -> *mut DEVICE_OBJECT; + pub fn get_device_object_ref(&self) -> Option<&mut DEVICE_OBJECT>; + pub fn set_driver_unload(&mut self, driver_unload: UnloadFnType); + pub fn set_read_fn(&mut self, mj_fn: MjFnType); + pub fn set_write_fn(&mut self, mj_fn: MjFnType); + pub fn set_create_fn(&mut self, mj_fn: MjFnType); + pub fn set_device_control_fn(&mut self, mj_fn: MjFnType); + pub fn set_close_fn(&mut self, mj_fn: MjFnType); + pub fn set_cleanup_fn(&mut self, mj_fn: MjFnType); + fn set_major_fn(&mut self, fn_index: u32, mj_fn: MjFnType); +} +``` + +#### `UnloadFnType` + +```rust +pub type UnloadFnType = unsafe extern "system" fn(driver_object: *const DRIVER_OBJECT); +``` + +#### `MjFnType` + +```rust +pub type MjFnType = unsafe extern "system" fn(&mut DEVICE_OBJECT, &mut IRP) -> NTSTATUS; +``` + +### Trait implementations + +```rust +unsafe impl Sync for Driver +``` + +--- + +## `wdk/src/fast_mutex.rs` + +### Types + +#### `FastMutex` + +```rust +pub struct FastMutex { + kmutex: UnsafeCell>, + val: UnsafeCell, +} +``` + +```rust +impl FastMutex { + pub const fn default(val: T) -> Self; + pub fn init(&self); + pub fn deinit(&self); + pub fn lock(&self) -> Result, ()>; + pub fn try_lock(&self) -> Option>; + fn get<'a>(&self) -> *mut T; + fn unlock(&self); +} +``` + +#### `LockGuard` + +```rust +pub struct LockGuard<'a, T> { + mutex: &'a FastMutex, +} +``` + +```rust +impl<'a, T> LockGuard<'a, T> { + fn new(mutex: &'a FastMutex) -> Self; +} +``` + +### Trait implementations + +```rust +impl Drop for FastMutex + +impl<'a, T> Drop for LockGuard<'a, T> + +impl<'a, T> Deref for LockGuard<'a, T> + +impl<'a, T> DerefMut for LockGuard<'a, T> +``` + +### Functions + +The ExInitializeFastMutex routine initializes a fast mutex variable, used to synchronize mutually exclusive access by a set of threads to a shared resource. +ExInitializeFastMutex must be called before any calls to other ExXxxFastMutex routines occur. + +```rust +unsafe fn ExInitializeFastMutex(kmutex: *mut FAST_MUTEX); +``` + +### Extern blocks + +```rust +#[link(name = "NtosKrnl", kind = "static")] +extern "C" { + fn KeInitializeEvent(event: *mut KEVENT, event_type: EVENT_TYPE, state: bool); + /// The ExAcquireFastMutex routine acquires the given fast mutex with APCs to the current thread disabled. + fn ExAcquireFastMutex(kmutex: *mut FAST_MUTEX); + /// The ExTryToAcquireFastMutex routine acquires the given fast mutex, if possible, with APCs to the current thread disabled. + fn ExTryToAcquireFastMutex(kmutex: *mut FAST_MUTEX) -> bool; + fn ExReleaseFastMutex(kmutex: *mut FAST_MUTEX); +} +``` + +--- + +## `wdk/src/ffi.rs` + +### Constants & statics + +```rust +pub(crate) const FWPS_INJECTION_TYPE_STREAM: u32 = 0x00000001; +``` + +```rust +pub(crate) const FWPS_INJECTION_TYPE_TRANSPORT: u32 = 0x00000002; +``` + +```rust +pub(crate) const FWPS_INJECTION_TYPE_NETWORK: u32 = 0x00000004; +``` + +```rust +pub(crate) const FWPS_INJECTION_TYPE_FORWARD: u32 = 0x00000008; +``` + +```rust +pub(crate) const FWPS_INJECTION_TYPE_L2: u32 = 0x00000010; +``` + +```rust +pub(crate) const FWPS_INJECTION_TYPE_VSWITCH_TRANSPORT: u32 = 0x00000020; +``` + +```rust +pub(crate) const NDIS_OBJECT_TYPE_DEFAULT: u8 = 0x80; +``` + +```rust +pub(crate) const NET_BUFFER_LIST_POOL_PARAMETERS_REVISION_1: u8 = 1; +``` + +### Types + +#### `FwpsCalloutClassifyFn` + +```rust +pub(crate) type FwpsCalloutClassifyFn = unsafe extern "C" fn( + inFixedValues: *const IncomingValues, + inMetaValues: *const FwpsIncomingMetadataValues, + layerData: *mut c_void, + classifyContext: *mut c_void, + filter: *const FWPS_FILTER2, + flowContext: u64, + classifyOut: *mut ClassifyOut, +); +``` + +#### `FwpsCalloutNotifyFn` + +```rust +pub(crate) type FwpsCalloutNotifyFn = unsafe extern "C" fn( + notifyType: u32, + filterKey: *const GUID, + filter: *mut FWPS_FILTER2, +) -> NTSTATUS; +``` + +#### `FwpsCalloutFlowDeleteNotifyFn` + +```rust +pub(crate) type FwpsCalloutFlowDeleteNotifyFn = unsafe extern "C" fn( + layerId: u16, + calloutId: u32, + flowContext: u64, +); +``` + +#### `FWPS_ACTION0` + +The FWPS_ACTION0 structure specifies the run-time action that the filter engine takes if all of the filter's filtering conditions are true. + +```rust +#[repr(C)] +pub(crate) struct FWPS_ACTION0 { + r#type: u32, + calloutId: u32, +} +``` + +#### `FWPS_FILTER_CONDITION0` + +The FWPS_FILTER_CONDITION0 structure defines a run-time filtering condition for a filter. + +```rust +#[repr(C)] +pub(crate) struct FWPS_FILTER_CONDITION0 { + fieldId: u16, + reserved: u16, + matchType: FWP_MATCH_TYPE, + conditionValue: FWP_CONDITION_VALUE0, +} +``` + +#### `WdfExecutionLevel` + +The WdfExecutionLevel enumeration type specifies the maximum IRQL at which the framework will call the event callback functions that a driver has supplied for a framework object. + +```rust +#[repr(C)] +enum WdfExecutionLevel { + Invalid = 0, + InheritFromParent, + Passive, + Dispatch, +} +``` + +#### `WdfSynchronizationScope` + +The WDF_SYNCHRONIZATION_SCOPE enumeration type specifies how the framework will synchronize execution of an object's event callback functions. + +```rust +#[repr(C)] +enum WdfSynchronizationScope { + Invalid = 0x00, + InheritFromParent, + Device, + Queue, + None, +} +``` + +#### `FWPS_FILTER2` + +The FWPS_FILTER2 structure defines a run-time filter in the filter engine. + +```rust +#[repr(C)] +pub(crate) struct FWPS_FILTER2 { + pub(crate) filterId: u64, + pub(crate) weight: FWP_VALUE0, + pub(crate) subLayerWeight: u16, + pub(crate) flags: u16, + pub(crate) numFilterConditions: u32, + pub(crate) filterCondition: *mut FWPS_FILTER_CONDITION0, + pub(crate) action: FWPS_ACTION0, + pub(crate) context: u64, + pub(crate) providerContext: *mut FWPM_PROVIDER_CONTEXT2, +} +``` + +#### `FWPS_CALLOUT3` + +The FWPS_CALLOUT3 structure defines the data that is required for a callout driver to register a callout with the filter engine. + +```rust +#[repr(C)] +pub(crate) struct FWPS_CALLOUT3 { + pub(crate) calloutKey: GUID, + pub(crate) flags: u32, + pub(crate) classifyFn: Option, + pub(crate) notifyFn: Option, + pub(crate) flowDeleteFn: Option, +} +``` + +#### `FWPS_INJECT_COMPLETE0` + +The filter engine calls a callout's completionFn callout function whenever packet data, described by the netBufferList parameter in one of the packet injection functions, has been injected into the network stack. + +```rust +type FWPS_INJECT_COMPLETE0 = unsafe extern "C" fn( + context: *mut c_void, + net_buffer_list: *mut NET_BUFFER_LIST, + dispatch_level: bool, +); +``` + +#### `FWPS_TRANSPORT_SEND_PARAMS1` + +The FWPS_TRANSPORT_SEND_PARAMS1 structure defines properties of an outbound transport layer packet. + +```rust +#[repr(C)] +pub(crate) struct FWPS_TRANSPORT_SEND_PARAMS1 { + pub(crate) remote_address: *const u8, + pub(crate) remote_scope_id: SCOPE_ID, + pub(crate) control_data: *mut c_void, + pub(crate) control_data_length: u32, + pub(crate) header_include_header: *mut u8, + pub(crate) header_include_header_length: u32, +} +``` + +#### `FWPS_PACKET_INJECTION_STATE` + +The FWPS_PACKET_INJECTION_STATE enumeration type specifies the injection state of a network buffer list. + +```rust +#[repr(C)] +pub(crate) enum FWPS_PACKET_INJECTION_STATE { + FWPS_PACKET_NOT_INJECTED, + FWPS_PACKET_INJECTED_BY_SELF, + FWPS_PACKET_INJECTED_BY_OTHER, + FWPS_PACKET_PREVIOUSLY_INJECTED_BY_SELF, + FWPS_PACKET_INJECTION_STATE_MAX, +} +``` + +#### `NBListHeader` + +The NBListHeader is the header of NET_BUFFER_LIST struct. + +```rust +#[repr(C)] +pub(crate) struct NBListHeader { + pub(crate) next: *mut NET_BUFFER_LIST, + pub(crate) first_net_buffer: *mut NET_BUFFER, +} +``` + +#### `NET_BUFFER_LIST` + +The NET_BUFFER_LIST structure specifies a linked list of NET_BUFFER structures. +This is internal struct should never be allocated from the driver. Use provided functions by microsoft. + +```rust +#[repr(C)] +pub struct NET_BUFFER_LIST { + pub(crate) Header: NBListHeader, + pub(crate) Context: *mut c_void, + pub(crate) ParentNetBufferList: *mut NET_BUFFER_LIST, + pub(crate) NdisPoolHandle: NDIS_HANDLE, + pub(crate) NdisReserved: [*mut c_void; 2], + pub(crate) ProtocolReserved: [*mut c_void; 4], + pub(crate) MiniportReserved: [*mut c_void; 2], + pub(crate) Scratch: *mut c_void, + pub(crate) SourceHandle: NDIS_HANDLE, + pub(crate) NblFlags: u32, + pub(crate) ChildRefCount: i32, + pub(crate) Flags: u32, + pub(crate) Status: NDIS_STATUS, + pub(crate) NetBufferListInfo: [*mut c_void; 20], +} +``` + +#### `NBSize` + +```rust +#[repr(C)] +pub union NBSize { + pub DataLength: u32, + pub stDataLength: u64, +} +``` + +#### `NET_BUFFER` + +This is internal struct should never be allocated from the driver. Use provided functions by microsoft. +The NET_BUFFER structure specifies data that is transmitted or received over the network. + +```rust +#[repr(C)] +pub struct NET_BUFFER { + pub(crate) Next: *mut NET_BUFFER, + pub(crate) CurrentMdl: *mut MDL, + pub(crate) CurrentMdlOffset: u32, + pub(crate) nbSize: NBSize, + pub(crate) MdlChain: *mut MDL, + pub(crate) DataOffset: u32, + pub(crate) ChecksumBias: u16, + pub(crate) Reserved: u16, + pub(crate) NdisPoolHandle: NDIS_HANDLE, + pub(crate) NdisReserved: [*mut c_void; 2], + pub(crate) ProtocolReserved: [*mut c_void; 6], + pub(crate) MiniportReserved: [*mut c_void; 4], + pub(crate) DataPhysicalAddress: u64, + pub(crate) SharedMemoryInfo: *mut c_void, +} +``` + +#### `NDIS_HANDLE` + +This data type is used as the generic handle type in NDIS function calls. + +```rust +pub type NDIS_HANDLE = *mut c_void; +``` + +#### `NDIS_STATUS` + +This data type is used to indicate success and error states in numerous functions and object identifiers. + +```rust +pub type NDIS_STATUS = i32; +``` + +#### `NDIS_OBJECT_HEADER` + +The NDIS_OBJECT_HEADER structure packages the object type, version, and size information that is required in many NDIS 6.0 structures. + +```rust +#[repr(C)] +pub(crate) struct NDIS_OBJECT_HEADER { + pub(crate) Type: u8, + pub(crate) Revision: u8, + pub(crate) Size: u16, +} +``` + +#### `NET_BUFFER_LIST_POOL_PARAMETERS` + +The NET_BUFFER_LIST_POOL_PARAMETERS structure defines the parameters for a pool of NET_BUFFER_LIST structures. + +```rust +#[repr(C)] +pub(crate) struct NET_BUFFER_LIST_POOL_PARAMETERS { + pub(crate) Header: NDIS_OBJECT_HEADER, + pub(crate) ProtocolId: u8, + pub(crate) fAllocateNetBuffer: bool, + pub(crate) ContextSize: u16, + pub(crate) PoolTag: u32, + pub(crate) DataSize: u32, + pub(crate) Flags: u32, +} +``` + +#### `WdfObjectContextTypeInfo` + +WdfObjectContextTypeInfo is a description of the device context. + +```rust +#[repr(C)] +pub struct WdfObjectContextTypeInfo { + size: u32, + context_name: *const u8, + context_size: usize, + unique_type: *const WdfObjectContextTypeInfo, + _evt_driver_get_unique_context_type: *const c_void, +} +``` + +```rust +impl WdfObjectContextTypeInfo { + pub const fn default(null_terminated_name: &'static str) -> Self; +} +``` + +#### `WdfObjectAttributes` + +WdfObjectAttributes contains attributes for the device context. + +```rust +#[repr(C)] +pub struct WdfObjectAttributes { + size: u32, + evt_cleanup_callback: Option, + evt_destroy_callback: Option, + execution_level: WdfExecutionLevel, + synchronization_scope: WdfSynchronizationScope, + parent_object: HANDLE, + context_size_override: usize, + context_type_info: *const WdfObjectContextTypeInfo, +} +``` + +```rust +impl WdfObjectAttributes { + pub fn new() -> Self; + pub fn add_context( + &mut self, + context_info: &'static mut WdfObjectContextTypeInfo, + ); + pub fn set_cleanup_fn(&mut self, callback: extern "system" fn(wdf_object: HANDLE)); + pub fn set_destroy_fn(&mut self, callback: extern "system" fn(wdf_object: HANDLE)); +} +``` + +### Trait implementations + +```rust +unsafe impl Sync for WdfObjectContextTypeInfo +``` + +### Extern blocks + +```rust +#[link(name = "Fwpkclnt", kind = "static")] +#[link(name = "Fwpuclnt", kind = "static")] +#[link(name = "WdfDriverEntry", kind = "static")] +#[link(name = "WdfLdr", kind = "static")] +#[link(name = "BufferOverflowK", kind = "static")] +#[link(name = "uuid", kind = "static")] +#[link(name = "wdmsec", kind = "static")] +#[link(name = "wmilib", kind = "static")] +#[link(name = "NtosKrnl", kind = "static")] +#[link(name = "ndis", kind = "static")] +#[link(name = "hal", kind = "static")] +#[link(name = "c_helper", kind = "static")] +extern "C" { + /// The FwpsCalloutUnregisterById0 function unregisters a callout from the filter engine. + pub(crate) fn FwpsCalloutUnregisterById0(id: u32) -> NTSTATUS; + /// The FwpsCalloutRegister3 function registers a callout with the filter engine. + pub(crate) fn FwpsCalloutRegister3( + deviceObject: *mut c_void, + callout: *const FWPS_CALLOUT3, + calloutId: *mut u32, + ) -> NTSTATUS; + /// The FwpsPendOperation0 function is called by a callout to suspend packet processing pending completion of another operation. + pub(crate) fn FwpsPendOperation0( + completionHandle: HANDLE, + completionContext: *mut HANDLE, + ) -> NTSTATUS; + /// The FwpsCompleteOperation0 function is called by a callout to resume packet processing that was suspended pending completion of another operation. + pub(crate) fn FwpsCompleteOperation0( + completionContext: HANDLE, + netBufferList: *mut NET_BUFFER_LIST, + ); + /// The FwpsAcquireClassifyHandle0 function generates a classification handle that is used to identify asynchronous classification operations and requests for writable layer data. + pub(crate) fn FwpsAcquireClassifyHandle0( + classify_context: *mut c_void, + reserved: u32, + classify_handle: *mut u64, + ) -> NTSTATUS; + /// A callout driver calls FwpsReleaseClassifyHandle0 to release a classification handle that was previously acquired through a call to FwpsAcquireClassifyHandle0. + pub(crate) fn FwpsReleaseClassifyHandle0(classify_handle: u64); + /// A callout's classifyFn function calls FwpsPendClassify0 to pend the current classify request. After the request is pended, the callout driver must complete the processing of the classify request asynchronously by calling FwpsCompleteClassify0. + pub(crate) fn FwpsPendClassify0( + classify_handle: u64, + filterId: u64, + flags: u32, + classifyOut: *const ClassifyOut, + ) -> NTSTATUS; + /// A callout driver calls FwpsCompleteClassify0 to asynchronously complete a pended classify request. The callout driver's classifyFn function must have previously called FwpsPendClassify0 to pend the classify request. + pub(crate) fn FwpsCompleteClassify0( + classify_handle: u64, + flags: u32, + classifyOut: *const ClassifyOut, + ); + /// The FwpsAcquireWritableLayerDataPointer0 function returns layer-specific data that can be inspected and changed. + pub(crate) fn FwpsAcquireWritableLayerDataPointer0( + classify_handle: u64, + filter_id: u64, + flags: u32, + writable_layer_data: *mut c_void, + classify_out: *mut ClassifyOut, + ) -> NTSTATUS; + /// The FwpsApplyModifiedLayerData0 function applies changes to layer-specific data made after a call to FwpsAcquireWritableLayerDataPointer0. + pub(crate) fn FwpsApplyModifiedLayerData0( + classifyHandle: u64, + modifiedLayerData: *mut *mut c_void, + flags: u32, + ); + /// pm_InitDriverObject initialize driver object. This function initializes requerd memory for the device context. + pub(crate) fn pm_InitDriverObject( + driver_object: *mut DRIVER_OBJECT, + registry_path: *mut UNICODE_STRING, + wdf_driver: *mut HANDLE, + wdf_device: *mut HANDLE, + win_driver_path: PCWSTR, + dos_driver_path: PCWSTR, + object_attributes: *mut WdfObjectAttributes, + wdf_driver_unload: extern "C" fn(HANDLE), + ) -> NTSTATUS; + /// pm_WdfObjectGetTypedContextWerker 1to1 reference to the WdfObjectGetTypedContextWorker macro. The WdfObjectGetTypedContext macro returns a pointer to an object's context space. + pub(crate) fn pm_WdfObjectGetTypedContextWorker( + wdf_object: HANDLE, + type_info: *const WdfObjectContextTypeInfo, + ) -> *mut c_void; + /// WdfObjectGetTypedContext 1to1 reference to WdfDeviceWdmGetDeviceObject. The WdfDeviceWdmGetDeviceObject method returns the Windows Driver Model (WDM) device object that is associated with a specified framework device object. + pub(crate) fn pm_GetDeviceObject(wdf_device: HANDLE) -> *mut DEVICE_OBJECT; + /// The FwpsInjectNetworkSendAsync0 function injects packet data into the send data path. + pub(crate) fn FwpsInjectNetworkSendAsync0( + injectionHandle: HANDLE, + injectionContext: HANDLE, + flags: u32, + compartmentId: COMPARTMENT_ID, + netBufferList: *mut NET_BUFFER_LIST, + completionFn: FWPS_INJECT_COMPLETE0, + completionContext: *mut c_void, + ) -> NTSTATUS; + /// The FwpsInjectNetworkReceiveAsync0 function injects packet data into the receive data path. + pub(crate) fn FwpsInjectNetworkReceiveAsync0( + injectionHandle: HANDLE, + injectionContext: HANDLE, + flags: u32, + compartmentId: COMPARTMENT_ID, + interfaceIndex: u32, + subInterfaceIndex: u32, + netBufferList: *mut NET_BUFFER_LIST, + completionFn: FWPS_INJECT_COMPLETE0, + completionContext: *mut c_void, + ) -> NTSTATUS; + /// The FwpsInjectTransportSendAsync1 function injects packet data from the transport, datagram data, or ICMP error layers into the send data path. This function differs from the previous version (FwpsInjectTransportSendAsync0) in that it takes an updated parameters structure as an argument. + pub(crate) fn FwpsInjectTransportSendAsync1( + injectionHandle: HANDLE, + injectionContext: HANDLE, + endpointHandle: u64, + flags: u32, + sendArgs: *mut FWPS_TRANSPORT_SEND_PARAMS1, + addressFamily: ADDRESS_FAMILY, + compartmentId: COMPARTMENT_ID, + netBufferList: *mut NET_BUFFER_LIST, + completionFn: FWPS_INJECT_COMPLETE0, + completionContext: *mut c_void, + ) -> NTSTATUS; + /// The FwpsInjectTransportReceiveAsync0 function injects packet data from the transport, datagram data, or ICMP error layers into the receive data path. + pub(crate) fn FwpsInjectTransportReceiveAsync0( + injectionHandle: HANDLE, + injectionContext: HANDLE, + reserved: *const c_void, + flags: u32, + addressFamily: ADDRESS_FAMILY, + compartmentId: COMPARTMENT_ID, + interfaceIndex: u32, + subInterfaceIndex: u32, + netBufferList: *mut NET_BUFFER_LIST, + completionFn: FWPS_INJECT_COMPLETE0, + completionContext: *mut c_void, + ) -> NTSTATUS; + /// The FwpsInjectionHandleCreate0 function creates a handle that can be used by packet injection functions to inject packet or stream data into the TCP/IP network stack and by the FwpsQueryPacketInjectionState0 function to query the packet injection state. + pub(crate) fn FwpsInjectionHandleCreate0( + addressFamily: ADDRESS_FAMILY, + flags: u32, + injectionHandle: &mut HANDLE, + ) -> NTSTATUS; + /// The FwpsQueryPacketInjectionState0 function is called by a callout to query the injection state of packet data. + pub(crate) fn FwpsQueryPacketInjectionState0( + injectionHandle: HANDLE, + netBufferList: *const NET_BUFFER_LIST, + injectionContext: *mut HANDLE, + ) -> FWPS_PACKET_INJECTION_STATE; + /// The FwpsInjectionHandleDestroy0 function destroys an injection handle that was previously created by calling the FwpsInjectionHandleCreate0 function. + pub(crate) fn FwpsInjectionHandleDestroy0(injectionHandle: HANDLE) -> NTSTATUS; + /// The FwpsReferenceNetBufferList0 function increments the reference count for a NET_BUFFER_LIST structure. + pub(crate) fn FwpsReferenceNetBufferList0( + netBufferList: *mut NET_BUFFER_LIST, + intendToModify: bool, + ); + /// The FwpsDereferenceNetBufferList0 function decrements the reference count for a NET_BUFFER_LIST structure that a callout driver had acquired earlier using the FwpsReferenceNetBufferList0 function. + pub(crate) fn FwpsDereferenceNetBufferList0( + netBufferList: *mut NET_BUFFER_LIST, + dispatchLevel: bool, + ); + /// Call the NdisGetDataBuffer function to gain access to a contiguous block of data from a NET_BUFFER structure. + pub(crate) fn NdisGetDataBuffer( + NetBuffer: *const NET_BUFFER, + BytesNeeded: u32, + Storage: *mut u8, + AlignMultiple: u32, + AlignOffset: u32, + ) -> *mut u8; + /// Call the NdisAllocateCloneNetBufferList function to create a new clone NET_BUFFER_LIST structure. + pub(crate) fn NdisAllocateCloneNetBufferList( + OriginalNetBufferList: *mut NET_BUFFER_LIST, + NetBufferListPoolHandle: NDIS_HANDLE, + NetBufferPoolHandle: NDIS_HANDLE, + AllocateCloneFlag: u32, + ) -> *mut NET_BUFFER_LIST; + /// Call the NdisFreeCloneNetBufferList function to free a NET_BUFFER_LIST structure and all associated NET_BUFFER structures and MDL chains that were previously allocated by calling the NdisAllocateCloneNetBufferList function. + pub(crate) fn NdisFreeCloneNetBufferList( + CloneNetBufferList: *mut NET_BUFFER_LIST, + FreeCloneFlags: u32, + ); + /// The FwpsAllocateNetBufferAndNetBufferList0 function allocates a new NET_BUFFER_LIST structure. + pub(crate) fn FwpsAllocateNetBufferAndNetBufferList0( + poolHandle: NDIS_HANDLE, + contextSize: u16, + contextBackFill: u16, + mdlChain: *mut MDL, + dataOffset: u32, + dataLength: u64, + netBufferList: *mut *mut NET_BUFFER_LIST, + ) -> NTSTATUS; + /// The FwpsFreeNetBufferList0 function frees a NET_BUFFER_LIST structure that was previously allocated by a call to the FwpsAllocateNetBufferAndNetBufferList0 function. + pub(crate) fn FwpsFreeNetBufferList0(netBufferList: *mut NET_BUFFER_LIST); + /// Call the NdisAllocateNetBufferListPool function to allocate a pool of NET_BUFFER_LIST structures. + pub(crate) fn NdisAllocateNetBufferListPool( + NdisHandle: NDIS_HANDLE, + Parameters: *const NET_BUFFER_LIST_POOL_PARAMETERS, + ) -> NDIS_HANDLE; + /// Call the NdisFreeNetBufferListPool function to free a NET_BUFFER_LIST structure pool. + pub(crate) fn NdisFreeNetBufferListPool(PoolHandle: NDIS_HANDLE); + /// Call the NdisRetreatNetBufferDataStart function to access more used data space in the MDL chain of a NET_BUFFER structure. + pub(crate) fn NdisRetreatNetBufferDataStart( + NetBuffer: *mut NET_BUFFER, + DataOffsetDelta: u32, + DataBackFill: u32, + AllocateMdlHandler: *mut c_void, + ) -> NDIS_STATUS; + /// Call the NdisAdvanceNetBufferDataStart function to release the used data space that was added with the NdisRetreatNetBufferDataStart function. + pub(crate) fn NdisAdvanceNetBufferDataStart( + NetBuffer: *mut NET_BUFFER, + DataOffsetDelta: u32, + FreeMdl: bool, + FreeMdlHandler: *mut c_void, + ); + /// The KeQuerySystemTime routine obtains the current system time. + /// System time is a count of 100-nanosecond intervals since January 1, 1601. System time is typically updated approximately every ten milliseconds. This value is computed for the GMT time zone. + pub(crate) fn pm_QuerySystemTime() -> u64; + /// The KeQueryPerformanceCounter routine retrieves the current value and frequency of the performance counter. + /// Use KeQueryPerformanceCounter to acquire high resolution (<1µs) time stamps for time interval measurements. + pub(crate) fn KeQueryPerformanceCounter(frequency: *mut i64) -> i64; + /// The KeGetCurrentIrql routine returns the current IRQL. + pub(crate) fn KeGetCurrentIrql() -> u8; + /// The KeDelayExecutionThread routine puts the current thread into an alertable or + /// nonalertable wait state for a given interval. + /// `interval` is a count of 100-nanosecond units: negative is relative to now, positive is an + /// absolute system time. Callable only at IRQL <= APC_LEVEL (PASSIVE_LEVEL if alertable). + pub(crate) fn KeDelayExecutionThread( + wait_mode: i8, + alertable: u8, + interval: *const i64, + ) -> NTSTATUS; +} +``` + +--- + +## `wdk/src/filter_engine/callout.rs` + +### Types + +#### `FilterType` + +```rust +pub enum FilterType { + Resettable, + NonResettable, +} +``` + +#### `Callout` + +```rust +pub struct Callout { + pub(crate) id: u32, + pub(super) address: u64, + pub(crate) name: String, + pub(crate) description: String, + pub(crate) guid: u128, + pub(crate) layer: Layer, + pub(crate) action: u32, + pub(crate) registered: bool, + pub(crate) filter_type: FilterType, + pub(crate) filter_id: u64, + pub(crate) callout_fn: fn(CalloutData), +} +``` + +```rust +impl Callout { + pub fn new( + name: &str, + description: &str, + guid: u128, + layer: Layer, + action: u32, + filter_type: FilterType, + callout_fn: fn(CalloutData), + ) -> Self; + pub fn register_filter( + &mut self, + filter_engine_handle: isize, + sublayer_guid: u128, + ) -> Result<(), String>; + pub(crate) fn register_callout( + &mut self, + filter_engine_handle: isize, + device_object: *mut DEVICE_OBJECT, + callout_fn: FwpsCalloutClassifyFn, + ) -> Result<(), String>; +} +``` + +--- + +## `wdk/src/filter_engine/callout_data.rs` + +### Types + +#### `ClassifyDefer` + +```rust +pub enum ClassifyDefer { + Initial(HANDLE, Option), + Reauthorization(usize, Option), +} +``` + +```rust +impl ClassifyDefer { + /// Completes the deferral where there is something to complete, and reports the resulting state + /// of the connection together with the packet that was captured with it. + pub fn resolve(self) -> DeferResolution; +} +``` + +#### `DeferResolution` + +The state of the deferred connection, and the packet that was captured with it. + +```rust +pub enum DeferResolution { + /// The pended operation has been completed. WFP has resumed the classify, so the connection is + /// no longer held. + Completed(Option), + /// There was no pended operation to complete, because the classify had no completion handle. + /// The connection is still held by the filter that blocked it, and stays held for as long as + /// that filter exists. + HeldByFilter(Option), +} +``` + +#### `CalloutData` + +```rust +pub struct CalloutData<'a> { + pub layer: Layer, + pub(crate) callout_id: usize, + pub(crate) values: &'a [Value], + pub(crate) metadata: *const FwpsIncomingMetadataValues, + pub(crate) classify_out: *mut ClassifyOut, + pub(crate) layer_data: *mut c_void, +} +``` + +```rust +impl<'a> CalloutData<'a> { + pub fn get_value_type(&self, index: usize) -> ValueType; + pub fn get_value_u8(&'a self, index: usize) -> u8; + pub fn get_value_u16(&'a self, index: usize) -> u16; + pub fn get_value_u32(&'a self, index: usize) -> u32; + pub fn get_value_byte_array16(&'a self, index: usize) -> &'a [u8; 16]; + pub fn get_process_id(&self) -> Option; + pub fn get_process_path(&self) -> Option; + pub fn get_transport_endpoint_handle(&self) -> Option; + /// Size of the IP header, including options (IPv4) or the extension header + /// chain (IPv6). `None` if the layer does not provide the field. + pub fn get_ip_header_size(&self) -> Option; + pub fn get_transport_header_size(&self) -> u32; + pub fn get_remote_scope_id(&self) -> Option; + pub fn get_control_data(&self) -> Option>; + pub fn get_control_data_copy(&self) -> Option>; + pub fn get_layer_data(&self) -> *mut c_void; + pub fn get_stream_callout_packet(&self) -> Option<&mut StreamCalloutIoPacket>; + pub fn pend_operation( + &mut self, + packet_list: Option, + ) -> Result; + pub fn pend_filter_rest( + &mut self, + packet_list: Option, + ) -> ClassifyDefer; + pub fn action_permit(&mut self); + pub fn action_continue(&mut self); + pub fn action_block(&mut self); + pub fn action_none(&mut self); + pub fn block_and_absorb(&mut self); + pub fn clear_write_flag(&mut self); + pub fn is_reauthorize(&self, flags_index: usize) -> bool; + pub fn parmit_and_absorb(&mut self); + pub fn get_callout_id(&self) -> usize; +} +``` + +--- + +## `wdk/src/filter_engine/classify.rs` + +### Constants & statics + +```rust +const FWP_ACTION_FLAG_TERMINATING: u32 = 0x00001000; +``` + +```rust +const FWP_ACTION_FLAG_NON_TERMINATING: u32 = 0x00002000; +``` + +```rust +const FWP_ACTION_FLAG_CALLOUT: u32 = 0x00004000; +``` + +```rust +const FWP_ACTION_BLOCK: u32 = 0x00000001 | FWP_ACTION_FLAG_TERMINATING; +``` + +```rust +const FWP_ACTION_PERMIT: u32 = 0x00000002 | FWP_ACTION_FLAG_TERMINATING; +``` + +```rust +const FWP_ACTION_CALLOUT_TERMINATING: u32 = ...; +``` + +```rust +const FWP_ACTION_CALLOUT_INSPECTION: u32 = ...; +``` + +```rust +const FWP_ACTION_CALLOUT_UNKNOWN: u32 = 0x00000005 | FWP_ACTION_FLAG_CALLOUT; +``` + +```rust +const FWP_ACTION_CONTINUE: u32 = 0x00000006 | FWP_ACTION_FLAG_NON_TERMINATING; +``` + +```rust +const FWP_ACTION_NONE: u32 = 0x00000007; +``` + +```rust +const FWP_ACTION_NONE_NO_MATCH: u32 = 0x00000008; +``` + +```rust +const FWP_CONDITION_FLAG_IS_LOOPBACK: u32 = 0x00000001; +``` + +```rust +const FWP_CONDITION_FLAG_IS_IPSEC_SECURED: u32 = 0x00000002; +``` + +```rust +const FWP_CONDITION_FLAG_IS_REAUTHORIZE: u32 = 0x00000004; +``` + +```rust +const FWP_CONDITION_FLAG_IS_WILDCARD_BIND: u32 = 0x00000008; +``` + +```rust +const FWP_CONDITION_FLAG_IS_RAW_ENDPOINT: u32 = 0x00000010; +``` + +```rust +const FWP_CONDITION_FLAG_IS_FRAGMENT: u32 = 0x00000020; +``` + +```rust +const FWP_CONDITION_FLAG_IS_FRAGMENT_GROUP: u32 = 0x00000040; +``` + +```rust +const FWP_CONDITION_FLAG_IS_IPSEC_NATT_RECLASSIFY: u32 = 0x00000080; +``` + +```rust +const FWP_CONDITION_FLAG_REQUIRES_ALE_CLASSIFY: u32 = 0x00000100; +``` + +```rust +const FWP_CONDITION_FLAG_IS_IMPLICIT_BIND: u32 = 0x00000200; +``` + +```rust +const FWP_CONDITION_FLAG_IS_REASSEMBLED: u32 = 0x00000400; +``` + +```rust +const FWP_CONDITION_FLAG_IS_NAME_APP_SPECIFIED: u32 = 0x00004000; +``` + +```rust +const FWP_CONDITION_FLAG_IS_PROMISCUOUS: u32 = 0x00008000; +``` + +```rust +const FWP_CONDITION_FLAG_IS_AUTH_FW: u32 = 0x00010000; +``` + +```rust +const FWP_CONDITION_FLAG_IS_RECLASSIFY: u32 = 0x00020000; +``` + +```rust +const FWP_CONDITION_FLAG_IS_OUTBOUND_PASS_THRU: u32 = 0x00040000; +``` + +```rust +const FWP_CONDITION_FLAG_IS_INBOUND_PASS_THRU: u32 = 0x00080000; +``` + +```rust +const FWP_CONDITION_FLAG_IS_CONNECTION_REDIRECTED: u32 = 0x00100000; +``` + +```rust +const FWPS_RIGHT_ACTION_WRITE: u32 = 0x00000001; +``` + +### Types + +#### `ClassifyOut` + +```rust +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ClassifyOut { + action_type: u32, + _out_context: u64, + _filter_id: u64, + rights: u32, + flags: u32, + reserved: u32, +} +``` + +```rust +impl ClassifyOut { + pub fn can_set_action(&self) -> bool; + /// Set block action. Write flag should be cleared, after this. + pub fn action_block(&mut self); + /// Set permit action. + pub fn action_permit(&mut self); + pub fn action_continue(&mut self); + pub fn set_none(&mut self); + pub fn set_absorb(&mut self); + pub fn clear_write_flag(&mut self); +} +``` + +--- + +## `wdk/src/filter_engine/connect_request.rs` + +### Types + +#### `FwpsConnectRequest0` + +```rust +#[repr(C)] +pub(crate) struct FwpsConnectRequest0 { + pub(crate) local_address_and_port: [u8; 128], + pub(crate) remote_address_and_port: [u8; 128], + pub(crate) port_reservation_token: u64, + pub(crate) local_redirect_target_pid: u32, + pub(crate) previous_version: *const FwpsConnectRequest0, + pub(crate) modifier_filter_id: u64, + pub(crate) local_redirect_handle: HANDLE, + pub(crate) local_redirect_context: *mut c_void, + pub(crate) local_redirect_context_size: usize, +} +``` + +```rust +impl FwpsConnectRequest0 { + pub(crate) fn set_remote(&mut self, ip: &[u8], port: u16); +} +``` + +#### `SocketAddressGeneric` + +```rust +#[repr(C)] +struct SocketAddressGeneric { + family: u16, + padding: [u8; 128 - 2], +} +``` + +#### `SocketAddressIPv4` + +```rust +#[repr(C)] +struct SocketAddressIPv4 { + family: u16, + port: u16, + addr: [u8; 4], + zero: [u8; 8], + padding: [u8; 128 - 2 - 2 - 4 - 8], +} +``` + +#### `SocketAddressIPv6` + +```rust +#[repr(C)] +struct SocketAddressIPv6 { + family: u16, + port: u16, + flowinfo: u16, + addr: [u8; 16], + scope_id: u32, + padding: [u8; 128 - 2 - 2 - 2 - 16 - 4], +} +``` + +--- + +## `wdk/src/filter_engine/ffi.rs` + +### Functions + +```rust +pub(crate) fn create_filter_engine() -> Result; +``` + +```rust +pub(crate) fn register_sublayer( + filter_engine_handle: HANDLE, + name: &str, + description: &str, + guid: u128, +) -> Result<(), String>; +``` + +```rust +pub(crate) fn unregister_sublayer( + filter_engine_handle: HANDLE, + guid: u128, +) -> Result<(), String>; +``` + +```rust +unsafe extern "C" fn generic_notify( + _notify_type: u32, + _filter_key: *const GUID, + _filter: *mut FWPS_FILTER2, +) -> NTSTATUS; +``` + +```rust +unsafe extern "C" fn generic_delete_notify( + _layer_id: u16, + _callout_id: u32, + _flow_context: u64, +); +``` + +```rust +pub(crate) fn register_callout( + device_object: *mut DEVICE_OBJECT, + filter_engine_handle: HANDLE, + name: &str, + description: &str, + guid: u128, + layer: Layer, + callout_fn: FwpsCalloutClassifyFn, +) -> Result; +``` + +```rust +fn callout_add( + filter_engine_handle: HANDLE, + guid: u128, + layer: Layer, + name: &str, + description: &str, +) -> Result<(), String>; +``` + +```rust +pub(crate) fn unregister_callout(callout_id: u32) -> Result<(), String>; +``` + +```rust +pub(crate) fn register_filter( + filter_engine_handle: HANDLE, + sublayer_guid: u128, + name: &str, + description: &str, + callout_guid: u128, + layer: Layer, + action: u32, + context: u64, +) -> Result; +``` + +```rust +pub(crate) fn unregister_filter( + filter_engine_handle: HANDLE, + filter_id: u64, +) -> Result<(), String>; +``` + +```rust +pub(crate) fn filter_engine_close(filter_engine_handle: HANDLE) -> Result<(), String>; +``` + +Returns the raw NTSTATUS on failure, unlike the other wrappers here: the caller has to tell +`STATUS_FWP_TXN_IN_PROGRESS` (another transaction is open on this session, try again later) +apart from the failures that are not worth repeating. + +```rust +pub(crate) fn filter_engine_transaction_begin( + filter_engine_handle: HANDLE, + flags: u32, +) -> Result<(), i32>; +``` + +```rust +pub(crate) fn filter_engine_transaction_commit( + filter_engine_handle: HANDLE, +) -> Result<(), String>; +``` + +```rust +pub(crate) fn filter_engine_transaction_abort( + filter_engine_handle: HANDLE, +) -> Result<(), String>; +``` + +--- + +## `wdk/src/filter_engine/layer.rs` + +### Types + +#### `Value` + +```rust +#[repr(C)] +pub(crate) struct Value { + pub(crate) value_type: ValueType, + pub(crate) value: ValueData, +} +``` + +#### `IncomingValues` + +```rust +#[repr(C)] +pub(crate) struct IncomingValues { + pub(crate) layer_id: u16, + pub(crate) value_count: u32, + pub(crate) incoming_value_array: *const Value, +} +``` + +#### `ValueData` + +```rust +#[repr(C)] +pub(crate) union ValueData { + pub(crate) uint8: u8, + pub(crate) uint16: u16, + pub(crate) uint32: u32, + pub(crate) uint64: *const u64, + pub(crate) byte_array16: *const [u8; 16], +} +``` + +#### `ValueType` + +```rust +#[repr(C)] +#[derive(Copy, Clone, Debug)] +pub enum ValueType { + FwpEmpty = 0, + FwpUint8 = 1, + FwpUint16 = 2, + FwpUint32 = 3, + FwpUint64 = 4, + FwpInt8 = 5, + FwpInt16 = 6, + FwpInt32 = 7, + FwpInt64 = 8, + FwpFloat = 9, + FwpDouble = 10, + FwpByteArray16Type = 11, + FwpByteBlobType = 12, + FwpSid = 13, + FwpSecurityDescriptorType = 14, + FwpTokenInformationType = 15, + FwpTokenAccessInformationType = 16, + FwpUnicodeStringType = 17, + FwpByteArray6Type = 18, + FwpSingleDataTypeMax = 0xff, + FwpV4AddrMask = 0xff + 1, + FwpV6AddrMask = 0xff + 2, + FwpRangeType = 0xff + 3, + FwpDataTypeMax = 0xff + 4, +} +``` + +#### `Layer` + +```rust +#[derive(Copy, Clone, Debug)] +pub enum Layer { + InboundIppacketV4, + InboundIppacketV4Discard, + InboundIppacketV6, + InboundIppacketV6Discard, + OutboundIppacketV4, + OutboundIppacketV4Discard, + OutboundIppacketV6, + OutboundIppacketV6Discard, + IpforwardV4, + IpforwardV4Discard, + IpforwardV6, + IpforwardV6Discard, + InboundTransportV4, + InboundTransportV4Discard, + InboundTransportV6, + InboundTransportV6Discard, + OutboundTransportV4, + OutboundTransportV4Discard, + OutboundTransportV6, + OutboundTransportV6Discard, + StreamV4, + StreamV4Discard, + StreamV6, + StreamV6Discard, + DatagramDataV4, + DatagramDataV4Discard, + DatagramDataV6, + DatagramDataV6Discard, + InboundIcmpErrorV4, + InboundIcmpErrorV4Discard, + InboundIcmpErrorV6, + InboundIcmpErrorV6Discard, + OutboundIcmpErrorV4, + OutboundIcmpErrorV4Discard, + OutboundIcmpErrorV6, + OutboundIcmpErrorV6Discard, + AleResourceAssignmentV4, + AleResourceAssignmentV4Discard, + AleResourceAssignmentV6, + AleResourceAssignmentV6Discard, + AleAuthListenV4, + AleAuthListenV4Discard, + AleAuthListenV6, + AleAuthListenV6Discard, + AleAuthRecvAcceptV4, + AleAuthRecvAcceptV4Discard, + AleAuthRecvAcceptV6, + AleAuthRecvAcceptV6Discard, + AleAuthConnectV4, + AleAuthConnectV4Discard, + AleAuthConnectV6, + AleAuthConnectV6Discard, + AleFlowEstablishedV4, + AleFlowEstablishedV4Discard, + AleFlowEstablishedV6, + AleFlowEstablishedV6Discard, + AleConnectRedirectV4, + AleConnectRedirectV6, + AleBindRedirectV4, + AleBindRedirectV6, + AleResourceReleaseV4, + AleResourceReleaseV6, + AleEndpointClosureV4, + AleEndpointClosureV6, +} +``` + +```rust +impl Layer { + pub fn get_guid(&self) -> GUID; +} +``` + +#### `FieldsInboundIppacketV4` + +```rust +#[repr(usize)] +pub enum FieldsInboundIppacketV4 { + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} +``` + +#### `FieldsInboundIppacketV6` + +```rust +#[repr(usize)] +pub enum FieldsInboundIppacketV6 { + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} +``` + +#### `FieldsOutboundIppacketV4` + +```rust +#[repr(usize)] +pub enum FieldsOutboundIppacketV4 { + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} +``` + +#### `FieldsOutboundIppacketV6` + +```rust +#[repr(usize)] +pub enum FieldsOutboundIppacketV6 { + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} +``` + +#### `FieldsIpforwardV4` + +```rust +#[repr(usize)] +pub enum FieldsIpforwardV4 { + IpSourceAddress, + IpDestinationAddress, + IpDestinationAddressType, + IpLocalInterface, + IpForwardInterface, + SourceInterfaceIndex, + SourceSubInterfaceIndex, + DestinationInterfaceIndex, + DestinationSubInterfaceIndex, + Flags, + IpPhysicalArrivalInterface, + ArrivalInterfaceProfileId, + IpPhysicalNexthopInterface, + NexthopInterfaceProfileId, + CompartmentId, + Max, +} +``` + +#### `FieldsIpforwardV6` + +```rust +#[repr(usize)] +pub enum FieldsIpforwardV6 { + IpSourceAddress, + IpDestinationAddress, + IpDestinationAddressType, + IpLocalInterface, + IpForwardInterface, + SourceInterfaceIndex, + SourceSubInterfaceIndex, + DestinationInterfaceIndex, + DestinationSubInterfaceIndex, + Flags, + IpPhysicalArrivalInterface, + ArrivalInterfaceProfileId, + IpPhysicalNexthopInterface, + NexthopInterfaceProfileId, + CompartmentId, + Max, +} +``` + +#### `FieldsInboundTransportV4` + +```rust +#[repr(usize)] +pub enum FieldsInboundTransportV4 { + IpProtocol, + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + ProfileId, + IpsecSecurityRealmId, + CompartmentId, + Max, +} +``` + +#### `FieldsInboundTransportFas` + +```rust +#[repr(usize)] +pub enum FieldsInboundTransportFas { + FieldInboundTransportFastMax, +} +``` + +#### `FieldsOutboundTransportFas` + +```rust +#[repr(usize)] +pub enum FieldsOutboundTransportFas { + FieldOutboundTransportFastMax, +} +``` + +#### `FieldsInboundTransportV6` + +```rust +#[repr(usize)] +pub enum FieldsInboundTransportV6 { + IpProtocol, + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + ProfileId, + IpsecSecurityRealmId, + CompartmentId, + Max, +} +``` + +#### `FieldsOutboundTransportV4` + +```rust +#[repr(usize)] +pub enum FieldsOutboundTransportV4 { + IpProtocol, + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + IpDestinationAddressType, + Flags, + InterfaceType, + TunnelType, + ProfileId, + IpsecSecurityRealmId, + CompartmentId, + Max, +} +``` + +#### `FieldsOutboundTransportV6` + +```rust +#[repr(usize)] +pub enum FieldsOutboundTransportV6 { + IpProtocol, + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + IpDestinationAddressType, + Flags, + InterfaceType, + TunnelType, + ProfileId, + IpsecSecurityRealmId, + CompartmentId, + Max, +} +``` + +#### `FieldsStreamV4` + +```rust +#[repr(usize)] +pub enum FieldsStreamV4 { + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + Direction, + Flags, + CompartmentId, + Max, +} +``` + +#### `FieldsStreamV6` + +```rust +#[repr(usize)] +pub enum FieldsStreamV6 { + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + Direction, + Flags, + CompartmentId, + Max, +} +``` + +#### `FieldsDatagramDataV4` + +```rust +#[repr(usize)] +pub enum FieldsDatagramDataV4 { + IpProtocol, + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Direction, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} +``` + +#### `FieldsDatagramDataV6` + +```rust +#[repr(usize)] +pub enum FieldsDatagramDataV6 { + IpProtocol, + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Direction, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} +``` + +#### `FieldsStreamPacketV4` + +```rust +#[repr(usize)] +pub enum FieldsStreamPacketV4 { + IpLocalAddress, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Direction, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} +``` + +#### `FieldsStreamPacketV6` + +```rust +#[repr(usize)] +pub enum FieldsStreamPacketV6 { + IpLocalAddress, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Direction, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} +``` + +#### `FieldsInboundIcmpErrorV4` + +```rust +#[repr(usize)] +pub enum FieldsInboundIcmpErrorV4 { + EmbeddedProtocol, + IpLocalAddress, + IpRemoteAddress, + EmbeddedRemoteAddress, + EmbeddedLocalAddressType, + EmbeddedLocalPort, + EmbeddedRemotePort, + IpLocalInterface, + IcmpType, + IcmpCode, + InterfaceIndex, + SubInterfaceIndex, + InterfaceType, + TunnelType, + IpArrivalInterface, + ArrivalInterfaceIndex, + ArrivalInterfaceType, + ArrivalTunnelType, + Flags, + ArrivalInterfaceProfileId, + InterfaceQuarantineEpoch, + CompartmentId, + Max, +} +``` + +#### `FieldsInboundIcmpErrorV6` + +```rust +#[repr(usize)] +pub enum FieldsInboundIcmpErrorV6 { + EmbeddedProtocol, + IpLocalAddress, + IpRemoteAddress, + EmbeddedRemoteAddress, + EmbeddedLocalAddressType, + EmbeddedLocalPort, + EmbeddedRemotePort, + IpLocalInterface, + IcmpType, + IcmpCode, + InterfaceIndex, + SubInterfaceIndex, + InterfaceType, + TunnelType, + IpArrivalInterface, + ArrivalInterfaceIndex, + ArrivalInterfaceType, + ArrivalTunnelType, + Flags, + ArrivalInterfaceProfileId, + InterfaceQuarantineEpoch, + CompartmentId, + Max, +} +``` + +#### `FieldsOutboundIcmpErrorV4` + +```rust +#[repr(usize)] +pub enum FieldsOutboundIcmpErrorV4 { + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalInterface, + IcmpType, + IcmpCode, + InterfaceIndex, + SubInterfaceIndex, + InterfaceType, + TunnelType, + Flags, + NexthopInterfaceProfileId, + InterfaceQuarantineEpoch, + CompartmentId, + Max, +} +``` + +#### `FieldsOutboundIcmpErrorV6` + +```rust +#[repr(usize)] +pub enum FieldsOutboundIcmpErrorV6 { + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalInterface, + IpLocalPort, + IpRemotePort, + InterfaceIndex, + SubInterfaceIndex, + InterfaceType, + TunnelType, + Flags, + NexthopInterfaceProfileId, + InterfaceQuarantineEpoch, + CompartmentId, + Max, +} +``` + +#### `FieldsAleResourceAssignmentV4` + +```rust +#[repr(usize)] +pub enum FieldsAleResourceAssignmentV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + AlePromiscuousMode, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + LocalInterfaceProfileId, + SioFirewallSocketProperty, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Max, +} +``` + +#### `FieldsAleResourceAssignmentV6` + +```rust +#[repr(usize)] +pub enum FieldsAleResourceAssignmentV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + AlePromiscuousMode, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + LocalInterfaceProfileId, + SioFirewallSocketProperty, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Max, +} +``` + +#### `FieldsAleResourceReleaseV4` + +```rust +#[repr(usize)] +pub enum FieldsAleResourceReleaseV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpLocalInterface, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleResourceReleaseV6` + +```rust +#[repr(usize)] +pub enum FieldsAleResourceReleaseV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpLocalInterface, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleEndpointClosureV4` + +```rust +#[repr(usize)] +pub enum FieldsAleEndpointClosureV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + IpLocalInterface, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleEndpointClosureV6` + +```rust +#[repr(usize)] +pub enum FieldsAleEndpointClosureV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + IpLocalInterface, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleAuthListenV4` + +```rust +#[repr(usize)] +pub enum FieldsAleAuthListenV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + LocalInterfaceProfileId, + SioFirewallSocketProperty, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleAuthListenV6` + +```rust +#[repr(usize)] +pub enum FieldsAleAuthListenV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + LocalInterfaceProfileId, + SioFirewallSocketProperty, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleAuthRecvAcceptV4` + +```rust +#[repr(usize)] +pub enum FieldsAleAuthRecvAcceptV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpLocalInterface, + Flags, + SioFirewallSystemPort, + NapContext, + InterfaceType, + TunnelType, + InterfaceIndex, + SubInterfaceIndex, + IpArrivalInterface, + ArrivalInterfaceType, + ArrivalTunnelType, + ArrivalInterfaceIndex, + NexthopSubInterfaceIndex, + IpNexthopInterface, + NexthopInterfaceType, + NexthopTunnelType, + NexthopInterfaceIndex, + OriginalProfileId, + CurrentProfileId, + ReauthorizeReason, + OriginalIcmpType, + InterfaceQuarantineEpoch, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} +``` + +#### `FieldsAleAuthRecvAcceptV6` + +```rust +#[repr(usize)] +pub enum FieldsAleAuthRecvAcceptV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpLocalInterface, + Flags, + SioFirewallSystemPort, + NapContext, + InterfaceType, + TunnelType, + InterfaceIndex, + SubInterfaceIndex, + IpArrivalInterface, + ArrivalInterfaceType, + ArrivalTunnelType, + ArrivalInterfaceIndex, + NexthopSubInterfaceIndex, + IpNexthopInterface, + NexthopInterfaceType, + NexthopTunnelType, + NexthopInterfaceIndex, + OriginalProfileId, + CurrentProfileId, + ReauthorizeReason, + OriginalIcmpType, + InterfaceQuarantineEpoch, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} +``` + +#### `FieldsAleBindRedirectV4` + +```rust +#[repr(usize)] +pub enum FieldsAleBindRedirectV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleBindRedirectV6` + +```rust +#[repr(usize)] +pub enum FieldsAleBindRedirectV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleConnectRedirectV4` + +```rust +#[repr(usize)] +pub enum FieldsAleConnectRedirectV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpDestinationAddressType, + IpRemotePort, + Flags, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleConnectRedirectV6` + +```rust +#[repr(usize)] +pub enum FieldsAleConnectRedirectV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpDestinationAddressType, + IpRemotePort, + Flags, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsAleAuthConnectV4` + +```rust +#[repr(usize)] +pub enum FieldsAleAuthConnectV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpDestinationAddressType, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + InterfaceIndex, + SubInterfaceIndex, + IpArrivalInterface, + ArrivalInterfaceType, + ArrivalTunnelType, + ArrivalInterfaceIndex, + NexthopSubInterfaceIndex, + IpNexthopInterface, + NexthopInterfaceType, + NexthopTunnelType, + NexthopInterfaceIndex, + OriginalProfileId, + CurrentProfileId, + ReauthorizeReason, + PeerName, + OriginalIcmpType, + InterfaceQuarantineEpoch, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + AleEffectiveName, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} +``` + +#### `FieldsAleAuthConnectV6` + +```rust +#[repr(usize)] +pub enum FieldsAleAuthConnectV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpDestinationAddressType, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + InterfaceIndex, + SubInterfaceIndex, + IpArrivalInterface, + ArrivalInterfaceType, + ArrivalTunnelType, + ArrivalInterfaceIndex, + NexthopSubInterfaceIndex, + IpNexthopInterface, + NexthopInterfaceType, + NexthopTunnelType, + NexthopInterfaceIndex, + OriginalProfileId, + CurrentProfileId, + ReauthorizeReason, + PeerName, + OriginalIcmpType, + InterfaceQuarantineEpoch, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + AleEffectiveName, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} +``` + +#### `FieldsAleFlowEstablishedV4` + +```rust +#[repr(usize)] +pub enum FieldsAleFlowEstablishedV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpDestinationAddressType, + IpLocalInterface, + Direction, + InterfaceType, + TunnelType, + Flags, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} +``` + +#### `FieldsAleFlowEstablishedV6` + +```rust +#[repr(usize)] +pub enum FieldsAleFlowEstablishedV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpDestinationAddressType, + IpLocalInterface, + Direction, + InterfaceType, + TunnelType, + Flags, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} +``` + +#### `FieldsNameResolutionCacheV4` + +```rust +#[repr(usize)] +pub enum FieldsNameResolutionCacheV4 { + AleUserId, + AleAppId, + IpRemoteAddress, + PeerName, + CompartmentId, + Max, +} +``` + +#### `FieldsNameResolutionCacheV6` + +```rust +#[repr(usize)] +pub enum FieldsNameResolutionCacheV6 { + AleUserId, + AleAppId, + IpRemoteAddress, + PeerName, + CompartmentId, + Max, +} +``` + +#### `FieldsInboundMacFrameEthernet` + +```rust +#[repr(usize)] +pub enum FieldsInboundMacFrameEthernet { + InterfaceMacAddress, + MacLocalAddress, + MacRemoteAddress, + MacLocalAddressType, + MacRemoteAddressType, + EtherType, + VlanId, + Interface, + InterfaceIndex, + NdisPort, + L2Flags, + CompartmentId, + Max, +} +``` + +#### `FieldsOutboundMacFrameEthernet` + +```rust +#[repr(usize)] +pub enum FieldsOutboundMacFrameEthernet { + InterfaceMacAddress, + MacLocalAddress, + MacRemoteAddress, + MacLocalAddressType, + MacRemoteAddressType, + EtherType, + VlanId, + Interface, + InterfaceIndex, + NdisPort, + L2Flags, + CompartmentId, + Max, +} +``` + +#### `FieldsInboundMacFrameNative` + +```rust +#[repr(usize)] +pub enum FieldsInboundMacFrameNative { + NdisMediaType, + NdisPhysicalMediaType, + Interface, + InterfaceType, + InterfaceIndex, + NdisPort, + L2Flags, + CompartmentId, + Max, +} +``` + +#### `FieldsInboundMacFrameNativeFast` + +```rust +#[repr(usize)] +pub enum FieldsInboundMacFrameNativeFast { + FastMax, +} +``` + +#### `FieldsOutboundMacFrameNative` + +```rust +#[repr(usize)] +pub enum FieldsOutboundMacFrameNative { + NdisMediaType, + NdisPhysicalMediaType, + Interface, + InterfaceType, + InterfaceIndex, + NdisPort, + L2Flags, + CompartmentId, + Max, +} +``` + +#### `FieldsOutboundMacFrameNativeFast` + +```rust +#[repr(usize)] +pub enum FieldsOutboundMacFrameNativeFast { + Max, +} +``` + +#### `FieldsIngressVswitchEthernet` + +```rust +#[repr(usize)] +pub enum FieldsIngressVswitchEthernet { + MacSourceAddress, + MacSourceAddressType, + MacDestinationAddress, + MacDestinationAddressType, + EtherType, + VlanId, + VswitchTenantNetworkId, + VswitchId, + VswitchNetworkType, + VswitchSourceInterfaceId, + VswitchSourceInterfaceType, + VswitchSourceVmId, + L2Flags, + CompartmentId, + Max, +} +``` + +#### `FieldsEgressVswitchEthernet` + +```rust +#[repr(usize)] +pub enum FieldsEgressVswitchEthernet { + MacSourceAddress, + MacSourceAddressType, + MacDestinationAddress, + MacDestinationAddressType, + EtherType, + VlanId, + VswitchTenantNetworkId, + VswitchId, + VswitchNetworkType, + VswitchSourceInterfaceId, + VswitchSourceInterfaceType, + VswitchSourceVmId, + VswitchDestinationInterfaceId, + VswitchDestinationInterfaceType, + VswitchDestinationVmId, + L2Flags, + CompartmentId, + Max, +} +``` + +#### `FieldsIngressVswitchTransportV4` + +```rust +#[repr(usize)] +pub enum FieldsIngressVswitchTransportV4 { + IpSourceAddress, + IpDestinationAddress, + IpProtocol, + IpSourcePort, + IpDestinationPort, + VlanId, + VswitchTenantNetworkId, + VswitchId, + VswitchNetworkType, + VswitchSourceInterfaceId, + VswitchSourceInterfaceType, + VswitchSourceVmId, + L2Flags, + CompartmentId, + Max, +} +``` + +#### `FieldsIngressVswitchTransportV6` + +```rust +#[repr(usize)] +pub enum FieldsIngressVswitchTransportV6 { + IpSourceAddress, + IpDestinationAddress, + IpProtocol, + IpSourcePort, + IpDestinationPort, + VlanId, + VswitchTenantNetworkId, + VswitchId, + VswitchNetworkType, + VswitchSourceInterfaceId, + VswitchSourceInterfaceType, + VswitchSourceVmId, + L2Flags, + CompartmentId, + Max, +} +``` + +#### `FieldsEgressVswitchTransportV4` + +```rust +#[repr(usize)] +pub enum FieldsEgressVswitchTransportV4 { + IpSourceAddress, + IpDestinationAddress, + IpProtocol, + IpSourcePort, + IpDestinationPort, + VlanId, + VswitchTenantNetworkId, + VswitchId, + VswitchNetworkType, + VswitchSourceInterfaceId, + VswitchSourceInterfaceType, + VswitchSourceVmId, + VswitchDestinationInterfaceId, + VswitchDestinationInterfaceType, + VswitchDestinationVmId, + L2Flags, + CompartmentId, + Max, +} +``` + +#### `FieldsEgressVswitchTransportV6` + +```rust +#[repr(usize)] +pub enum FieldsEgressVswitchTransportV6 { + IpSourceAddress, + IpDestinationAddress, + IpProtocol, + IpSourcePort, + IpDestinationPort, + VlanId, + VswitchTenantNetworkId, + VswitchId, + VswitchNetworkType, + VswitchSourceInterfaceId, + VswitchSourceInterfaceType, + VswitchSourceVmId, + VswitchDestinationInterfaceId, + VswitchDestinationInterfaceType, + VswitchDestinationVmId, + L2Flags, + CompartmentId, + Max, +} +``` + +#### `FieldsIpsecKmDemuxV4` + +```rust +#[repr(usize)] +pub enum FieldsIpsecKmDemuxV4 { + IpLocalAddress, + IpRemoteAddress, + QmMode, + IpLocalInterface, + CurrentProfileId, + IpsecSecurityRealmId, + Max, +} +``` + +#### `FieldsIpsecKmDemuxV6` + +```rust +#[repr(usize)] +pub enum FieldsIpsecKmDemuxV6 { + IpLocalAddress, + IpRemoteAddress, + QmMode, + IpLocalInterface, + CurrentProfileId, + IpsecSecurityRealmId, + Max, +} +``` + +#### `FieldsIpsecV4` + +```rust +#[repr(usize)] +pub enum FieldsIpsecV4 { + IpProtocol, + IpLocalAddress, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + ProfileId, + IpsecSecurityRealmId, + Max, +} +``` + +#### `FieldsIpsecV6` + +```rust +#[repr(usize)] +pub enum FieldsIpsecV6 { + IpProtocol, + IpLocalAddress, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + ProfileId, + IpsecSecurityRealmId, + Max, +} +``` + +#### `FieldsIkeextV4` + +```rust +#[repr(usize)] +pub enum FieldsIkeextV4 { + IpLocalAddress, + IpRemoteAddress, + IpLocalInterface, + ProfileId, + IpsecSecurityRealmId, + Max, +} +``` + +#### `FieldsIkeextV6` + +```rust +#[repr(usize)] +pub enum FieldsIkeextV6 { + IpLocalAddress, + IpRemoteAddress, + IpLocalInterface, + ProfileId, + IpsecSecurityRealmId, + Max, +} +``` + +#### `FieldsRpcUm` + +```rust +#[repr(usize)] +pub enum FieldsRpcUm { + RemoteUserToken, + AuthLevel, + AuthType, + DcomAppId, + IfFlag, + IfUuid, + IfVersion, + ImageName, + LocalAddrV4, + LocalAddrV6, + LocalPort, + Max, + Pipe, + Protocol, + RemoteAddrV4, + RemoteAddrV6, + SecEncryptAlgorithm, + SecKeySize, +} +``` + +#### `FieldsRpcEpmap` + +```rust +#[repr(usize)] +pub enum FieldsRpcEpmap { + RemoteUserToken, + IfUuid, + IfVersion, + Protocol, + AuthType, + AuthLevel, + SecEncryptAlgorithm, + SecKeySize, + LocalAddrV4, + LocalAddrV6, + LocalPort, + Pipe, + RemoteAddrV4, + RemoteAddrV6, + Max, +} +``` + +#### `FieldsRpcEpAdd` + +```rust +#[repr(usize)] +pub enum FieldsRpcEpAdd { + ProcessWithRpcIfUuid, + Protocol, + EpValue, + EpFlags, + Max, +} +``` + +#### `FieldsRpcProxyConn` + +```rust +#[repr(usize)] +pub enum FieldsRpcProxyConn { + ClientToken, + ServerName, + ServerPort, + ProxyAuthType, + ClientCertKeyLength, + ClientCertOid, + Max, +} +``` + +#### `FieldsRpcProxyIf` + +```rust +#[repr(usize)] +pub enum FieldsRpcProxyIf { + ClientToken, + IfUuid, + IfVersion, + ServerName, + ServerPort, + ProxyAuthType, + ClientCertKeyLength, + ClientCertOid, + Max, +} +``` + +#### `FieldsKmAuthorization` + +```rust +#[repr(usize)] +pub enum FieldsKmAuthorization { + RemoteId, + AuthenticationType, + KmType, + Direction, + KmMode, + IpsecPolicyKey, + NapContext, + Max, +} +``` + +#### `FieldsInboundReserved2` + +```rust +#[repr(usize)] +pub enum FieldsInboundReserved2 { + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Reserved4, + Reserved5, + Reserved6, + Reserved7, + Reserved8, + Reserved9, + Reserved10, + Reserved11, + Reserved12, + Reserved13, + Reserved14, + Reserved15, + Max, +} +``` + +#### `FieldsOutboundNetworkConnectionPolicyV4` + +```rust +#[repr(usize)] +pub enum FieldsOutboundNetworkConnectionPolicyV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpDestinationAddressType, + IpRemotePort, + Flags, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +#### `FieldsOutboundNetworkConnectionPolicyV6` + +```rust +#[repr(usize)] +pub enum FieldsOutboundNetworkConnectionPolicyV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpDestinationAddressType, + IpRemotePort, + Flags, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} +``` + +--- + +## `wdk/src/filter_engine/metadata.rs` + +### Types + +#### `FwpsIncomingMetadataValues` + +```rust +#[repr(C)] +pub(crate) struct FwpsIncomingMetadataValues { + /// Bitmask representing which values are set. + current_metadata_values: u32, + /// Internal flags; + flags: u32, + /// Reserved for system use. + reserved: u64, + /// Discard module and reason. + discard_metadata: FwpsDiscardMetadata0, + /// Flow Handle. + flow_handle: u64, + /// IP Header size. + ip_header_size: u32, + /// Transport Header size + transport_header_size: u32, + /// Process Path. + process_path: *const FWP_BYTE_BLOB, + /// Token used for authorization. + token: u64, + /// Process Id. + process_id: u64, + /// Source and Destination interface indices for discard indications. + source_interface_index: u32, + destination_interface_index: u32, + /// Compartment Id for injection APIs. + compartment_id: u32, + /// Fragment data for inbound fragments. + fragment_metadata: FwpsInboundFragmentMetadata0, + /// Path MTU for outbound packets (to enable calculation of fragments). + path_mtu: u32, + /// Completion handle (required in order to be able to pend at this layer). + completion_handle: HANDLE, + /// Endpoint handle for use in outbound transport layer injection. + transport_endpoint_handle: u64, + /// Remote scope id for use in outbound transport layer injection. + remote_scope_id: SCOPE_ID, + /// Socket control data (and length) for use in outbound transport layer injection. + control_data: *const u8, + control_data_length: u32, + /// Direction for the current packet. Only specified for ALE re-authorization. + packet_direction: FWP_DIRECTION, + /// Raw IP header (and length) if the packet is sent with IP header from a RAW socket. + header_include_header: *mut c_void, + header_include_header_length: u32, + destination_prefix: IP_ADDRESS_PREFIX, + frame_length: u16, + parent_endpoint_handle: u64, + icmp_id_and_sequence: u32, + /// PID of the process that will be accepting the redirected connection + local_redirect_target_pid: u64, + /// original destination of a redirected connection + original_destination: *mut c_void, + redirect_records: HANDLE, + /// Bitmask representing which L2 values are set. + current_l2_metadata_values: u32, + /// L2 layer Flags; + l2_flags: u32, + ethernet_mac_header_size: u32, + wifi_operation_mode: u32, + padding0: u32, + padding1: u16, + padding2: u32, + v_switch_packet_context: HANDLE, + sub_process_tag: *mut c_void, + reserved1: u64, +} +``` + +```rust +impl FwpsIncomingMetadataValues { + pub(crate) fn has_field(&self, field: u32) -> bool; + /// Size of the IP header, including any options / extension headers. + /// + /// Only meaningful when the corresponding metadata field is set; at the + /// inbound IP packet layers it is the amount the net buffer has to be + /// retreated by to expose the IP header. + pub(crate) fn get_ip_header_size(&self) -> Option; + pub(crate) fn get_transport_header_size(&self) -> u32; + pub(crate) fn get_process_id(&self) -> Option; + pub(crate) unsafe fn get_process_path(&self) -> Option; + pub(crate) fn get_completion_handle(&self) -> Option; + pub(crate) fn get_transport_endpoint_handle(&self) -> Option; + pub(crate) fn get_remote_scope_id(&self) -> Option; + pub(crate) unsafe fn get_control_data(&self) -> Option>; +} +``` + +#### `FwpsDiscardModule0` + +```rust +#[repr(C)] +enum FwpsDiscardModule0 { + FwpsDiscardModuleNetwork = 0, + FwpsDiscardModuleTransport = 1, + FwpsDiscardModuleGeneral = 2, + FwpsDiscardModuleMax = 3, +} +``` + +#### `FwpsDiscardMetadata0` + +```rust +#[repr(C)] +struct FwpsDiscardMetadata0 { + discard_module: FwpsDiscardModule0, + discard_reason: u32, + filter_id: u64, +} +``` + +#### `FwpsInboundFragmentMetadata0` + +```rust +#[repr(C)] +struct FwpsInboundFragmentMetadata0 { + fragment_identification: u32, + fragment_offset: u16, + fragment_length: u32, +} +``` + +--- + +## `wdk/src/filter_engine/mod.rs` + +### Types + +#### `ResetError` + +Why `FilterEngine::reset_all_filters` did not apply the reset. + +```rust +pub enum ResetError { + /// Another transaction is in progress on the filter engine session and only one can be open at + /// a time. Nothing was changed: no filter was removed and none was re-registered, so the reset + /// can be repeated as-is once the other transaction is done. + TransactionInProgress, + /// Anything else. Not transient, repeating it is pointless. + Failed(String), +} +``` + +#### `FilterEngine` + +```rust +pub struct FilterEngine { + device_object: *mut DEVICE_OBJECT, + handle: HANDLE, + sublayer_guid: u128, + committed: bool, + callouts: Option>>, +} +``` + +```rust +impl FilterEngine { + pub fn new(driver: &Driver, layer_guid: u128) -> Result; + pub fn commit(&mut self, callouts: Vec) -> Result<(), String>; + /// Reports whether any filter is still registered. + fn has_registered_filters(&self) -> bool; + /// Removes all registered filters, leaving the callouts in place. Once the transaction is + /// committed no new classify call can reach the callouts. This is the first step of the + /// teardown: complete the operations that are still pended, then call `unregister_callouts`, + /// which `FwpsCalloutUnregisterById0` refuses to do while one is outstanding. + /// + /// Does nothing when the filters are already gone, so it is safe to call more than once. + pub fn unregister_filters(&mut self) -> Result<(), String>; + /// Unregisters all registered callouts. This is the last step of the teardown and must run + /// after `unregister_filters` and after every pended operation has been completed: + /// `FwpsCalloutUnregisterById0` fails with STATUS_DEVICE_BUSY while an operation is still + /// pended, and letting the driver unload over a callout that is still registered strands that + /// operation for good — nothing can complete its IRP afterwards, so the thread waiting on it + /// never returns from the kernel and its process can no longer be terminated. + /// + /// Skips the callouts that are already gone, so it is safe to call more than once. + pub fn unregister_callouts(&mut self) -> Result<(), String>; + /// Removes and re-registers every resettable filter, which makes WFP reauthorize the existing + /// connections against them. This is the only way to release a connection that was deferred + /// without a completion handle (see `CalloutData::pend_filter_rest`), because a single filter + /// cannot be reset on its own. + /// + /// Does not retry on its own. Only one transaction can be open on the filter engine session, so + /// concurrent callers collide; `ResetError::TransactionInProgress` tells them apart from the + /// real failures and leaves the decision of when to try again to the caller, which can batch + /// several deferred connections into one reset instead of racing per connection. + pub fn reset_all_filters(&mut self) -> Result<(), ResetError>; + fn register_sublayer(&self) -> Result<(), String>; +} +``` + +### Trait implementations + +```rust +impl From for String + +impl Drop for FilterEngine +``` + +### Functions + +```rust +#[no_mangle] +unsafe extern "C" fn catch_all_callout( + fixed_values: *const IncomingValues, + meta_values: *const FwpsIncomingMetadataValues, + layer_data: *mut c_void, + _context: *mut c_void, + filter: *const FWPS_FILTER2, + _flow_context: u64, + classify_out: *mut ClassifyOut, +); +``` + +--- + +## `wdk/src/filter_engine/net_buffer.rs` + +### Types + +#### `NetBuffer` + +A single NET_BUFFER inside a NET_BUFFER_LIST. +A list can carry a chain of net buffers, and each one is an independent +packet with its own data offset, length and MDL chain. Everything that acts +on packet bytes -- retreating the data start, reading headers, cloning -- +is therefore per net buffer; doing it at the list level only ever touches +the head of the chain and silently ignores the rest. + +```rust +pub struct NetBuffer { + nb: *mut NET_BUFFER, + advance_on_drop: Option, +} +``` + +```rust +impl NetBuffer { + pub fn new(nb: *mut NET_BUFFER) -> NetBuffer; + pub fn get_data_length(&self) -> u32; + /// Reads `buffer.len()` bytes from the current data start of the net buffer. + /// + /// Fails if the net buffer holds fewer bytes than requested. + pub fn read_bytes(&self, buffer: &mut [u8]) -> Result<(), ()>; + /// Copies this net buffer into a new, standalone NET_BUFFER_LIST holding a + /// single net buffer. + /// + /// Each net buffer of a chain is cloned into its own list rather than into a + /// chain of its own, because that is what the injection path takes: one + /// packet per injected list. + pub fn clone_as_nbl( + &self, + net_allocator: &NetworkAllocator, + ) -> Result; + /// Retreats the data start of the net buffer, exposing bytes that precede it. + /// + /// With `auto_advance` the retreat is undone when the value is dropped. + /// Returns an error if the retreat failed, in which case the data offset is + /// unchanged and reads would still return data from the old offset. + pub fn retreat(&mut self, size: u32, auto_advance: bool) -> Result<(), String>; + /// Advances the data start of the net buffer, undoing a retreat. + pub fn advance(&self, size: u32); +} +``` + +#### `NetBufferIter` + +```rust +pub struct NetBufferIter(*mut NET_BUFFER); +``` + +```rust +impl NetBufferIter { + pub fn new(nb: *mut NET_BUFFER) -> Self; +} +``` + +#### `NetBufferList` + +```rust +pub struct NetBufferList { + pub(crate) nbl: *mut NET_BUFFER_LIST, + data: Option>, +} +``` + +```rust +impl NetBufferList { + pub fn new(nbl: *mut NET_BUFFER_LIST) -> NetBufferList; + pub fn iter(&self) -> NetBufferListIter; + /// Iterates over every net buffer in this list. + pub fn iter_net_buffers(&self) -> NetBufferIter; + /// Clones the first net buffer of the list. + /// + /// Only valid where the list is known to hold a single net buffer. Use + /// [`NetBufferList::iter_net_buffers`] with [`NetBuffer::clone_as_nbl`] to + /// clone a chain without dropping everything past the head. + pub fn clone( + &self, + net_allocator: &NetworkAllocator, + ) -> Result; + pub fn get_data_mut(&mut self) -> Option<&mut [u8]>; + pub fn get_data(&self) -> Option<&[u8]>; + pub fn get_data_length(&self) -> u64; +} +``` + +#### `NetBufferListIter` + +```rust +pub struct NetBufferListIter(*mut NET_BUFFER_LIST); +``` + +```rust +impl NetBufferListIter { + pub fn new(nbl: *mut NET_BUFFER_LIST) -> Self; +} +``` + +#### `RetreatGuard` + +```rust +pub struct RetreatGuard { + size: u32, + nbl: *mut NET_BUFFER_LIST, +} +``` + +#### `NetworkAllocator` + +```rust +pub struct NetworkAllocator { + pool_handle: NDIS_HANDLE, +} +``` + +```rust +impl NetworkAllocator { + pub fn new() -> Self; + pub fn wrap_packet_in_nbl( + &self, + packet_data: &[u8], + ) -> Result<*mut NET_BUFFER_LIST, String>; + pub fn free_net_buffer(nbl: *mut NET_BUFFER_LIST); + /// Retreats the data start of the first net buffer. + /// + /// Returns an error if the retreat failed; the returned guard is `None` when + /// the retreat succeeded but `auto_advance` was not requested. + pub fn retreat_net_buffer( + nbl: *mut NET_BUFFER_LIST, + size: u32, + auto_advance: bool, + ) -> Result, String>; + pub fn advance_net_buffer(nbl: *mut NET_BUFFER_LIST, size: u32); +} +``` + +### Trait implementations + +```rust +impl Drop for NetBuffer + +impl Iterator for NetBufferIter + +impl Drop for NetBufferList + +impl Iterator for NetBufferListIter + +impl Drop for RetreatGuard + +impl Drop for NetworkAllocator +``` + +### Functions + +```rust +pub fn read_packet_partial<'a>( + nbl: *mut NET_BUFFER_LIST, + buffer: &'a mut [u8], +) -> Result<(), ()>; +``` + +--- + +## `wdk/src/filter_engine/packet.rs` + +### Types + +#### `TransportPacketList` + +```rust +pub struct TransportPacketList { + ipv6: bool, + pub net_buffer_list_queue: NetBufferList, + remote_ip: [u8; 16], + endpoint_handle: u64, + remote_scope_id: SCOPE_ID, + control_data: Option>, + inbound: bool, + interface_index: u32, + sub_interface_index: u32, +} +``` + +#### `TransportInjectContext` + +Owns everything an asynchronous transport injection references. +`FwpsInjectTransport*Async*` returns before the packet is actually sent, and +the network stack keeps reading the NET_BUFFER_LIST, the control data blob +(`FWPS_TRANSPORT_SEND_PARAMS1.control_data`) and the remote address buffer +(`remote_address`) after the call returns. All three must therefore stay +alive until the injection completion routine (`free_transport_packet`) runs, +not just for the duration of the inject call. The `FWPS_TRANSPORT_SEND_PARAMS1` +struct itself does not need to outlive the call — WFP copies it synchronously — +only the buffers it points into. + +```rust +struct TransportInjectContext { + nbl: NetBufferList, + control_data: Option>, + remote_ip: [u8; 16], +} +``` + +#### `InjectInfo` + +```rust +pub struct InjectInfo { + pub ipv6: bool, + pub inbound: bool, + pub loopback: bool, + pub interface_index: u32, + pub sub_interface_index: u32, + pub compartment_id: COMPARTMENT_ID, +} +``` + +#### `Injector` + +```rust +pub struct Injector { + transport_inject_handle: HANDLE, + packet_inject_handle_v4: HANDLE, + packet_inject_handle_v6: HANDLE, +} +``` + +```rust +impl Injector { + pub fn new() -> Self; + pub fn from_ale_callout( + ipv6: bool, + callout_data: &CalloutData, + net_buffer_list: NetBufferList, + remote_ip_slice: &[u8], + inbound: bool, + interface_index: u32, + sub_interface_index: u32, + ) -> TransportPacketList; + pub fn inject_packet_list_transport( + &self, + packet_list: TransportPacketList, + ) -> Result<(), String>; + pub fn inject_net_buffer_list( + &self, + net_buffer_list: NetBufferList, + inject_info: InjectInfo, + ) -> Result<(), String>; + pub fn was_network_packet_injected_by_self( + &self, + nbl: *const NET_BUFFER_LIST, + ipv6: bool, + ) -> bool; + pub fn was_network_packet_injected_by_self_ale( + &self, + nbl: *const NET_BUFFER_LIST, + ) -> bool; +} +``` + +### Trait implementations + +```rust +impl Drop for Injector +``` + +### Functions + +```rust +unsafe extern "C" fn free_packet( + context: *mut c_void, + net_buffer_list: *mut NET_BUFFER_LIST, + _dispatch_level: bool, +); +``` + +```rust +unsafe extern "C" fn free_transport_packet( + context: *mut c_void, + net_buffer_list: *mut NET_BUFFER_LIST, + _dispatch_level: bool, +); +``` + +--- + +## `wdk/src/filter_engine/stream_data.rs` + +### Constants & statics + +```rust +const FWPS_STREAM_FLAG_RECEIVE: u32 = 0x00000001; +``` + +### Types + +#### `StreamActionType` + +```rust +#[repr(C)] +pub enum StreamActionType { + None, + NeedMoreData, + DropConnection, + Defer, + AllowConnection, + TypeMax, +} +``` + +#### `StreamCalloutIoPacket` + +```rust +#[repr(C)] +pub struct StreamCalloutIoPacket { + stream_data: *mut StreamData, + missed_bytes: usize, + count_bytes_required: usize, + count_bytes_enforced: usize, + stream_action: StreamActionType, +} +``` + +```rust +impl StreamCalloutIoPacket { + pub fn get_data_len(&self) -> usize; + pub fn is_receive(&self) -> bool; +} +``` + +#### `StreamDataOffset` + +```rust +#[repr(C)] +pub struct StreamDataOffset { + net_buffer_list: *mut NET_BUFFER_LIST, + net_buffer: *mut NET_BUFFER, + mdl: *mut MDL, + mdl_offset: u32, + net_buffer_offset: u32, + stream_data_offset: usize, +} +``` + +#### `StreamData` + +```rust +#[repr(C)] +pub struct StreamData { + flags: u32, + data_offset: StreamDataOffset, + data_length: usize, + net_buffer_list_chain: *mut NET_BUFFER_LIST, +} +``` + +--- + +## `wdk/src/filter_engine/transaction.rs` + +### Types + +#### `BeginError` + +Why beginning a filter engine transaction failed. + +```rust +pub(super) enum BeginError { + /// Another transaction is already in progress on the filter engine session. Only one can be + /// open at a time. Nothing was changed, so the caller may simply try again later. + InProgress, + /// Anything else. Not worth repeating. + Failed(String), +} +``` + +#### `Transaction` + +Transaction guard for Filter Engine. Internally useses a lock. DO NOT USE WITH OTHER LOCKS. + +```rust +pub(super) struct Transaction<'a> { + filter_engine: &'a mut FilterEngine, + committed: bool, +} +``` + +```rust +impl<'a> Transaction<'a> { + fn begin( + filter_engine: &'a mut FilterEngine, + flags: u32, + ) -> Result; + /// Creates a read only guard for filter engine transaction. + pub(super) fn begin_read( + filter_engine: &'a mut FilterEngine, + ) -> Result; + /// Creates a read/write guard for filter engine transaction. + pub(super) fn begin_write( + filter_engine: &'a mut FilterEngine, + ) -> Result; + /// Same as `begin_write`, but waits and tries again while another transaction is in progress. + /// + /// Sleeps between the attempts, so it is only usable at IRQL <= APC_LEVEL. Callers that must + /// not block, or that want to batch the retries with other work (the driver does that for the + /// filter resets), use `begin_write` and handle `BeginError::InProgress` themselves. + pub(super) fn begin_write_retrying( + filter_engine: &'a mut FilterEngine, + ) -> Result; + /// Applying all the changes and releases the lock. + pub(super) fn commit(&mut self) -> Result<(), String>; +} +``` + +### Trait implementations + +```rust +impl From for String + +impl<'a> Deref for Transaction<'a> + +impl<'a> DerefMut for Transaction<'a> + +impl<'a> Drop for Transaction<'a> +``` + +--- + +## `wdk/src/interface.rs` + +### Functions + +```rust +pub fn dbg_print(str: String); +``` + +```rust +pub fn init_driver_object( + driver_object: *mut DRIVER_OBJECT, + registry_path: *mut UNICODE_STRING, + driver_name: &str, + object_attributes: *mut WdfObjectAttributes, +) -> Result; +``` + +```rust +pub fn get_device_context_from_wdf_device( + wdf_device: HANDLE, + type_info: &'static WdfObjectContextTypeInfo, +) -> *mut T; +``` + +```rust +pub(crate) fn wdf_device_wdm_get_device_object( + wdf_device: HANDLE, +) -> *mut DEVICE_OBJECT; +``` + +```rust +pub fn get_device_context_from_device_object<'a, T>( + device_object: &mut DEVICE_OBJECT, +) -> Result<&'a mut T, ()>; +``` + +Empty unload event + +```rust +extern "C" fn empty_wdf_driver_unload(_driver: HANDLE); +``` + +--- + +## `wdk/src/ioqueue.rs` + +### Types + +#### `Status` + +```rust +#[derive(Debug)] +pub enum Status { + Uninitialized, + Timeout, + UserAPC, + Abandoned, +} +``` + +#### `KprocessorMode` + +```rust +#[repr(i8)] +pub enum KprocessorMode { + KernelMode = 0, + UserMode = 1, +} +``` + +#### `Entry` + +```rust +#[repr(C)] +struct Entry { + list: LIST_ENTRY, + entry: T, +} +``` + +#### `IOQueue` + +```rust +pub struct IOQueue { + kernel_queue: Pin>>, + initialized: AtomicBool, + _type: PhantomData, +} +``` + +```rust +impl IOQueue { + /// Make sure `rundown` is called on exit, if `drop()` is not called for queue. + pub fn new() -> Self; + /// Mirror of the host mock's blocking constructor. The kernel queue already + /// blocks in `wait_and_pop` (NULL timeout), so this is just an alias for + /// `new`; the parameter exists so `Device::new_with_blocking_reads` can name + /// one constructor across both builds. + pub fn new_blocking() -> Self; + /// Pushes new entry of any type. + pub fn push(&self, entry: T) -> Result<(), Status>; + /// Returns an Element or a status. + fn pop_internal(&self, timeout: *const i64) -> Result; + /// Returns element or a status. Waits until element is pushed or the queue is interrupted. + pub fn wait_and_pop(&self) -> Result; + /// Returns element or a status. Does not wait. + pub fn pop(&self) -> Result; + /// Returns element or a status. Waits the specified timeout. + pub fn pop_timeout(&self, timeout: i64) -> Result; + /// Removes all elements and frees all the memory. The object can't be used after this function is called. + pub fn rundown(&self); +} +``` + +### Trait implementations + +```rust +impl Display for Status + +unsafe impl Sync for IOQueue + +impl Drop for IOQueue +``` + +### Extern blocks + +```rust +#[link(name = "NtosKrnl", kind = "static")] +extern "C" { + fn KeInitializeQueue(queue: *mut KQUEUE, count: u64); + fn KeInsertQueue(queue: *mut KQUEUE, list_entry: *mut c_void) -> i32; + fn KeRemoveQueue( + queue: *mut KQUEUE, + waitmode: KprocessorMode, + timeout: *const i64, + ) -> *mut LIST_ENTRY; + fn KeRundownQueue(queue: *mut KQUEUE) -> *mut LIST_ENTRY; +} +``` + +--- + +## `wdk/src/irp_helpers.rs` + +### Types + +#### `ReadRequest` + +```rust +pub struct ReadRequest<'a> { + irp: &'a mut IRP, + buffer: &'a mut [u8], + fill_index: usize, +} +``` + +```rust +impl ReadRequest<'_> { + pub fn new(irp: &'_ mut IRP) -> ReadRequest<'_>; + pub fn free_space(&self) -> usize; + pub fn complete(&mut self); + pub fn end_of_file(&mut self); + pub fn timeout(&mut self); + pub fn get_status(&self) -> NTSTATUS; + pub fn write(&mut self, bytes: &[u8]) -> usize; +} +``` + +#### `WriteRequest` + +```rust +pub struct WriteRequest<'a> { + irp: &'a mut IRP, + buffer: &'a mut [u8], +} +``` + +```rust +impl WriteRequest<'_> { + pub fn new(irp: &'_ mut IRP) -> WriteRequest<'_>; + pub fn get_buffer(&self) -> &[u8]; + pub fn mark_all_as_read(&mut self); + pub fn complete(&mut self); + pub fn get_status(&self) -> NTSTATUS; +} +``` + +#### `DeviceControlRequest` + +```rust +pub struct DeviceControlRequest<'a> { + irp: &'a mut IRP, + buffer: &'a mut [u8], + fill_index: usize, + control_code: u32, +} +``` + +```rust +impl DeviceControlRequest<'_> { + pub fn new(irp: &'_ mut IRP) -> DeviceControlRequest<'_>; + pub fn get_buffer(&self) -> &[u8]; + pub fn write(&mut self, bytes: &[u8]) -> usize; + pub fn complete(&mut self); + pub fn not_implemented(&mut self); + pub fn get_status(&self) -> NTSTATUS; + pub fn get_control_code(&self) -> u32; + pub fn free_space(&self) -> usize; +} +``` + +#### `DeviceIOControlParams` + +```rust +#[repr(C)] +struct DeviceIOControlParams { + output_buffer_length: u32, + _padding1: u32, + input_buffer_length: u32, + _padding2: u32, + io_control_code: u32, + _padding3: u32, +} +``` + +--- + +## `wdk/src/lib.rs` + +### Constants & statics + +```rust +#[cfg(not(test))] +#[export_name = "_fltused"] +static _FLTUSED: i32 = ...; +``` + +### Functions + +```rust +#[cfg(not(test))] +#[no_mangle] +pub extern "system" fn __CxxFrameHandler3( + _: *mut u8, + _: *mut u8, + _: *mut u8, + _: *mut u8, +) -> i32; +``` + +--- + +## `wdk/src/lib.rs` — `mod kernel_types` + +Re-exports of the kernel / Win32 types the driver's entry plumbing names +(`DriverEntry`, the IRP dispatch handlers). The driver refers to them through +`wdk::kernel_types::*` so the host mock can provide API-compatible stand-ins +under the same path, letting the same `entry.rs` compile in both builds. + +### Re-exports + +```rust +pub use windows_sys::Wdk::Foundation::{DEVICE_OBJECT, DRIVER_OBJECT, IRP}; +pub use windows_sys::Win32::Foundation::{ + NTSTATUS, STATUS_FAILED_DRIVER_ENTRY, STATUS_SUCCESS, UNICODE_STRING, +}; +``` + +--- + +## `wdk/src/rw_spin_lock.rs` + +### Types + +#### `RwSpinLock` + +A reader-writer spin lock implementation. +This lock allows multiple readers to access the data simultaneously, +but only one writer can access the data at a time. It uses a spin loop +to wait for the lock to become available. + +```rust +pub struct RwSpinLock { + data: UnsafeCell, +} +``` + +```rust +impl RwSpinLock { + /// Creates a new `RwSpinLock` with the default initial value. + pub const fn default() -> Self; + pub fn read_lock(&self) -> u8; + pub fn write_lock(&self) -> u8; + pub fn read_unlock(&self, old_irq: u8); + pub fn write_unlock(&self, old_irq: u8); +} +``` + +#### `Mutex` + +```rust +pub struct Mutex { + lock: RwSpinLock, + data: UnsafeCell, +} +``` + +```rust +impl Mutex { + pub const fn new(data: T) -> Self; + pub fn read_lock(&self) -> MutexReadGuard<'_, T>; + pub fn write_lock(&self) -> MutexWriteGuard<'_, T>; +} +``` + +#### `MutexWriteGuard` + +```rust +pub struct MutexWriteGuard<'a, T> { + lock: &'a Mutex, + old_irq: u8, +} +``` + +#### `MutexReadGuard` + +```rust +pub struct MutexReadGuard<'a, T> { + lock: &'a Mutex, + old_irq: u8, +} +``` + +### Trait implementations + +```rust +unsafe impl Sync for Mutex + +unsafe impl Send for Mutex + +impl<'a, T> Deref for MutexWriteGuard<'a, T> + +impl<'a, T> Drop for MutexWriteGuard<'a, T> + +impl<'a, T> DerefMut for MutexWriteGuard<'a, T> + +impl<'a, T> Deref for MutexReadGuard<'a, T> + +impl<'a, T> Drop for MutexReadGuard<'a, T> +``` + +--- + +## `wdk/src/spin_lock.rs` + +### Types + +#### `KSpinLockQueue` + +```rust +#[repr(C)] +struct KSpinLockQueue { + next: *mut c_void, + lock: *mut c_void, +} +``` + +#### `KLockQueueHandle` + +```rust +pub struct KLockQueueHandle { + lock: KLOCK_QUEUE_HANDLE, +} +``` + +#### `KSpinLock` + +```rust +#[repr(C)] +pub struct KSpinLock { + ptr: *mut usize, +} +``` + +```rust +impl KSpinLock { + pub fn create() -> Self; + pub fn lock(&mut self) -> KLockQueueHandle; +} +``` + +### Trait implementations + +```rust +impl Drop for KLockQueueHandle +``` + +--- + +## `wdk/src/utils.rs` + +### Constants & statics + +Kernel wait intervals are counted in 100-nanosecond units. + +```rust +const HUNDRED_NANOS_PER_MILLISECOND: i64 = 10_000; +``` + +### Functions + +Returns the symbolic name of an NTSTATUS value, or a placeholder if it is not a known code. + +```rust +pub fn ntstatus_name(status: i32) -> String; +``` + +```rust +pub fn check_ntstatus(status: i32) -> Result<(), String>; +``` + +```rust +pub fn get_system_timestamp_ms() -> u64; +``` + +Blocks the calling thread for at least `milliseconds`, giving the rest of the system a chance +to make progress. The wait is non-alertable, so an APC does not cut it short. +Only usable at IRQL <= APC_LEVEL. Waiting above that would bugcheck, so the level is checked +and reported as an error instead. + +```rust +pub fn sleep_ms(milliseconds: u32) -> Result<(), String>; +``` + +```rust +pub fn get_startup_time_ns() -> u64; +``` + +--- + diff --git a/driver/Cargo.lock b/driver/Cargo.lock index 38f4b62..2000d09 100644 --- a/driver/Cargo.lock +++ b/driver/Cargo.lock @@ -8,12 +8,33 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + [[package]] name = "byteorder" version = "1.5.0" @@ -30,13 +51,60 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" name = "driver" version = "0.1.0" dependencies = [ + "mock_wdk", "num", "num-derive", "num-traits", + "proptest", "protocol", "smoltcp", "wdk", - "windows-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", ] [[package]] @@ -58,12 +126,28 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "managed" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" +[[package]] +name = "mock_wdk" +version = "0.1.0" + [[package]] name = "ntstatus" version = "0.1.2" @@ -165,6 +249,21 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -174,6 +273,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.13.0", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "protocol" version = "0.1.0" @@ -183,6 +301,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.47" @@ -192,19 +316,100 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +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.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "smoltcp" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f73d40463bba65efc9adc6370b56df76d563cc46e2482bba58351b4afb7535e" dependencies = [ - "bitflags", + "bitflags 1.3.2", "byteorder", "cfg-if", "heapless", @@ -239,19 +444,56 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wdk" version = "0.1.0" dependencies = [ "ntstatus", "widestring", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -260,6 +502,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.52.0" @@ -268,6 +516,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.0" @@ -316,3 +573,29 @@ source = "git+https://github.com/microsoft/windows-rs?rev=41ad38d8c42c92fd23fe25 name = "windows_x86_64_msvc" version = "0.52.0" source = "git+https://github.com/microsoft/windows-rs?rev=41ad38d8c42c92fd23fe25ba4dca76c2d861ca06#41ad38d8c42c92fd23fe25ba4dca76c2d861ca06" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/driver/Cargo.toml b/driver/Cargo.toml index 2876512..c35f05b 100644 --- a/driver/Cargo.toml +++ b/driver/Cargo.toml @@ -8,7 +8,10 @@ edition = "2021" [lib] name = "driver" path = "src/lib.rs" -crate-type = ["staticlib"] +# "rlib" is added so the fuzz/ crate can depend on `driver` as a normal Rust +# library. It is harmless for the production build: the WDK link step consumes +# the `staticlib` output (driver.lib); the extra rlib is ignored. +crate-type = ["staticlib", "rlib"] [profile.dev] panic = "abort" @@ -16,16 +19,29 @@ panic = "abort" [profile.release] panic = "abort" +[features] +# Production build (default): link the real kernel `wdk` + windows-sys. +default = ["real_wdk"] +real_wdk = ["dep:wdk", "dep:windows-sys"] +# Host fuzz/test build: link `mock_wdk` instead (see src/lib.rs extern crate rename). +# Build with: cargo build --no-default-features --features mock +mock = ["dep:mock_wdk"] + [dependencies] -wdk = { path = "../wdk" } +wdk = { path = "../wdk", optional = true } +mock_wdk = { path = "../mock_wdk", optional = true } protocol = { path = "../protocol" } num = { version = "0.4", default-features = false } num-derive = { version = "0.5", default-features = false } num-traits = { version = "0.2", default-features = false } smoltcp = { version = "0.13", default-features = false, features = ["proto-ipv4", "proto-ipv6", "medium-ethernet", "socket-raw"] } +[dev-dependencies] +proptest = "1" + # WARNING: Do not update. The version was chosen for a reason. See wdk/README.md for more details. [dependencies.windows-sys] git = "https://github.com/microsoft/windows-rs" rev = "41ad38d8c42c92fd23fe25ba4dca76c2d861ca06" +optional = true features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_SystemServices", "Win32_Foundation", "Win32_Security", "Win32_System_IO", "Win32_System_Kernel", "Win32_System_Power", "Win32_System_WindowsProgramming", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_NetworkManagement_WindowsFilteringPlatform"] diff --git a/driver/fuzz/.gitignore b/driver/fuzz/.gitignore new file mode 100644 index 0000000..9eca9c2 --- /dev/null +++ b/driver/fuzz/.gitignore @@ -0,0 +1,3 @@ +/target +/artifacts +/coverage diff --git a/driver/fuzz/Cargo.lock b/driver/fuzz/Cargo.lock new file mode 100644 index 0000000..f343210 --- /dev/null +++ b/driver/fuzz/Cargo.lock @@ -0,0 +1,300 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "driver" +version = "0.1.0" +dependencies = [ + "mock_wdk", + "num", + "num-derive", + "num-traits", + "protocol", + "smoltcp", +] + +[[package]] +name = "driver-fuzz" +version = "0.0.0" +dependencies = [ + "driver", + "libfuzzer-sys", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + +[[package]] +name = "mock_wdk" +version = "0.1.0" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e98dc3b890f6c23a0f9d3d491a2823d0dea0fa656302a13dd225fa924112a8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "protocol" +version = "0.1.0" +dependencies = [ + "num", + "num-derive", + "num-traits", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smoltcp" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f73d40463bba65efc9adc6370b56df76d563cc46e2482bba58351b4afb7535e" +dependencies = [ + "bitflags", + "byteorder", + "cfg-if", + "heapless", + "managed", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" diff --git a/driver/fuzz/Cargo.toml b/driver/fuzz/Cargo.toml new file mode 100644 index 0000000..9a47508 --- /dev/null +++ b/driver/fuzz/Cargo.toml @@ -0,0 +1,64 @@ +[package] +name = "driver-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +# cargo-fuzz / libFuzzer harness for the ZenithFence driver. This crate is the +# build root when fuzzing, so its profiles win and the driver's `panic = "abort"` +# is irrelevant here. It depends on `driver` with the `mock` feature so the +# packet-handling code links against the host mock_wdk (no kernel). +# +# Requires a nightly toolchain + `cargo install cargo-fuzz`. Most reliable on +# Linux/WSL (libFuzzer + AddressSanitizer). Run with: `just fuzz `. + +[package.metadata] +cargo-fuzz = true + +# Shared fuzz-target logic, used by BOTH the libFuzzer targets (Linux) and the +# `replay` binary (runs on Windows). Keeps one source of truth. +[lib] +name = "driver_fuzz" +path = "src/lib.rs" + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.driver] +path = ".." +default-features = false +features = ["mock"] + +# Keep symbols/line info for good ASAN reports. +[profile.release] +debug = 1 + +# Windows-runnable replay / smoke runner (has a real main; no libFuzzer). +# `cargo run --bin replay -- [files...]` +[[bin]] +name = "replay" +path = "src/bin/replay.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "callouts" +path = "fuzz_targets/callouts.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "callouts_mt" +path = "fuzz_targets/callouts_mt.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "simulation" +path = "fuzz_targets/simulation.rs" +test = false +doc = false +bench = false diff --git a/driver/fuzz/README.md b/driver/fuzz/README.md new file mode 100644 index 0000000..62d10aa --- /dev/null +++ b/driver/fuzz/README.md @@ -0,0 +1,70 @@ +# Fuzzing the ZenithFence driver + +A coverage-guided (libFuzzer) fuzz target for the driver's stateful callout +pipeline. It links the driver against the host **mock_wdk** (via the driver's +`mock` feature), so no kernel is involved. + +## Requirements + +- A nightly Rust toolchain +- `cargo install cargo-fuzz` +- Linux or WSL recommended (libFuzzer + AddressSanitizer are most reliable there) + +## Target + +| Target | Under test | +|------------|----------------------------------------------------------------------------| +| `callouts` | The full ALE + packet-layer callout pipeline against one persistent device | + +The input is decoded into a sequence of operations replayed against a single +persistent mock `Device`: ALE callouts pend flows, `Verdict`/`Update` commands +resolve them, and packet-layer callouts then read the resulting verdict. Oracles +range from "never panic / no OOB / no UB" up to "a permanent verdict fully +determines the packet-layer action for a found TCP/UDP flow" (see the doc comment +on `targets::callouts` in `src/targets.rs`). + +## Run (coverage-guided, Linux) + +```sh +# from repo root +just fuzz callouts 120 # run target for 120s +just fuzz-build # build all targets (CI smoke check) + +# or directly +cd driver/fuzz && cargo +nightly fuzz run callouts -- -max_total_time=60 +``` + +## Run on Windows (replay / smoke -- NOT coverage-guided) + +The `fuzz_targets/` binaries are `#![no_main]`; their entry point is supplied by +the libFuzzer runtime, which is only linked by `cargo fuzz` (Linux). So +`cargo run --bin ` on Windows fails with `LNK1561: entry point must be +defined` -- that is expected, not a misconfiguration. + +For Windows, use the ordinary `replay` binary, which shares the same target +logic (`src/targets.rs`): + +```sh +# from repo root +just fuzz-replay callouts # random-input smoke loop +just fuzz-replay callouts crash-abc # replay a specific input file + +# or directly +cd driver/fuzz +cargo run --bin replay -- callouts # random smoke (env: ITERS, SEED) +cargo run --bin replay -- callouts artifacts/x # reproduce a Linux crash on Windows +``` + +`replay` is random/replay testing, not coverage-guided -- it complements the +`proptest` suite (`just test-driver`) for everyday Windows checks, and lets you +reproduce a Linux fuzzer crash locally. Coverage-guided runs happen on Linux. + +## Corpus & regressions + +`corpus/callouts/` is seeded with operation-stream inputs that drive the pipeline +through its main states. + +On a crash: `cargo +nightly fuzz tmin callouts `, then commit the +minimized input under `corpus/callouts/` and add a deterministic `#[test]` in the +driver crate so it also runs in the fast (non-fuzz) test job. You can reproduce +any crash file on Windows with `just fuzz-replay callouts `. diff --git a/driver/fuzz/fuzz_targets/callouts.rs b/driver/fuzz/fuzz_targets/callouts.rs new file mode 100644 index 0000000..e6a1b14 --- /dev/null +++ b/driver/fuzz/fuzz_targets/callouts.rs @@ -0,0 +1,7 @@ +#![no_main] +//! Stateful ALE + packet-layer callout pipeline. See driver_fuzz::targets::callouts. +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + driver_fuzz::targets::callouts(data); +}); diff --git a/driver/fuzz/fuzz_targets/callouts_mt.rs b/driver/fuzz/fuzz_targets/callouts_mt.rs new file mode 100644 index 0000000..feb1294 --- /dev/null +++ b/driver/fuzz/fuzz_targets/callouts_mt.rs @@ -0,0 +1,10 @@ +#![no_main] +//! Multithreaded ALE + packet-layer callout stress: many worker threads drive +//! the callouts against one shared device at the same time. See +//! driver_fuzz::targets::callouts_mt. Best run with ThreadSanitizer +//! (`just fuzz-tsan callouts_mt`). +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + driver_fuzz::targets::callouts_mt(data); +}); diff --git a/driver/fuzz/fuzz_targets/simulation.rs b/driver/fuzz/fuzz_targets/simulation.rs new file mode 100644 index 0000000..969e326 --- /dev/null +++ b/driver/fuzz/fuzz_targets/simulation.rs @@ -0,0 +1,11 @@ +#![no_main] +//! Full-driver simulation: load the real driver over the mocked WDK and drive it +//! through the user-space channel with a real producer thread (callouts) and a +//! real consumer thread (reads events, writes verdicts). See +//! driver_fuzz::targets::simulation. Run with ThreadSanitizer for race coverage +//! (`just fuzz-tsan simulation`) or ASAN (`just fuzz simulation`). +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + driver_fuzz::targets::simulation(data); +}); diff --git a/driver/fuzz/src/lib.rs b/driver/fuzz/src/lib.rs new file mode 100644 index 0000000..0cf2851 --- /dev/null +++ b/driver/fuzz/src/lib.rs @@ -0,0 +1,8 @@ +//! Shared fuzz-target bodies for the ZenithFence driver. +//! +//! Each `targets::(data: &[u8])` contains the actual logic + oracles for +//! one fuzz target. The libFuzzer harnesses in `fuzz_targets/` and the +//! Windows-runnable `replay` binary both call these, so there is a single +//! source of truth and the same code is exercised on both platforms. + +pub mod targets; diff --git a/driver/fuzz/src/targets.rs b/driver/fuzz/src/targets.rs new file mode 100644 index 0000000..cf84097 --- /dev/null +++ b/driver/fuzz/src/targets.rs @@ -0,0 +1,505 @@ +//! Fuzz-target bodies. Each takes a raw `&[u8]` (what libFuzzer / the replay +//! runner provide) and runs the code under test plus its oracles. A violated +//! oracle panics, which libFuzzer reports as a crash and the replay runner +//! surfaces as a process abort. + +/// The set of valid target names (kept in sync with the functions below). +pub const NAMES: &[&str] = &["callouts", "callouts_mt", "simulation"]; + +/// Dispatch by name. Returns `false` for an unknown target. +pub fn run(name: &str, data: &[u8]) -> bool { + match name { + "callouts" => callouts(data), + "callouts_mt" => callouts_mt(data), + "simulation" => simulation(data), + _ => return false, + } + true +} + +/// Number of op variants the byte decoder recognizes. +const N_OPS: u8 = 18; + +// Self-drain thresholds. With one persistent device across inputs, an op stream +// that pends flows but never answers them would grow the packet cache, and one +// that opens connections but never ends them would grow the connection cache, +// without bound -- draining here keeps that growth from being mistaken for a +// leak. Both caches are keyed by the small fuzz address pool, so these limits +// are only a backstop and are rarely hit in practice. +const PACKET_CACHE_LIMIT: usize = 8192; +const CONN_CACHE_LIMIT: usize = 8192; +// The mock injector records every injected packet (so harnesses can replay +// them); with one persistent device that queue would grow without bound too. +const INJECTED_LIMIT: usize = 8192; + +/// Minimal byte-stream reader for the op grammar. Reads past the end yield +/// `0`/empty; the loop stops once the cursor passes the end, so a truncated +/// final op simply runs with zeroed fields (still fully deterministic, which +/// keeps hand-authored corpus seeds stable). +struct Stream<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Stream<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + fn done(&self) -> bool { + self.pos >= self.data.len() + } + fn u8(&mut self) -> u8 { + let b = self.data.get(self.pos).copied().unwrap_or(0); + self.pos += 1; + b + } + fn u16(&mut self) -> u16 { + u16::from_le_bytes([self.u8(), self.u8()]) + } + fn take(&mut self, n: usize) -> &'a [u8] { + let start = self.pos.min(self.data.len()); + let end = (self.pos + n).min(self.data.len()); + self.pos += n; + &self.data[start..end] + } +} + +// Map one length byte to a payload size. Wider than a single header (0..=510) +// so the `raw` packet path stresses the in-callout parser with arbitrary, +// larger packet data, and structured packets carry varied payloads. +fn payload_len(b: u8) -> usize { + (b as usize) * 2 +} + +/// Stateful callout pipeline -- the main reason this harness exists. Decodes the +/// input into a sequence of operations replayed against one persistent mock +/// `Device`: ALE callouts pend flows, `Verdict`/`Update` commands resolve them, +/// and packet-layer callouts then read the resulting verdict. Oracles: +/// * Tier 1: no panic / no OOB / no UB (libFuzzer + ASAN). +/// * Tier 2: ALE + structured packet callouts always set an action; after a +/// `Shutdown` op the packet cache is empty. +/// * Tier 3: for a structured TCP/UDP flow carrying a permanent verdict, the +/// packet-layer action matches that verdict. +pub fn callouts(data: &[u8]) { + driver::fuzz_api::ensure_device(); + let mut s = Stream::new(data); + drive_ops(&mut s, false); +} + +/// Multithreaded callout stress -- one shared persistent `Device`, many worker +/// threads driving the callouts at the same time, in all combinations. +/// +/// Layout of `data`: byte 0 selects the worker count (2..=8); the rest is one +/// op stream. Every worker runs the *whole* op stream, phase-shifted by a +/// distinct rotation, so all workers hammer overlapping connection-pool slots +/// concurrently with varied op orders -- maximizing contention on the same RCU +/// ports / packet cache. Workers are released together by a barrier. +/// +/// Oracles under concurrency are interleaving-independent: +/// * Tier 1: no panic / no ASAN / no ThreadSanitizer report (the main one). +/// * Tier 2 (per call): ALE + structured packet callouts always set an action. +/// * Post-join quiesce: with no worker running, a `Shutdown` must fully drain +/// the packet cache. +/// The Tier-3 cross-layer verdict mapping and the mid-stream "Shutdown -> empty" +/// assert are dropped here: another thread can change a verdict or pend a packet +/// between the observation and the check, so they are not race-stable. +pub fn callouts_mt(data: &[u8]) { + use driver::fuzz_api as api; + api::ensure_device(); + + if data.len() < 2 { + // Too short for a header + body; still run a single pass so the input + // isn't wasted (and the empty/short cases get exercised). + let mut s = Stream::new(data); + drive_ops(&mut s, true); + return; + } + + let n_threads = 2 + (data[0] as usize % 7); // 2..=8 workers + let body: Vec = data[1..].to_vec(); + let len = body.len(); + if len == 0 { + return; + } + // Spread each worker's starting phase across the body. + let step = (len / n_threads).max(1); + + let barrier = std::sync::Arc::new(std::sync::Barrier::new(n_threads)); + let mut handles = Vec::with_capacity(n_threads); + for i in 0..n_threads { + let mut chunk = body.clone(); + let rot = (i * step) % len; + chunk.rotate_left(rot); + let barrier = barrier.clone(); + handles.push(std::thread::spawn(move || { + // Release all workers at once for maximum overlap. + barrier.wait(); + let mut s = Stream::new(&chunk); + drive_ops(&mut s, true); + })); + } + + // Join all workers; a worker panic (oracle violation) surfaces as a join + // error, which we re-raise on the main thread so libFuzzer records a crash. + // (Memory/race errors abort the whole process via the sanitizer directly.) + let mut panicked = false; + for h in handles { + if h.join().is_err() { + panicked = true; + } + } + if panicked { + panic!("callouts_mt: a worker thread panicked (oracle violation / crash)"); + } + + // Quiesce oracle: nothing is running now, so a shutdown must fully drain the + // packet cache regardless of how the threads interleaved. + api::device_shutdown(); + assert_eq!( + api::packet_cache_len(), + 0, + "packet cache not drained after MT quiesce + shutdown" + ); +} + +/// Full-driver simulation -- the highest-fidelity target. Loads the *real* +/// driver via `DriverEntry` (over the mocked WDK), then runs the asynchronous +/// channel pipeline with real OS threads: +/// * a "kernel" producer (this thread) drives event-producing callouts from +/// the op stream -- ALE/packet callouts pend flows and push events, endpoint +/// closures and resource events fire; +/// * a user-space consumer thread blocks on `DriverConnection::read`, parses +/// the event stream, and writes `Verdict` commands back over the channel +/// (the verdict bytes come from the fuzz input, so all verdict kinds -- +/// accept/block/drop/redirect and the permanent variants -- get explored). +/// +/// This exercises the full lifecycle (load -> dispatch -> unload), the blocking +/// event queue, the IRP read/write path, and the pend -> event -> verdict -> +/// inject loop under genuine producer/consumer concurrency (so ThreadSanitizer +/// sees real races if any exist). +/// +/// Oracles (interleaving-independent): +/// * Tier 1: no panic / no ASAN / no ThreadSanitizer report. +/// * Tier 2 (per call): ALE + structured packet callouts always set an action. +/// * Post-shutdown quiesce: once the producer is done and the driver is shut +/// down, the packet cache is empty (every pended flow was resolved or drained). +pub fn simulation(data: &[u8]) { + use driver::sim; + + // Each input gets a fresh driver: full DriverEntry -> run -> unload lifecycle. + let Ok(simulation) = sim::Simulation::start() else { + return; + }; + + // User-space side: reply to each connection event with a fuzz-chosen verdict. + // `Device::write` pops the pended packet for any verdict byte (valid kinds + // inject, invalid ones just drop it), so the packet cache drains as events + // flow regardless of the bytes. + let verdict_seed: Vec = data.to_vec(); + let mut vc: usize = 0; + let consumer = sim::spawn_consumer( + simulation.connect(), + move |_event| { + let verdict = if verdict_seed.is_empty() { + 3 // PermanentAccept + } else { + let b = verdict_seed[vc % verdict_seed.len()]; + vc = vc.wrapping_add(1); + b + }; + Some(verdict) + }, + None, + ); + + // "Kernel" side: drive the event-producing callouts from the op stream. + let mut s = Stream::new(data); + drive_producer(&mut s); + + // Quiesce: resolve pending packets and run down the queue so the consumer's + // blocking read returns end-of-file, then wait for it to finish. + simulation.shutdown(); + let stats = consumer.join_timeout(std::time::Duration::from_secs(60)); + assert!( + stats.is_some(), + "simulation: consumer thread did not exit after shutdown (hang)" + ); + + // With the producer done and the driver shut down, nothing remains pended. + assert_eq!( + driver::fuzz_api::packet_cache_len(), + 0, + "simulation: packet cache not drained after shutdown" + ); + + drop(simulation); +} + +/// Number of producer (event-generating) op variants for the simulation target. +const N_PRODUCER_OPS: u8 = 12; + +/// Decode and run only the event-*producing* callouts against the installed +/// device. Unlike [`drive_ops`], this issues no `Verdict`/`Update`/`Shutdown` +/// commands and never reads the event queue -- in the simulation those are the +/// user-space consumer's job, over the channel -- so it never blocks on the +/// (now blocking) event queue. +fn drive_producer(s: &mut Stream) { + use driver::fuzz_api as api; + + while !s.done() { + let op = s.u8() % N_PRODUCER_OPS; + match op { + // ---- ALE callouts: always set an action ---- + 0 | 1 | 2 | 3 => { + let conn = s.u8(); + let proto = s.u8(); + let flags = s.u8(); + let pid = s.u16() as u64; + let plen = payload_len(s.u8()); + let payload = s.take(plen); + let reauth = flags & 1 != 0; + let raw = flags & 2 != 0; + let r = match op { + 0 => api::run_ale_connect_v4(conn, proto, reauth, pid, payload, raw), + 1 => api::run_ale_accept_v4(conn, proto, reauth, pid, payload, raw), + 2 => api::run_ale_connect_v6(conn, proto, reauth, pid, payload, raw), + _ => api::run_ale_accept_v6(conn, proto, reauth, pid, payload, raw), + }; + assert!(r.action != 0, "ALE callout {op} left no action set"); + } + // ---- Packet-layer callouts ---- + 4 | 5 | 6 | 7 => { + let conn = s.u8(); + let proto = s.u8(); + let flags = s.u8(); + let plen = payload_len(s.u8()); + let payload = s.take(plen); + let raw = flags & 2 != 0; + let r = match op { + 4 => api::run_packet_in_v4(conn, proto, payload, raw), + 5 => api::run_packet_out_v4(conn, proto, payload, raw), + 6 => api::run_packet_in_v6(conn, proto, payload, raw), + _ => api::run_packet_out_v6(conn, proto, payload, raw), + }; + if !raw && api::proto_is_connectable(proto) { + assert!(r.action != 0, "packet callout {op} left no action set"); + } + } + // ---- Endpoint closure (emits a connection-end event) ---- + 8 => { + let conn = s.u8(); + let proto = s.u8(); + let pid = s.u16() as u64; + api::run_endpoint_close_v4(conn, proto, pid); + } + 9 => { + let conn = s.u8(); + let proto = s.u8(); + let pid = s.u16() as u64; + api::run_endpoint_close_v6(conn, proto, pid); + } + // ---- Resource monitor (port assignment-discard / release) ---- + 10 => { + let kind = s.u8(); + let proto = s.u8(); + let port = s.u16(); + api::run_resource_v4(kind, proto, port); + } + _ => { + let kind = s.u8(); + let proto = s.u8(); + let port = s.u16(); + api::run_resource_v6(kind, proto, port); + } + } + } +} + +/// Decode and execute the op stream against the (already installed) global +/// device. `concurrent` selects the oracle set: when other threads may be +/// mutating the same device, race-sensitive checks (Tier-3 verdict mapping and +/// the mid-stream shutdown-empties-cache assert) are skipped; everything else +/// (no panic, per-call "an action was set") still holds. +fn drive_ops(s: &mut Stream, concurrent: bool) { + use driver::fuzz_api as api; + + while !s.done() { + // Keep persistent state bounded (see thresholds above). + if api::packet_cache_len() > PACKET_CACHE_LIMIT { + api::device_shutdown(); + } + if api::active_conn_count() > CONN_CACHE_LIMIT { + api::device_clear_cache(); + } + if api::injected_len() > INJECTED_LIMIT { + let _ = api::drain_injected(); + } + + let op = s.u8() % N_OPS; + match op { + // ---- ALE callouts: always set an action ---- + 0 | 1 | 2 | 3 => { + let conn = s.u8(); + let proto = s.u8(); + let flags = s.u8(); + let pid = s.u16() as u64; + let plen = payload_len(s.u8()); + let payload = s.take(plen); + let reauth = flags & 1 != 0; + let raw = flags & 2 != 0; + let r = match op { + 0 => api::run_ale_connect_v4(conn, proto, reauth, pid, payload, raw), + 1 => api::run_ale_accept_v4(conn, proto, reauth, pid, payload, raw), + 2 => api::run_ale_connect_v6(conn, proto, reauth, pid, payload, raw), + _ => api::run_ale_accept_v6(conn, proto, reauth, pid, payload, raw), + }; + assert!(r.action != 0, "ALE callout {op} left no action set"); + } + // ---- Packet-layer callouts ---- + 4 | 5 | 6 | 7 => { + let conn = s.u8(); + let proto = s.u8(); + let flags = s.u8(); + let plen = payload_len(s.u8()); + let payload = s.take(plen); + let raw = flags & 2 != 0; + let v4 = op == 4 || op == 5; + // Verdict the packet layer is about to act on (read before the op; + // a permanent verdict is not modified by the packet layer). Only + // meaningful single-threaded -- under concurrency another thread + // could change it, so we don't read or assert on it. + let verdict = if concurrent { + None + } else if v4 { + api::verdict_for_v4(conn, proto) + } else { + api::verdict_for_v6(conn, proto) + }; + let r = match op { + 4 => api::run_packet_in_v4(conn, proto, payload, raw), + 5 => api::run_packet_out_v4(conn, proto, payload, raw), + 6 => api::run_packet_in_v6(conn, proto, payload, raw), + _ => api::run_packet_out_v6(conn, proto, payload, raw), + }; + // A structured TCP/UDP packet always parses, so an action is set. + if !raw && api::proto_is_connectable(proto) { + assert!(r.action != 0, "packet callout {op} left no action set"); + if let Some(v) = verdict { + check_perm_verdict(op, v, r); + } + } + } + // ---- Verdict command (resolve a pended packet) ---- + 8 => { + let id_sel = s.u8(); + let verdict = s.u8(); + let ids = api::live_ids(); + let id = if ids.is_empty() { + id_sel as u64 + } else { + ids[id_sel as usize % ids.len()] + }; + api::device_write_verdict(id, verdict); + } + // ---- Update commands (set a verdict by key) ---- + 9 => { + let conn = s.u8(); + let proto = s.u8(); + let verdict = s.u8(); + api::device_write_update_v4(conn, proto, verdict); + } + 10 => { + let conn = s.u8(); + let proto = s.u8(); + let verdict = s.u8(); + api::device_write_update_v6(conn, proto, verdict); + } + // ---- Endpoint closure ---- + 11 => { + let conn = s.u8(); + let proto = s.u8(); + let pid = s.u16() as u64; + api::run_endpoint_close_v4(conn, proto, pid); + } + 12 => { + let conn = s.u8(); + let proto = s.u8(); + let pid = s.u16() as u64; + api::run_endpoint_close_v6(conn, proto, pid); + } + // ---- Resource monitor (port assignment-discard / release) ---- + 13 => { + let kind = s.u8(); + let proto = s.u8(); + let port = s.u16(); + api::run_resource_v4(kind, proto, port); + } + 14 => { + let kind = s.u8(); + let proto = s.u8(); + let port = s.u16(); + api::run_resource_v6(kind, proto, port); + } + // ---- Read / drain events ---- + 15 => { + let n = s.u8(); + api::drain_events(n); + } + // ---- Clear connection cache ---- + 16 => { + api::device_clear_cache(); + } + // ---- Shutdown: must leave the packet cache empty ---- + _ => { + api::device_shutdown(); + // Race-stable only when single-threaded: another worker may pend + // a packet right after the shutdown drains the cache. + if !concurrent { + assert_eq!( + api::packet_cache_len(), + 0, + "packet cache not drained by shutdown" + ); + } + } + } + } +} + +// Tier-3 oracle: a permanent verdict fully determines the packet-layer action +// for a found TCP/UDP flow. `op` is only used to label assertion failures. +fn check_perm_verdict(op: u8, verdict: u8, r: driver::fuzz_api::CalloutResult) { + use driver::fuzz_api::{FWP_ACTION_BLOCK, FWP_ACTION_PERMIT}; + match verdict { + // PermanentAccept + 3 => assert_eq!( + r.action, FWP_ACTION_PERMIT, + "op {op}: PermanentAccept must permit" + ), + // PermanentBlock + 5 => { + assert_eq!( + r.action, FWP_ACTION_BLOCK, + "op {op}: PermanentBlock must block" + ); + assert!(!r.absorb, "op {op}: PermanentBlock must not absorb"); + } + // PermanentDrop + 7 => { + assert_eq!( + r.action, FWP_ACTION_BLOCK, + "op {op}: PermanentDrop must block" + ); + assert!(r.absorb, "op {op}: PermanentDrop must absorb"); + } + // RedirectNameServer / RedirectTunnel: the original packet is blocked+absorbed. + 8 | 9 => { + assert_eq!( + r.action, FWP_ACTION_BLOCK, + "op {op}: Redirect must block original" + ); + assert!(r.absorb, "op {op}: Redirect must absorb original"); + } + _ => {} // temporary / invalid verdicts have no single-valued mapping + } +} diff --git a/driver/proptest-regressions/connection_cache.txt b/driver/proptest-regressions/connection_cache.txt new file mode 100644 index 0000000..1cb0296 --- /dev/null +++ b/driver/proptest-regressions/connection_cache.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 98fc96da1ccc8986c0140eb586c1d46cc2ed4710e10c9193a481061efad7e3eb # shrinks to ops = [Add(0), Advance(70000), Advance(30000), Advance(30000), Clean] diff --git a/driver/src/ale_callouts.rs b/driver/src/ale_callouts.rs index 3a8839e..b9e03b6 100644 --- a/driver/src/ale_callouts.rs +++ b/driver/src/ale_callouts.rs @@ -760,3 +760,319 @@ pub fn ale_resource_monitor(data: CalloutData) { _ => {} } } + +#[cfg(all(test, feature = "mock"))] +mod tests { + use super::ale_layer_accept_v4; + use crate::connection::{Key, Verdict}; + use crate::device::Device; + use crate::entry::{clear_device, get_device, set_device}; + use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Address}; + use wdk::driver::Driver; + use wdk::filter_engine::callout_data::{CalloutData, Value}; + use wdk::filter_engine::classify::{ClassifyOut, FWP_ACTION_BLOCK, FWP_ACTION_PERMIT}; + use wdk::filter_engine::layer::{FieldsAleAuthRecvAcceptV4, FieldsInboundIppacketV4, Layer}; + use wdk::filter_engine::net_buffer::NET_BUFFER_LIST; + use wdk::irp_helpers::WriteRequest; + + // Callouts reach the device through the global `static mut DEVICE`, so tests + // that install a device must not run concurrently -- with each other or with + // the global-slot tests in other modules (entry, fuzz_api, sim), hence the + // shared process-wide lock. + use crate::entry::DEVICE_TEST_LOCK as DEVICE_LOCK; + + fn install_device() { + let device = Box::new(Device::new(&Driver::mock()).expect("mock device")); + set_device(Box::into_raw(device)); + } + + fn uninstall_device() { + let ptr = clear_device(); + if !ptr.is_null() { + unsafe { drop(Box::from_raw(ptr)) }; + } + } + + fn ale_recv_v4_values( + proto: u8, + local_ip: [u8; 4], + local_port: u16, + remote_ip: [u8; 4], + remote_port: u16, + ) -> Vec { + type F = FieldsAleAuthRecvAcceptV4; + let mut v = vec![Value::U32(0); F::Max as usize]; + v[F::IpProtocol as usize] = Value::U8(proto); + v[F::IpLocalAddress as usize] = Value::U32(u32::from_be_bytes(local_ip)); + v[F::IpLocalPort as usize] = Value::U16(local_port); + v[F::IpRemoteAddress as usize] = Value::U32(u32::from_be_bytes(remote_ip)); + v[F::IpRemotePort as usize] = Value::U16(remote_port); + v[F::Flags as usize] = Value::U32(0); + v + } + + fn inbound_ippacket_v4_values() -> Vec { + vec![Value::U32(0); FieldsInboundIppacketV4::Max as usize] + } + + // Minimal IPv4 + TCP header carrying the given addresses/ports. Only the + // fields `get_key_from_nb_v4` reads are populated. + fn ipv4_tcp_packet(src: [u8; 4], dst: [u8; 4], src_port: u16, dst_port: u16) -> Vec { + let mut p = vec![0u8; 40]; + p[0] = 0x45; // version 4, IHL 5 + p[9] = 6; // TCP + p[12..16].copy_from_slice(&src); + p[16..20].copy_from_slice(&dst); + p[20..22].copy_from_slice(&src_port.to_be_bytes()); + p[22..24].copy_from_slice(&dst_port.to_be_bytes()); + p + } + + fn ale_key() -> Key { + Key { + protocol: IpProtocol::Tcp, + local_address: IpAddress::Ipv4(Ipv4Address::new(10, 0, 0, 5)), + local_port: 8080, + remote_address: IpAddress::Ipv4(Ipv4Address::new(93, 184, 216, 34)), + remote_port: 12345, + } + } + + // Full pipeline: inbound ALE accept (pends) -> user-space PermanentAccept + // verdict -> packet layer permits the matching flow. Asserts the verdict set + // via the command channel is the one observed at the packet layer. + #[test] + fn ale_accept_verdict_packet_pipeline() { + let _g = DEVICE_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + install_device(); + + // 1. New inbound connection -> block + absorb, pended for user space. + let mut co = ClassifyOut::new(); + let mut ale_nbl = NET_BUFFER_LIST::new_box(vec![0u8; 60], 40); + let data = CalloutData::mock( + Layer::AleAuthRecvAcceptV4, + ale_recv_v4_values(6, [10, 0, 0, 5], 8080, [93, 184, 216, 34], 12345), + Some(4321), + &mut co as *mut _, + ale_nbl.as_mut() as *mut _, + 20, + 20, + ); + ale_layer_accept_v4(data); + + assert_eq!(co.action(), FWP_ACTION_BLOCK); + assert!(co.is_absorb()); + { + let device = get_device().unwrap(); + assert_eq!( + device.connection_cache.get_verdict(&ale_key()).map(|v| v as u8), + Some(Verdict::Undecided as u8), + ); + assert_eq!(device.packet_cache.get_entries_count(), 1); + } + + // 2. User space returns PermanentAccept for packet id 1. + let mut cmd = vec![1u8]; // CommandType::Verdict + cmd.extend_from_slice(&1u64.to_le_bytes()); + cmd.push(Verdict::PermanentAccept as u8); + { + let device = get_device().unwrap(); + let mut wr = WriteRequest::from_bytes(&cmd); + device.write(&mut wr); + assert_eq!( + device.connection_cache.get_verdict(&ale_key()).map(|v| v as u8), + Some(Verdict::PermanentAccept as u8), + ); + assert_eq!(device.packet_cache.get_entries_count(), 0); + } + + // 3. Packet layer sees the same flow and permits it. + let mut co2 = ClassifyOut::new(); + let mut pkt_nbl = NET_BUFFER_LIST::new_box( + ipv4_tcp_packet([93, 184, 216, 34], [10, 0, 0, 5], 12345, 8080), + 20, + ); + let data2 = CalloutData::mock( + Layer::InboundIppacketV4, + inbound_ippacket_v4_values(), + None, + &mut co2 as *mut _, + pkt_nbl.as_mut() as *mut _, + 0, + 0, + ); + crate::packet_callouts::ip_packet_layer_inbound_v4(data2); + assert_eq!(co2.action(), FWP_ACTION_PERMIT); + + uninstall_device(); + } + + // The ALE accept callout must not panic for any protocol value, and must + // always set some action. + #[test] + fn ale_accept_never_panics_across_protocols() { + let _g = DEVICE_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + install_device(); + for proto in [6u8, 17, 1, 0, 255] { + let mut co = ClassifyOut::new(); + let mut nbl = NET_BUFFER_LIST::new_box(vec![0u8; 60], 40); + let data = CalloutData::mock( + Layer::AleAuthRecvAcceptV4, + ale_recv_v4_values(proto, [10, 0, 0, 9], 1111, [1, 2, 3, 4], 2222), + Some(7), + &mut co as *mut _, + nbl.as_mut() as *mut _, + 20, + 20, + ); + ale_layer_accept_v4(data); + assert_ne!(co.action(), 0); + } + uninstall_device(); + } + + // Sanity check that the `fuzz_api` callout harness actually links the + // pipeline (ALE pend -> verdict command -> packet layer reads the verdict) + // and that its Tier-3 oracle is not vacuous. Shares DEVICE_LOCK with the + // other global-device tests. Mirrors what the `callouts` fuzz target asserts. + #[test] + fn fuzz_api_pipeline_links_and_oracle_holds() { + use crate::fuzz_api; + let _g = DEVICE_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + install_device(); + + // PermanentAccept pipeline on pool connection 0 (v4 TCP). + let r = fuzz_api::run_ale_accept_v4(0, 0, false, 100, &[], false); + assert_ne!(r.action, 0, "ALE accept must set an action"); + let ids = fuzz_api::live_ids(); + assert_eq!(ids.len(), 1, "ALE accept must pend exactly one packet"); + fuzz_api::device_write_verdict(ids[0], Verdict::PermanentAccept as u8); + assert_eq!( + fuzz_api::verdict_for_v4(0, 0), + Some(Verdict::PermanentAccept as u8), + "verdict command must reach the connection cache", + ); + let r = fuzz_api::run_packet_in_v4(0, 0, &[], false); + assert_eq!(r.action, FWP_ACTION_PERMIT, "PermanentAccept must permit"); + assert!(!r.absorb); + + // PermanentBlock pipeline on pool connection 1 (v4 UDP). + let r = fuzz_api::run_ale_accept_v4(1, 1, false, 100, &[], false); + assert_ne!(r.action, 0); + let ids = fuzz_api::live_ids(); + assert_eq!(ids.len(), 1, "previous packet should have been consumed"); + fuzz_api::device_write_verdict(ids[0], Verdict::PermanentBlock as u8); + let r = fuzz_api::run_packet_in_v4(1, 1, &[], false); + assert_eq!(r.action, FWP_ACTION_BLOCK, "PermanentBlock must block"); + assert!(!r.absorb, "PermanentBlock must not absorb"); + + uninstall_device(); + } + + // One worker thread: a deterministic, varied mix of every callout + command + // op, all aimed at a small "hot" set of connection slots so threads collide + // hard on the same RCU ports and the packet cache. The only per-call + // invariant asserted is that an ALE callout always sets an action -- true + // regardless of how the threads interleave. + fn mt_worker(tid: u64, iters: u64) { + use crate::fuzz_api as api; + // Inline xorshift64, seeded per thread (deterministic per run). + let mut st = tid.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1; + const HOT: u8 = 6; // few connections -> maximum contention + for _ in 0..iters { + st ^= st << 13; + st ^= st >> 7; + st ^= st << 17; + let r = st; + let conn = (r as u8) % HOT; + let proto = (r >> 8) as u8; + let pid = (r >> 16) as u16 as u64; + let verdict = ((r >> 32) as u8) % 10; + match ((r >> 24) as u8) % 14 { + 0 => assert_ne!( + api::run_ale_connect_v4(conn, proto, false, pid, &[], false).action, + 0 + ), + 1 => assert_ne!( + api::run_ale_accept_v4(conn, proto, false, pid, &[], false).action, + 0 + ), + 2 => assert_ne!( + api::run_ale_connect_v6(conn, proto, false, pid, &[], false).action, + 0 + ), + 3 => assert_ne!( + api::run_ale_accept_v6(conn, proto, false, pid, &[], false).action, + 0 + ), + 4 => { + api::run_packet_in_v4(conn, proto, &[], false); + } + 5 => { + api::run_packet_out_v4(conn, proto, &[], false); + } + 6 => { + api::run_packet_in_v6(conn, proto, &[], false); + } + 7 => { + api::run_packet_out_v6(conn, proto, &[], false); + } + 8 => { + let ids = api::live_ids(); + let id = if ids.is_empty() { + r + } else { + ids[(r as usize) % ids.len()] + }; + api::device_write_verdict(id, verdict); + } + 9 => api::device_write_update_v4(conn, proto, verdict), + 10 => api::device_write_update_v6(conn, proto, verdict), + 11 => api::run_endpoint_close_v4(conn, proto, pid), + 12 => api::run_resource_v4((r >> 40) as u8, proto, (r >> 48) as u16), + _ => api::drain_events(2), + } + } + } + + // Stress: many threads pounding ONE shared device with concurrent callouts + + // commands in every combination. This is the "should never fail" check, and + // it runs natively on Windows via `just test-driver`. It exercises the real + // reader-writer lock in mock_wdk and the RCU reclamation path under genuine + // contention; it catches panics, deadlocks (test hang), use-after-free / + // double-free, and the kind of duplicate-id race the atomic IdCache fix + // closed. If the mock lock were still a no-op this test would corrupt/crash. + #[test] + fn callouts_mt_shared_device_stress() { + let _g = DEVICE_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + install_device(); + + const THREADS: usize = 8; + const ITERS: u64 = 10_000; + + let barrier = std::sync::Arc::new(std::sync::Barrier::new(THREADS)); + let mut handles = Vec::with_capacity(THREADS); + for t in 0..THREADS { + let barrier = barrier.clone(); + handles.push(std::thread::spawn(move || { + barrier.wait(); // release all workers together for maximum overlap + mt_worker(t as u64 + 1, ITERS); + })); + } + for h in handles { + h.join() + .expect("a worker thread panicked under concurrent callouts"); + } + + // Quiesce oracle: with all workers joined, a shutdown must fully drain + // the packet cache regardless of how the threads interleaved. + crate::fuzz_api::device_shutdown(); + assert_eq!( + crate::fuzz_api::packet_cache_len(), + 0, + "packet cache not drained after MT quiesce + shutdown" + ); + + uninstall_device(); + } +} diff --git a/driver/src/connection_cache.rs b/driver/src/connection_cache.rs index 43a42e4..fd89fc5 100644 --- a/driver/src/connection_cache.rs +++ b/driver/src/connection_cache.rs @@ -326,10 +326,6 @@ pub struct ConnectionCache { tcp_v6: Box>, udp_v6: Box>, - // Holds ended connection that need to be send as an event to user space. - tmp_ended_connections_buffer_v4: Vec>, - tmp_ended_connections_buffer_v6: Vec>, - // Holds unlinked connections arrays. unlinked_ports_v4: MpscQueue>, unlinked_ports_v6: MpscQueue>, @@ -343,8 +339,6 @@ impl ConnectionCache { udp_v4: alloc_port_array(), tcp_v6: alloc_port_array(), udp_v6: alloc_port_array(), - tmp_ended_connections_buffer_v4: Vec::with_capacity(100), - tmp_ended_connections_buffer_v6: Vec::with_capacity(100), unlinked_ports_v4: MpscQueue::new(), unlinked_ports_v6: MpscQueue::new(), } @@ -383,30 +377,31 @@ impl ConnectionCache { } // clean_ended_connections is not thread safe and should be called from one place only. - pub fn clean_ended_connections<'a>( - &'a mut self, - ) -> ( - &'a mut Vec>, - &'a mut Vec>, - ) { - self.tmp_ended_connections_buffer_v4.clear(); - self.tmp_ended_connections_buffer_v6.clear(); + // Returns the connections that have ended, so they can be sent as events to + // user space. Takes `&self` (the port arrays are internally locked) so the + // whole `Device` API can stay `&self`; the result is freshly owned. + pub fn clean_ended_connections( + &self, + ) -> (Vec>, Vec>) { + // The capacity is load-bearing, not just an optimization: `ports_clean_ended` + // only evicts a stale (inactive) connection while `capacity() > len()`, which + // bounds the eviction/event work per call. Allocating with capacity 100 + // preserves the original behaviour (this was a reused `with_capacity(100)` field). + let mut ended_v4 = Vec::with_capacity(100); + let mut ended_v6 = Vec::with_capacity(100); ports_clean_ended( &self.tcp_v4, &self.udp_v4, - &mut self.tmp_ended_connections_buffer_v4, + &mut ended_v4, &self.unlinked_ports_v4, ); ports_clean_ended( &self.tcp_v6, &self.udp_v6, - &mut self.tmp_ended_connections_buffer_v6, + &mut ended_v6, &self.unlinked_ports_v6, ); - return ( - &mut self.tmp_ended_connections_buffer_v4, - &mut self.tmp_ended_connections_buffer_v6, - ); + return (ended_v4, ended_v6); } pub fn get_verdict(&self, key: &Key) -> Option { @@ -472,6 +467,234 @@ impl ConnectionCache { } } +#[cfg(all(test, feature = "mock"))] +mod tests { + use super::*; + use crate::connection::{Direction, Verdict}; + use proptest::prelude::*; + use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Address, Ipv6Address}; + + // A non-loopback 2001:: address whose last byte is `last`. + fn v6(last: u8) -> Ipv6Address { + Ipv6Address::from_octets([ + 0x20, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, last, + ]) + } + + // Fixed pool of 6 distinct keys. None use the DNS (53) / SPN (717) remote + // port or a loopback/equal remote, so `redirect_equals` never matches and + // `get_verdict` resolves a key to at most its own connection. Key 5 shares + // the TCP/1000 bucket with key 0 (tests multiple connections per port). + fn key(i: usize) -> Key { + match i { + 0 => Key { + protocol: IpProtocol::Tcp, + local_address: IpAddress::Ipv4(Ipv4Address::new(10, 0, 0, 1)), + local_port: 1000, + remote_address: IpAddress::Ipv4(Ipv4Address::new(1, 1, 1, 1)), + remote_port: 80, + }, + 1 => Key { + protocol: IpProtocol::Udp, + local_address: IpAddress::Ipv4(Ipv4Address::new(10, 0, 0, 1)), + local_port: 1000, + remote_address: IpAddress::Ipv4(Ipv4Address::new(8, 8, 8, 8)), + remote_port: 5353, + }, + 2 => Key { + protocol: IpProtocol::Tcp, + local_address: IpAddress::Ipv4(Ipv4Address::new(10, 0, 0, 2)), + local_port: 2000, + remote_address: IpAddress::Ipv4(Ipv4Address::new(1, 1, 1, 1)), + remote_port: 443, + }, + 3 => Key { + protocol: IpProtocol::Tcp, + local_address: IpAddress::Ipv6(v6(1)), + local_port: 3000, + remote_address: IpAddress::Ipv6(v6(0x80)), + remote_port: 80, + }, + 4 => Key { + protocol: IpProtocol::Udp, + local_address: IpAddress::Ipv6(v6(2)), + local_port: 4000, + remote_address: IpAddress::Ipv6(v6(0x88)), + remote_port: 5353, + }, + _ => Key { + protocol: IpProtocol::Tcp, + local_address: IpAddress::Ipv4(Ipv4Address::new(10, 0, 0, 1)), + local_port: 1000, + remote_address: IpAddress::Ipv4(Ipv4Address::new(2, 2, 2, 2)), + remote_port: 80, + }, + } + } + + const POOL: usize = 6; + + fn verdict_from(v: u8) -> Verdict { + num_traits::FromPrimitive::from_u8(v).unwrap() + } + + #[derive(Clone, Copy, Debug)] + struct ConnModel { + verdict: u8, + ended: bool, + end_time: u64, + last_access: u64, + } + + #[derive(Clone, Debug)] + enum Op { + Add(usize), + Update(usize, u8), + End(usize), + EndAllOnPort(usize), + Clean, + Clear, + Advance(u64), + CheckVerdict(usize), + } + + fn op_strategy() -> impl Strategy { + prop_oneof![ + (0..POOL).prop_map(Op::Add), + (0..POOL, prop::sample::select(vec![0u8, 1, 2, 3, 4, 5, 6, 7, 10])) + .prop_map(|(i, v)| Op::Update(i, v)), + (0..POOL).prop_map(Op::End), + (0..POOL).prop_map(Op::EndAllOnPort), + Just(Op::Clean), + Just(Op::Clear), + prop::sample::select(vec![0u64, 30_000, 70_000, 130_000]).prop_map(Op::Advance), + (0..POOL).prop_map(Op::CheckVerdict), + ] + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(32))] + + // Model-based test of the connection cache against a simple reference + // model. Exercises the real RCU / unlinked-queue reclamation + // (alloc_zeroed + Box::from_raw) under randomized op sequences, asserting + // verdict lookups and active/ended counts stay consistent with the model. + #[test] + fn cache_state_machine(ops in proptest::collection::vec(op_strategy(), 0..32)) { + let mut now: u64 = 1_000_000; + wdk::utils::set_mock_time_ms(now); + + let cache = ConnectionCache::new(); + let mut model: [Option; POOL] = [None; POOL]; + + for op in ops { + match op { + Op::Add(i) => { + if model[i].is_none() { + let k = key(i); + if k.is_ipv6() { + let c = ConnectionV6::from_key(&k, 1, Direction::Outbound).unwrap(); + cache.add_v6(c); + } else { + let c = ConnectionV4::from_key(&k, 1, Direction::Outbound).unwrap(); + cache.add_v4(c); + } + model[i] = Some(ConnModel { + verdict: 0, + ended: false, + end_time: 0, + last_access: now, + }); + } + } + Op::Update(i, v) => { + cache.update_connection(key(i), verdict_from(v)); + if let Some(cm) = &mut model[i] { + cm.verdict = v; + } + } + Op::End(i) => { + let k = key(i); + if k.is_ipv6() { + cache.end_v6(k); + } else { + cache.end_v4(k); + } + if let Some(cm) = &mut model[i] { + cm.ended = true; + cm.end_time = now; + } + } + Op::EndAllOnPort(i) => { + let k = key(i); + if k.is_ipv6() { + cache.end_all_on_port_v6((k.protocol, k.local_port)); + } else { + cache.end_all_on_port_v4((k.protocol, k.local_port)); + } + for j in 0..POOL { + let kj = key(j); + if kj.is_ipv6() == k.is_ipv6() + && kj.protocol == k.protocol + && kj.local_port == k.local_port + { + if let Some(cm) = &mut model[j] { + if !cm.ended { + cm.ended = true; + cm.end_time = now; + } + } + } + } + } + Op::Clean => { + cache.clean_ended_connections(); + for slot in model.iter_mut() { + if let Some(cm) = *slot { + let remove = (cm.ended && cm.end_time < now.saturating_sub(60_000)) + || (cm.last_access < now.saturating_sub(120_000)); + if remove { + *slot = None; + } + } + } + } + Op::Clear => { + cache.clear(); + for slot in model.iter_mut() { + *slot = None; + } + } + Op::Advance(d) => { + now += d; + wdk::utils::set_mock_time_ms(now); + } + Op::CheckVerdict(i) => { + let got = cache.get_verdict(&key(i)).map(|v| v as u8); + let expected = model[i].map(|cm| cm.verdict); + prop_assert_eq!(got, expected); + if let Some(cm) = &mut model[i] { + cm.last_access = now; + } + } + } + } + + // Final invariants: verdict lookups and active/ended counts match. + for i in 0..POOL { + let got = cache.get_verdict(&key(i)).map(|v| v as u8); + let expected = model[i].map(|cm| cm.verdict); + prop_assert_eq!(got, expected, "verdict mismatch for key {}", i); + } + let (active, ended) = cache.get_entries_count(); + let exp_active = model.iter().filter(|m| matches!(m, Some(c) if !c.ended)).count(); + let exp_ended = model.iter().filter(|m| matches!(m, Some(c) if c.ended)).count(); + prop_assert_eq!(active, exp_active, "active count mismatch"); + prop_assert_eq!(ended, exp_ended, "ended count mismatch"); + } + } +} + impl Drop for ConnectionCache { fn drop(&mut self) { // Clear the cache diff --git a/driver/src/device.rs b/driver/src/device.rs index 0b96517..9784457 100644 --- a/driver/src/device.rs +++ b/driver/src/device.rs @@ -33,9 +33,61 @@ pub enum Packet { AleLayer(ClassifyDefer), } +/// Gives `&self` access to the `FilterEngine`, which needs `&mut` for its WFP transactions. +/// +/// It cannot sit behind the `rw_spin_lock::Mutex` like the rest of the shared state: acquiring +/// that raises the IRQL to DISPATCH_LEVEL, while the filter engine calls (`FwpmTransaction*`, +/// filter and callout (un)registration) must run at PASSIVE_LEVEL, and the reset and teardown +/// paths wait between attempts (`wdk::utils::sleep_ms` refuses to above APC_LEVEL). +/// +/// In the kernel build the mutable access is serialized by the driver instead: a filter reset +/// only ever runs on the thread holding the `filter_reset_queue` drain claim, and the teardown +/// runs from `shutdown`. WFP itself rejects overlapping transactions with +/// `STATUS_FWP_TXN_IN_PROGRESS`, which both paths retry on. Under the host mock a std mutex +/// guards it instead, so the multi-threaded harnesses stay free of data races. +pub(crate) struct FilterEngineCell { + #[cfg(not(feature = "mock"))] + inner: core::cell::UnsafeCell, + #[cfg(feature = "mock")] + inner: std::sync::Mutex, +} + +// See the type documentation for how access is serialized. +#[cfg(not(feature = "mock"))] +unsafe impl Sync for FilterEngineCell {} + +impl FilterEngineCell { + fn new(filter_engine: FilterEngine) -> Self { + Self { + #[cfg(not(feature = "mock"))] + inner: core::cell::UnsafeCell::new(filter_engine), + #[cfg(feature = "mock")] + inner: std::sync::Mutex::new(filter_engine), + } + } + + /// Runs `f` with mutable access to the filter engine. Must only be called at PASSIVE_LEVEL, + /// from the paths described on the type, and never re-entered from within `f`. + pub(crate) fn with(&self, f: impl FnOnce(&mut FilterEngine) -> R) -> R { + #[cfg(not(feature = "mock"))] + { + // SAFETY: see the type documentation. + f(unsafe { &mut *self.inner.get() }) + } + #[cfg(feature = "mock")] + { + let mut guard = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + f(&mut guard) + } + } +} + // Device Context pub struct Device { - pub(crate) filter_engine: FilterEngine, + // The device-facing API is `&self` (see `get_device`) so producer (callout) + // and consumer (read) threads can share one `&Device` soundly. Every field + // is interior-mutable; the filter engine through `FilterEngineCell`. + pub(crate) filter_engine: FilterEngineCell, pub(crate) read_leftover: ArrayHolder, pub(crate) event_queue: IOQueue, pub(crate) packet_cache: IdCache, @@ -53,7 +105,25 @@ pub struct Device { impl Device { /// Initialize all members of the device. Memory is handled by windows. /// Make sure everything is initialized here. + /// + /// The event queue is non-blocking: this is the constructor the synchronous + /// fuzz harness uses, where reads are drained on the same thread that drives + /// the callouts (a blocking read there would hang). The real driver and the + /// full-driver simulation use [`Device::new_with_blocking_reads`] instead, so + /// this is only reachable from the host (mock) builds. + #[cfg_attr(not(feature = "mock"), allow(dead_code))] pub fn new(driver: &Driver) -> Result { + Self::new_with_blocking_reads(driver, false) + } + + /// Like [`Device::new`] but selects whether `Device::read` blocks when the + /// event queue is empty. The real kernel queue always blocks, so under the + /// real `wdk` `blocking_reads` makes no difference; under the host mock it + /// chooses between the blocking and non-blocking `IOQueue`. + pub fn new_with_blocking_reads( + driver: &Driver, + blocking_reads: bool, + ) -> Result { let mut filter_engine = match FilterEngine::new(driver, 0xf19f6eef_9031_4339_b90a_613da727ee29) { Ok(fe) => fe, @@ -65,9 +135,13 @@ impl Device { } Ok(Self { - filter_engine, + filter_engine: FilterEngineCell::new(filter_engine), read_leftover: ArrayHolder::default(), - event_queue: IOQueue::new(), + event_queue: if blocking_reads { + IOQueue::new_blocking() + } else { + IOQueue::new() + }, packet_cache: IdCache::new(), connection_cache: ConnectionCache::new(), injector: Injector::new(), @@ -87,7 +161,7 @@ impl Device { /// Cleanup is called just before drop. // pub fn cleanup(&mut self) {} - fn write_buffer(&mut self, read_request: &mut ReadRequest, info: Info) { + fn write_buffer(&self, read_request: &mut ReadRequest, info: Info) { let bytes = info.as_bytes(); let count = read_request.write(bytes); @@ -99,7 +173,7 @@ impl Device { } /// Called when handle. Read is called from user-space. - pub fn read(&mut self, read_request: &mut ReadRequest) { + pub fn read(&self, read_request: &mut ReadRequest) { if let Some(data) = self.read_leftover.load() { // There are leftovers from previous request. let count = read_request.write(&data); @@ -144,12 +218,12 @@ impl Device { } // Called when handle.Write is called from user-space. - pub fn write(&mut self, write_request: &mut WriteRequest) { + pub fn write(&self, write_request: &mut WriteRequest) { // Try parsing the command. let mut buffer = write_request.get_buffer(); let command = protocol::command::parse_type(buffer); let Some(command) = command else { - err!("Unknown command number: {}", buffer[0]); + err!("unknown or empty command ({} bytes)", buffer.len()); return; }; @@ -164,7 +238,10 @@ impl Device { self.shutdown(); } CommandType::Verdict => { - let verdict = protocol::command::parse_verdict(buffer); + let Some(verdict) = protocol::command::parse_verdict(buffer) else { + err!("verdict command too short ({} bytes)", buffer.len()); + return; + }; wdk::dbg!("Verdict command"); // Received verdict decision for a specific connection. if let Some((key, mut packet)) = self.packet_cache.pop_id(verdict.id) { @@ -207,7 +284,10 @@ impl Device { } } CommandType::UpdateV4 => { - let update = protocol::command::parse_update_v4(buffer); + let Some(update) = protocol::command::parse_update_v4(buffer) else { + err!("updateV4 command too short ({} bytes)", buffer.len()); + return; + }; // Build the new action. if let Some(verdict) = FromPrimitive::from_u8(update.verdict) { // Update with new action. @@ -229,7 +309,10 @@ impl Device { } } CommandType::UpdateV6 => { - let update = protocol::command::parse_update_v6(buffer); + let Some(update) = protocol::command::parse_update_v6(buffer) else { + err!("updateV6 command too short ({} bytes)", buffer.len()); + return; + }; // Build the new action. if let Some(verdict) = FromPrimitive::from_u8(update.verdict) { // Update with new action. @@ -258,7 +341,10 @@ impl Device { self.reset_filters_and_inject(None); } CommandType::GetConnectionsUpdate => { - let update = protocol::command::parse_update_info(buffer); + let Some(update) = protocol::command::parse_update_info(buffer) else { + err!("getConnectionsUpdate command too short ({} bytes)", buffer.len()); + return; + }; let timestamp = update.timestamp; wdk::dbg!("GetConnectionsUpdate command"); @@ -422,8 +508,6 @@ impl Device { _ = self.event_queue.push(info); } - conn_v4.clear(); - // Process ended ipv6 connections for conn in conn_v6.iter() { let info = protocol::info::connection_end_event_v6_info( @@ -441,7 +525,6 @@ impl Device { ); _ = self.event_queue.push(info); } - conn_v6.clear(); } } } @@ -452,12 +535,12 @@ impl Device { /// Reachable from the shutdown command (write and device-control) and from the unload path, /// and safe to call more than once: the one-time part runs once, the rest runs on every call /// so anything that raced in afterwards is still resolved. - pub fn shutdown(&mut self) { + pub fn shutdown(&self) { if !self.shutdown_started.swap(true, Ordering::SeqCst) { // Remove the filters before anything else. Once they are gone no new classify call can // reach the callouts, so no new connection can be pended while the teardown runs. The // flag set above covers the classify calls that are already in flight. - if let Err(err) = self.filter_engine.unregister_filters() { + if let Err(err) = self.filter_engine.with(|fe| fe.unregister_filters()) { err!("failed to unregister filters on shutdown: {}", err); } @@ -471,7 +554,7 @@ impl Device { // Nothing is pended anymore, so the callouts can be removed. This has to happen here and // not be left to the filter engine being dropped: unregistering a callout fails while an // operation is still pended on it, and the drop has no way to complete one. - if let Err(err) = self.filter_engine.unregister_callouts() { + if let Err(err) = self.filter_engine.with(|fe| fe.unregister_callouts()) { err!("failed to unregister callouts on shutdown: {}", err); } } @@ -481,7 +564,7 @@ impl Device { /// IRP can never be completed, the thread that owns it never leaves kernel mode, and its /// process can no longer be terminated. Everything is resolved with a verdict here so the /// callouts can be unregistered cleanly. - fn complete_pending_packets(&mut self) { + fn complete_pending_packets(&self) { for _ in 0..10 { // Wait before every pass, including the first one, so a classify that was still on its // way to pend_operation when the filters were removed has time to land in the cache. @@ -508,7 +591,7 @@ impl Device { } } - pub fn inject_packet(&mut self, packet: Packet, blocked: bool) -> Result<(), String> { + pub fn inject_packet(&self, packet: Packet, blocked: bool) -> Result<(), String> { match packet { Packet::PacketLayer(nbl, inject_info) => { if !blocked { @@ -558,7 +641,7 @@ impl Device { /// /// Reports failures through the log rather than to the caller: a reset serves a whole batch, so /// a failed one is not attributable to the connection that happened to trigger it. - fn reset_filters_and_inject(&mut self, pending: PendingReset) { + fn reset_filters_and_inject(&self, pending: PendingReset) { if !self.filter_reset_queue.push_and_claim(pending) { return; } @@ -577,7 +660,7 @@ impl Device { return; }; - match self.filter_engine.reset_all_filters() { + match self.filter_engine.with(|fe| fe.reset_all_filters()) { Ok(()) => { attempts = 0; let len = batch.len(); @@ -649,3 +732,47 @@ impl Drop for Device { // dbg!("Device Context drop called."); } } + +#[cfg(all(test, feature = "mock"))] +mod tests { + use super::*; + use proptest::prelude::*; + use wdk::driver::Driver; + use wdk::irp_helpers::WriteRequest; + + fn mock_device() -> Device { + Device::new(&Driver::mock()).expect("mock device construction") + } + + fn write(device: &mut Device, bytes: &[u8]) { + let mut wr = WriteRequest::from_bytes(bytes); + device.write(&mut wr); + } + + // Regression for the two confirmed command-channel bugs: an empty buffer + // (used to panic on `bytes[0]`) and short command tails (used to OOB-read + // `size_of::()` bytes in `as_type`). Must now be handled without panicking. + #[test] + fn device_write_handles_empty_and_short_commands() { + let mut device = mock_device(); + write(&mut device, &[]); + for cmd in 0u8..=8 { + write(&mut device, &[cmd]); + write(&mut device, &[cmd, 0]); + write(&mut device, &[cmd, 0, 0, 0]); + } + for cmd in [9u8, 50, 200, 255] { + write(&mut device, &[cmd, 1, 2, 3]); + } + } + + proptest! { + // The user-space command channel must never panic on arbitrary input. + #![proptest_config(ProptestConfig::with_cases(64))] + #[test] + fn device_write_never_panics(bytes in proptest::collection::vec(any::(), 0..96)) { + let mut device = mock_device(); + write(&mut device, &bytes); + } + } +} diff --git a/driver/src/entry.rs b/driver/src/entry.rs index a1a9241..0699045 100644 --- a/driver/src/entry.rs +++ b/driver/src/entry.rs @@ -3,24 +3,62 @@ use crate::device; use alloc::boxed::Box; use num_traits::FromPrimitive; use wdk::irp_helpers::{DeviceControlRequest, ReadRequest, WriteRequest}; +use wdk::kernel_types::{ + DEVICE_OBJECT, DRIVER_OBJECT, IRP, NTSTATUS, STATUS_FAILED_DRIVER_ENTRY, STATUS_SUCCESS, + UNICODE_STRING, +}; use wdk::{err, info, interface}; -use windows_sys::Wdk::Foundation::{DEVICE_OBJECT, DRIVER_OBJECT, IRP}; -use windows_sys::Win32::Foundation::{NTSTATUS, STATUS_SUCCESS}; - -static VERSION: [u8; 4] = include!("../../kext_interface/version.txt"); +// Global device pointer. Available in all build configurations: the packet +// callouts reach the device through `get_device()`, the host fuzz harness +// installs a mock `Device` here, and the full-driver simulation installs one via +// the real `driver_entry` below. `get_device` hands out a shared `&'static` so +// producer (callout) and consumer (read) threads can share one device soundly. static mut DEVICE: *mut device::Device = core::ptr::null_mut(); -pub fn get_device() -> Option<&'static mut device::Device> { - return unsafe { DEVICE.as_mut() }; + +pub fn get_device() -> Option<&'static device::Device> { + return unsafe { DEVICE.as_ref() }; } -// DriverEntry is the entry point of the driver (main function). Will be called when driver is loaded. -// Name should not be changed -#[export_name = "DriverEntry"] +/// Test/fuzz helper: install a device pointer as the global device. The caller +/// owns the allocation (typically `Box::into_raw`) and must clear it with +/// `clear_device` afterwards. Not compiled into production. +#[cfg(any(test, feature = "mock"))] +pub fn set_device(device: *mut device::Device) { + unsafe { DEVICE = device }; +} + +/// Test/fuzz helper: detach and return the current global device pointer. +#[cfg(any(test, feature = "mock"))] +pub fn clear_device() -> *mut device::Device { + unsafe { + let ptr = DEVICE; + DEVICE = core::ptr::null_mut(); + ptr + } +} + +/// Serializes tests that install a `Device` into the global slot, which is a +/// process-wide singleton. Any test (in any module) that calls `driver_entry`, +/// `set_device`, or otherwise touches the slot must hold this for its duration. +#[cfg(any(test, feature = "mock"))] +pub static DEVICE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +static VERSION: [u8; 4] = include!("../../kext_interface/version.txt"); + +// The real WDM/WFP driver entry plumbing. It is now build-agnostic: under the +// host mock the kernel types resolve to `mock_wdk` stand-ins, so the full-driver +// simulation drives this exact code (DriverEntry + the IRP dispatch handlers) +// instead of a parallel re-implementation. Only the exported `DriverEntry` +// symbol is real-build specific. + +// DriverEntry is the entry point of the driver (main function). Will be called +// when the driver is loaded. The exported name must not change. +#[cfg_attr(not(feature = "mock"), export_name = "DriverEntry")] pub extern "system" fn driver_entry( - driver_object: *mut windows_sys::Wdk::Foundation::DRIVER_OBJECT, - registry_path: *mut windows_sys::Win32::Foundation::UNICODE_STRING, -) -> windows_sys::Win32::Foundation::NTSTATUS { + driver_object: *mut DRIVER_OBJECT, + registry_path: *mut UNICODE_STRING, +) -> NTSTATUS { info!("Starting initialization..."); // Initialize driver object. @@ -33,7 +71,7 @@ pub extern "system" fn driver_entry( Ok(driver) => driver, Err(status) => { err!("driver_entry: failed to initialize driver: {}", status); - return windows_sys::Win32::Foundation::STATUS_FAILED_DRIVER_ENTRY; + return STATUS_FAILED_DRIVER_ENTRY; } }; @@ -43,13 +81,16 @@ pub extern "system" fn driver_entry( driver.set_write_fn(driver_write); driver.set_device_control_fn(device_control); - // Initialize device. + // Initialize device. Reads block until an event is available (or the queue is + // run down): on the real kernel the queue always blocks; under the mock this + // makes a user-space reader thread block on `Device::read` like the real + // driver. The synchronous fuzz path uses `Device::new` (non-blocking) instead. unsafe { - let device = match device::Device::new(&driver) { + let device = match device::Device::new_with_blocking_reads(&driver, true) { Ok(device) => Box::new(device), Err(err) => { wdk::err!("filed to initialize device: {}", err); - return -1; + return STATUS_FAILED_DRIVER_ENTRY; } }; DEVICE = Box::into_raw(device); @@ -59,13 +100,14 @@ pub extern "system" fn driver_entry( } // driver_unload function is called when service delete is called from user-space. -unsafe extern "system" fn driver_unload(_object: *const DRIVER_OBJECT) { +pub(crate) unsafe extern "system" fn driver_unload(_object: *const DRIVER_OBJECT) { info!("Unloading complete"); unsafe { if !DEVICE.is_null() { // Dropping the device completes every pending operation and unregisters the callouts, // so no callout can still be running once this returns. Only then is it safe to clear - // the pointer the callouts read through. + // the pointer the callouts read through. Nulling it also keeps a subsequent load in + // the same process (as the simulation/tests do) from a use-after-free. _ = Box::from_raw(DEVICE); DEVICE = core::ptr::null_mut(); } @@ -73,10 +115,7 @@ unsafe extern "system" fn driver_unload(_object: *const DRIVER_OBJECT) { } // driver_read event triggered from user-space on file.Read. -unsafe extern "system" fn driver_read( - _device_object: &mut DEVICE_OBJECT, - irp: &mut IRP, -) -> NTSTATUS { +unsafe extern "system" fn driver_read(_device_object: &mut DEVICE_OBJECT, irp: &mut IRP) -> NTSTATUS { let mut read_request = ReadRequest::new(irp); let Some(device) = get_device() else { read_request.complete(); @@ -137,3 +176,35 @@ unsafe extern "system" fn device_control( control_request.complete(); control_request.get_status() } + +#[cfg(all(test, feature = "mock"))] +mod tests { + use super::*; + use wdk::kernel_types::{ + DRIVER_OBJECT, IRP_MJ_DEVICE_CONTROL, IRP_MJ_READ, IRP_MJ_WRITE, UNICODE_STRING, + }; + + // The real DriverEntry, run under the mock, installs a device in the global + // slot and registers the IRP dispatch handlers; the registered DriverUnload + // tears it back down and clears the slot. + #[test] + fn driver_entry_loads_device_and_registers_handlers() { + let _g = DEVICE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + + let mut driver_object = DRIVER_OBJECT::new(); + let mut registry = UNICODE_STRING; + let status = driver_entry(&mut driver_object, &mut registry); + + assert_eq!(status, STATUS_SUCCESS); + assert!(get_device().is_some()); + assert!(driver_object.MajorFunction[IRP_MJ_READ].is_some()); + assert!(driver_object.MajorFunction[IRP_MJ_WRITE].is_some()); + assert!(driver_object.MajorFunction[IRP_MJ_DEVICE_CONTROL].is_some()); + let unload = driver_object.DriverUnload.expect("DriverUnload registered"); + + // Tearing down via the registered unload frees the device and clears the + // slot (so a stale pointer can't be dereferenced afterwards). + unsafe { unload(&driver_object) }; + assert!(get_device().is_none()); + } +} diff --git a/driver/src/fuzz_api.rs b/driver/src/fuzz_api.rs new file mode 100644 index 0000000..67c172c --- /dev/null +++ b/driver/src/fuzz_api.rs @@ -0,0 +1,1480 @@ +//! Public test/fuzz surface for the `driver` crate. +//! +//! Compiled only under the `mock` feature, so it is never part of the +//! production driver's public API. It re-exports the packet-handling functions +//! (some of which are `pub(crate)`) and provides small constructors that build +//! mock inputs, so external fuzz targets (in the `fuzz/` crate) can drive the +//! driver without knowing the internal module layout. +#![allow(missing_docs)] + +use alloc::boxed::Box; +use alloc::string::String; +use alloc::vec::Vec; +use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Address, Ipv6Address}; + +use protocol::command::CommandType; +use wdk::filter_engine::callout_data::{CalloutData, Value}; +use wdk::filter_engine::classify::ClassifyOut; +pub use wdk::filter_engine::layer::{self, Layer}; +pub use wdk::filter_engine::packet::InjectedPacket; +use wdk::irp_helpers::{ReadRequest, WriteRequest}; + +pub use crate::connection::{Direction, Key, Verdict}; +pub use crate::device::{Device, Packet}; +pub use crate::entry::{clear_device, get_device, set_device}; +pub use crate::packet_util::{get_key_from_nb_v4, get_key_from_nb_v6, recalc_header_checksums}; + +// Re-export the mock NBL type so fuzz targets can construct packet buffers. +pub use wdk::driver::Driver; +pub use wdk::filter_engine::net_buffer::{NetBuffer, NetBufferList, NET_BUFFER_LIST}; +// Re-export the classify action constants so the callout fuzz target can assert +// the verdict an action produced without depending on `wdk`/`mock_wdk` directly. +pub use wdk::filter_engine::classify::{FWP_ACTION_BLOCK, FWP_ACTION_PERMIT}; + +/// Construct a mock `Device` for end-to-end harnesses (command channel / callouts). +pub fn new_mock_device() -> Result { + let driver = Driver::mock(); + Device::new(&driver) +} + +// ============================================================================ +// Callout fuzzing surface +// +// Drives the real ALE and packet-layer callout entry points (`ale_layer_*`, +// `ip_packet_layer_*`, `endpoint_closure_*`, `ale_resource_monitor`) against a +// live `Device` installed in the global slot, exactly like the kernel does. +// +// All the mock-fidelity rules live here in ONE place: the classify `values` +// vector must be sized to the layer's `Fields::Max` (the mock indexes it and +// panics otherwise), each field must use the matching `Value` variant (else the +// mock silently reads 0 and keys don't match), and inbound NBLs need a +// `data_offset` that covers the headers the callout retreats past. The fuzz +// crate only decodes an op stream and asserts oracles -- it never touches these +// invariants. +// ============================================================================ + +/// Outcome of one callout invocation, as observed on the `ClassifyOut`. +#[derive(Clone, Copy)] +pub struct CalloutResult { + /// The action the callout set (`0` if it left none, e.g. on a parse error). + pub action: u32, + /// Whether the callout set the ABSORB flag. + pub absorb: bool, +} + +/// Number of distinct connection slots the fuzzer can address. The slot index +/// (op byte `conn` mod `POOL_SIZE`) selects a *stable* 5-tuple so the +/// ALE-pend -> verdict -> packet-layer pipeline links up across ops (fully +/// random tuples would never collide). The pool is deliberately diverse and +/// spans the whole address space (see `pool_v4`/`pool_v6`) so coverage is wide +/// while collisions stay common. The last slot is always a loopback connection +/// (special-cased by ALE). Fully fuzzer-controlled, arbitrary-range packets are +/// exercised separately via the `raw` packet path (the bytes become the packet +/// verbatim, so `get_key_from_nb` parses arbitrary addresses/ports/protocols). +pub const POOL_SIZE: u8 = 64; + +const IP4_HDR: u32 = 20; +const IP6_HDR: u32 = 40; +const TP_HDR: u32 = 20; + +#[derive(Clone, Copy)] +struct TupleV4 { + proto: IpProtocol, + local: Ipv4Address, + lport: u16, + remote: Ipv4Address, + rport: u16, +} + +#[derive(Clone, Copy)] +struct TupleV6 { + proto: IpProtocol, + local: Ipv6Address, + lport: u16, + remote: Ipv6Address, + rport: u16, +} + +/// An explicit IPv4 5-tuple for the `*_tuple` entry points, so harnesses (the +/// sim traffic producer) are not limited to the fixed fuzz pool. `protocol` is +/// the raw IP protocol number (6 TCP, 17 UDP, 1 ICMP, ...). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TupleSpecV4 { + pub protocol: u8, + pub local: [u8; 4], + pub local_port: u16, + pub remote: [u8; 4], + pub remote_port: u16, +} + +/// An explicit IPv6 5-tuple, see [`TupleSpecV4`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TupleSpecV6 { + pub protocol: u8, + pub local: [u8; 16], + pub local_port: u16, + pub remote: [u8; 16], + pub remote_port: u16, +} + +impl TupleV4 { + fn from_spec(spec: &TupleSpecV4) -> Self { + TupleV4 { + proto: IpProtocol::from(spec.protocol), + local: Ipv4Address::from_octets(spec.local), + lport: spec.local_port, + remote: Ipv4Address::from_octets(spec.remote), + rport: spec.remote_port, + } + } +} + +impl TupleV6 { + fn from_spec(spec: &TupleSpecV6) -> Self { + TupleV6 { + proto: IpProtocol::from(spec.protocol), + local: Ipv6Address::from_octets(spec.local), + lport: spec.local_port, + remote: Ipv6Address::from_octets(spec.remote), + rport: spec.remote_port, + } + } +} + +/// Deterministic 32-bit mix (splitmix-style), used to spread slot indices over +/// the entire address/port space. +fn mix32(x: u32) -> u32 { + let mut z = x.wrapping_mul(0x9E37_79B1).wrapping_add(0x7F4A_7C15); + z = (z ^ (z >> 16)).wrapping_mul(0x85EB_CA6B); + z = (z ^ (z >> 13)).wrapping_mul(0xC2B2_AE35); + z ^ (z >> 16) +} + +fn proto_from_sel(sel: u8) -> IpProtocol { + // Keep TCP/UDP frequent so the connection-tracked pipeline links; still + // cover ICMP and arbitrary "other" protocol numbers (not connection-tracked). + match sel % 8 { + 0 | 1 | 2 => IpProtocol::Tcp, + 3 | 4 | 5 => IpProtocol::Udp, + 6 => IpProtocol::Icmp, + _ => IpProtocol::from(sel), + } +} + +fn ports_for(proto: IpProtocol, lport: u16, rport: u16) -> (u16, u16) { + match proto { + IpProtocol::Tcp | IpProtocol::Udp => (lport, rport), + _ => (0, 0), + } +} + +/// Whether a `proto_sel` op byte maps to a connection-tracked protocol +/// (TCP/UDP). Kept here so the callout fuzz oracle doesn't hard-code the private +/// `proto_from_sel` mapping: a *structured* TCP/UDP packet always parses, so the +/// packet callout must set an action. +pub fn proto_is_connectable(proto_sel: u8) -> bool { + matches!(proto_from_sel(proto_sel), IpProtocol::Tcp | IpProtocol::Udp) +} + +// IPv6 address with a 2-byte prefix and a one-byte tail (rest zero). +fn v6_prefix(p0: u8, p1: u8, last: u8) -> Ipv6Address { + Ipv6Address::from_octets([p0, p1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, last]) +} + +// Full-spread IPv6 address derived from a seed (covers the whole v6 space). +fn v6_from_seed(seed: u32) -> Ipv6Address { + let mut o = [0u8; 16]; + let mut s = seed; + for chunk in o.chunks_mut(4) { + s = mix32(s); + chunk.copy_from_slice(&s.to_be_bytes()); + } + Ipv6Address::from_octets(o) +} + +fn pool_v4(conn: u8, proto_sel: u8) -> TupleV4 { + let c = conn % POOL_SIZE; + // Curated low slots (clean flows + edge cases), computed full-range middle + // slots, loopback always last. Slots 0/1 stay clean+distinct (the native + // pipeline test relies on them). Ports apply only to TCP/UDP (see ports_for). + let (local, remote, lport, rport) = match c { + 0 => ( + Ipv4Address::new(10, 0, 0, 1), + Ipv4Address::new(93, 184, 216, 34), + 1000, + 8000, + ), + 1 => ( + Ipv4Address::new(10, 0, 0, 2), + Ipv4Address::new(8, 8, 8, 8), + 1001, + 8001, + ), + 2 => ( + Ipv4Address::new(192, 168, 1, 5), + Ipv4Address::new(1, 1, 1, 1), + 53, + 53, + ), + 3 => ( + Ipv4Address::new(172, 16, 0, 9), + Ipv4Address::new(140, 82, 121, 4), + 443, + 50000, + ), + 4 => ( + Ipv4Address::new(169, 254, 0, 7), + Ipv4Address::new(169, 254, 0, 8), + 5353, + 5353, + ), + 5 => ( + Ipv4Address::new(100, 64, 0, 1), + Ipv4Address::new(203, 0, 113, 9), + 717, + 1, + ), + 6 => ( + Ipv4Address::new(0, 0, 0, 0), + Ipv4Address::new(255, 255, 255, 255), + 0, + 65535, + ), + 7 => ( + Ipv4Address::new(10, 0, 0, 3), + Ipv4Address::new(224, 0, 0, 251), + 5353, + 5353, + ), + _ if c == POOL_SIZE - 1 => ( + Ipv4Address::new(127, 0, 0, 1), + Ipv4Address::new(127, 0, 0, 2), + 1000, + 8000, + ), + _ => { + let a = mix32(c as u32 * 2); + let b = mix32(c as u32 * 2 + 1); + ( + Ipv4Address::from_octets(a.to_be_bytes()), + Ipv4Address::from_octets(b.to_be_bytes()), + (a >> 16) as u16, + (b & 0xffff) as u16, + ) + } + }; + TupleV4 { + proto: proto_from_sel(proto_sel), + local, + lport, + remote, + rport, + } +} + +fn pool_v6(conn: u8, proto_sel: u8) -> TupleV6 { + let c = conn % POOL_SIZE; + let (local, remote, lport, rport) = match c { + 0 => ( + v6_prefix(0x20, 0x01, 1), + v6_prefix(0x20, 0x01, 0x80), + 1000, + 8000, + ), + 1 => ( + v6_prefix(0x20, 0x01, 2), + v6_prefix(0x20, 0x01, 0x88), + 1001, + 8001, + ), + 2 => (v6_prefix(0xfe, 0x80, 1), v6_prefix(0xfe, 0x80, 2), 53, 53), // link-local + 3 => ( + v6_prefix(0xfc, 0x00, 1), + v6_prefix(0x20, 0x01, 0x90), + 443, + 50000, + ), // ULA + 4 => ( + v6_prefix(0xff, 0x02, 1), + v6_prefix(0x20, 0x01, 0x91), + 5353, + 5353, + ), // multicast + 5 => ( + Ipv6Address::from_octets([0u8; 16]), + v6_prefix(0x20, 0x01, 0x92), + 0, + 65535, + ), + _ if c == POOL_SIZE - 1 => ( + Ipv6Address::LOCALHOST, + v6_prefix(0x20, 0x01, 0x88), + 1000, + 8000, + ), + _ => { + let a = mix32(c as u32 * 2 + 0x6000); + let b = mix32(c as u32 * 2 + 0x6001); + ( + v6_from_seed(a), + v6_from_seed(b), + (a >> 16) as u16, + (b & 0xffff) as u16, + ) + } + }; + TupleV6 { + proto: proto_from_sel(proto_sel), + local, + lport, + remote, + rport, + } +} + +fn key_v4(t: &TupleV4) -> Key { + let (local_port, remote_port) = ports_for(t.proto, t.lport, t.rport); + Key { + protocol: t.proto, + local_address: IpAddress::Ipv4(t.local), + local_port, + remote_address: IpAddress::Ipv4(t.remote), + remote_port, + } +} + +fn key_v6(t: &TupleV6) -> Key { + let (local_port, remote_port) = ports_for(t.proto, t.lport, t.rport); + Key { + protocol: t.proto, + local_address: IpAddress::Ipv6(t.local), + local_port, + remote_address: IpAddress::Ipv6(t.remote), + remote_port, + } +} + +fn reauth_flag(reauthorize: bool) -> u32 { + if reauthorize { + wdk::consts::FWP_CONDITION_FLAG_IS_REAUTHORIZE + } else { + 0 + } +} + +// Build an IPv4 packet whose extracted 5-tuple is `t` for the given direction. +// `raw` returns the fuzzer bytes verbatim instead, to exercise the in-callout +// parser on garbage. `get_key_from_nb_v4` uses `next_header`, addresses and the +// first 4 transport bytes (ports); the rest is padding/payload. +fn build_packet_v4(t: &TupleV4, direction: Direction, payload: &[u8], raw: bool) -> Vec { + if raw { + return payload.to_vec(); + } + let (src, dst, sport, dport) = match direction { + Direction::Outbound => (t.local, t.remote, t.lport, t.rport), + Direction::Inbound => (t.remote, t.local, t.rport, t.lport), + }; + let mut p = alloc::vec![0u8; 40]; // IPv4 header (20) + room for a TCP/UDP header + p[0] = 0x45; // version 4, IHL 5 + p[9] = u8::from(t.proto); + p[12..16].copy_from_slice(&src.octets()); + p[16..20].copy_from_slice(&dst.octets()); + p[20..22].copy_from_slice(&sport.to_be_bytes()); + p[22..24].copy_from_slice(&dport.to_be_bytes()); + p.extend_from_slice(payload); + let total = (p.len() as u16).to_be_bytes(); + p[2..4].copy_from_slice(&total); + p +} + +fn build_packet_v6(t: &TupleV6, direction: Direction, payload: &[u8], raw: bool) -> Vec { + if raw { + return payload.to_vec(); + } + let (src, dst, sport, dport) = match direction { + Direction::Outbound => (t.local, t.remote, t.lport, t.rport), + Direction::Inbound => (t.remote, t.local, t.rport, t.lport), + }; + let mut p = alloc::vec![0u8; 44]; // IPv6 header (40) + 4 bytes of ports + p[0] = 0x60; // version 6 + p[6] = u8::from(t.proto); // next header + p[7] = 64; // hop limit + p[8..24].copy_from_slice(&src.octets()); + p[24..40].copy_from_slice(&dst.octets()); + p[40..42].copy_from_slice(&sport.to_be_bytes()); + p[42..44].copy_from_slice(&dport.to_be_bytes()); + p.extend_from_slice(payload); + let payload_len = ((p.len() - 40) as u16).to_be_bytes(); + p[4..6].copy_from_slice(&payload_len); + p +} + +// Build the mock CalloutData around `values` + `packet`, run `callout`, and +// report what it wrote to the ClassifyOut. The NBL box and ClassifyOut outlive +// the borrow taken by `CalloutData` (raw pointers), so they are read afterwards. +fn drive( + layer: Layer, + values: Vec, + process_id: Option, + packet: Vec, + data_offset: usize, + ip_header_size: u32, + transport_header_size: u32, + callout: F, +) -> CalloutResult +where + F: FnOnce(CalloutData), +{ + let nbl = NET_BUFFER_LIST::new_box(packet, data_offset); + drive_nbl( + layer, + values, + process_id, + nbl, + ip_header_size, + transport_header_size, + callout, + ) +} + +// Like `drive`, but over a caller-built NBL (used to replay injected packets +// carrying the injected-by-self mark). +fn drive_nbl( + layer: Layer, + values: Vec, + process_id: Option, + mut nbl: Box, + ip_header_size: u32, + transport_header_size: u32, + callout: F, +) -> CalloutResult +where + F: FnOnce(CalloutData), +{ + let mut classify_out = ClassifyOut::new(); + { + let data = CalloutData::mock( + layer, + values, + process_id, + &mut classify_out as *mut _, + nbl.as_mut() as *mut _, + ip_header_size, + transport_header_size, + ); + callout(data); + } + // `nbl` is owned here and only dropped at end of scope; the callout has + // returned, so reading `classify_out` is sound. + drop(nbl); + CalloutResult { + action: classify_out.action(), + absorb: classify_out.is_absorb(), + } +} + +// ---- Device lifecycle / observers ---------------------------------------- + +/// Install a fresh mock `Device` in the global slot if none is present. The +/// callout fuzz target keeps one device for the whole process (persistent +/// stateful fuzzing), so this is a no-op after the first call. +pub fn ensure_device() { + if crate::entry::get_device().is_none() { + if let Ok(device) = new_mock_device() { + crate::entry::set_device(Box::into_raw(Box::new(device))); + } + } +} + +/// Number of packets currently pending in the id/packet cache. +pub fn packet_cache_len() -> usize { + crate::entry::get_device() + .map(|device| device.packet_cache.get_entries_count()) + .unwrap_or(0) +} + +/// Number of active (not-yet-ended) connections in the connection cache. +pub fn active_conn_count() -> usize { + crate::entry::get_device() + .map(|device| device.connection_cache.get_entries_count().0) + .unwrap_or(0) +} + +/// Snapshot of packet ids currently pending, for issuing matching verdicts. +pub fn live_ids() -> Vec { + crate::entry::get_device() + .map(|device| device.packet_cache.fuzz_live_ids()) + .unwrap_or_default() +} + +/// The verdict the connection cache currently holds for pool connection +/// `(conn, proto_sel)`, if any. Used as the cross-layer (Tier-3) oracle: the +/// packet layer's action for this flow must agree with this verdict. +pub fn verdict_for_v4(conn: u8, proto_sel: u8) -> Option { + let key = key_v4(&pool_v4(conn, proto_sel)); + crate::entry::get_device()? + .connection_cache + .get_verdict(&key) + .map(|verdict| verdict as u8) +} + +pub fn verdict_for_v6(conn: u8, proto_sel: u8) -> Option { + let key = key_v6(&pool_v6(conn, proto_sel)); + crate::entry::get_device()? + .connection_cache + .get_verdict(&key) + .map(|verdict| verdict as u8) +} + +/// The connection cache's verdict for an explicit tuple, if any. This is how a +/// harness observes the agent's (asynchronous) verdict landing, like the OS +/// observes the ALE re-authorization outcome. +pub fn verdict_for_key_v4(spec: TupleSpecV4) -> Option { + let key = key_v4(&TupleV4::from_spec(&spec)); + crate::entry::get_device()? + .connection_cache + .get_verdict(&key) + .map(|verdict| verdict as u8) +} + +/// See [`verdict_for_key_v4`]; v6 flavor. +pub fn verdict_for_key_v6(spec: TupleSpecV6) -> Option { + let key = key_v6(&TupleV6::from_spec(&spec)); + crate::entry::get_device()? + .connection_cache + .get_verdict(&key) + .map(|verdict| verdict as u8) +} + +/// Take every packet the driver injected since the last drain (the mock +/// injector records instead of injecting). A harness feeds these back through +/// the packet-layer callouts to model the OS re-presenting injected packets. +pub fn drain_injected() -> Vec { + crate::entry::get_device() + .map(|device| device.injector.drain_injected()) + .unwrap_or_default() +} + +/// Number of injected packets not yet drained. +pub fn injected_len() -> usize { + crate::entry::get_device() + .map(|device| device.injector.injected_len()) + .unwrap_or(0) +} + +/// Reset the device's transient per-flow state to a clean slate: drop every +/// tracked connection and any still-pended packet, and discard recorded injects. +/// An independent replay then sees every connection as new again instead of +/// inheriting the previous run's cached verdicts, and a long soak does not grow +/// the injector without bound. Callout registration and the filter engine are +/// left intact -- only transient state resets. No-op if no device is installed. +pub fn reset_device_state() { + if let Some(device) = crate::entry::get_device() { + device.connection_cache.clear(); + device.packet_cache.pop_all(); + device.injector.drain_injected(); + } +} + +/// Snapshot of the filter engine's registration state, for asserting the +/// driver loaded like the kernel would see it (sublayer committed, expected +/// callouts on expected layers, live filter ids). +pub struct FilterEngineSnapshot { + pub committed: bool, + /// `(name, layer, filter_id)` per registered callout, in registration order. + pub callouts: Vec<(String, Layer, u64)>, +} + +pub fn filter_engine_snapshot() -> Option { + let device = crate::entry::get_device()?; + Some(device.filter_engine.with(|filter_engine| FilterEngineSnapshot { + committed: filter_engine.is_committed(), + callouts: filter_engine + .registrations() + .iter() + .map(|reg| (reg.name.clone(), reg.layer, reg.filter_id)) + .collect(), + })) +} + +// ---- ALE callouts --------------------------------------------------------- + +pub fn run_ale_connect_v4( + conn: u8, + proto_sel: u8, + reauthorize: bool, + process_id: u64, + payload: &[u8], + raw: bool, +) -> CalloutResult { + ale_connect_v4_with( + pool_v4(conn, proto_sel), + reauthorize, + process_id, + payload, + raw, + ) +} + +/// ALE outbound-connect against an explicit tuple (see [`TupleSpecV4`]). +pub fn run_ale_connect_v4_tuple( + spec: TupleSpecV4, + reauthorize: bool, + process_id: u64, + payload: &[u8], +) -> CalloutResult { + ale_connect_v4_with( + TupleV4::from_spec(&spec), + reauthorize, + process_id, + payload, + false, + ) +} + +fn ale_connect_v4_with( + t: TupleV4, + reauthorize: bool, + process_id: u64, + payload: &[u8], + raw: bool, +) -> CalloutResult { + use layer::FieldsAleAuthConnectV4 as F; + let mut v = alloc::vec![Value::U32(0); F::Max as usize]; + v[F::IpProtocol as usize] = Value::U8(u8::from(t.proto)); + v[F::IpLocalAddress as usize] = Value::U32(u32::from_be_bytes(t.local.octets())); + v[F::IpLocalPort as usize] = Value::U16(t.lport); + v[F::IpRemoteAddress as usize] = Value::U32(u32::from_be_bytes(t.remote.octets())); + v[F::IpRemotePort as usize] = Value::U16(t.rport); + v[F::Flags as usize] = Value::U32(reauth_flag(reauthorize)); + let packet = build_packet_v4(&t, Direction::Outbound, payload, raw); + drive( + Layer::AleAuthConnectV4, + v, + Some(process_id), + packet, + 0, + IP4_HDR, + TP_HDR, + |d| crate::ale_callouts::ale_layer_connect_v4(d), + ) +} + +pub fn run_ale_accept_v4( + conn: u8, + proto_sel: u8, + reauthorize: bool, + process_id: u64, + payload: &[u8], + raw: bool, +) -> CalloutResult { + ale_accept_v4_with( + pool_v4(conn, proto_sel), + reauthorize, + process_id, + payload, + raw, + ) +} + +/// ALE inbound-accept against an explicit tuple (see [`TupleSpecV4`]). +pub fn run_ale_accept_v4_tuple( + spec: TupleSpecV4, + reauthorize: bool, + process_id: u64, + payload: &[u8], +) -> CalloutResult { + ale_accept_v4_with( + TupleV4::from_spec(&spec), + reauthorize, + process_id, + payload, + false, + ) +} + +fn ale_accept_v4_with( + t: TupleV4, + reauthorize: bool, + process_id: u64, + payload: &[u8], + raw: bool, +) -> CalloutResult { + use layer::FieldsAleAuthRecvAcceptV4 as F; + let mut v = alloc::vec![Value::U32(0); F::Max as usize]; + v[F::IpProtocol as usize] = Value::U8(u8::from(t.proto)); + v[F::IpLocalAddress as usize] = Value::U32(u32::from_be_bytes(t.local.octets())); + v[F::IpLocalPort as usize] = Value::U16(t.lport); + v[F::IpRemoteAddress as usize] = Value::U32(u32::from_be_bytes(t.remote.octets())); + v[F::IpRemotePort as usize] = Value::U16(t.rport); + v[F::Flags as usize] = Value::U32(reauth_flag(reauthorize)); + // Inbound ALE: the data pointer starts past IP+transport; the callout + // retreats `ip_header_size + transport_header_size` (= 40) before cloning. + let packet = build_packet_v4(&t, Direction::Inbound, payload, raw); + drive( + Layer::AleAuthRecvAcceptV4, + v, + Some(process_id), + packet, + (IP4_HDR + TP_HDR) as usize, + IP4_HDR, + TP_HDR, + |d| crate::ale_callouts::ale_layer_accept_v4(d), + ) +} + +pub fn run_ale_connect_v6( + conn: u8, + proto_sel: u8, + reauthorize: bool, + process_id: u64, + payload: &[u8], + raw: bool, +) -> CalloutResult { + ale_connect_v6_with( + pool_v6(conn, proto_sel), + reauthorize, + process_id, + payload, + raw, + ) +} + +/// ALE outbound-connect against an explicit tuple (see [`TupleSpecV6`]). +pub fn run_ale_connect_v6_tuple( + spec: TupleSpecV6, + reauthorize: bool, + process_id: u64, + payload: &[u8], +) -> CalloutResult { + ale_connect_v6_with( + TupleV6::from_spec(&spec), + reauthorize, + process_id, + payload, + false, + ) +} + +fn ale_connect_v6_with( + t: TupleV6, + reauthorize: bool, + process_id: u64, + payload: &[u8], + raw: bool, +) -> CalloutResult { + use layer::FieldsAleAuthConnectV6 as F; + let mut v = alloc::vec![Value::U32(0); F::Max as usize]; + v[F::IpProtocol as usize] = Value::U8(u8::from(t.proto)); + v[F::IpLocalAddress as usize] = Value::Bytes16(t.local.octets()); + v[F::IpLocalPort as usize] = Value::U16(t.lport); + v[F::IpRemoteAddress as usize] = Value::Bytes16(t.remote.octets()); + v[F::IpRemotePort as usize] = Value::U16(t.rport); + v[F::Flags as usize] = Value::U32(reauth_flag(reauthorize)); + let packet = build_packet_v6(&t, Direction::Outbound, payload, raw); + drive( + Layer::AleAuthConnectV6, + v, + Some(process_id), + packet, + 0, + IP6_HDR, + TP_HDR, + |d| crate::ale_callouts::ale_layer_connect_v6(d), + ) +} + +pub fn run_ale_accept_v6( + conn: u8, + proto_sel: u8, + reauthorize: bool, + process_id: u64, + payload: &[u8], + raw: bool, +) -> CalloutResult { + ale_accept_v6_with( + pool_v6(conn, proto_sel), + reauthorize, + process_id, + payload, + raw, + ) +} + +/// ALE inbound-accept against an explicit tuple (see [`TupleSpecV6`]). +pub fn run_ale_accept_v6_tuple( + spec: TupleSpecV6, + reauthorize: bool, + process_id: u64, + payload: &[u8], +) -> CalloutResult { + ale_accept_v6_with( + TupleV6::from_spec(&spec), + reauthorize, + process_id, + payload, + false, + ) +} + +fn ale_accept_v6_with( + t: TupleV6, + reauthorize: bool, + process_id: u64, + payload: &[u8], + raw: bool, +) -> CalloutResult { + use layer::FieldsAleAuthRecvAcceptV6 as F; + let mut v = alloc::vec![Value::U32(0); F::Max as usize]; + v[F::IpProtocol as usize] = Value::U8(u8::from(t.proto)); + v[F::IpLocalAddress as usize] = Value::Bytes16(t.local.octets()); + v[F::IpLocalPort as usize] = Value::U16(t.lport); + v[F::IpRemoteAddress as usize] = Value::Bytes16(t.remote.octets()); + v[F::IpRemotePort as usize] = Value::U16(t.rport); + v[F::Flags as usize] = Value::U32(reauth_flag(reauthorize)); + let packet = build_packet_v6(&t, Direction::Inbound, payload, raw); + drive( + Layer::AleAuthRecvAcceptV6, + v, + Some(process_id), + packet, + (IP6_HDR + TP_HDR) as usize, + IP6_HDR, + TP_HDR, + |d| crate::ale_callouts::ale_layer_accept_v6(d), + ) +} + +// ---- Packet-layer callouts ------------------------------------------------ + +pub fn run_packet_in_v4(conn: u8, proto_sel: u8, payload: &[u8], raw: bool) -> CalloutResult { + packet_in_v4_with(pool_v4(conn, proto_sel), payload, raw) +} + +fn packet_in_v4_with(t: TupleV4, payload: &[u8], raw: bool) -> CalloutResult { + use layer::FieldsInboundIppacketV4 as F; + let v = alloc::vec![Value::U32(0); F::Max as usize]; + let packet = build_packet_v4(&t, Direction::Inbound, payload, raw); + drive( + Layer::InboundIppacketV4, + v, + None, + packet, + IP4_HDR as usize, + 0, + 0, + |d| crate::packet_callouts::ip_packet_layer_inbound_v4(d), + ) +} + +pub fn run_packet_out_v4(conn: u8, proto_sel: u8, payload: &[u8], raw: bool) -> CalloutResult { + packet_out_v4_with(pool_v4(conn, proto_sel), payload, raw) +} + +/// Packet-layer classify for an explicit tuple, in either direction, with a +/// structured packet built around `payload`. +pub fn run_packet_v4_tuple( + spec: TupleSpecV4, + direction: Direction, + payload: &[u8], +) -> CalloutResult { + let t = TupleV4::from_spec(&spec); + match direction { + Direction::Inbound => packet_in_v4_with(t, payload, false), + Direction::Outbound => packet_out_v4_with(t, payload, false), + } +} + +fn packet_out_v4_with(t: TupleV4, payload: &[u8], raw: bool) -> CalloutResult { + use layer::FieldsOutboundIppacketV4 as F; + let v = alloc::vec![Value::U32(0); F::Max as usize]; + let packet = build_packet_v4(&t, Direction::Outbound, payload, raw); + drive(Layer::OutboundIppacketV4, v, None, packet, 0, 0, 0, |d| { + crate::packet_callouts::ip_packet_layer_outbound_v4(d) + }) +} + +pub fn run_packet_in_v6(conn: u8, proto_sel: u8, payload: &[u8], raw: bool) -> CalloutResult { + packet_in_v6_with(pool_v6(conn, proto_sel), payload, raw) +} + +fn packet_in_v6_with(t: TupleV6, payload: &[u8], raw: bool) -> CalloutResult { + use layer::FieldsInboundIppacketV6 as F; + let v = alloc::vec![Value::U32(0); F::Max as usize]; + let packet = build_packet_v6(&t, Direction::Inbound, payload, raw); + drive( + Layer::InboundIppacketV6, + v, + None, + packet, + IP6_HDR as usize, + 0, + 0, + |d| crate::packet_callouts::ip_packet_layer_inbound_v6(d), + ) +} + +pub fn run_packet_out_v6(conn: u8, proto_sel: u8, payload: &[u8], raw: bool) -> CalloutResult { + packet_out_v6_with(pool_v6(conn, proto_sel), payload, raw) +} + +/// Packet-layer classify for an explicit tuple, see [`run_packet_v4_tuple`]. +pub fn run_packet_v6_tuple( + spec: TupleSpecV6, + direction: Direction, + payload: &[u8], +) -> CalloutResult { + let t = TupleV6::from_spec(&spec); + match direction { + Direction::Inbound => packet_in_v6_with(t, payload, false), + Direction::Outbound => packet_out_v6_with(t, payload, false), + } +} + +/// Run the v4 packet-layer callout on an already-built packet buffer, as the +/// OS re-presents a packet: `injected_by_self` marks it as network-injected by +/// the driver (the FWPS injection state), so the callout's self-inject permit +/// branch runs. `bytes` must start at the IP header. +pub fn run_packet_bytes_v4( + bytes: &[u8], + direction: Direction, + injected_by_self: bool, +) -> CalloutResult { + let (data_offset, ipv6) = match direction { + // Inbound packet-layer NBLs start past the IP header; the callout retreats. + Direction::Inbound => (IP4_HDR as usize, false), + Direction::Outbound => (0, false), + }; + let nbl = if injected_by_self { + NET_BUFFER_LIST::new_box_injected(bytes.to_vec(), data_offset, ipv6) + } else { + NET_BUFFER_LIST::new_box(bytes.to_vec(), data_offset) + }; + match direction { + Direction::Inbound => { + use layer::FieldsInboundIppacketV4 as F; + drive_nbl( + Layer::InboundIppacketV4, + alloc::vec![Value::U32(0); F::Max as usize], + None, + nbl, + 0, + 0, + |d| crate::packet_callouts::ip_packet_layer_inbound_v4(d), + ) + } + Direction::Outbound => { + use layer::FieldsOutboundIppacketV4 as F; + drive_nbl( + Layer::OutboundIppacketV4, + alloc::vec![Value::U32(0); F::Max as usize], + None, + nbl, + 0, + 0, + |d| crate::packet_callouts::ip_packet_layer_outbound_v4(d), + ) + } + } +} + +/// See [`run_packet_bytes_v4`]; v6 flavor. +pub fn run_packet_bytes_v6( + bytes: &[u8], + direction: Direction, + injected_by_self: bool, +) -> CalloutResult { + let data_offset = match direction { + Direction::Inbound => IP6_HDR as usize, + Direction::Outbound => 0, + }; + let nbl = if injected_by_self { + NET_BUFFER_LIST::new_box_injected(bytes.to_vec(), data_offset, true) + } else { + NET_BUFFER_LIST::new_box(bytes.to_vec(), data_offset) + }; + match direction { + Direction::Inbound => { + use layer::FieldsInboundIppacketV6 as F; + drive_nbl( + Layer::InboundIppacketV6, + alloc::vec![Value::U32(0); F::Max as usize], + None, + nbl, + 0, + 0, + |d| crate::packet_callouts::ip_packet_layer_inbound_v6(d), + ) + } + Direction::Outbound => { + use layer::FieldsOutboundIppacketV6 as F; + drive_nbl( + Layer::OutboundIppacketV6, + alloc::vec![Value::U32(0); F::Max as usize], + None, + nbl, + 0, + 0, + |d| crate::packet_callouts::ip_packet_layer_outbound_v6(d), + ) + } + } +} + +fn packet_out_v6_with(t: TupleV6, payload: &[u8], raw: bool) -> CalloutResult { + use layer::FieldsOutboundIppacketV6 as F; + let v = alloc::vec![Value::U32(0); F::Max as usize]; + let packet = build_packet_v6(&t, Direction::Outbound, payload, raw); + drive(Layer::OutboundIppacketV6, v, None, packet, 0, 0, 0, |d| { + crate::packet_callouts::ip_packet_layer_outbound_v6(d) + }) +} + +// ---- Teardown callouts (no classify action expected) ---------------------- + +pub fn run_endpoint_close_v4(conn: u8, proto_sel: u8, process_id: u64) { + endpoint_close_v4_with(pool_v4(conn, proto_sel), process_id) +} + +/// Endpoint closure for an explicit tuple (see [`TupleSpecV4`]). +pub fn run_endpoint_close_v4_tuple(spec: TupleSpecV4, process_id: u64) { + endpoint_close_v4_with(TupleV4::from_spec(&spec), process_id) +} + +fn endpoint_close_v4_with(t: TupleV4, process_id: u64) { + use layer::FieldsAleEndpointClosureV4 as F; + let mut v = alloc::vec![Value::U32(0); F::Max as usize]; + v[F::IpProtocol as usize] = Value::U8(u8::from(t.proto)); + // Must be FwpUint32-typed or the callout treats it as an invalid address. + v[F::IpLocalAddress as usize] = Value::U32(u32::from_be_bytes(t.local.octets())); + v[F::IpLocalPort as usize] = Value::U16(t.lport); + v[F::IpRemoteAddress as usize] = Value::U32(u32::from_be_bytes(t.remote.octets())); + v[F::IpRemotePort as usize] = Value::U16(t.rport); + let _ = drive( + Layer::AleEndpointClosureV4, + v, + Some(process_id), + Vec::new(), + 0, + 0, + 0, + |d| crate::ale_callouts::endpoint_closure_v4(d), + ); +} + +pub fn run_endpoint_close_v6(conn: u8, proto_sel: u8, process_id: u64) { + endpoint_close_v6_with(pool_v6(conn, proto_sel), process_id) +} + +/// Endpoint closure for an explicit tuple (see [`TupleSpecV6`]). +pub fn run_endpoint_close_v6_tuple(spec: TupleSpecV6, process_id: u64) { + endpoint_close_v6_with(TupleV6::from_spec(&spec), process_id) +} + +fn endpoint_close_v6_with(t: TupleV6, process_id: u64) { + use layer::FieldsAleEndpointClosureV6 as F; + let mut v = alloc::vec![Value::U32(0); F::Max as usize]; + v[F::IpProtocol as usize] = Value::U8(u8::from(t.proto)); + // Both local and remote must be FwpByteArray16-typed for the v6 callout. + v[F::IpLocalAddress as usize] = Value::Bytes16(t.local.octets()); + v[F::IpLocalPort as usize] = Value::U16(t.lport); + v[F::IpRemoteAddress as usize] = Value::Bytes16(t.remote.octets()); + v[F::IpRemotePort as usize] = Value::U16(t.rport); + let _ = drive( + Layer::AleEndpointClosureV6, + v, + Some(process_id), + Vec::new(), + 0, + 0, + 0, + |d| crate::ale_callouts::endpoint_closure_v6(d), + ); +} + +/// Resource assignment-discard (`kind` even) or resource release (`kind` odd). +pub fn run_resource_v4(kind: u8, proto_sel: u8, port: u16) { + let proto = proto_from_sel(proto_sel); + if kind % 2 == 0 { + use layer::FieldsAleResourceAssignmentV4 as F; + let mut v = alloc::vec![Value::U32(0); F::Max as usize]; + v[F::IpProtocol as usize] = Value::U8(u8::from(proto)); + v[F::IpLocalPort as usize] = Value::U16(port); + let _ = drive( + Layer::AleResourceAssignmentV4Discard, + v, + Some(0), + Vec::new(), + 0, + 0, + 0, + |d| crate::ale_callouts::ale_resource_monitor(d), + ); + } else { + use layer::FieldsAleResourceReleaseV4 as F; + let mut v = alloc::vec![Value::U32(0); F::Max as usize]; + v[F::IpProtocol as usize] = Value::U8(u8::from(proto)); + v[F::IpLocalPort as usize] = Value::U16(port); + let _ = drive( + Layer::AleResourceReleaseV4, + v, + Some(0), + Vec::new(), + 0, + 0, + 0, + |d| crate::ale_callouts::ale_resource_monitor(d), + ); + } +} + +pub fn run_resource_v6(kind: u8, proto_sel: u8, port: u16) { + let proto = proto_from_sel(proto_sel); + if kind % 2 == 0 { + use layer::FieldsAleResourceAssignmentV6 as F; + let mut v = alloc::vec![Value::U32(0); F::Max as usize]; + v[F::IpProtocol as usize] = Value::U8(u8::from(proto)); + v[F::IpLocalPort as usize] = Value::U16(port); + let _ = drive( + Layer::AleResourceAssignmentV6Discard, + v, + Some(0), + Vec::new(), + 0, + 0, + 0, + |d| crate::ale_callouts::ale_resource_monitor(d), + ); + } else { + use layer::FieldsAleResourceReleaseV6 as F; + let mut v = alloc::vec![Value::U32(0); F::Max as usize]; + v[F::IpProtocol as usize] = Value::U8(u8::from(proto)); + v[F::IpLocalPort as usize] = Value::U16(port); + let _ = drive( + Layer::AleResourceReleaseV6, + v, + Some(0), + Vec::new(), + 0, + 0, + 0, + |d| crate::ale_callouts::ale_resource_monitor(d), + ); + } +} + +/// Resource release for a raw protocol number and port (unlike +/// [`run_resource_v4`], which maps through the fuzz `proto_sel` distribution). +pub fn run_resource_release_v4_port(protocol: u8, port: u16) { + use layer::FieldsAleResourceReleaseV4 as F; + let mut v = alloc::vec![Value::U32(0); F::Max as usize]; + v[F::IpProtocol as usize] = Value::U8(protocol); + v[F::IpLocalPort as usize] = Value::U16(port); + let _ = drive( + Layer::AleResourceReleaseV4, + v, + Some(0), + Vec::new(), + 0, + 0, + 0, + |d| crate::ale_callouts::ale_resource_monitor(d), + ); +} + +/// See [`run_resource_release_v4_port`]; v6 flavor. +pub fn run_resource_release_v6_port(protocol: u8, port: u16) { + use layer::FieldsAleResourceReleaseV6 as F; + let mut v = alloc::vec![Value::U32(0); F::Max as usize]; + v[F::IpProtocol as usize] = Value::U8(protocol); + v[F::IpLocalPort as usize] = Value::U16(port); + let _ = drive( + Layer::AleResourceReleaseV6, + v, + Some(0), + Vec::new(), + 0, + 0, + 0, + |d| crate::ale_callouts::ale_resource_monitor(d), + ); +} + +// ---- User-space command channel (the verdict side of the pipeline) -------- + +fn write_global(bytes: &[u8]) { + if let Some(device) = crate::entry::get_device() { + let mut request = WriteRequest::from_bytes(bytes); + device.write(&mut request); + } +} + +/// Deliver a verdict for a pended packet id (mirrors a user-space `Verdict` +/// command). `id` should usually come from [`live_ids`]. +pub fn device_write_verdict(id: u64, verdict: u8) { + let mut command = Vec::with_capacity(10); + command.push(CommandType::Verdict as u8); + command.extend_from_slice(&id.to_le_bytes()); + command.push(verdict); + write_global(&command); +} + +pub fn device_write_update_v4(conn: u8, proto_sel: u8, verdict: u8) { + let t = pool_v4(conn, proto_sel); + let mut command = Vec::new(); + command.push(CommandType::UpdateV4 as u8); + command.push(u8::from(t.proto)); + command.extend_from_slice(&t.local.octets()); + command.extend_from_slice(&t.lport.to_le_bytes()); + command.extend_from_slice(&t.remote.octets()); + command.extend_from_slice(&t.rport.to_le_bytes()); + command.push(verdict); + write_global(&command); +} + +pub fn device_write_update_v6(conn: u8, proto_sel: u8, verdict: u8) { + let t = pool_v6(conn, proto_sel); + let mut command = Vec::new(); + command.push(CommandType::UpdateV6 as u8); + command.push(u8::from(t.proto)); + command.extend_from_slice(&t.local.octets()); + command.extend_from_slice(&t.lport.to_le_bytes()); + command.extend_from_slice(&t.remote.octets()); + command.extend_from_slice(&t.rport.to_le_bytes()); + command.push(verdict); + write_global(&command); +} + +pub fn device_clear_cache() { + write_global(&[CommandType::ClearCache as u8]); +} + +pub fn device_shutdown() { + write_global(&[CommandType::Shutdown as u8]); +} + +/// Drain up to `max` events from the device (mirrors user-space `Read` calls). +/// `wait_and_pop` is non-blocking in the mock, so an empty queue just ends. +pub fn drain_events(max: u8) { + let Some(device) = crate::entry::get_device() else { + return; + }; + let mut buffer = [0u8; 256]; + for _ in 0..max { + let mut request = ReadRequest::from_buffer(&mut buffer); + device.read(&mut request); + } +} + +#[cfg(all(test, feature = "mock"))] +mod tests { + use super::*; + use crate::entry::DEVICE_TEST_LOCK; + + // Install a fresh device in the global slot for the duration of `test`, + // serialized against every other global-slot test. + fn with_fresh_device(test: impl FnOnce()) { + let _guard = DEVICE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let stale = crate::entry::clear_device(); + if !stale.is_null() { + unsafe { drop(Box::from_raw(stale)) }; + } + ensure_device(); + test(); + let device = crate::entry::clear_device(); + if !device.is_null() { + unsafe { drop(Box::from_raw(device)) }; + } + } + + // Loading fidelity: the real driver_entry, run over the mock, must commit + // the filter engine with exactly the production callout set on the expected + // layers -- a registration regression in callouts.rs fails here (and fails + // the sim daemon at load, which does the same assertion). + #[test] + fn driver_entry_registers_expected_callouts() { + let simulation = crate::sim::Simulation::start().expect("driver entry"); + let snapshot = filter_engine_snapshot().expect("device installed"); + assert!(snapshot.committed); + + let layers: Vec = snapshot + .callouts + .iter() + .map(|(_, layer, _)| *layer) + .collect(); + assert_eq!( + layers, + alloc::vec![ + Layer::AleAuthConnectV4, + Layer::AleAuthRecvAcceptV4, + Layer::AleAuthConnectV6, + Layer::AleAuthRecvAcceptV6, + Layer::AleEndpointClosureV4, + Layer::AleEndpointClosureV6, + Layer::AleResourceReleaseV4, + Layer::AleResourceReleaseV6, + Layer::OutboundIppacketV4, + Layer::InboundIppacketV4, + Layer::OutboundIppacketV6, + Layer::InboundIppacketV6, + ] + ); + + let mut filter_ids: Vec = snapshot.callouts.iter().map(|(_, _, id)| *id).collect(); + filter_ids.sort_unstable(); + filter_ids.dedup(); + assert_eq!(filter_ids.len(), 12, "filter ids must be distinct"); + assert!(!filter_ids.contains(&0), "0 means unregistered"); + + drop(simulation); + } + + // Injection loopback: a pended packet released by a verdict is recorded by + // the mock injector; replayed at the packet layer with the injected-by-self + // mark it must hit the self-inject permit branch, and without the mark it + // must pend again (the mark, not the bytes, drives the branch). + #[test] + fn injected_packet_loops_back_and_is_permitted() { + with_fresh_device(|| { + let _ = drain_injected(); + let spec = TupleSpecV4 { + protocol: 1, // ICMP: not connection-tracked, pends per-packet + local: [10, 0, 0, 9], + local_port: 0, + remote: [10, 0, 0, 10], + remote_port: 0, + }; + + let result = run_packet_v4_tuple(spec, Direction::Outbound, &[8, 0, 0, 0]); + assert!( + result.absorb, + "ICMP packet must pend for a per-packet verdict" + ); + let ids = live_ids(); + assert_eq!(ids.len(), 1); + + device_write_verdict(ids[0], Verdict::PermanentAccept as u8); + assert_eq!(packet_cache_len(), 0); + let injected = drain_injected(); + assert_eq!(injected.len(), 1, "accept verdict must inject the clone"); + let packet = &injected[0]; + assert!(!packet.transport); + assert!(!packet.inbound); + assert!(!packet.ipv6); + + let direction = if packet.inbound { + Direction::Inbound + } else { + Direction::Outbound + }; + let replay = run_packet_bytes_v4(&packet.data, direction, true); + assert_eq!( + replay.action, FWP_ACTION_PERMIT, + "self-injected packet must be permitted" + ); + assert_eq!( + packet_cache_len(), + 0, + "self-injected packet must not pend again" + ); + + let replay = run_packet_bytes_v4(&packet.data, direction, false); + assert!( + replay.absorb, + "unmarked packet must go through the normal path" + ); + assert_eq!(packet_cache_len(), 1, "and pend for a fresh verdict"); + }); + } + + // Tuple pipeline: an arbitrary (non-pool) tuple flows ALE pend -> verdict -> + // packet-layer permit; an inbound accept carrying payload records a + // transport inject when its verdict releases the defer. + #[test] + fn tuple_pipeline_links_ale_verdict_and_packet_layer() { + with_fresh_device(|| { + let _ = drain_injected(); + let spec = TupleSpecV4 { + protocol: 6, + local: [10, 1, 2, 3], + local_port: 34567, + remote: [52, 10, 20, 30], + remote_port: 443, + }; + + let result = run_ale_connect_v4_tuple(spec, false, 4242, &[]); + assert!(result.absorb, "new connection must pend"); + assert_eq!(verdict_for_key_v4(spec), Some(Verdict::Undecided as u8)); + + let ids = live_ids(); + assert_eq!(ids.len(), 1); + device_write_verdict(ids[0], Verdict::PermanentAccept as u8); + assert_eq!( + verdict_for_key_v4(spec), + Some(Verdict::PermanentAccept as u8) + ); + // An outbound TCP connect defer carries no packet data, so nothing + // is recorded by the injector. + assert_eq!(injected_len(), 0); + + let result = run_packet_v4_tuple(spec, Direction::Outbound, b"data"); + assert_eq!(result.action, FWP_ACTION_PERMIT); + + // Inbound accept with payload: the verdict completes the ALE defer + // and the pended first packet is transport-injected. + let spec_in = TupleSpecV4 { + protocol: 6, + local: [10, 1, 2, 3], + local_port: 8080, + remote: [52, 10, 20, 31], + remote_port: 51000, + }; + let result = run_ale_accept_v4_tuple(spec_in, false, 4243, &[1, 2, 3, 4]); + assert!(result.absorb); + let ids = live_ids(); + assert_eq!(ids.len(), 1); + device_write_verdict(ids[0], Verdict::PermanentAccept as u8); + let injected = drain_injected(); + assert_eq!(injected.len(), 1); + assert!(injected[0].transport); + assert!(injected[0].inbound); + }); + } + + // Loop-mode reset: after a connection is decided and cached, a repeat classify + // rides the cache without pending; `reset_device_state` wipes that, so the + // very same connection pends fresh again -- each replay is independent. + #[test] + fn reset_device_state_makes_the_next_replay_fresh() { + with_fresh_device(|| { + let spec = TupleSpecV4 { + protocol: 6, + local: [10, 5, 6, 7], + local_port: 51000, + remote: [93, 184, 216, 34], + remote_port: 443, + }; + // First classify on a new key pends; a verdict then caches the flow. + assert!( + run_ale_connect_v4_tuple(spec, false, 100, &[]).absorb, + "new connection pends" + ); + let ids = live_ids(); + device_write_verdict(ids[0], Verdict::PermanentAccept as u8); + assert!(active_conn_count() > 0); + // A repeat of the same key now hits the cache and does not pend. + assert!( + !run_ale_connect_v4_tuple(spec, false, 100, &[]).absorb, + "cached connection does not pend" + ); + + reset_device_state(); + + assert_eq!(active_conn_count(), 0, "reset clears the connection cache"); + assert!( + run_ale_connect_v4_tuple(spec, false, 100, &[]).absorb, + "after reset the same connection pends fresh again" + ); + }); + } +} diff --git a/driver/src/id_cache.rs b/driver/src/id_cache.rs index 3cf757e..515226f 100644 --- a/driver/src/id_cache.rs +++ b/driver/src/id_cache.rs @@ -1,4 +1,5 @@ use core::mem; +use core::sync::atomic::{AtomicU64, Ordering}; use alloc::collections::VecDeque; use protocol::info::Info; @@ -19,29 +20,37 @@ pub struct Entry { pub struct IdCache { values: Mutex>>, - next_id: u64, + // Interior-mutable so `push` can take `&self`: in the kernel multiple + // callouts pend packets concurrently (the device is reached through a + // shared global), so the cache must be safe under `&self`. The id is handed + // out with `fetch_add` *while holding the values lock* (see `push`). + next_id: AtomicU64, } impl IdCache { pub fn new() -> Self { Self { values: Mutex::new(VecDeque::with_capacity(1000)), - next_id: 1, // 0 is invalid id + next_id: AtomicU64::new(1), // 0 is invalid id } } pub fn push( - &mut self, + &self, value: (Key, Packet), process_id: u64, direction: Direction, ale_layer: bool, ) -> Option { + // Assign the id and enqueue under the *same* lock. This keeps the deque + // ordered by id (pop_id relies on `binary_search_by_key`) and makes + // duplicate ids impossible even when two threads push concurrently -- + // assigning the id before the lock would let the push order invert + // relative to id order and break the search invariant. let mut values = self.values.write_lock(); - let id = self.next_id; + let id = self.next_id.fetch_add(1, Ordering::SeqCst); // Assuming this will not overflow. let info = build_info(&value.0, id, process_id, direction, &value.1, ale_layer); values.push_back(Entry { value, id }); - self.next_id = self.next_id.wrapping_add(1); // Assuming this will not overflow. // PACKET_MISSING_ID is not checked since there needs to be 18446744073709551614 connection created until it reaches that id. // Practically impossible and the impact is just one dropped packet. @@ -49,7 +58,7 @@ impl IdCache { return info; } - pub fn pop_id(&mut self, id: u64) -> Option<(Key, Packet)> { + pub fn pop_id(&self, id: u64) -> Option<(Key, Packet)> { if id == PACKET_MISSING_ID { return None; } @@ -67,7 +76,17 @@ impl IdCache { return values.len(); } - pub fn pop_all(&mut self) -> VecDeque> { + /// Fuzz helper: snapshot the ids currently pending in the cache. The callout + /// fuzz harness uses this to issue `Verdict` commands that reference a real + /// pended packet (instead of an id that would never match), so the full + /// ALE-pend -> user-verdict -> packet-layer pipeline actually links up. + #[cfg(feature = "mock")] + pub fn fuzz_live_ids(&self) -> alloc::vec::Vec { + let values = self.values.read_lock(); + values.iter().map(|entry| entry.id).collect() + } + + pub fn pop_all(&self) -> VecDeque> { let mut new_values = VecDeque::with_capacity(1); let mut values = self.values.write_lock(); mem::swap(&mut *values, &mut new_values); @@ -93,6 +112,65 @@ fn get_payload<'a>(packet: &'a Packet) -> Option<&'a [u8]> { } } +#[cfg(all(test, feature = "mock"))] +mod tests { + use super::*; + use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Address}; + use wdk::filter_engine::net_buffer::NetBufferList; + use wdk::filter_engine::packet::InjectInfo; + + fn test_key() -> Key { + Key { + protocol: IpProtocol::Tcp, + local_address: IpAddress::Ipv4(Ipv4Address::new(10, 0, 0, 1)), + local_port: 1234, + remote_address: IpAddress::Ipv4(Ipv4Address::new(1, 1, 1, 1)), + remote_port: 80, + } + } + + fn mk_packet() -> Packet { + Packet::PacketLayer( + NetBufferList::owned_from_bytes(alloc::vec![0u8; 40]), + InjectInfo { + ipv6: false, + inbound: false, + loopback: false, + interface_index: 0, + sub_interface_index: 0, + compartment_id: 0, + }, + ) + } + + #[test] + fn push_assigns_sequential_ids_and_pops_once() { + let cache = IdCache::new(); + let k = test_key(); + // ids are assigned sequentially starting at 1. + for _ in 0..5 { + cache.push((k, mk_packet()), 1, Direction::Outbound, false); + } + assert_eq!(cache.get_entries_count(), 5); + + // The missing-id sentinel is always None. + assert!(cache.pop_id(PACKET_MISSING_ID).is_none()); + + // Pop out of order; each id pops exactly once. This validates that the + // deque stays sorted by id so `binary_search_by_key` is correct. + assert!(cache.pop_id(3).is_some()); + assert!(cache.pop_id(3).is_none()); + assert!(cache.pop_id(1).is_some()); + assert!(cache.pop_id(5).is_some()); + assert!(cache.pop_id(99).is_none()); + + assert_eq!(cache.get_entries_count(), 2); // ids 2 and 4 remain + let rest = cache.pop_all(); + assert_eq!(rest.len(), 2); + assert_eq!(cache.get_entries_count(), 0); + } +} + /// Builds an informational-only event that carries no packet to reinject (it uses /// [`PACKET_MISSING_ID`]). Used when the ALE layer only needs to tell user space about a /// connection and its process id, leaving the real packet to be sent and reinjected by the diff --git a/driver/src/lib.rs b/driver/src/lib.rs index 19a1e53..bf60c35 100644 --- a/driver/src/lib.rs +++ b/driver/src/lib.rs @@ -1,7 +1,16 @@ -#![cfg_attr(not(test), no_std)] -#![cfg_attr(not(test), no_main)] +#![cfg_attr(not(any(test, feature = "mock")), no_std)] +#![cfg_attr(not(any(test, feature = "mock")), no_main)] #![allow(clippy::needless_return)] +// In the host fuzz/test build, redirect every `wdk::...` path in this crate to +// the pure-Rust `mock_wdk` crate. The production build (default `real_wdk` +// feature) leaves this out and uses the real kernel `wdk` dependency. +#[cfg(feature = "mock")] +extern crate mock_wdk as wdk; + +#[cfg(all(feature = "real_wdk", feature = "mock"))] +compile_error!("features `real_wdk` and `mock` are mutually exclusive; build mock with --no-default-features --features mock"); + extern crate alloc; mod ale_callouts; @@ -20,22 +29,34 @@ pub mod logger; mod packet_callouts; mod packet_util; -#[cfg(not(test))] +// Public surface used only by host property tests and the external fuzz crate. +// Gated on `mock` (not `test`) because it references mock-only constructors; a +// plain `cargo test` with the real `wdk` cannot link the driver anyway. Run +// tests with: cargo test --no-default-features --features mock +#[cfg(feature = "mock")] +pub mod fuzz_api; + +// Full-driver simulation harness: loads the real driver over the mocked WDK and +// drives it through the user-space channel with real producer/consumer threads. +#[cfg(feature = "mock")] +pub mod sim; + +#[cfg(not(any(test, feature = "mock")))] use wdk::allocator::WindowsAllocator; -#[cfg(not(test))] +#[cfg(not(any(test, feature = "mock")))] use core::panic::PanicInfo; // Declaration of the global memory allocator -#[cfg(not(test))] +#[cfg(not(any(test, feature = "mock")))] #[global_allocator] static HEAP: WindowsAllocator = WindowsAllocator {}; -#[cfg(not(test))] +#[cfg(not(any(test, feature = "mock")))] #[no_mangle] pub extern "system" fn _DllMainCRTStartup() {} -#[cfg(not(test))] +#[cfg(not(any(test, feature = "mock")))] #[panic_handler] fn panic(info: &PanicInfo) -> ! { use wdk::err; diff --git a/driver/src/logger.rs b/driver/src/logger.rs index 695169b..92748ef 100644 --- a/driver/src/logger.rs +++ b/driver/src/logger.rs @@ -1,30 +1,24 @@ use alloc::boxed::Box; use alloc::vec::Vec; -use core::{ - mem::MaybeUninit, - sync::atomic::{AtomicPtr, AtomicUsize, Ordering}, -}; +use core::sync::atomic::{AtomicPtr, AtomicUsize, Ordering}; use protocol::info::{Info, Severity}; pub const LOG_LEVEL: u8 = Severity::Error as u8; pub const MAX_LOG_LINE_SIZE: usize = 150; const SIZE_OF_LOG_LINE_BUFFER: usize = 1024; -static mut LOG_LINES: [AtomicPtr; SIZE_OF_LOG_LINE_BUFFER] = - unsafe { MaybeUninit::zeroed().assume_init() }; -static START_INDEX: AtomicUsize = unsafe { MaybeUninit::zeroed().assume_init() }; -static END_INDEX: AtomicUsize = unsafe { MaybeUninit::zeroed().assume_init() }; +static LOG_LINES: [AtomicPtr; SIZE_OF_LOG_LINE_BUFFER] = + [const { AtomicPtr::new(core::ptr::null_mut()) }; SIZE_OF_LOG_LINE_BUFFER]; +static START_INDEX: AtomicUsize = AtomicUsize::new(0); +static END_INDEX: AtomicUsize = AtomicUsize::new(0); pub fn add_line(log_line: Info) { - let mut index = END_INDEX.fetch_add(1, Ordering::SeqCst); - unsafe { - index %= SIZE_OF_LOG_LINE_BUFFER; - let ptr = &mut LOG_LINES[index]; - let line = Box::new(log_line); - let old = ptr.swap(Box::into_raw(line), Ordering::SeqCst); - if !old.is_null() { - _ = Box::from_raw(old); - } + let index = END_INDEX.fetch_add(1, Ordering::SeqCst) % SIZE_OF_LOG_LINE_BUFFER; + let line = Box::new(log_line); + let old = LOG_LINES[index].swap(Box::into_raw(line), Ordering::SeqCst); + if !old.is_null() { + // `old` was produced by `Box::into_raw` in a previous `add_line`. + unsafe { _ = Box::from_raw(old) }; } } @@ -35,14 +29,13 @@ pub fn flush() -> Vec { if end_index <= start_index { return vec; } - unsafe { - let count = end_index - start_index; - for i in start_index..start_index + count { - let index = i % SIZE_OF_LOG_LINE_BUFFER; - let ptr = LOG_LINES[index].swap(core::ptr::null_mut(), Ordering::SeqCst); - if !ptr.is_null() { - vec.push(*Box::from_raw(ptr)); - } + let count = end_index - start_index; + for i in start_index..start_index + count { + let index = i % SIZE_OF_LOG_LINE_BUFFER; + let ptr = LOG_LINES[index].swap(core::ptr::null_mut(), Ordering::SeqCst); + if !ptr.is_null() { + // `ptr` was produced by `Box::into_raw` in `add_line`. + vec.push(*unsafe { Box::from_raw(ptr) }); } } @@ -64,8 +57,8 @@ macro_rules! log_internal { macro_rules! crit { ($($arg:tt)*) => ({ if protocol::info::Severity::Critical as u8 >= $crate::logger::LOG_LEVEL { - let message = alloc::format!($($arg)*); - $crate::logger::add_line(protocol::info::Severity::Critical, alloc::format!("{}:{} ", file!(), line!()), message) + let mut log_line = protocol::info::log_line(protocol::info::Severity::Critical, $crate::logger::MAX_LOG_LINE_SIZE); + $crate::log_internal!(log_line, $($arg)*); } }); } diff --git a/driver/src/packet_callouts.rs b/driver/src/packet_callouts.rs index bb8e4aa..7128516 100644 --- a/driver/src/packet_callouts.rs +++ b/driver/src/packet_callouts.rs @@ -293,7 +293,7 @@ fn ip_packet_layer( } fn clone_packet( - device: &mut Device, + device: &Device, nb: &NetBuffer, direction: Direction, ipv6: bool, diff --git a/driver/src/packet_util.rs b/driver/src/packet_util.rs index 848d7bc..daa58c4 100644 --- a/driver/src/packet_util.rs +++ b/driver/src/packet_util.rs @@ -71,7 +71,7 @@ impl Redirect for Packet { /// It also updates the checksums for the IP and transport layer headers. /// If the `unify` parameter is true, it sets the source and destination addresses to be the same. /// If the remote address is a loopback address, it sets the source address to the loopback address. -fn redirect_outbound_packet( +pub(crate) fn redirect_outbound_packet( packet: &mut [u8], remote_address: IpAddress, remote_port: u16, @@ -153,7 +153,7 @@ fn redirect_outbound_packet( /// * `original_remote_address` - The original remote IP address of the packet. /// * `original_remote_port` - The original remote port of the packet. /// -fn redirect_inbound_packet( +pub(crate) fn redirect_inbound_packet( packet: &mut [u8], local_address: IpAddress, original_remote_address: IpAddress, @@ -284,7 +284,7 @@ fn print_packet(packet: &[u8]) { /// /// * `Ok(Key)` - A key containing the protocol, local and remote addresses and ports. /// * `Err(String)` - An error message if the function fails to get net_buffer data. -fn get_ports(packet: &[u8], protocol: smoltcp::wire::IpProtocol) -> (u16, u16) { +pub(crate) fn get_ports(packet: &[u8], protocol: smoltcp::wire::IpProtocol) -> (u16, u16) { match protocol { smoltcp::wire::IpProtocol::Tcp => { let tcp_packet = TcpPacket::new_unchecked(packet); @@ -555,3 +555,107 @@ pub fn get_key_from_nb_v6(nb: &NetBuffer, direction: Direction) -> Result None, // } // } + +#[cfg(all(test, feature = "mock"))] +mod tests { + use super::*; + use crate::connection::Direction; + use proptest::prelude::*; + use smoltcp::wire::{IpAddress, Ipv4Address, Ipv6Address}; + use wdk::filter_engine::net_buffer::NetBuffer; + + fn v6(last: u8) -> Ipv6Address { + let mut o = [0u8; 16]; + o[15] = last; + Ipv6Address::from_octets(o) + } + + proptest! { + // The IPv4 key extractor must never panic / read out of bounds on any + // input, regardless of length (the fixed-size header read guards it). + #[test] + fn get_key_v4_never_panics( + bytes in proptest::collection::vec(any::(), 0..256), + inbound in any::(), + ) { + let dir = if inbound { Direction::Inbound } else { Direction::Outbound }; + let nb = NetBuffer::owned_from_bytes(bytes); + let _ = get_key_from_nb_v4(&nb, dir); + } + + #[test] + fn get_key_v6_never_panics( + bytes in proptest::collection::vec(any::(), 0..256), + inbound in any::(), + ) { + let dir = if inbound { Direction::Inbound } else { Direction::Outbound }; + let nb = NetBuffer::owned_from_bytes(bytes); + let _ = get_key_from_nb_v6(&nb, dir); + } + + // Inbound/outbound extract a 5-tuple that is the reverse of each other. + #[test] + fn get_key_v4_direction_symmetry( + bytes in proptest::collection::vec(any::(), 24..64), + ) { + let nb_in = NetBuffer::owned_from_bytes(bytes.clone()); + let nb_out = NetBuffer::owned_from_bytes(bytes); + if let (Ok(k_in), Ok(k_out)) = ( + get_key_from_nb_v4(&nb_in, Direction::Inbound), + get_key_from_nb_v4(&nb_out, Direction::Outbound), + ) { + prop_assert!(k_in == k_out.reverse()); + } + } + + // recalc_header_checksums must never panic and must be idempotent. + #[test] + fn recalc_never_panics_and_idempotent( + mut bytes in proptest::collection::vec(any::(), 0..256), + ipv6 in any::(), + ) { + recalc_header_checksums(&mut bytes, ipv6); + let after_once = bytes.clone(); + recalc_header_checksums(&mut bytes, ipv6); + prop_assert_eq!(after_once, bytes); + } + + // Redirect mutates in place: the packet length must be invariant and it + // must never panic for any input. + #[test] + fn redirect_outbound_len_invariant( + mut bytes in proptest::collection::vec(any::(), 0..256), + port in any::(), + unify in any::(), + v6_addr in any::(), + ) { + let len_before = bytes.len(); + let addr = if v6_addr { + IpAddress::Ipv6(Ipv6Address::LOCALHOST) + } else { + IpAddress::Ipv4(Ipv4Address::new(127, 0, 0, 1)) + }; + redirect_outbound_packet(&mut bytes, addr, port, unify); + prop_assert_eq!(len_before, bytes.len()); + } + + #[test] + fn redirect_inbound_len_invariant( + mut bytes in proptest::collection::vec(any::(), 0..256), + port in any::(), + v6_addr in any::(), + ) { + let len_before = bytes.len(); + let (local, remote) = if v6_addr { + (IpAddress::Ipv6(Ipv6Address::LOCALHOST), IpAddress::Ipv6(v6(2))) + } else { + ( + IpAddress::Ipv4(Ipv4Address::new(127, 0, 0, 1)), + IpAddress::Ipv4(Ipv4Address::new(8, 8, 8, 8)), + ) + }; + redirect_inbound_packet(&mut bytes, local, remote, port); + prop_assert_eq!(len_before, bytes.len()); + } + } +} diff --git a/driver/src/sim.rs b/driver/src/sim.rs new file mode 100644 index 0000000..98f9c04 --- /dev/null +++ b/driver/src/sim.rs @@ -0,0 +1,321 @@ +//! Full-driver simulation harness (host / mock only). +//! +//! Loads the *real* driver via its `DriverEntry` (over the mocked WDK), then lets +//! a user-space side talk to it through the same channel a real agent uses +//! ([`wdk::connection::DriverConnection`] = `ReadFile`/`WriteFile`/`DeviceIoControl`). +//! Callouts are driven on one thread (the "kernel" producer) while a separate +//! user-space consumer thread blocks on `read`, parses the event stream, and +//! replies with verdicts — exercising the asynchronous pend -> event -> verdict +//! -> inject pipeline, the blocking event queue, and the IRP dispatch, with real +//! OS threads (so ThreadSanitizer sees genuine concurrency). +//! +//! Teardown order is load-bearing: `shutdown` runs down the event queue so a +//! blocked reader wakes with end-of-file; join the consumer; only then drop the +//! `Simulation`, which unloads the driver (frees the device, clears the slot). + +use std::sync::mpsc; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use protocol::command::CommandType; +use wdk::connection::DriverConnection; +use wdk::kernel_types::{DRIVER_OBJECT, NTSTATUS, STATUS_END_OF_FILE, STATUS_SUCCESS, UNICODE_STRING}; + +/// A loaded driver instance. Owns the `DRIVER_OBJECT` (which must outlive every +/// connection and worker thread) and holds the global-device test lock for its +/// lifetime so concurrent tests don't fight over the singleton slot. +pub struct Simulation { + _guard: std::sync::MutexGuard<'static, ()>, + driver_object: Box, + torn_down: bool, +} + +impl Simulation { + /// Load the driver: run the real `DriverEntry` against a fresh + /// `DRIVER_OBJECT`. The installed `Device` uses blocking reads, so a consumer + /// thread blocks on `read` like a real user-space agent. + pub fn start() -> Result { + let guard = crate::entry::DEVICE_TEST_LOCK + .lock() + .unwrap_or_else(|p| p.into_inner()); + + // A previous run that panicked may have left a device installed; free it + // so this run starts clean. + let stale = crate::entry::clear_device(); + if !stale.is_null() { + unsafe { drop(Box::from_raw(stale)) }; + } + + let mut driver_object = Box::new(DRIVER_OBJECT::new()); + let mut registry = UNICODE_STRING; + let status = crate::entry::driver_entry(driver_object.as_mut(), &mut registry); + if status != STATUS_SUCCESS { + return Err(status); + } + + Ok(Self { + _guard: guard, + driver_object, + torn_down: false, + }) + } + + /// Open a fresh user-space connection to the driver. Each thread should use + /// its own (the dispatch handlers take `&mut` device/IRP per call). + pub fn connect(&self) -> DriverConnection { + DriverConnection::open(&self.driver_object).expect("driver loaded") + } + + /// Request shutdown: resolve pending packets and run down the event queue so + /// any blocked reader wakes with end-of-file. Idempotent; safe to call from + /// the producer once the op stream is done. + pub fn shutdown(&self) { + if let Some(device) = crate::entry::get_device() { + device.shutdown(); + } + } + + fn teardown(&mut self) { + if self.torn_down { + return; + } + self.torn_down = true; + // Unload via the registered DriverUnload (frees the device and nulls the + // slot). Fall back to a direct clear if somehow unregistered. + if let Some(unload) = self.driver_object.DriverUnload { + unsafe { unload(self.driver_object.as_ref()) }; + } else { + let ptr = crate::entry::clear_device(); + if !ptr.is_null() { + unsafe { drop(Box::from_raw(ptr)) }; + } + } + } +} + +impl Drop for Simulation { + fn drop(&mut self) { + self.teardown(); + } +} + +/// One decoded event frame from the driver's read stream +/// (`[InfoType:u8, size:u32 LE, payload]`). +pub struct Event { + pub info_type: u8, + pub payload: Vec, +} + +impl Event { + // ConnectionIpv4 (1) / ConnectionIpv6 (2) carry the pended packet id as the + // first u64 of the payload; that's what a verdict command refers to. + pub fn packet_id(&self) -> Option { + const CONNECTION_IPV4: u8 = 1; + const CONNECTION_IPV6: u8 = 2; + if matches!(self.info_type, CONNECTION_IPV4 | CONNECTION_IPV6) && self.payload.len() >= 8 { + let mut id = [0u8; 8]; + id.copy_from_slice(&self.payload[..8]); + Some(u64::from_le_bytes(id)) + } else { + None + } + } +} + +/// Streaming parser for the driver's event byte stream. A single `read` may +/// return several whole frames, and the driver may split a frame across reads +/// (via its read-leftover buffer), so bytes are accumulated and whole frames are +/// pulled off the front. +pub struct EventParser { + buf: Vec, +} + +impl EventParser { + pub fn new() -> Self { + Self { buf: Vec::new() } + } + + pub fn feed(&mut self, bytes: &[u8]) { + self.buf.extend_from_slice(bytes); + } + + /// Pull the next complete frame, or `None` if a full frame isn't buffered yet. + pub fn next_event(&mut self) -> Option { + const HEADER: usize = 5; // info_type(1) + size(4) + if self.buf.len() < HEADER { + return None; + } + let size = u32::from_le_bytes([self.buf[1], self.buf[2], self.buf[3], self.buf[4]]) as usize; + if self.buf.len() < HEADER + size { + return None; + } + let info_type = self.buf[0]; + let payload = self.buf[HEADER..HEADER + size].to_vec(); + self.buf.drain(0..HEADER + size); + Some(Event { info_type, payload }) + } +} + +impl Default for EventParser { + fn default() -> Self { + Self::new() + } +} + +/// Serialize a `Verdict` command (the wire form a user-space agent writes back +/// to resolve a pended packet). +pub fn verdict_command(id: u64, verdict: u8) -> Vec { + let mut cmd = Vec::with_capacity(10); + cmd.push(CommandType::Verdict as u8); + cmd.extend_from_slice(&id.to_le_bytes()); + cmd.push(verdict); + cmd +} + +/// What a finished consumer thread observed. +pub struct ConsumerStats { + pub events: u64, + pub replies: u64, +} + +/// Handle to a running user-space consumer thread. +pub struct ConsumerHandle { + join: JoinHandle, +} + +impl ConsumerHandle { + /// Wait for the consumer to exit (it exits when the channel reaches + /// end-of-file, i.e. after `Simulation::shutdown`). Re-raises a consumer panic. + pub fn join(self) -> ConsumerStats { + match self.join.join() { + Ok(stats) => stats, + Err(payload) => std::panic::resume_unwind(payload), + } + } + + /// Like [`join`](Self::join) but gives up after `dur`, returning `None` (used + /// by the no-hang test to fail fast if shutdown didn't release the reader). + pub fn join_timeout(self, dur: Duration) -> Option { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let _ = tx.send(self.join.join()); + }); + match rx.recv_timeout(dur) { + Ok(Ok(stats)) => Some(stats), + Ok(Err(payload)) => std::panic::resume_unwind(payload), + Err(_) => None, + } + } +} + +/// Spawn a user-space consumer thread: it blocks on `conn.read`, parses events, +/// and for each event asks `verdict_policy` for a verdict to write back. If +/// `processed` is set, the packet id of each replied-to event is sent on it (so a +/// test can synchronize on "the verdict has been applied"). The thread exits when +/// the channel reaches end-of-file (after shutdown). +pub fn spawn_consumer( + mut conn: DriverConnection, + mut verdict_policy: F, + processed: Option>, +) -> ConsumerHandle +where + F: FnMut(&Event) -> Option + Send + 'static, +{ + let join = thread::spawn(move || { + let mut parser = EventParser::new(); + let mut buf = [0u8; 4096]; + let mut stats = ConsumerStats { + events: 0, + replies: 0, + }; + loop { + let (n, status) = conn.read(&mut buf); + if status == STATUS_END_OF_FILE { + break; + } + // Blocking reads only return SUCCESS (with data) or end-of-file; any + // other status stops the loop rather than risk a spin. + if status != STATUS_SUCCESS { + break; + } + parser.feed(&buf[..n]); + while let Some(event) = parser.next_event() { + stats.events += 1; + let Some(verdict) = verdict_policy(&event) else { + continue; + }; + let Some(id) = event.packet_id() else { + continue; + }; + conn.write(&verdict_command(id, verdict)); + stats.replies += 1; + if let Some(tx) = &processed { + let _ = tx.send(id); + } + } + } + stats + }); + ConsumerHandle { join } +} + +#[cfg(all(test, feature = "mock"))] +mod tests { + use super::*; + use crate::connection::Verdict; + + // End-to-end async pipeline over the real channel: a new inbound TCP + // connection pends and emits an event; a user-space consumer thread reads it + // through `DriverConnection`, replies PermanentAccept, and that verdict is + // observed in the connection cache. + #[test] + fn pipeline_event_to_verdict_over_channel() { + let sim = Simulation::start().expect("driver loads"); + + let (tx, rx) = mpsc::channel(); + let consumer = spawn_consumer( + sim.connect(), + |_event| Some(Verdict::PermanentAccept as u8), + Some(tx), + ); + + // Producer ("kernel"): inbound TCP accept on a clean pool slot pends the + // connection and pushes a ConnectionIpv4 event. + let (conn_slot, proto_tcp) = (0u8, 0u8); + assert!(crate::fuzz_api::proto_is_connectable(proto_tcp)); + let _ = crate::fuzz_api::run_ale_accept_v4(conn_slot, proto_tcp, false, 4321, &[], false); + + // Wait for the consumer to read the event and write the verdict back. + rx.recv_timeout(Duration::from_secs(5)) + .expect("consumer read the event and replied within the deadline"); + + assert_eq!( + crate::fuzz_api::verdict_for_v4(conn_slot, proto_tcp), + Some(Verdict::PermanentAccept as u8), + "verdict sent over the channel is applied in the connection cache" + ); + + sim.shutdown(); + consumer + .join_timeout(Duration::from_secs(5)) + .expect("consumer exits after shutdown"); + } + + // A consumer blocked in `read` with no events must unblock promptly when the + // driver is shut down (event queue run down -> end-of-file). Guards against a + // teardown hang. + #[test] + fn consumer_unblocks_on_shutdown() { + let sim = Simulation::start().expect("driver loads"); + let consumer = spawn_consumer(sim.connect(), |_event| None, None); + + // Let the consumer park in the blocking read, then shut down. + thread::sleep(Duration::from_millis(20)); + sim.shutdown(); + + assert!( + consumer.join_timeout(Duration::from_secs(5)).is_some(), + "consumer thread did not exit after shutdown (hang)" + ); + } +} diff --git a/go.work b/go.work new file mode 100644 index 0000000..d3a0f57 --- /dev/null +++ b/go.work @@ -0,0 +1,10 @@ +go 1.20 + +// Build kext_tester against the local kext_interface (incl. pipe_connection.go) +// instead of the pinned remote pseudo-version in kext_tester/go.mod. Required by +// the real-world simulation harness (see sim/ and the Justfile `sim-*` recipes). +use ( + ./kext_interface + ./kext_tester + ./tools/godoc-gen +) diff --git a/kext_interface/diff_test.go b/kext_interface/diff_test.go new file mode 100644 index 0000000..a481447 --- /dev/null +++ b/kext_interface/diff_test.go @@ -0,0 +1,239 @@ +package kext_interface + +// Deterministic Rust <-> Go differential tests of the user/kernel wire format. +// +// Each record's field values are derived from its index by diffMix (a +// splitmix64-style hash implemented identically here and in +// protocol/src/difftest.rs). Both languages compute the same expected values +// from the record position, so any encoding/decoding divergence (endianness, +// field order, type width, struct size) surfaces as a mismatch. +// +// commands TestGenerateCommandDiff writes go_command_diff.bin -> Rust reads +// info Rust writes info_diff.bin -> TestInfoDiffFile reads +// +// KEEP THE FORMULAS BELOW IN SYNC WITH protocol/src/difftest.rs. + +import ( + "encoding/binary" + "io" + "os" + "testing" +) + +const commandDiffFile = "go_command_diff.bin" +const infoDiffFile = "../protocol/info_diff.bin" +const commandDiffRecords = 4000 + +// MUST match diff_mix in protocol/src/difftest.rs. Go uint64 arithmetic wraps, +// matching Rust's wrapping_mul. +func diffMix(i uint32, f uint32) uint64 { + x := uint64(i)*0x9E3779B97F4A7C15 ^ uint64(f)*0xC2B2AE3D27D4EB4F + x ^= x >> 29 + x *= 0xBF58476D1CE4E5B9 + x ^= x >> 32 + return x +} + +func diffB4(i uint32, f uint32) [4]byte { + var out [4]byte + binary.LittleEndian.PutUint32(out[:], uint32(diffMix(i, f))) + return out +} + +func diffB16(i uint32, f uint32) [16]byte { + var out [16]byte + binary.LittleEndian.PutUint64(out[:8], diffMix(i, f)) + binary.LittleEndian.PutUint64(out[8:], diffMix(i, f+0x1000)) + return out +} + +func diffVerdict(i uint32, f uint32) uint8 { + return uint8(diffMix(i, f) % 11) +} + +// Go writes random-valued commands; the Rust side parses and verifies them +// (protocol::difftest::test_go_command_diff). +func TestGenerateCommandDiff(t *testing.T) { + file, err := os.Create(commandDiffFile) + if err != nil { + t.Fatalf("failed to create file: %s", err) + } + defer file.Close() //nolint:errcheck + + for i := uint32(0); i < commandDiffRecords; i++ { + switch i % 4 { + case 0: + _ = SendVerdictCommand(file, Verdict{ + Id: diffMix(i, 0), + Verdict: diffVerdict(i, 1), + }) + case 1: + _ = SendUpdateV4Command(file, UpdateV4{ + Protocol: uint8(diffMix(i, 0)), + LocalAddress: diffB4(i, 1), + LocalPort: uint16(diffMix(i, 2)), + RemoteAddress: diffB4(i, 3), + RemotePort: uint16(diffMix(i, 4)), + Verdict: diffVerdict(i, 5), + }) + case 2: + _ = SendUpdateV6Command(file, UpdateV6{ + Protocol: uint8(diffMix(i, 0)), + LocalAddress: diffB16(i, 1), + LocalPort: uint16(diffMix(i, 2)), + RemoteAddress: diffB16(i, 3), + RemotePort: uint16(diffMix(i, 4)), + Verdict: diffVerdict(i, 5), + }) + case 3: + _ = SendGetConnectionsUpdateCommand(file, diffMix(i, 0)) + } + } +} + +// Go reads the Info packets Rust wrote and verifies every field against the same +// diffMix formula. +func TestInfoDiffFile(t *testing.T) { + file, err := os.Open(infoDiffFile) + if err != nil { + t.Fatalf("open %s: %s (run `cargo test` in protocol/ first)", infoDiffFile, err) + } + defer file.Close() //nolint:errcheck + + var i uint32 = 0 + for { + info, err := RecvInfo(file) + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("record %d: %s", i, err) + } + + switch i % 6 { + case 0: + v, ok := info.(*ConnectionEndV4) + if !ok { + t.Fatalf("record %d: expected *ConnectionEndV4, got %T", i, info) + } + expected := ConnectionEndV4{ + ProcessId: diffMix(i, 0), + Direction: uint8(diffMix(i, 1)), + Protocol: uint8(diffMix(i, 2)), + LocalIp: diffB4(i, 3), + RemoteIp: diffB4(i, 4), + LocalPort: uint16(diffMix(i, 5)), + RemotePort: uint16(diffMix(i, 6)), + RxBytes: diffMix(i, 7), + RxPackets: diffMix(i, 8), + TxBytes: diffMix(i, 9), + TxPackets: diffMix(i, 10), + } + if *v != expected { + t.Fatalf("record %d ConnectionEndV4:\n got %+v\nwant %+v", i, *v, expected) + } + case 1: + v, ok := info.(*ConnectionEndV6) + if !ok { + t.Fatalf("record %d: expected *ConnectionEndV6, got %T", i, info) + } + expected := ConnectionEndV6{ + ProcessId: diffMix(i, 0), + Direction: uint8(diffMix(i, 1)), + Protocol: uint8(diffMix(i, 2)), + LocalIp: diffB16(i, 3), + RemoteIp: diffB16(i, 4), + LocalPort: uint16(diffMix(i, 5)), + RemotePort: uint16(diffMix(i, 6)), + RxBytes: diffMix(i, 7), + RxPackets: diffMix(i, 8), + TxBytes: diffMix(i, 9), + TxPackets: diffMix(i, 10), + } + if *v != expected { + t.Fatalf("record %d ConnectionEndV6:\n got %+v\nwant %+v", i, *v, expected) + } + case 2: + v, ok := info.(*ConnectionUpdateV4) + if !ok { + t.Fatalf("record %d: expected *ConnectionUpdateV4, got %T", i, info) + } + expected := ConnectionUpdateV4{ + Protocol: uint8(diffMix(i, 0)), + LocalIp: diffB4(i, 1), + RemoteIp: diffB4(i, 2), + LocalPort: uint16(diffMix(i, 3)), + RemotePort: uint16(diffMix(i, 4)), + RxBytes: diffMix(i, 5), + RxPackets: diffMix(i, 6), + TxBytes: diffMix(i, 7), + TxPackets: diffMix(i, 8), + } + if *v != expected { + t.Fatalf("record %d ConnectionUpdateV4:\n got %+v\nwant %+v", i, *v, expected) + } + case 3: + v, ok := info.(*ConnectionUpdateV6) + if !ok { + t.Fatalf("record %d: expected *ConnectionUpdateV6, got %T", i, info) + } + expected := ConnectionUpdateV6{ + Protocol: uint8(diffMix(i, 0)), + LocalIp: diffB16(i, 1), + RemoteIp: diffB16(i, 2), + LocalPort: uint16(diffMix(i, 3)), + RemotePort: uint16(diffMix(i, 4)), + RxBytes: diffMix(i, 5), + RxPackets: diffMix(i, 6), + TxBytes: diffMix(i, 7), + TxPackets: diffMix(i, 8), + } + if *v != expected { + t.Fatalf("record %d ConnectionUpdateV6:\n got %+v\nwant %+v", i, *v, expected) + } + case 4: + v, ok := info.(*ConnectionV4) + if !ok { + t.Fatalf("record %d: expected *ConnectionV4, got %T", i, info) + } + if v.Id != diffMix(i, 0) || v.ProcessId != diffMix(i, 1) || + v.Direction != uint8(diffMix(i, 2)) || v.Protocol != uint8(diffMix(i, 3)) || + v.LocalIp != diffB4(i, 4) || v.RemoteIp != diffB4(i, 5) || + v.LocalPort != uint16(diffMix(i, 6)) || v.RemotePort != uint16(diffMix(i, 7)) || + v.PayloadLayer != uint8(diffMix(i, 8)) { + t.Fatalf("record %d ConnectionV4 header mismatch: %+v", i, v) + } + checkPayload(t, i, v.Payload) + case 5: + v, ok := info.(*ConnectionV6) + if !ok { + t.Fatalf("record %d: expected *ConnectionV6, got %T", i, info) + } + if v.Id != diffMix(i, 0) || v.ProcessId != diffMix(i, 1) || + v.Direction != uint8(diffMix(i, 2)) || v.Protocol != uint8(diffMix(i, 3)) || + v.LocalIp != diffB16(i, 4) || v.RemoteIp != diffB16(i, 5) || + v.LocalPort != uint16(diffMix(i, 6)) || v.RemotePort != uint16(diffMix(i, 7)) || + v.PayloadLayer != uint8(diffMix(i, 8)) { + t.Fatalf("record %d ConnectionV6 header mismatch: %+v", i, v) + } + checkPayload(t, i, v.Payload) + } + i++ + } + + if i == 0 { + t.Fatalf("no records read from %s", infoDiffFile) + } +} + +func checkPayload(t *testing.T, i uint32, payload []byte) { + expLen := int(diffMix(i, 9) % 17) + if len(payload) != expLen { + t.Fatalf("record %d: payload len got %d want %d", i, len(payload), expLen) + } + for j := 0; j < expLen; j++ { + if payload[j] != byte(diffMix(i, uint32(100+j))) { + t.Fatalf("record %d: payload[%d] mismatch", i, j) + } + } +} diff --git a/kext_interface/go_command_diff.bin b/kext_interface/go_command_diff.bin new file mode 100644 index 0000000..7329247 Binary files /dev/null and b/kext_interface/go_command_diff.bin differ diff --git a/kext_interface/ioctl.go b/kext_interface/ioctl.go index 64b3b87..4f2221e 100644 --- a/kext_interface/ioctl.go +++ b/kext_interface/ioctl.go @@ -25,7 +25,15 @@ var ( IOCTL_SHUTDOWN_REQUEST = ctlCode(SIOCTL_TYPE, 0x801, METHOD_BUFFERED, windows.FILE_READ_DATA|windows.FILE_WRITE_DATA) ) +// ReadVersion queries the driver's version. A simulation pipe (OpenPipe) has no +// IOCTL channel; the daemon runs the driver built from this same tree, so the +// interface version is reported instead. func ReadVersion(file *KextFile) ([]uint8, error) { + if file.pipe { + version := InterfaceVersion + return version[:], nil + } + data := make([]uint8, 4) err := file.deviceIOControl(IOCTL_VERSION, nil, data) diff --git a/kext_interface/kext.go b/kext_interface/kext.go index 8269b94..cdbdc04 100644 --- a/kext_interface/kext.go +++ b/kext_interface/kext.go @@ -4,39 +4,14 @@ package kext_interface import ( - _ "embed" "errors" "fmt" - "strconv" - "strings" "syscall" "time" "golang.org/x/sys/windows" ) -var ( - //go:embed version.txt - versionTxt string - - // 4 byte version of the Kext interface - InterfaceVersion = func() (v [4]byte) { - // Parse version from file "version.txt". Expected format: [0, 1, 2, 3] - s := strings.TrimSpace(versionTxt) - s = strings.TrimPrefix(s, "[") - s = strings.TrimSuffix(s, "]") - str_ver := strings.Split(s, ",") - for i := range v { - n, err := strconv.Atoi(strings.TrimSpace(str_ver[i])) - if err != nil { - panic(err) - } - v[i] = byte(n) - } - return - }() -) - const winInvalidHandleValue = windows.Handle(^uintptr(0)) // Max value const stopServiceTimeoutDuration = time.Duration(30 * time.Second) diff --git a/kext_interface/kext_file.go b/kext_interface/kext_file.go index c57eb29..aefa8b1 100644 --- a/kext_interface/kext_file.go +++ b/kext_interface/kext_file.go @@ -27,6 +27,11 @@ type KextFile struct { buffer []byte read_slice []byte + // pipe is set when the file is the simulation named pipe (OpenPipe) rather + // than the kernel device. The pipe has no IOCTL channel (see ReadVersion), + // and a server-side disconnect is reported as end-of-file (see + // runOverlapped) so callers see the same errors as from the device. + pipe bool } // Read reads the next chunk of data from the kext. @@ -154,6 +159,11 @@ func (f *KextFile) runOverlapped(issue func(handle windows.Handle, overlapped *w err = windows.GetOverlappedResult(handle, overlapped, &done, true) } if err != nil { + if f.pipe && (errors.Is(err, windows.ERROR_BROKEN_PIPE) || errors.Is(err, windows.ERROR_PIPE_NOT_CONNECTED)) { + // The daemon went away: report it the way the device reports its + // event queue running down. + err = windows.ERROR_HANDLE_EOF + } return done, err } diff --git a/kext_interface/mock_service.go b/kext_interface/mock_service.go new file mode 100644 index 0000000..e547a33 --- /dev/null +++ b/kext_interface/mock_service.go @@ -0,0 +1,171 @@ +package kext_interface + +import ( + "fmt" + "os" + "os/exec" + "sync" + "time" +) + +// startupGrace is how long Start waits after spawning the daemon to catch an +// immediate death (driver_entry / registration / endpoint creation failing). If the +// daemon is still alive after this, the real "is the endpoint up yet" wait is +// left to OpenFile, which retries while the server comes up. +const startupGrace = 300 * time.Millisecond + +// MockKextService is a drop-in stand-in for KextService that is backed by the +// userspace `zf-sim` daemon instead of a kernel driver service. The daemon loads +// the *real* driver over the host WDK mock (mock_wdk) and runs its actual +// driver_entry, then exposes it on a named pipe (a Unix socket on Unix; see +// SimConn). +// +// It mirrors the KextService lifecycle so the agent drives the mock exactly as it +// drives a kernel driver: +// +// - CreateMockKextService -> CreateKextService (register the driver image) +// - Start -> StartService (kernel loads driver.sys, DriverEntry runs) +// - OpenFile -> OpenFile (open the device / endpoint) +// - Stop / Delete -> Stop / Delete (unload / deregister the driver) +// +// The mock "load and start" is spawning the daemon process; the OS reclaims the +// driver, its I/O pumps and the endpoint when that process exits, so Stop/Delete are +// a hard process termination rather than a graceful service stop. +type MockKextService struct { + pipeName string + daemonPath string + args []string + + cmd *exec.Cmd + done chan struct{} // closed once the reaper goroutine has Wait()ed on cmd + + mu sync.Mutex + finished bool + killed bool + exitCode int + waitErr error +} + +// CreateMockKextService prepares (but does not start) a simulated kext service +// backed by the `zf-sim` daemon at daemonPath, reachable on SimEndpoint(pipeName). +// Any extra args are passed to the daemon; the daemon also inherits this +// process's environment (ZF_SIM_SCENARIO, ZF_SIM_SEED, ...). Mirrors +// CreateKextService: it validates the daemon image exists, analogous to the SCM +// checking the driver path, but starts nothing until Start is called. +func CreateMockKextService(pipeName string, daemonPath string, args ...string) (*MockKextService, error) { + if pipeName == "" { + return nil, fmt.Errorf("mock kext service requires a pipe name") + } + if daemonPath == "" { + return nil, fmt.Errorf("mock kext service requires a daemon path") + } + if _, err := os.Stat(daemonPath); err != nil { + return nil, fmt.Errorf("mock driver daemon not found at %q: %w", daemonPath, err) + } + return &MockKextService{pipeName: pipeName, daemonPath: daemonPath, args: args}, nil +} + +// Start spawns the daemon, which loads the driver over mock_wdk and runs its +// driver_entry -- the mock analog of StartService loading the kernel driver. The +// daemon's stdout/stderr are inherited so its logs interleave with the agent's. +// When wait is set, Start fails fast if the daemon dies during startup; otherwise +// it returns as soon as the process is launched. +func (s *MockKextService) Start(wait bool) error { + if s.cmd != nil { + return fmt.Errorf("mock kext service already started") + } + + cmd := exec.Command(s.daemonPath, s.args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + // No stdin: the daemon is non-interactive when driven by the agent. + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start mock driver daemon %q: %w", s.daemonPath, err) + } + s.cmd = cmd + s.done = make(chan struct{}) + + // One reaper owns cmd.Wait(); everyone else observes completion via `done`, + // so Wait/Stop/Delete can be called (and deferred) freely without racing on a + // second cmd.Wait(). + go func() { + err := cmd.Wait() + s.mu.Lock() + s.finished = true + s.waitErr = err + if cmd.ProcessState != nil { + s.exitCode = cmd.ProcessState.ExitCode() + } + s.mu.Unlock() + close(s.done) + }() + + if wait { + select { + case <-s.done: + return fmt.Errorf("mock driver daemon exited during startup (code %d)", s.exitCode) + case <-time.After(startupGrace): + // Still alive; OpenFile handles the endpoint-available wait. + } + } + return nil +} + +// OpenFile connects to the daemon's endpoint and returns a SimConn whose +// Read/Write behave as they do against the kernel device (on Windows it is the +// *KextFile from OpenPipe; use OpenPipe directly where a *KextFile is needed, +// e.g. for ReadVersion). It retries while the +// daemon finishes coming up, so Start need not block until the endpoint is ready. +func (s *MockKextService) OpenFile(readBufferSize int) (SimConn, error) { + return OpenSimConn(s.pipeName, readBufferSize) +} + +// Wait blocks until the daemon exits on its own (e.g. a scenario finished +// replaying) and returns its process exit code, so a caller can propagate the +// mock driver's pass/fail result. Safe to call after Stop/Delete (returns the +// recorded code). +func (s *MockKextService) Wait() (int, error) { + if s.cmd == nil { + return 0, fmt.Errorf("mock kext service not started") + } + <-s.done + s.mu.Lock() + defer s.mu.Unlock() + return s.exitCode, s.waitErr +} + +// Stop terminates the daemon if it is still running -- the mock analog of +// stopping the service. The producer runs forever, so a hard kill +// (TerminateProcess / SIGKILL) is how the agent stops it; the OS reclaims the +// driver, pumps and endpoint with the process (the daemon replaces a stale Unix +// socket file on its next start). Idempotent, and a no-op once the daemon has already exited. With wait +// set it blocks until the process is fully reaped. +func (s *MockKextService) Stop(wait bool) error { + if s.cmd == nil { + return nil + } + + s.mu.Lock() + needKill := !s.finished && !s.killed + if needKill { + s.killed = true + } + s.mu.Unlock() + + if needKill { + // Ignore the error: a race where the daemon exits between the check and + // the kill reports "process already finished", which is fine. + _ = s.cmd.Process.Kill() + } + if wait { + <-s.done + } + return nil +} + +// Delete ensures the daemon is gone -- the mock analog of deleting the service. +// It is idempotent (delegates to Stop), so deferring both Stop and Delete is safe. +func (s *MockKextService) Delete() error { + return s.Stop(true) +} diff --git a/kext_interface/pipe_connection.go b/kext_interface/pipe_connection.go new file mode 100644 index 0000000..e9edcee --- /dev/null +++ b/kext_interface/pipe_connection.go @@ -0,0 +1,55 @@ +//go:build windows +// +build windows + +package kext_interface + +import ( + "fmt" + "syscall" + "time" + + "golang.org/x/sys/windows" +) + +// OpenPipe connects to a userspace fake-driver named pipe and returns a +// *KextFile that is interchangeable with the one KextService.OpenFile returns +// for the kernel device. pipeName is either a full pipe path (\\.\pipe\...) or a +// bare name, which maps to \\.\pipe\ (see SimEndpoint). This is the +// "different loading" path for the simulation harness; production uses +// KextService.OpenFile instead. +// +// The handle is opened with FILE_FLAG_OVERLAPPED, exactly like the device, so a +// pending Read on one goroutine does not block Writes from others and Close can +// cancel the pending Read. The client's default read mode is byte-stream, which +// is what the event parser (RecvInfo) expects, so message boundaries on the +// command direction don't affect event reassembly. CreateFile is retried while +// the server is still coming up, so daemon-first ordering is not required. +func OpenPipe(pipeName string, readBufferSize int) (*KextFile, error) { + path := SimEndpoint(pipeName) + pathU16, err := syscall.UTF16FromString(path) + if err != nil { + return nil, fmt.Errorf("failed to convert pipe name to UTF16 string: %w", err) + } + + deadline := time.Now().Add(10 * time.Second) + for { + handle, err := windows.CreateFile( + &pathU16[0], + windows.GENERIC_READ|windows.GENERIC_WRITE, + 0, nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_OVERLAPPED, + 0) + if err == nil { + return &KextFile{handle: handle, buffer: make([]byte, readBufferSize), pipe: true}, nil + } + + // The daemon may not have created the pipe yet, or it may be momentarily + // busy between instances; retry until the deadline. + if (err == windows.ERROR_FILE_NOT_FOUND || err == windows.ERROR_PIPE_BUSY) && time.Now().Before(deadline) { + time.Sleep(50 * time.Millisecond) + continue + } + return nil, fmt.Errorf("failed to open pipe %s: %w", path, err) + } +} diff --git a/kext_interface/sim_conn.go b/kext_interface/sim_conn.go new file mode 100644 index 0000000..91f1dd7 --- /dev/null +++ b/kext_interface/sim_conn.go @@ -0,0 +1,22 @@ +package kext_interface + +import "io" + +// SimConn is a connection to the userspace fake-driver daemon (`zf-sim`, see +// sim/). Read/Write behave like a *KextFile against the kernel device: Read +// fills the whole buffer from the event stream, and each Write delivers exactly +// one command to the driver. +// +// The daemon's endpoint is addressed by a logical name (ZF_SIM_PIPE), mapped +// per OS by SimEndpoint. The rule must match `endpoint` in sim/src/pipe/mod.rs: +// - Windows: itself if it is a full \\.\pipe\ path, else the named +// pipe \\.\pipe\ +// - Unix: itself if it contains '/', else $XDG_RUNTIME_DIR/.sock, +// falling back to /tmp/.sock +type SimConn = io.ReadWriteCloser + +// OpenSimConn connects to the daemon endpoint for name, retrying while the +// daemon is still coming up, so daemon-first ordering is not required. +func OpenSimConn(name string, readBufferSize int) (SimConn, error) { + return openSimConn(name, readBufferSize) +} diff --git a/kext_interface/sim_conn_unix.go b/kext_interface/sim_conn_unix.go new file mode 100644 index 0000000..d7082ec --- /dev/null +++ b/kext_interface/sim_conn_unix.go @@ -0,0 +1,80 @@ +//go:build unix + +package kext_interface + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "os" + "strings" + "sync" + "syscall" + "time" +) + +// SimEndpoint returns the Unix socket path for the logical endpoint name. +func SimEndpoint(name string) string { + if strings.Contains(name, "/") { + return name + } + if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { + return strings.TrimRight(dir, "/") + "/" + name + ".sock" + } + return "/tmp/" + name + ".sock" +} + +// openSimConn dials the daemon's Unix socket, retrying while the daemon has not +// created it yet (ENOENT) or is not accepting yet (ECONNREFUSED). The read +// buffer size is unused: the socket buffers in the kernel. +func openSimConn(name string, _ int) (SimConn, error) { + path := SimEndpoint(name) + deadline := time.Now().Add(10 * time.Second) + for { + conn, err := net.Dial("unix", path) + if err == nil { + return &framedConn{conn: conn}, nil + } + retryable := errors.Is(err, syscall.ENOENT) || errors.Is(err, syscall.ECONNREFUSED) + if retryable && time.Now().Before(deadline) { + time.Sleep(50 * time.Millisecond) + continue + } + return nil, fmt.Errorf("failed to open socket %s: %w", path, err) + } +} + +// framedConn restores the kernel device's "one write = one command" semantics +// over a stream socket, which has no message boundaries: each Write goes out as +// a u32 little-endian length followed by the payload, and the daemon hands +// each frame to the driver as one write. Events come back as an unframed byte +// stream, as from the device. +type framedConn struct { + conn net.Conn + // Serializes frames from concurrent Send*Command callers. + writeMutex sync.Mutex +} + +// Read fills the whole buffer, as *KextFile.Read does; RecvInfo relies on it. +func (c *framedConn) Read(buffer []byte) (int, error) { + return io.ReadFull(c.conn, buffer) +} + +func (c *framedConn) Write(buffer []byte) (int, error) { + frame := make([]byte, 4+len(buffer)) + binary.LittleEndian.PutUint32(frame, uint32(len(buffer))) + copy(frame[4:], buffer) + + c.writeMutex.Lock() + defer c.writeMutex.Unlock() + if _, err := c.conn.Write(frame); err != nil { + return 0, err + } + return len(buffer), nil +} + +func (c *framedConn) Close() error { + return c.conn.Close() +} diff --git a/kext_interface/sim_conn_unix_test.go b/kext_interface/sim_conn_unix_test.go new file mode 100644 index 0000000..382e67e --- /dev/null +++ b/kext_interface/sim_conn_unix_test.go @@ -0,0 +1,81 @@ +//go:build unix + +package kext_interface + +import ( + "encoding/binary" + "io" + "net" + "path/filepath" + "testing" +) + +// Two back-to-back commands must reach the daemon as two separate frames, since +// the driver parses exactly one command per write. +func TestFramedConnKeepsCommandBoundaries(t *testing.T) { + path := filepath.Join(t.TempDir(), "zf.sock") + listener, err := net.Listen("unix", path) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + conn, err := openSimConn(path, 0) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + server, err := listener.Accept() + if err != nil { + t.Fatal(err) + } + defer server.Close() + + if err := SendVerdictCommand(conn, Verdict{Id: 42, Verdict: uint8(VerdictPermanentAccept)}); err != nil { + t.Fatal(err) + } + if err := SendGetLogsCommand(conn); err != nil { + t.Fatal(err) + } + + readFrame := func() []byte { + var size uint32 + if err := binary.Read(server, binary.LittleEndian, &size); err != nil { + t.Fatal(err) + } + payload := make([]byte, size) + if _, err := io.ReadFull(server, payload); err != nil { + t.Fatal(err) + } + return payload + } + + verdict := readFrame() + if want := binary.Size(Verdict{}); len(verdict) != want || verdict[0] != CommandVerdict { + t.Fatalf("verdict frame = %v, want %d bytes starting with %d", verdict, want, CommandVerdict) + } + logs := readFrame() + if len(logs) != 1 || logs[0] != CommandGetLogs { + t.Fatalf("get-logs frame = %v, want [%d]", logs, CommandGetLogs) + } +} + +// Read must fill the caller's buffer even when the bytes arrive in pieces. +func TestFramedConnReadFillsBuffer(t *testing.T) { + client, server := net.Pipe() + conn := &framedConn{conn: client} + defer conn.Close() + + go func() { + server.Write([]byte{1, 2}) + server.Write([]byte{3, 4, 5}) + server.Close() + }() + + buffer := make([]byte, 5) + n, err := conn.Read(buffer) + if err != nil || n != 5 || buffer[4] != 5 { + t.Fatalf("Read = %d, %v, %v", n, err, buffer) + } +} diff --git a/kext_interface/sim_conn_windows.go b/kext_interface/sim_conn_windows.go new file mode 100644 index 0000000..3116df1 --- /dev/null +++ b/kext_interface/sim_conn_windows.go @@ -0,0 +1,26 @@ +//go:build windows +// +build windows + +package kext_interface + +import "strings" + +// The named pipe is message-mode, so each Write already reaches the driver as +// one command; the pipe handle needs no extra framing. +var _ SimConn = (*KextFile)(nil) + +// pipePrefix is the namespace every named-pipe path lives under. +const pipePrefix = `\\.\pipe\` + +// SimEndpoint returns the named-pipe path for the logical endpoint name. A name +// that is already a full pipe path (\\.\pipe\...) is returned unchanged. +func SimEndpoint(name string) string { + if strings.HasPrefix(strings.ToLower(name), pipePrefix) { + return name + } + return pipePrefix + name +} + +func openSimConn(name string, readBufferSize int) (SimConn, error) { + return OpenPipe(name, readBufferSize) +} diff --git a/kext_interface/version.go b/kext_interface/version.go new file mode 100644 index 0000000..152d818 --- /dev/null +++ b/kext_interface/version.go @@ -0,0 +1,29 @@ +package kext_interface + +import ( + _ "embed" + "strconv" + "strings" +) + +var ( + //go:embed version.txt + versionTxt string + + // 4 byte version of the Kext interface + InterfaceVersion = func() (v [4]byte) { + // Parse version from file "version.txt". Expected format: [0, 1, 2, 3] + s := strings.TrimSpace(versionTxt) + s = strings.TrimPrefix(s, "[") + s = strings.TrimSuffix(s, "]") + str_ver := strings.Split(s, ",") + for i := range v { + n, err := strconv.Atoi(strings.TrimSpace(str_ver[i])) + if err != nil { + panic(err) + } + v[i] = byte(n) + } + return + }() +) diff --git a/kext_tester/main.go b/kext_tester/main.go deleted file mode 100644 index f4ff67e..0000000 --- a/kext_tester/main.go +++ /dev/null @@ -1,133 +0,0 @@ -//go:build windows - -package main - -import ( - "bufio" - "fmt" - "log" - "net" - "os" - "time" - - "github.com/vlabo/zenithfence/kext_interface" -) - -type Verdict int8 - -var protocols = map[int]string{ - 1: "icmp", - 2: "igmp", - 6: "tcp", - 17: "udp", - 58: "ipv6-icmp", -} - -func main() { - driverName := "ZenithFence" - sysPath := "C:\\Dev\\driver.sys" - kext, err := kext_interface.CreateKextService(driverName, sysPath) - if err != nil { - log.Panicf("failed to create driver service: %s", err) - } - defer kext.Delete() - - err = kext.Start(true) - if err != nil { - log.Panicf("failed to start service: %s", err) - } - defer kext.Stop(true) - - file, err := kext.OpenFile(1024) - if err != nil { - log.Panicf("failed to open driver file: %s", err) - } - defer file.Close() - - version, err := kext_interface.ReadVersion(file) - if err == nil { - log.Printf("Kext version: %d.%d.%d.%d\n", version[0], version[1], version[2], version[3]) - log.Printf("KextI version: %d.%d.%d.%d\n", kext_interface.InterfaceVersion[0], kext_interface.InterfaceVersion[1], kext_interface.InterfaceVersion[2], kext_interface.InterfaceVersion[3]) - } else { - log.Printf("Error reading version: %s\n", err) - } - - ticker := time.NewTicker(1 * time.Second) - go func() { - for range ticker.C { - err := kext_interface.SendGetLogsCommand(file) - if err != nil { - return - } - } - }() - - go func() { - for true { - info, err := kext_interface.RecvInfo(file) - if err != nil { - log.Printf("error reading from file %s", err) - return - } - switch info := info.(type) { - case *kext_interface.ConnectionV4: - { - // direction := "->" - if info.Direction == 1 { - // direction = "<-" - // kext_interface.WriteVerdictCommand(file, kext_interface.BuildVerdict(kext_interface.Verdict{Id: connection.Id, Verdict: uint8(VerdictBlock)})) - kext_interface.SendVerdictCommand(file, kext_interface.Verdict{Id: info.Id, Verdict: uint8(kext_interface.VerdictPermanentAccept)}) - } else { - if info.RemoteIp == [4]byte{1, 1, 1, 1} { - kext_interface.SendVerdictCommand(file, kext_interface.Verdict{Id: info.Id, Verdict: uint8(kext_interface.VerdictPermanentBlock)}) - log.Printf("blocked packet: %d pid=%d %+v:%d %s %+v:%d %s\n", info.Id, info.ProcessId, net.IP(info.LocalIp[:]), info.LocalPort, "->", net.IP(info.RemoteIp[:]), info.RemotePort, protocols[int(info.Protocol)]) - } else { - time.Sleep(200 * time.Millisecond) - kext_interface.SendVerdictCommand(file, kext_interface.Verdict{Id: info.Id, Verdict: uint8(kext_interface.VerdictPermanentAccept)}) - } - } - - // log.Printf("infov4: %d pid=%d %+v:%d %s %+v:%d %s\n", conn.Id, conn.ProcessId, net.IP(conn.LocalIp[:]), conn.LocalPort, direction, net.IP(conn.RemoteIp[:]), conn.RemotePort, protocols[int(conn.Protocol)]) - } - case *kext_interface.ConnectionV6: - { - // direction := "->" - if info.Direction == 1 { - kext_interface.SendVerdictCommand(file, kext_interface.Verdict{Id: info.Id, Verdict: uint8(kext_interface.VerdictPermanentAccept)}) - // direction = "<-" - } else { - kext_interface.SendVerdictCommand(file, kext_interface.Verdict{Id: info.Id, Verdict: uint8(kext_interface.VerdictPermanentAccept)}) - } - // log.Printf("infov6: %d pid=%d [%+v]:%d %s [%+v]:%d %s\n", conn.Id, conn.ProcessId, net.IP(conn.LocalIp[:]), conn.LocalPort, direction, net.IP(conn.RemoteIp[:]), conn.RemotePort, protocols[int(conn.Protocol)]) - } - case *kext_interface.ConnectionEndV4: - { - // conn := info.ConnectionEndV4 - // direction := "->" - // if conn.Direction == 1 { - // direction = "<-" - // } - // log.Printf("conn end v4: pid=%d %+v:%d %s %+v:%d %s\n", conn.ProcessId, net.IP(conn.LocalIp[:]), conn.LocalPort, direction, net.IP(conn.RemoteIp[:]), conn.RemotePort, protocols[int(conn.Protocol)]) - } - case *kext_interface.ConnectionEndV6: - { - // conn := info.ConnectionEndV6 - // direction := "->" - // if conn.Direction == 1 { - // direction = "<-" - // } - // log.Printf("conn end v6: pid=%d [%+v]:%d %s [%+v]:%d %s\n", conn.ProcessId, net.IP(conn.LocalIp[:]), conn.LocalPort, direction, net.IP(conn.RemoteIp[:]), conn.RemotePort, protocols[int(conn.Protocol)]) - } - case *kext_interface.LogLine: - { - log.Println(info.Line) - } - } - } - }() - - fmt.Print("Press enter to exit\n") - input := bufio.NewScanner(os.Stdin) - input.Scan() - kext_interface.SendShutdownCommand(file) -} diff --git a/kext_tester/main_unix.go b/kext_tester/main_unix.go new file mode 100644 index 0000000..09a1c85 --- /dev/null +++ b/kext_tester/main_unix.go @@ -0,0 +1,19 @@ +//go:build !windows + +package main + +import ( + "fmt" + "os" +) + +// Off Windows there is no kernel driver to load, so the agent only runs in +// simulation mode, against the userspace fake driver (see sim.go). +func main() { + pipeName := os.Getenv("ZF_SIM_PIPE") + if pipeName == "" { + fmt.Fprintln(os.Stderr, "production mode is Windows-only; set ZF_SIM_PIPE to run against the simulated driver (see `just sim-run`)") + os.Exit(2) + } + os.Exit(runSimulation(pipeName)) +} diff --git a/kext_tester/main_windows.go b/kext_tester/main_windows.go new file mode 100644 index 0000000..51d1ef3 --- /dev/null +++ b/kext_tester/main_windows.go @@ -0,0 +1,77 @@ +//go:build windows + +package main + +import ( + "bufio" + "fmt" + "log" + "os" + "time" + + "github.com/vlabo/zenithfence/kext_interface" +) + +func main() { + // Real-world simulation mode: the same agent, but driving a userspace fake + // driver instead of a kernel service. Switched purely by the environment, so + // the production binary is otherwise unchanged. runSimulation returns the + // mock driver's exit code so a scenario's pass/fail propagates out. + if pipeName := os.Getenv("ZF_SIM_PIPE"); pipeName != "" { + os.Exit(runSimulation(pipeName)) + } + + driverName := "ZenithFence" + sysPath := "C:\\Dev\\driver.sys" + kext, err := kext_interface.CreateKextService(driverName, sysPath) + if err != nil { + log.Panicf("failed to create driver service: %s", err) + } + defer kext.Delete() + + err = kext.Start(true) + if err != nil { + log.Panicf("failed to start service: %s", err) + } + defer kext.Stop(true) + + file, err := kext.OpenFile(1024) + if err != nil { + log.Panicf("failed to open driver file: %s", err) + } + defer file.Close() + + version, err := kext_interface.ReadVersion(file) + if err == nil { + log.Printf("Kext version: %d.%d.%d.%d\n", version[0], version[1], version[2], version[3]) + log.Printf("KextI version: %d.%d.%d.%d\n", kext_interface.InterfaceVersion[0], kext_interface.InterfaceVersion[1], kext_interface.InterfaceVersion[2], kext_interface.InterfaceVersion[3]) + } else { + log.Printf("Error reading version: %s\n", err) + } + + ticker := time.NewTicker(1 * time.Second) + go func() { + for range ticker.C { + err := kext_interface.SendGetLogsCommand(file) + if err != nil { + return + } + } + }() + + go func() { + for { + info, err := kext_interface.RecvInfo(file) + if err != nil { + log.Printf("error reading from file %s", err) + return + } + handleInfo(file, info) + } + }() + + fmt.Print("Press enter to exit\n") + input := bufio.NewScanner(os.Stdin) + input.Scan() + kext_interface.SendShutdownCommand(file) +} diff --git a/kext_tester/sim.go b/kext_tester/sim.go new file mode 100644 index 0000000..163f5cc --- /dev/null +++ b/kext_tester/sim.go @@ -0,0 +1,153 @@ +package main + +// The simulation mode and the verdict policy, shared by every OS. On Windows the +// same policy also drives the real kernel driver (see main_windows.go). + +import ( + "fmt" + "io" + "log" + "net" + "os" + "os/signal" + "time" + + "github.com/vlabo/zenithfence/kext_interface" +) + +type Verdict int8 + +var protocols = map[int]string{ + 1: "icmp", + 2: "igmp", + 6: "tcp", + 17: "udp", + 58: "ipv6-icmp", +} + +// runSimulation drives the same read/verdict loop as production against a +// userspace fake driver. When ZF_SIM_DAEMON names the `zf-sim` binary the agent +// *loads and starts the mock driver itself* -- it spawns the daemon (which runs +// the real driver over mock_wdk via driver_entry), opens its endpoint, and stops it +// on exit -- mirroring how production loads and starts the kernel driver before +// opening the device. With ZF_SIM_DAEMON unset it attaches to a daemon started +// separately. Returns the process exit code. +func runSimulation(pipeName string) int { + daemonPath := os.Getenv("ZF_SIM_DAEMON") + if daemonPath == "" { + return runSimulationAttached(pipeName) + } + + svc, err := kext_interface.CreateMockKextService(pipeName, daemonPath) + if err != nil { + log.Panicf("sim: %s", err) + } + defer svc.Delete() + + // Ctrl+C must tear the daemon down: the random producer runs forever, so + // without this an interrupted agent would orphan the daemon (os.Exit on + // signal skips deferred cleanup). + installInterrupt(func() { _ = svc.Delete() }) + + log.Printf("sim: loading and starting mock driver (%s)", daemonPath) + if err := svc.Start(true); err != nil { + log.Panicf("sim: failed to start mock driver: %s", err) + } + + log.Printf("sim: opening fake driver %s", kext_interface.SimEndpoint(pipeName)) + file, err := svc.OpenFile(1024) + if err != nil { + log.Panicf("sim: failed to open driver endpoint: %s", err) + } + defer file.Close() + + logInterfaceVersion() + pumpEvents(file) + + // The endpoint closed because the daemon exited on its own (a scenario finished). + // Reap it and propagate its pass/fail exit code. + code, _ := svc.Wait() + log.Printf("sim: mock driver exited with code %d", code) + return code +} + +// runSimulationAttached connects to an already-running fake-driver daemon without +// starting one (ZF_SIM_DAEMON unset, e.g. the daemon launched separately under a +// debugger). The read/verdict loop is identical; only the driver's lifecycle is +// out of the agent's hands. +func runSimulationAttached(pipeName string) int { + log.Printf("sim: attaching to fake driver %s", kext_interface.SimEndpoint(pipeName)) + file, err := kext_interface.OpenSimConn(pipeName, 1024) + if err != nil { + log.Panicf("sim: failed to open driver endpoint: %s", err) + } + defer file.Close() + + logInterfaceVersion() + pumpEvents(file) + return 0 +} + +// pumpEvents runs the read/verdict loop shared by every simulation mode: read one +// event, apply the verdict policy, repeat until the connection closes. +func pumpEvents(file io.ReadWriter) { + for { + info, err := kext_interface.RecvInfo(file) + if err != nil { + log.Printf("sim: connection closed: %s", err) + return + } + handleInfo(file, info) + } +} + +func logInterfaceVersion() { + log.Printf("sim: agent interface version %d.%d.%d.%d", + kext_interface.InterfaceVersion[0], kext_interface.InterfaceVersion[1], + kext_interface.InterfaceVersion[2], kext_interface.InterfaceVersion[3]) +} + +// installInterrupt runs cleanup on the first Ctrl+C, then exits. Producer mode +// never ends on its own, so without this the daemon would be orphaned. +func installInterrupt(cleanup func()) { + ch := make(chan os.Signal, 1) + signal.Notify(ch, os.Interrupt) + go func() { + <-ch + log.Println("sim: interrupt received; stopping mock driver") + cleanup() + os.Exit(130) + }() +} + +// handleInfo applies the agent's verdict policy to one event. Shared by the +// production and simulation paths so the decision logic is identical. +func handleInfo(file io.ReadWriter, info kext_interface.Info) { + switch info := info.(type) { + case *kext_interface.ConnectionV4: + if info.Direction == 1 { + kext_interface.SendVerdictCommand(file, kext_interface.Verdict{Id: info.Id, Verdict: uint8(kext_interface.VerdictPermanentAccept)}) + log.Printf("handling packet packet: %d pid=%d %+v:%d %s %+v:%d %s\n", info.Id, info.ProcessId, net.IP(info.LocalIp[:]), info.LocalPort, "->", net.IP(info.RemoteIp[:]), info.RemotePort, protocols[int(info.Protocol)]) + } else { + if info.RemoteIp == [4]byte{1, 1, 1, 1} { + kext_interface.SendVerdictCommand(file, kext_interface.Verdict{Id: info.Id, Verdict: uint8(kext_interface.VerdictPermanentBlock)}) + log.Printf("blocked packet: %d pid=%d %+v:%d %s %+v:%d %s\n", info.Id, info.ProcessId, net.IP(info.LocalIp[:]), info.LocalPort, "->", net.IP(info.RemoteIp[:]), info.RemotePort, protocols[int(info.Protocol)]) + } else { + time.Sleep(20 * time.Millisecond) + kext_interface.SendVerdictCommand(file, kext_interface.Verdict{Id: info.Id, Verdict: uint8(kext_interface.VerdictPermanentAccept)}) + log.Printf("handling packet packet: %d pid=%d %+v:%d %s %+v:%d %s\n", info.Id, info.ProcessId, net.IP(info.LocalIp[:]), info.LocalPort, "->", net.IP(info.RemoteIp[:]), info.RemotePort, protocols[int(info.Protocol)]) + } + } + case *kext_interface.ConnectionV6: + kext_interface.SendVerdictCommand(file, kext_interface.Verdict{Id: info.Id, Verdict: uint8(kext_interface.VerdictPermanentAccept)}) + log.Printf("handling packet packet: %d pid=%d %+v:%d %s %+v:%d %s\n", info.Id, info.ProcessId, net.IP(info.LocalIp[:]), info.LocalPort, "->", net.IP(info.RemoteIp[:]), info.RemotePort, protocols[int(info.Protocol)]) + case *kext_interface.LogLine: + log.Println(info.Line) + case *kext_interface.ConnectionEndV4: + log.Printf("ending connection: pid=%d %+v:%d %s %+v:%d %s\n", info.ProcessId, net.IP(info.LocalIp[:]), info.LocalPort, "->", net.IP(info.RemoteIp[:]), info.RemotePort, protocols[int(info.Protocol)]) + case *kext_interface.ConnectionEndV6: + log.Printf("ending connection: pid=%d %+v:%d %s %+v:%d %s\n", info.ProcessId, net.IP(info.LocalIp[:]), info.LocalPort, "->", net.IP(info.RemoteIp[:]), info.RemotePort, protocols[int(info.Protocol)]) + default: + panic(fmt.Sprintf("unexpected kext_interface.Info: %#v", info)) + } +} diff --git a/mock_wdk/Cargo.lock b/mock_wdk/Cargo.lock new file mode 100644 index 0000000..ed3fff5 --- /dev/null +++ b/mock_wdk/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "mock_wdk" +version = "0.1.0" diff --git a/mock_wdk/Cargo.toml b/mock_wdk/Cargo.toml new file mode 100644 index 0000000..8a6a5b7 --- /dev/null +++ b/mock_wdk/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "mock_wdk" +version = "0.1.0" +edition = "2021" + +# A pure-safe-std, windows-sys-free reimplementation of the subset of the `wdk` +# crate's public API that the `driver` crate uses. It is linked into the driver +# in place of the real `wdk` (via `extern crate mock_wdk as wdk`) when the +# driver is built with `--features mock`, so the packet-handling logic can be +# fuzz/property tested in user space. NOT part of the production driver build. + +[lib] +name = "mock_wdk" +path = "src/lib.rs" + +[dependencies] diff --git a/mock_wdk/src/connection.rs b/mock_wdk/src/connection.rs new file mode 100644 index 0000000..1c70e1c --- /dev/null +++ b/mock_wdk/src/connection.rs @@ -0,0 +1,150 @@ +//! Host-only user-space client for the mocked driver. +//! +//! `DriverConnection` mirrors the Win32 device-handle API a real user-space +//! agent uses (`ReadFile` / `WriteFile` / `DeviceIoControl`): each call +//! synthesizes a mock `IRP` and invokes the dispatch handler the driver +//! registered in its `DRIVER_OBJECT`, exactly like the kernel I/O manager. This +//! is what lets the full-driver simulation talk to the driver "like a real +//! driver" instead of poking `Device` methods directly. +//! +//! Each connection owns its own `DEVICE_OBJECT`, and the dispatch methods take +//! `&mut self`, so the `&mut DEVICE_OBJECT`/`&mut IRP` a handler receives are +//! never shared across threads — every thread (producer, consumer) opens its own +//! connection from the same `DRIVER_OBJECT`. + +use crate::kernel_types::{ + MjFnType, DEVICE_OBJECT, DRIVER_OBJECT, IRP, IRP_MJ_DEVICE_CONTROL, IRP_MJ_READ, IRP_MJ_WRITE, + NTSTATUS, +}; + +// Returned when a request type has no registered dispatch handler (e.g. an IOCTL +// on a driver that never set a device-control function). +const STATUS_NO_HANDLER: NTSTATUS = 0xC000_0002u32 as i32; // STATUS_NOT_IMPLEMENTED + +pub struct DriverConnection { + device_object: DEVICE_OBJECT, + read_fn: MjFnType, + write_fn: MjFnType, + control_fn: Option, +} + +// Each connection is single-owner; methods take `&mut self`. Moving one into a +// worker thread is fine, sharing it is not (and the type system enforces that). +unsafe impl Send for DriverConnection {} + +impl DriverConnection { + /// Open a client against a driver that `driver_entry` has populated. Returns + /// `None` if the read/write dispatch handlers are absent (driver not loaded). + pub fn open(driver_object: &DRIVER_OBJECT) -> Option { + let read_fn = driver_object.MajorFunction[IRP_MJ_READ]?; + let write_fn = driver_object.MajorFunction[IRP_MJ_WRITE]?; + let control_fn = driver_object.MajorFunction[IRP_MJ_DEVICE_CONTROL]; + Some(Self { + device_object: DEVICE_OBJECT::new(), + read_fn, + write_fn, + control_fn, + }) + } + + /// `WriteFile` equivalent: hand `bytes` to the driver's write dispatch + /// (the command channel). Returns the completion status. + pub fn write(&mut self, bytes: &[u8]) -> NTSTATUS { + // The write path only reads the input buffer; the const-cast to `*mut` + // is sound because the handler never writes through it. + let mut irp = IRP::for_write(bytes.as_ptr() as *mut u8, bytes.len()); + unsafe { (self.write_fn)(&mut self.device_object, &mut irp) } + } + + /// `ReadFile` equivalent: drain pending events into `out`. Returns + /// `(bytes_written, status)`. If the driver was loaded with blocking reads + /// and the event queue is empty, this blocks until an event arrives or the + /// queue is run down (shutdown), which returns end-of-file. + pub fn read(&mut self, out: &mut [u8]) -> (usize, NTSTATUS) { + let mut irp = IRP::for_read(out.as_mut_ptr(), out.len()); + let status = unsafe { (self.read_fn)(&mut self.device_object, &mut irp) }; + (irp.information, status) + } + + /// `DeviceIoControl` equivalent (buffered method: input and output share one + /// system buffer). Returns `(bytes_written_to_out, status)`. + pub fn device_io_control( + &mut self, + control_code: u32, + input: &[u8], + out: &mut [u8], + ) -> (usize, NTSTATUS) { + let Some(control_fn) = self.control_fn else { + return (0, STATUS_NO_HANDLER); + }; + // Buffered IOCTL shares one buffer for in and out. Seed it with the input + // (the handler reads `input_len` from the front) and let the handler + // overwrite with output. + let copy = input.len().min(out.len()); + out[..copy].copy_from_slice(&input[..copy]); + let mut irp = IRP::for_ioctl(out.as_mut_ptr(), input.len(), out.len(), control_code); + let status = unsafe { control_fn(&mut self.device_object, &mut irp) }; + (irp.information, status) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kernel_types::DRIVER_OBJECT; + + unsafe extern "system" fn fake_read(_d: &mut DEVICE_OBJECT, irp: &mut IRP) -> NTSTATUS { + let buf = core::slice::from_raw_parts_mut(irp.system_buffer, irp.output_len); + let payload = [0xDE, 0xAD, 0xBE, 0xEF]; + let n = payload.len().min(buf.len()); + buf[..n].copy_from_slice(&payload[..n]); + irp.information = n; + irp.status = 0; + 0 + } + + unsafe extern "system" fn fake_write(_d: &mut DEVICE_OBJECT, irp: &mut IRP) -> NTSTATUS { + let input = core::slice::from_raw_parts(irp.system_buffer, irp.input_len); + // Surface the first input byte so the test can confirm the bytes arrived. + irp.information = if input.first() == Some(&0xAB) { 1 } else { 0 }; + irp.status = 0; + 0 + } + + unsafe extern "system" fn fake_ioctl(_d: &mut DEVICE_OBJECT, irp: &mut IRP) -> NTSTATUS { + let buf = core::slice::from_raw_parts_mut(irp.system_buffer, irp.output_len); + let bytes = irp.control_code.to_le_bytes(); + let n = bytes.len().min(buf.len()); + buf[..n].copy_from_slice(&bytes[..n]); + irp.information = n; + irp.status = 0; + 0 + } + + #[test] + fn open_returns_none_without_handlers() { + let drv = DRIVER_OBJECT::new(); + assert!(DriverConnection::open(&drv).is_none()); + } + + #[test] + fn dispatches_read_write_ioctl() { + let mut drv = DRIVER_OBJECT::new(); + drv.MajorFunction[IRP_MJ_READ] = Some(fake_read); + drv.MajorFunction[IRP_MJ_WRITE] = Some(fake_write); + drv.MajorFunction[IRP_MJ_DEVICE_CONTROL] = Some(fake_ioctl); + let mut conn = DriverConnection::open(&drv).expect("loaded"); + + let mut out = [0u8; 8]; + let (n, st) = conn.read(&mut out); + assert_eq!(st, 0); + assert_eq!(&out[..n], &[0xDE, 0xAD, 0xBE, 0xEF]); + + assert_eq!(conn.write(&[0xAB, 0x01]), 0); + + let mut vbuf = [0u8; 4]; + let (n, st) = conn.device_io_control(0x1234_5678, &[], &mut vbuf); + assert_eq!(st, 0); + assert_eq!(&vbuf[..n], &0x1234_5678u32.to_le_bytes()); + } +} diff --git a/mock_wdk/src/consts.rs b/mock_wdk/src/consts.rs new file mode 100644 index 0000000..9e10f40 --- /dev/null +++ b/mock_wdk/src/consts.rs @@ -0,0 +1,53 @@ +// Copied verbatim from wdk/src/consts.rs -- all plain u32 constants, no windows deps. +#![allow(dead_code)] + +// Actions +pub const FWP_ACTION_FLAG_TERMINATING: u32 = 0x00001000; +pub const FWP_ACTION_FLAG_NON_TERMINATING: u32 = 0x00002000; +pub const FWP_ACTION_FLAG_CALLOUT: u32 = 0x00004000; + +pub const FWP_ACTION_BLOCK: u32 = 0x00000001 | FWP_ACTION_FLAG_TERMINATING; +pub const FWP_ACTION_PERMIT: u32 = 0x00000002 | FWP_ACTION_FLAG_TERMINATING; +pub const FWP_ACTION_CALLOUT_TERMINATING: u32 = + 0x00000003 | FWP_ACTION_FLAG_CALLOUT | FWP_ACTION_FLAG_TERMINATING; +pub const FWP_ACTION_CALLOUT_INSPECTION: u32 = + 0x00000004 | FWP_ACTION_FLAG_CALLOUT | FWP_ACTION_FLAG_NON_TERMINATING; +pub const FWP_ACTION_CALLOUT_UNKNOWN: u32 = 0x00000005 | FWP_ACTION_FLAG_CALLOUT; +pub const FWP_ACTION_CONTINUE: u32 = 0x00000006 | FWP_ACTION_FLAG_NON_TERMINATING; +pub const FWP_ACTION_NONE: u32 = 0x00000007; +pub const FWP_ACTION_NONE_NO_MATCH: u32 = 0x00000008; +pub const FWP_CONDITION_FLAG_IS_LOOPBACK: u32 = 0x00000001; +pub const FWP_CONDITION_FLAG_IS_IPSEC_SECURED: u32 = 0x00000002; +pub const FWP_CONDITION_FLAG_IS_REAUTHORIZE: u32 = 0x00000004; +pub const FWP_CONDITION_FLAG_IS_WILDCARD_BIND: u32 = 0x00000008; +pub const FWP_CONDITION_FLAG_IS_RAW_ENDPOINT: u32 = 0x00000010; +pub const FWP_CONDITION_FLAG_IS_FRAGMENT: u32 = 0x00000020; +pub const FWP_CONDITION_FLAG_IS_FRAGMENT_GROUP: u32 = 0x00000040; +pub const FWP_CONDITION_FLAG_IS_IPSEC_NATT_RECLASSIFY: u32 = 0x00000080; +pub const FWP_CONDITION_FLAG_REQUIRES_ALE_CLASSIFY: u32 = 0x00000100; +pub const FWP_CONDITION_FLAG_IS_IMPLICIT_BIND: u32 = 0x00000200; +pub const FWP_CONDITION_FLAG_IS_REASSEMBLED: u32 = 0x00000400; +pub const FWP_CONDITION_FLAG_IS_NAME_APP_SPECIFIED: u32 = 0x00004000; +pub const FWP_CONDITION_FLAG_IS_PROMISCUOUS: u32 = 0x00008000; +pub const FWP_CONDITION_FLAG_IS_AUTH_FW: u32 = 0x00010000; +pub const FWP_CONDITION_FLAG_IS_RECLASSIFY: u32 = 0x00020000; +pub const FWP_CONDITION_FLAG_IS_OUTBOUND_PASS_THRU: u32 = 0x00040000; +pub const FWP_CONDITION_FLAG_IS_INBOUND_PASS_THRU: u32 = 0x00080000; +pub const FWP_CONDITION_FLAG_IS_CONNECTION_REDIRECTED: u32 = 0x00100000; + +// Driver +pub const METHOD_BUFFERED: u32 = 0; +pub const METHOD_IN_DIRECT: u32 = 1; +pub const METHOD_OUT_DIRECT: u32 = 2; +pub const METHOD_NEITHER: u32 = 3; + +pub const SIOCTL_TYPE: u32 = 40000; + +pub const FILE_READ_DATA: u32 = 0x00000001; +pub const FILE_READ_ATTRIBUTES: u32 = 0x00000080; +pub const FILE_READ_EA: u32 = 0x00000008; +pub const FILE_WRITE_DATA: u32 = 0x00000002; +pub const FILE_WRITE_ATTRIBUTES: u32 = 0x00000100; +pub const FILE_WRITE_EA: u32 = 0x00000010; +pub const FILE_APPEND_DATA: u32 = 0x00000004; +pub const FILE_EXECUTE: u32 = 0x00000020; diff --git a/mock_wdk/src/driver.rs b/mock_wdk/src/driver.rs new file mode 100644 index 0000000..1b2eb8c --- /dev/null +++ b/mock_wdk/src/driver.rs @@ -0,0 +1,100 @@ +//! Host mock of `wdk::driver::Driver`. +//! +//! In production this wraps the WDM `DRIVER_OBJECT`/`DEVICE_OBJECT` and registers +//! IRP dispatch handlers by writing function pointers into +//! `DRIVER_OBJECT.MajorFunction[IRP_MJ_*]`. The mock mirrors that exactly so the +//! real `DriverEntry` runs unchanged and a `DriverConnection` can read the +//! handlers back from the `DRIVER_OBJECT` and dispatch like the I/O manager. +//! +//! `Driver::mock()` (carrying a null driver object) keeps the older +//! `Device::new(&Driver::mock())` fuzz/test path working: with no driver object, +//! the `set_*_fn` registrations are no-ops, which is fine because that path never +//! dispatches through the registered handlers. It does carry a (static, zero +//! sized) device object, because the mock filter engine — like the kernel's +//! `FwpsCalloutRegister` — refuses callout registration without one. + +use core::ptr::null_mut; + +use crate::kernel_types::{ + MjFnType, UnloadFnType, DEVICE_OBJECT, DRIVER_OBJECT, IRP_MJ_CLEANUP, IRP_MJ_CLOSE, + IRP_MJ_CREATE, IRP_MJ_DEVICE_CONTROL, IRP_MJ_READ, IRP_MJ_WRITE, +}; + +pub struct Driver { + driver_object: *mut DRIVER_OBJECT, + device_object: *mut DEVICE_OBJECT, +} + +unsafe impl Sync for Driver {} +unsafe impl Send for Driver {} + +impl Driver { + /// Construct a driver with no backing driver object, for tests / fuzz + /// harnesses that build a `Device` directly without going through + /// `DriverEntry`. The device object is a process-wide stateless ZST so the + /// filter engine's registration check passes. + pub fn mock() -> Self { + static MOCK_DEVICE_OBJECT: DEVICE_OBJECT = DEVICE_OBJECT; + Driver { + driver_object: null_mut(), + device_object: core::ptr::addr_of!(MOCK_DEVICE_OBJECT) as *mut DEVICE_OBJECT, + } + } + + /// Bind a driver to a caller-owned `DRIVER_OBJECT` (used by + /// `interface::init_driver_object`). Handler registration writes into it; + /// the device object is the one driver init created in it, if any. + pub(crate) fn from_driver_object(driver_object: *mut DRIVER_OBJECT) -> Self { + let device_object = unsafe { driver_object.as_ref() } + .map(|object| object.DeviceObject) + .unwrap_or(null_mut()); + Driver { + driver_object, + device_object, + } + } + + pub fn get_device_object(&self) -> *mut DEVICE_OBJECT { + self.device_object + } + + pub fn get_device_object_ref(&self) -> Option<&mut DEVICE_OBJECT> { + unsafe { self.device_object.as_mut() } + } + + pub fn set_driver_unload(&mut self, driver_unload: UnloadFnType) { + if let Some(driver) = unsafe { self.driver_object.as_mut() } { + driver.DriverUnload = Some(driver_unload); + } + } + + pub fn set_read_fn(&mut self, mj_fn: MjFnType) { + self.set_major_fn(IRP_MJ_READ, mj_fn); + } + + pub fn set_write_fn(&mut self, mj_fn: MjFnType) { + self.set_major_fn(IRP_MJ_WRITE, mj_fn); + } + + pub fn set_create_fn(&mut self, mj_fn: MjFnType) { + self.set_major_fn(IRP_MJ_CREATE, mj_fn); + } + + pub fn set_device_control_fn(&mut self, mj_fn: MjFnType) { + self.set_major_fn(IRP_MJ_DEVICE_CONTROL, mj_fn); + } + + pub fn set_close_fn(&mut self, mj_fn: MjFnType) { + self.set_major_fn(IRP_MJ_CLOSE, mj_fn); + } + + pub fn set_cleanup_fn(&mut self, mj_fn: MjFnType) { + self.set_major_fn(IRP_MJ_CLEANUP, mj_fn); + } + + fn set_major_fn(&mut self, fn_index: usize, mj_fn: MjFnType) { + if let Some(driver) = unsafe { self.driver_object.as_mut() } { + driver.MajorFunction[fn_index] = Some(mj_fn); + } + } +} diff --git a/mock_wdk/src/filter_engine/callout.rs b/mock_wdk/src/filter_engine/callout.rs new file mode 100644 index 0000000..2167836 --- /dev/null +++ b/mock_wdk/src/filter_engine/callout.rs @@ -0,0 +1,43 @@ +use super::callout_data::CalloutData; +use super::layer::Layer; + +pub enum FilterType { + Resettable, + NonResettable, +} + +/// Host mock of `wdk::filter_engine::callout::Callout`. Stores the same fields +/// the driver passes to `Callout::new` (in particular the `callout_fn` pointer, +/// whose type must match so `callouts::get_callout_vec` type-checks). +/// Registration is a no-op handled by the mock `FilterEngine`. +pub struct Callout { + pub name: String, + pub description: String, + pub guid: u128, + pub layer: Layer, + pub action: u32, + pub filter_type: FilterType, + pub callout_fn: fn(CalloutData), +} + +impl Callout { + pub fn new( + name: &str, + description: &str, + guid: u128, + layer: Layer, + action: u32, + filter_type: FilterType, + callout_fn: fn(CalloutData), + ) -> Self { + Self { + name: name.to_owned(), + description: description.to_owned(), + guid, + layer, + action, + filter_type, + callout_fn, + } + } +} diff --git a/mock_wdk/src/filter_engine/callout_data.rs b/mock_wdk/src/filter_engine/callout_data.rs new file mode 100644 index 0000000..4d6c8f2 --- /dev/null +++ b/mock_wdk/src/filter_engine/callout_data.rs @@ -0,0 +1,218 @@ +use core::ffi::c_void; +use core::marker::PhantomData; + +use super::classify::ClassifyOut; +use super::layer::{Layer, ValueType}; +use super::net_buffer::NET_BUFFER_LIST; +use super::packet::TransportPacketList; + +/// One classify "fixed value". The real type is a tagged C union; the mock uses +/// a safe enum the harness can build directly. A callout reads it through the +/// `get_value_*` accessors below. +#[derive(Clone, Debug)] +pub enum Value { + Empty, + U8(u8), + U16(u16), + U32(u32), + Bytes16([u8; 16]), +} + +static ZERO16: [u8; 16] = [0u8; 16]; + +/// Host mock of `wdk::filter_engine::callout_data::ClassifyDefer`. +/// The first payload of `Initial` mirrors the real `HANDLE` (isize); the driver +/// only ever binds it as `_`. +pub enum ClassifyDefer { + Initial(isize, Option), + Reauthorization(usize, Option), +} + +/// Host mock of `wdk::filter_engine::callout_data::DeferResolution`. +pub enum DeferResolution { + /// The pended operation has been completed. + Completed(Option), + /// There was no completion handle; the connection stays held by the filter + /// until the filters are reset. + HeldByFilter(Option), +} + +impl ClassifyDefer { + /// Mirrors the real `resolve`: completing an `Initial` pend has no host-side + /// effect, a `Reauthorization` is reported as still held by its filter. + pub fn resolve(self) -> DeferResolution { + match self { + ClassifyDefer::Initial(_, packet_list) => DeferResolution::Completed(packet_list), + ClassifyDefer::Reauthorization(_, packet_list) => { + DeferResolution::HeldByFilter(packet_list) + } + } + } +} + +/// Host mock of `wdk::filter_engine::callout_data::CalloutData`. +/// +/// Construct one with [`CalloutData::mock`]. `classify_out` and `layer_data` +/// are raw pointers to harness-owned storage (a `ClassifyOut` and an NBL chain +/// respectively), matching the real layout; either may be null in harnesses +/// that don't exercise that path (the accessors null-check). +pub struct CalloutData<'a> { + pub layer: Layer, + callout_id: usize, + values: Vec, + process_id: Option, + classify_out: *mut ClassifyOut, + layer_data: *mut NET_BUFFER_LIST, + ip_header_size: u32, + transport_header_size: u32, + _marker: PhantomData<&'a ()>, +} + +impl<'a> CalloutData<'a> { + #[allow(clippy::too_many_arguments)] + pub fn mock( + layer: Layer, + values: Vec, + process_id: Option, + classify_out: *mut ClassifyOut, + layer_data: *mut NET_BUFFER_LIST, + ip_header_size: u32, + transport_header_size: u32, + ) -> Self { + Self { + layer, + callout_id: 0, + values, + process_id, + classify_out, + layer_data, + ip_header_size, + transport_header_size, + _marker: PhantomData, + } + } + + pub fn get_value_type(&self, index: usize) -> ValueType { + match &self.values[index] { + Value::Empty => ValueType::FwpEmpty, + Value::U8(_) => ValueType::FwpUint8, + Value::U16(_) => ValueType::FwpUint16, + Value::U32(_) => ValueType::FwpUint32, + Value::Bytes16(_) => ValueType::FwpByteArray16Type, + } + } + + pub fn get_value_u8(&self, index: usize) -> u8 { + match &self.values[index] { + Value::U8(v) => *v, + _ => 0, + } + } + + pub fn get_value_u16(&self, index: usize) -> u16 { + match &self.values[index] { + Value::U16(v) => *v, + _ => 0, + } + } + + pub fn get_value_u32(&self, index: usize) -> u32 { + match &self.values[index] { + Value::U32(v) => *v, + _ => 0, + } + } + + pub fn get_value_byte_array16(&self, index: usize) -> &[u8; 16] { + match &self.values[index] { + Value::Bytes16(a) => a, + _ => &ZERO16, + } + } + + pub fn get_process_id(&self) -> Option { + self.process_id + } + + /// Mirrors the real accessor, which returns `None` when the layer does not + /// set the metadata field. The mock models an absent field as 0. + pub fn get_ip_header_size(&self) -> Option { + if self.ip_header_size == 0 { + return None; + } + Some(self.ip_header_size) + } + + pub fn get_transport_header_size(&self) -> u32 { + self.transport_header_size + } + + pub fn get_layer_data(&self) -> *mut c_void { + self.layer_data as *mut c_void + } + + pub fn pend_operation( + &mut self, + packet_list: Option, + ) -> Result { + Ok(ClassifyDefer::Initial(0, packet_list)) + } + + pub fn pend_filter_rest(&mut self, packet_list: Option) -> ClassifyDefer { + ClassifyDefer::Reauthorization(self.callout_id, packet_list) + } + + pub fn action_permit(&mut self) { + if let Some(co) = unsafe { self.classify_out.as_mut() } { + co.action_permit(); + } + } + + pub fn action_continue(&mut self) { + if let Some(co) = unsafe { self.classify_out.as_mut() } { + co.action_continue(); + } + } + + pub fn action_block(&mut self) { + if let Some(co) = unsafe { self.classify_out.as_mut() } { + co.clear_write_flag(); + co.action_block(); + } + } + + pub fn action_none(&mut self) { + if let Some(co) = unsafe { self.classify_out.as_mut() } { + co.set_none(); + } + } + + pub fn block_and_absorb(&mut self) { + if let Some(co) = unsafe { self.classify_out.as_mut() } { + co.clear_write_flag(); + co.action_block(); + co.set_absorb(); + } + } + + pub fn parmit_and_absorb(&mut self) { + if let Some(co) = unsafe { self.classify_out.as_mut() } { + co.action_permit(); + co.set_absorb(); + } + } + + pub fn clear_write_flag(&mut self) { + if let Some(co) = unsafe { self.classify_out.as_mut() } { + co.clear_write_flag(); + } + } + + pub fn is_reauthorize(&self, flags_index: usize) -> bool { + self.get_value_u32(flags_index) & crate::consts::FWP_CONDITION_FLAG_IS_REAUTHORIZE > 0 + } + + pub fn get_callout_id(&self) -> usize { + self.callout_id + } +} diff --git a/mock_wdk/src/filter_engine/classify.rs b/mock_wdk/src/filter_engine/classify.rs new file mode 100644 index 0000000..8e0813a --- /dev/null +++ b/mock_wdk/src/filter_engine/classify.rs @@ -0,0 +1,92 @@ +// Host mock of `wdk::filter_engine::classify::ClassifyOut`. +// Mirrors the real action/flags semantics so a harness can assert the verdict +// a callout produced. `FWPS_CLASSIFY_OUT_FLAG_ABSORB` is inlined (no windows-sys). +#![allow(dead_code)] + +const FWP_ACTION_FLAG_TERMINATING: u32 = 0x00001000; +const FWP_ACTION_FLAG_NON_TERMINATING: u32 = 0x00002000; +const FWP_ACTION_FLAG_CALLOUT: u32 = 0x00004000; + +pub const FWP_ACTION_BLOCK: u32 = 0x00000001 | FWP_ACTION_FLAG_TERMINATING; +pub const FWP_ACTION_PERMIT: u32 = 0x00000002 | FWP_ACTION_FLAG_TERMINATING; +pub const FWP_ACTION_CONTINUE: u32 = 0x00000006 | FWP_ACTION_FLAG_NON_TERMINATING; +pub const FWP_ACTION_NONE: u32 = 0x00000007; + +const FWPS_RIGHT_ACTION_WRITE: u32 = 0x00000001; +const FWPS_CLASSIFY_OUT_FLAG_ABSORB: u32 = 0x00000001; + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ClassifyOut { + action_type: u32, + _out_context: u64, + _filter_id: u64, + rights: u32, + flags: u32, + reserved: u32, +} + +impl Default for ClassifyOut { + fn default() -> Self { + Self::new() + } +} + +impl ClassifyOut { + /// Construct a fresh `ClassifyOut` as the kernel would hand one to a + /// callout: action unset, write right granted. + pub fn new() -> Self { + Self { + action_type: 0, + _out_context: 0, + _filter_id: 0, + rights: FWPS_RIGHT_ACTION_WRITE, + flags: 0, + reserved: 0, + } + } + + pub fn can_set_action(&self) -> bool { + self.rights & FWPS_RIGHT_ACTION_WRITE > 0 + } + + pub fn action_block(&mut self) { + self.action_type = FWP_ACTION_BLOCK; + } + + pub fn action_permit(&mut self) { + self.action_type = FWP_ACTION_PERMIT; + } + + pub fn action_continue(&mut self) { + self.action_type = FWP_ACTION_CONTINUE; + } + + pub fn set_none(&mut self) { + self.action_type = FWP_ACTION_NONE; + } + + pub fn set_absorb(&mut self) { + self.flags |= FWPS_CLASSIFY_OUT_FLAG_ABSORB; + } + + pub fn clear_write_flag(&mut self) { + self.rights &= !FWPS_RIGHT_ACTION_WRITE; + } + + // ---- Test accessors (not present on the real type) ---- + + /// The action the callout set (0 if none). + pub fn action(&self) -> u32 { + self.action_type + } + + /// The current flags word (e.g. ABSORB). + pub fn flags(&self) -> u32 { + self.flags + } + + pub fn is_absorb(&self) -> bool { + self.flags & FWPS_CLASSIFY_OUT_FLAG_ABSORB > 0 + } +} diff --git a/mock_wdk/src/filter_engine/layer.rs b/mock_wdk/src/filter_engine/layer.rs new file mode 100644 index 0000000..07bc44e --- /dev/null +++ b/mock_wdk/src/filter_engine/layer.rs @@ -0,0 +1,947 @@ +// Host mock of `wdk::filter_engine::layer`. +// +// `ValueType`, `Layer`, and the per-layer `Fields*` index enums are transcribed +// VERBATIM (same variant order = same ordinals) from wdk/src/filter_engine/layer.rs. +// The variant ordinals are load-bearing: the driver uses `Fields::X as usize` +// to index the classify value array, so any reorder would change fuzz semantics. +// The windows-sys GUID machinery and the C `Value`/`ValueData`/`IncomingValues` +// types are intentionally omitted (the driver reaches values through the mock +// `CalloutData`, and never asks the mock for a layer GUID). +#![allow(dead_code)] + +#[repr(C)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ValueType { + FwpEmpty = 0, + FwpUint8 = 1, + FwpUint16 = 2, + FwpUint32 = 3, + FwpUint64 = 4, + FwpInt8 = 5, + FwpInt16 = 6, + FwpInt32 = 7, + FwpInt64 = 8, + FwpFloat = 9, + FwpDouble = 10, + FwpByteArray16Type = 11, + FwpByteBlobType = 12, + FwpSid = 13, + FwpSecurityDescriptorType = 14, + FwpTokenInformationType = 15, + FwpTokenAccessInformationType = 16, + FwpUnicodeStringType = 17, + FwpByteArray6Type = 18, + FwpSingleDataTypeMax = 0xff, + FwpV4AddrMask = 0xff + 1, + FwpV6AddrMask = 0xff + 2, + FwpRangeType = 0xff + 3, + FwpDataTypeMax = 0xff + 4, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Layer { + InboundIppacketV4, + InboundIppacketV4Discard, + InboundIppacketV6, + InboundIppacketV6Discard, + OutboundIppacketV4, + OutboundIppacketV4Discard, + OutboundIppacketV6, + OutboundIppacketV6Discard, + IpforwardV4, + IpforwardV4Discard, + IpforwardV6, + IpforwardV6Discard, + InboundTransportV4, + InboundTransportV4Discard, + InboundTransportV6, + InboundTransportV6Discard, + OutboundTransportV4, + OutboundTransportV4Discard, + OutboundTransportV6, + OutboundTransportV6Discard, + StreamV4, + StreamV4Discard, + StreamV6, + StreamV6Discard, + DatagramDataV4, + DatagramDataV4Discard, + DatagramDataV6, + DatagramDataV6Discard, + InboundIcmpErrorV4, + InboundIcmpErrorV4Discard, + InboundIcmpErrorV6, + InboundIcmpErrorV6Discard, + OutboundIcmpErrorV4, + OutboundIcmpErrorV4Discard, + OutboundIcmpErrorV6, + OutboundIcmpErrorV6Discard, + AleResourceAssignmentV4, + AleResourceAssignmentV4Discard, + AleResourceAssignmentV6, + AleResourceAssignmentV6Discard, + AleAuthListenV4, + AleAuthListenV4Discard, + AleAuthListenV6, + AleAuthListenV6Discard, + AleAuthRecvAcceptV4, + AleAuthRecvAcceptV4Discard, + AleAuthRecvAcceptV6, + AleAuthRecvAcceptV6Discard, + AleAuthConnectV4, + AleAuthConnectV4Discard, + AleAuthConnectV6, + AleAuthConnectV6Discard, + AleFlowEstablishedV4, + AleFlowEstablishedV4Discard, + AleFlowEstablishedV6, + AleFlowEstablishedV6Discard, + AleConnectRedirectV4, + AleConnectRedirectV6, + AleBindRedirectV4, + AleBindRedirectV6, + AleResourceReleaseV4, + AleResourceReleaseV6, + AleEndpointClosureV4, + AleEndpointClosureV6, +} + +#[repr(usize)] +pub enum FieldsInboundIppacketV4 { + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsInboundIppacketV6 { + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsOutboundIppacketV4 { + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsOutboundIppacketV6 { + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsIpforwardV4 { + IpSourceAddress, + IpDestinationAddress, + IpDestinationAddressType, + IpLocalInterface, + IpForwardInterface, + SourceInterfaceIndex, + SourceSubInterfaceIndex, + DestinationInterfaceIndex, + DestinationSubInterfaceIndex, + Flags, + IpPhysicalArrivalInterface, + ArrivalInterfaceProfileId, + IpPhysicalNexthopInterface, + NexthopInterfaceProfileId, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsIpforwardV6 { + IpSourceAddress, + IpDestinationAddress, + IpDestinationAddressType, + IpLocalInterface, + IpForwardInterface, + SourceInterfaceIndex, + SourceSubInterfaceIndex, + DestinationInterfaceIndex, + DestinationSubInterfaceIndex, + Flags, + IpPhysicalArrivalInterface, + ArrivalInterfaceProfileId, + IpPhysicalNexthopInterface, + NexthopInterfaceProfileId, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsInboundTransportV4 { + IpProtocol, + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + ProfileId, + IpsecSecurityRealmId, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsInboundTransportFas { + FieldInboundTransportFastMax, +} + +#[repr(usize)] +pub enum FieldsOutboundTransportFas { + FieldOutboundTransportFastMax, +} + +#[repr(usize)] +pub enum FieldsInboundTransportV6 { + IpProtocol, + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Flags, + InterfaceType, + TunnelType, + ProfileId, + IpsecSecurityRealmId, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsOutboundTransportV4 { + IpProtocol, + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + IpDestinationAddressType, + Flags, + InterfaceType, + TunnelType, + ProfileId, + IpsecSecurityRealmId, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsOutboundTransportV6 { + IpProtocol, + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + IpDestinationAddressType, + Flags, + InterfaceType, + TunnelType, + ProfileId, + IpsecSecurityRealmId, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsStreamV4 { + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + Direction, + Flags, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsStreamV6 { + IpLocalAddress, + IpLocalAddressType, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + Direction, + Flags, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsDatagramDataV4 { + IpProtocol, + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Direction, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsDatagramDataV6 { + IpProtocol, + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Direction, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsStreamPacketV4 { + IpLocalAddress, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Direction, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsStreamPacketV6 { + IpLocalAddress, + IpRemoteAddress, + IpLocalPort, + IpRemotePort, + IpLocalInterface, + InterfaceIndex, + SubInterfaceIndex, + Direction, + Flags, + InterfaceType, + TunnelType, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsInboundIcmpErrorV4 { + EmbeddedProtocol, + IpLocalAddress, + IpRemoteAddress, + EmbeddedRemoteAddress, + EmbeddedLocalAddressType, + EmbeddedLocalPort, + EmbeddedRemotePort, + IpLocalInterface, + IcmpType, + IcmpCode, + InterfaceIndex, + SubInterfaceIndex, + InterfaceType, + TunnelType, + IpArrivalInterface, + ArrivalInterfaceIndex, + ArrivalInterfaceType, + ArrivalTunnelType, + Flags, + ArrivalInterfaceProfileId, + InterfaceQuarantineEpoch, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsInboundIcmpErrorV6 { + EmbeddedProtocol, + IpLocalAddress, + IpRemoteAddress, + EmbeddedRemoteAddress, + EmbeddedLocalAddressType, + EmbeddedLocalPort, + EmbeddedRemotePort, + IpLocalInterface, + IcmpType, + IcmpCode, + InterfaceIndex, + SubInterfaceIndex, + InterfaceType, + TunnelType, + IpArrivalInterface, + ArrivalInterfaceIndex, + ArrivalInterfaceType, + ArrivalTunnelType, + Flags, + ArrivalInterfaceProfileId, + InterfaceQuarantineEpoch, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsOutboundIcmpErrorV4 { + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalInterface, + IcmpType, + IcmpCode, + InterfaceIndex, + SubInterfaceIndex, + InterfaceType, + TunnelType, + Flags, + NexthopInterfaceProfileId, + InterfaceQuarantineEpoch, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsOutboundIcmpErrorV6 { + IpLocalAddress, + IpRemoteAddress, + IpLocalAddressType, + IpLocalInterface, + IpLocalPort, + IpRemotePort, + InterfaceIndex, + SubInterfaceIndex, + InterfaceType, + TunnelType, + Flags, + NexthopInterfaceProfileId, + InterfaceQuarantineEpoch, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsAleResourceAssignmentV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + AlePromiscuousMode, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + LocalInterfaceProfileId, + SioFirewallSocketProperty, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Max, +} + +#[repr(usize)] +pub enum FieldsAleResourceAssignmentV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + AlePromiscuousMode, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + LocalInterfaceProfileId, + SioFirewallSocketProperty, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Max, +} + +#[repr(usize)] +pub enum FieldsAleResourceReleaseV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpLocalInterface, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsAleResourceReleaseV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpLocalInterface, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsAleEndpointClosureV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + IpLocalInterface, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsAleEndpointClosureV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + IpLocalInterface, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsAleAuthListenV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + LocalInterfaceProfileId, + SioFirewallSocketProperty, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsAleAuthListenV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + LocalInterfaceProfileId, + SioFirewallSocketProperty, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsAleAuthRecvAcceptV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpLocalInterface, + Flags, + SioFirewallSystemPort, + NapContext, + InterfaceType, + TunnelType, + InterfaceIndex, + SubInterfaceIndex, + IpArrivalInterface, + ArrivalInterfaceType, + ArrivalTunnelType, + ArrivalInterfaceIndex, + NexthopSubInterfaceIndex, + IpNexthopInterface, + NexthopInterfaceType, + NexthopTunnelType, + NexthopInterfaceIndex, + OriginalProfileId, + CurrentProfileId, + ReauthorizeReason, + OriginalIcmpType, + InterfaceQuarantineEpoch, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} + +#[repr(usize)] +pub enum FieldsAleAuthRecvAcceptV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpLocalInterface, + Flags, + SioFirewallSystemPort, + NapContext, + InterfaceType, + TunnelType, + InterfaceIndex, + SubInterfaceIndex, + IpArrivalInterface, + ArrivalInterfaceType, + ArrivalTunnelType, + ArrivalInterfaceIndex, + NexthopSubInterfaceIndex, + IpNexthopInterface, + NexthopInterfaceType, + NexthopTunnelType, + NexthopInterfaceIndex, + OriginalProfileId, + CurrentProfileId, + ReauthorizeReason, + OriginalIcmpType, + InterfaceQuarantineEpoch, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} + +#[repr(usize)] +pub enum FieldsAleBindRedirectV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsAleBindRedirectV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + Flags, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsAleConnectRedirectV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpDestinationAddressType, + IpRemotePort, + Flags, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsAleConnectRedirectV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpDestinationAddressType, + IpRemotePort, + Flags, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Max, +} + +#[repr(usize)] +pub enum FieldsAleAuthConnectV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpDestinationAddressType, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + InterfaceIndex, + SubInterfaceIndex, + IpArrivalInterface, + ArrivalInterfaceType, + ArrivalTunnelType, + ArrivalInterfaceIndex, + NexthopSubInterfaceIndex, + IpNexthopInterface, + NexthopInterfaceType, + NexthopTunnelType, + NexthopInterfaceIndex, + OriginalProfileId, + CurrentProfileId, + ReauthorizeReason, + PeerName, + OriginalIcmpType, + InterfaceQuarantineEpoch, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + AleEffectiveName, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} + +#[repr(usize)] +pub enum FieldsAleAuthConnectV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpDestinationAddressType, + IpLocalInterface, + Flags, + InterfaceType, + TunnelType, + InterfaceIndex, + SubInterfaceIndex, + IpArrivalInterface, + ArrivalInterfaceType, + ArrivalTunnelType, + ArrivalInterfaceIndex, + NexthopSubInterfaceIndex, + IpNexthopInterface, + NexthopInterfaceType, + NexthopTunnelType, + NexthopInterfaceIndex, + OriginalProfileId, + CurrentProfileId, + ReauthorizeReason, + PeerName, + OriginalIcmpType, + InterfaceQuarantineEpoch, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + AleEffectiveName, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} + +#[repr(usize)] +pub enum FieldsAleFlowEstablishedV4 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpDestinationAddressType, + IpLocalInterface, + Direction, + InterfaceType, + TunnelType, + Flags, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} + +#[repr(usize)] +pub enum FieldsAleFlowEstablishedV6 { + AleAppId, + AleUserId, + IpLocalAddress, + IpLocalAddressType, + IpLocalPort, + IpProtocol, + IpRemoteAddress, + IpRemotePort, + AleRemoteUserId, + AleRemoteMachineId, + IpDestinationAddressType, + IpLocalInterface, + Direction, + InterfaceType, + TunnelType, + Flags, + AleOriginalAppId, + AlePackageId, + AleSecurityAttributeFqbnValue, + CompartmentId, + Reserved0, + Reserved1, + Reserved2, + Reserved3, + Max, +} diff --git a/mock_wdk/src/filter_engine/mod.rs b/mock_wdk/src/filter_engine/mod.rs new file mode 100644 index 0000000..8d2a8f0 --- /dev/null +++ b/mock_wdk/src/filter_engine/mod.rs @@ -0,0 +1,299 @@ +pub mod callout; +pub mod callout_data; +pub mod classify; +pub mod layer; +pub mod net_buffer; +pub mod packet; + +use crate::driver::Driver; +use crate::kernel_types::DEVICE_OBJECT; + +use self::callout::{Callout, FilterType}; +use self::layer::Layer; + +/// Host mock of `wdk::filter_engine::ResetError`. +pub enum ResetError { + /// Another transaction is in progress on the filter engine session. The + /// mock has no concurrent transactions, so it never produces this, but the + /// driver matches on it. + TransactionInProgress, + Failed(String), +} + +impl From for String { + fn from(err: ResetError) -> Self { + match err { + ResetError::TransactionInProgress => { + "filter-engine: failed to begin transaction: STATUS_FWP_TXN_IN_PROGRESS".into() + } + ResetError::Failed(message) => message, + } + } +} + +/// One registered callout+filter pair, as the kernel would hold it after +/// `FwpsCalloutRegister` + `FwpmFilterAdd`. Exposed so harnesses can assert the +/// driver registered exactly the callouts it should, on the layers it should. +pub struct FilterRegistration { + pub name: String, + pub guid: u128, + pub layer: Layer, + pub callout_id: u32, + /// 0 once the filter has been removed (`unregister_filters`). + pub filter_id: u64, + pub resettable: bool, + /// Cleared by `unregister_callouts`. + pub callout_registered: bool, +} + +/// Host mock of `wdk::filter_engine::FilterEngine`. +/// +/// Models the real registration sequence instead of no-op'ing it: `commit` is a +/// write transaction that registers the sublayer once, then every callout and +/// its filter — all-or-nothing, like the kernel transaction. Failure modes the +/// kernel would produce are reproduced: a second commit fails (sublayer +/// ALREADY_EXISTS), registration without a device object fails +/// (`FwpsCalloutRegister` needs one), and a duplicate callout GUID fails and +/// leaves no partial state behind. +pub struct FilterEngine { + // Null-checked only, never dereferenced (the mock DEVICE_OBJECT is a ZST). + device_object: *mut DEVICE_OBJECT, + sublayer_guid: u128, + committed: bool, + // Keeps the callout vector (and its `callout_fn` pointers) alive for the + // engine's lifetime, matching the real one. + _callouts: Vec, + registrations: Vec, + // 0 means "unregistered" in the real code, so ids start at 1. + next_filter_id: u64, + next_callout_id: u32, +} + +// The raw device-object pointer suppresses the auto impls; it is only ever +// null-checked, so sharing the engine across threads stays sound. +unsafe impl Send for FilterEngine {} +unsafe impl Sync for FilterEngine {} + +impl FilterEngine { + pub fn new(driver: &Driver, layer_guid: u128) -> Result { + Ok(FilterEngine { + device_object: driver.get_device_object(), + sublayer_guid: layer_guid, + committed: false, + _callouts: Vec::new(), + registrations: Vec::new(), + next_filter_id: 0, + next_callout_id: 0, + }) + } + + pub fn commit(&mut self, callouts: Vec) -> Result<(), String> { + if self.committed { + // A second commit re-registers the sublayer, which the kernel + // rejects with FWP_E_ALREADY_EXISTS. + return Err(format!( + "filter engine: sublayer {:x} already registered", + self.sublayer_guid + )); + } + if self.device_object.is_null() { + return Err("filter engine: callout registration requires a device object".into()); + } + // Stage the whole batch before touching state: the kernel wraps + // registration in a write transaction, so either every callout+filter + // registers or none does. + let mut staged: Vec = Vec::with_capacity(callouts.len()); + for callout in &callouts { + if staged.iter().any(|reg| reg.guid == callout.guid) { + return Err(format!( + "filter engine: callout {} ({:x}) already registered", + callout.name, callout.guid + )); + } + staged.push(FilterRegistration { + name: callout.name.clone(), + guid: callout.guid, + layer: callout.layer, + callout_id: 0, + filter_id: 0, + resettable: matches!(callout.filter_type, FilterType::Resettable), + callout_registered: false, + }); + } + for reg in &mut staged { + self.next_callout_id += 1; + self.next_filter_id += 1; + reg.callout_id = self.next_callout_id; + reg.filter_id = self.next_filter_id; + reg.callout_registered = true; + } + self.registrations = staged; + self._callouts = callouts; + self.committed = true; + Ok(()) + } + + fn has_registered_filters(&self) -> bool { + self.registrations.iter().any(|reg| reg.filter_id != 0) + } + + /// Mirrors the real `unregister_filters`: removes every filter, leaving the + /// callouts registered. A no-op once the filters are gone. + pub fn unregister_filters(&mut self) -> Result<(), String> { + for reg in self.registrations.iter_mut() { + reg.filter_id = 0; + } + Ok(()) + } + + /// Mirrors the real `unregister_callouts`: unregisters every callout that is + /// still registered. A no-op for the ones already gone. + pub fn unregister_callouts(&mut self) -> Result<(), String> { + for reg in self.registrations.iter_mut() { + reg.callout_registered = false; + } + Ok(()) + } + + pub fn reset_all_filters(&mut self) -> Result<(), ResetError> { + if !self.committed { + // The real version begins a write transaction against the committed + // engine; before commit there is nothing to reset. + return Err(ResetError::Failed("filter engine: reset before commit".into())); + } + if !self.has_registered_filters() { + // Like the real one: once the teardown removed the filters there is + // nothing to reset, and re-adding them would revive the callouts. + return Ok(()); + } + for reg in self.registrations.iter_mut() { + if !reg.resettable { + continue; + } + if reg.filter_id == 0 { + return Err(ResetError::Failed(format!( + "filter engine: {} has no registered filter", + reg.name + ))); + } + // Re-adding the filter assigns a fresh id, like FwpmFilterAdd. + self.next_filter_id += 1; + reg.filter_id = self.next_filter_id; + } + Ok(()) + } + + /// Whether the sublayer/callouts/filters transaction has been committed. + pub fn is_committed(&self) -> bool { + self.committed + } + + /// The registered callout+filter pairs, in registration order. + pub fn registrations(&self) -> &[FilterRegistration] { + &self.registrations + } +} + +#[cfg(test)] +mod tests { + use super::callout_data::CalloutData; + use super::*; + use crate::kernel_types::DRIVER_OBJECT; + + fn noop(_data: CalloutData) {} + + fn callout(name: &str, guid: u128, filter_type: FilterType) -> Callout { + Callout::new(name, "", guid, Layer::AleAuthConnectV4, 0, filter_type, noop) + } + + fn engine() -> FilterEngine { + FilterEngine::new(&Driver::mock(), 0xf19f).expect("new filter engine") + } + + #[test] + fn commit_registers_all_with_distinct_ids() { + let mut fe = engine(); + assert!(!fe.is_committed()); + fe.commit(vec![ + callout("a", 1, FilterType::Resettable), + callout("b", 2, FilterType::NonResettable), + callout("c", 3, FilterType::Resettable), + ]) + .expect("commit"); + assert!(fe.is_committed()); + let regs = fe.registrations(); + assert_eq!(regs.len(), 3); + for reg in regs { + assert_ne!(reg.callout_id, 0); + assert_ne!(reg.filter_id, 0); + } + let mut filter_ids: Vec = regs.iter().map(|r| r.filter_id).collect(); + filter_ids.dedup(); + assert_eq!(filter_ids.len(), 3); + } + + #[test] + fn second_commit_fails() { + let mut fe = engine(); + fe.commit(vec![callout("a", 1, FilterType::Resettable)]).expect("first commit"); + assert!(fe.commit(vec![callout("b", 2, FilterType::Resettable)]).is_err()); + assert_eq!(fe.registrations().len(), 1); + } + + #[test] + fn commit_without_device_object_fails() { + // A DRIVER_OBJECT that never went through init_driver_object has no + // device object, and callout registration must refuse it. + let mut object = DRIVER_OBJECT::new(); + let driver = Driver::from_driver_object(&mut object); + let mut fe = FilterEngine::new(&driver, 0xf19f).expect("new filter engine"); + assert!(fe.commit(vec![callout("a", 1, FilterType::Resettable)]).is_err()); + assert!(!fe.is_committed()); + } + + #[test] + fn duplicate_guid_fails_atomically() { + let mut fe = engine(); + let result = fe.commit(vec![ + callout("a", 1, FilterType::Resettable), + callout("b", 1, FilterType::Resettable), + ]); + assert!(result.is_err()); + // Transaction semantics: nothing from the failed batch registered. + assert!(!fe.is_committed()); + assert!(fe.registrations().is_empty()); + } + + #[test] + fn reset_only_rotates_resettable_filter_ids() { + let mut fe = engine(); + assert!(fe.reset_all_filters().is_err(), "reset before commit must fail"); + fe.commit(vec![ + callout("a", 1, FilterType::Resettable), + callout("b", 2, FilterType::NonResettable), + ]) + .expect("commit"); + let before: Vec = fe.registrations().iter().map(|r| r.filter_id).collect(); + assert!(fe.reset_all_filters().is_ok(), "reset"); + let after: Vec = fe.registrations().iter().map(|r| r.filter_id).collect(); + assert_ne!(before[0], after[0], "resettable filter must get a fresh id"); + assert_eq!(before[1], after[1], "non-resettable filter must keep its id"); + } + + #[test] + fn teardown_removes_filters_then_callouts_and_stops_resets() { + let mut fe = engine(); + fe.commit(vec![callout("a", 1, FilterType::Resettable)]).expect("commit"); + fe.unregister_filters().expect("unregister filters"); + assert!(fe.registrations().iter().all(|r| r.filter_id == 0)); + assert!(fe.registrations().iter().all(|r| r.callout_registered)); + // A reset after the filters are gone must not bring them back. + assert!(fe.reset_all_filters().is_ok()); + assert!(fe.registrations().iter().all(|r| r.filter_id == 0)); + fe.unregister_callouts().expect("unregister callouts"); + assert!(fe.registrations().iter().all(|r| !r.callout_registered)); + // Both steps are idempotent. + fe.unregister_filters().expect("second unregister filters"); + fe.unregister_callouts().expect("second unregister callouts"); + } +} diff --git a/mock_wdk/src/filter_engine/net_buffer.rs b/mock_wdk/src/filter_engine/net_buffer.rs new file mode 100644 index 0000000..12e331b --- /dev/null +++ b/mock_wdk/src/filter_engine/net_buffer.rs @@ -0,0 +1,338 @@ +use core::ffi::c_void; + +/// Host stand-in for the kernel `NET_BUFFER_LIST`. Owns the packet bytes plus a +/// `data_offset` cursor that models where the current data view starts (the +/// kernel moves this with retreat/advance). `next` chains buffers so the driver +/// can iterate, exactly like the real NBL chain. +#[allow(non_camel_case_types)] +pub struct NET_BUFFER_LIST { + pub data: Vec, + pub data_offset: usize, + pub next: *mut NET_BUFFER_LIST, + /// Mock of the FWPS per-NBL injection state: `Some(ipv6)` marks the packet + /// as injected by the driver through the network handle of that family. + /// The kernel tracks this per injection handle; the family here models the + /// v4-vs-v6 handle split (see `Injector::was_network_packet_injected_by_self`). + pub injected_by_self: Option, +} + +impl NET_BUFFER_LIST { + /// Allocate a single NBL node on the heap. The caller owns the `Box` and is + /// responsible for keeping it alive while any `NetBufferList` view of it + /// exists. For inbound packets, set `data_offset` to the size of the headers + /// the kernel has already advanced past (so `retreat` brings it back). + pub fn new_box(data: Vec, data_offset: usize) -> Box { + Box::new(NET_BUFFER_LIST { + data, + data_offset, + next: core::ptr::null_mut(), + injected_by_self: None, + }) + } + + /// Like [`new_box`](Self::new_box), but carrying the injected-by-self mark + /// for the given family — a packet re-entering the stack after the driver + /// injected it through the v4 (`ipv6 = false`) or v6 network handle. + pub fn new_box_injected(data: Vec, data_offset: usize, ipv6: bool) -> Box { + let mut nbl = Self::new_box(data, data_offset); + nbl.injected_by_self = Some(ipv6); + nbl + } +} + +/// Host mock of `wdk::filter_engine::net_buffer::NetBuffer`. +/// +/// The real list carries a chain of NET_BUFFERs; the mock models every +/// `NET_BUFFER_LIST` node as holding exactly one net buffer, so a `NetBuffer` +/// views the node's bytes and its `data_offset` cursor directly (retreat and +/// advance move that cursor, like `NdisRetreat/AdvanceNetBufferDataStart`). +/// [`NetBuffer::owned_from_bytes`] builds a standalone one for tests. +pub struct NetBuffer { + nb: *mut NET_BUFFER_LIST, + data: Option>, + advance_on_drop: Option, +} + +impl NetBuffer { + pub fn new(nb: *mut NET_BUFFER_LIST) -> NetBuffer { + NetBuffer { + nb, + data: None, + advance_on_drop: None, + } + } + + /// Test helper: a net buffer over `bytes`, not backed by any list. + pub fn owned_from_bytes(bytes: Vec) -> NetBuffer { + NetBuffer { + nb: core::ptr::null_mut(), + data: Some(bytes), + advance_on_drop: None, + } + } + + fn view(&self) -> &[u8] { + if let Some(d) = &self.data { + return d; + } + unsafe { + match self.nb.as_ref() { + Some(nb) => &nb.data[core::cmp::min(nb.data_offset, nb.data.len())..], + None => &[], + } + } + } + + pub fn get_data_length(&self) -> u32 { + self.view().len() as u32 + } + + /// Reads `buffer.len()` bytes from the current data start. Fails if the net + /// buffer is empty or holds fewer bytes than requested. + pub fn read_bytes(&self, buffer: &mut [u8]) -> Result<(), ()> { + let view = self.view(); + if view.is_empty() || buffer.len() > view.len() { + return Err(()); + } + buffer.copy_from_slice(&view[..buffer.len()]); + Ok(()) + } + + /// Copies this net buffer into a new, owned single-buffer list. + pub fn clone_as_nbl(&self, _net_allocator: &NetworkAllocator) -> Result { + if self.data.is_none() && self.nb.is_null() { + return Err("net buffer is null".to_string()); + } + let view = self.view(); + if view.is_empty() { + return Err("can't clone empty packet".to_string()); + } + Ok(NetBufferList::owned_from_bytes(view.to_vec())) + } + + /// Retreats the data start by `size`. Like the real call (which passes no + /// allocation routine), it fails when there are not `size` bytes in front + /// of the current offset, leaving the offset unchanged. + pub fn retreat(&mut self, size: u32, auto_advance: bool) -> Result<(), String> { + unsafe { + let Some(nb) = self.nb.as_mut() else { + return Err("net buffer is null".to_string()); + }; + if size as usize > nb.data_offset { + return Err("NDIS_STATUS_RESOURCES".to_string()); + } + nb.data_offset -= size as usize; + } + if auto_advance { + self.advance_on_drop = Some(size); + } + Ok(()) + } + + /// Advances the data start by `size`, undoing a retreat. + pub fn advance(&self, size: u32) { + unsafe { + if let Some(nb) = self.nb.as_mut() { + nb.data_offset = core::cmp::min(nb.data_offset + size as usize, nb.data.len()); + } + } + } +} + +impl Drop for NetBuffer { + fn drop(&mut self) { + if let Some(advance_amount) = self.advance_on_drop { + self.advance(advance_amount); + } + } +} + +/// Host mock of `NetBufferIter`. Yields the single net buffer of a mock list. +pub struct NetBufferIter(*mut NET_BUFFER_LIST); + +impl NetBufferIter { + pub fn new(nb: *mut NET_BUFFER_LIST) -> Self { + Self(nb) + } +} + +impl Iterator for NetBufferIter { + type Item = NetBuffer; + + fn next(&mut self) -> Option { + if self.0.is_null() { + return None; + } + let current = self.0; + // One net buffer per mock list node. + self.0 = core::ptr::null_mut(); + Some(NetBuffer::new(current)) + } +} + +/// Host mock of `wdk::filter_engine::net_buffer::NetBufferList`. +/// +/// Two modes, mirroring the real type: +/// * *borrowed* -- `nbl` points at an externally owned `NET_BUFFER_LIST` +/// (from a callout's layer data or the iterator). `get_data*` return `None`. +/// * *owned* -- produced by `clone()`; `data` holds a copy and `get_data*` +/// return `Some`. This is the only mode `packet_util::redirect` mutates. +pub struct NetBufferList { + pub(crate) nbl: *mut NET_BUFFER_LIST, + data: Option>, +} + +impl NetBufferList { + pub fn new(nbl: *mut NET_BUFFER_LIST) -> NetBufferList { + NetBufferList { + nbl, + data: None, + } + } + + /// Test helper: build an *owned* NBL directly from bytes (so `get_data` / + /// `get_data_mut` return `Some`). Useful for exercising redirect/checksum. + pub fn owned_from_bytes(bytes: Vec) -> NetBufferList { + NetBufferList { + nbl: core::ptr::null_mut(), + data: Some(bytes), + } + } + + pub fn iter(&self) -> NetBufferListIter { + NetBufferListIter(self.nbl) + } + + /// Iterates over every net buffer in this list (one, in the mock). + pub fn iter_net_buffers(&self) -> NetBufferIter { + NetBufferIter(self.nbl) + } + + fn view_len(&self) -> usize { + if let Some(d) = &self.data { + return d.len(); + } + unsafe { + if let Some(nbl) = self.nbl.as_ref() { + return nbl.data.len().saturating_sub(nbl.data_offset); + } + } + 0 + } + + pub fn read_bytes(&self, buffer: &mut [u8]) -> Result<(), ()> { + let len = self.view_len(); + if len == 0 { + return Err(()); + } + if buffer.len() > len { + return Err(()); + } + if let Some(d) = &self.data { + buffer.copy_from_slice(&d[..buffer.len()]); + return Ok(()); + } + unsafe { + let Some(nbl) = self.nbl.as_ref() else { + return Err(()); + }; + let start = nbl.data_offset; + buffer.copy_from_slice(&nbl.data[start..start + buffer.len()]); + } + Ok(()) + } + + pub fn clone(&self, _net_allocator: &NetworkAllocator) -> Result { + let copy = if let Some(d) = &self.data { + d.clone() + } else { + unsafe { + let Some(nbl) = self.nbl.as_ref() else { + return Err("net buffer list is null".to_string()); + }; + let start = nbl.data_offset; + if nbl.data.len().saturating_sub(start) == 0 { + return Err("can't clone empty packet".to_string()); + } + nbl.data[start..].to_vec() + } + }; + Ok(NetBufferList { + nbl: core::ptr::null_mut(), + data: Some(copy), + }) + } + + pub fn get_data_mut(&mut self) -> Option<&mut [u8]> { + self.data.as_deref_mut() + } + + pub fn get_data(&self) -> Option<&[u8]> { + self.data.as_deref() + } + + pub fn get_data_length(&self) -> u64 { + if let Some(d) = &self.data { + return d.len() as u64; + } + let mut total: u64 = 0; + let mut nb = self.nbl; + unsafe { + while let Some(n) = nb.as_ref() { + total += n.data.len().saturating_sub(n.data_offset) as u64; + nb = n.next; + } + } + total + } +} + +pub struct NetBufferListIter(*mut NET_BUFFER_LIST); + +impl NetBufferListIter { + pub fn new(nbl: *mut NET_BUFFER_LIST) -> Self { + Self(nbl) + } +} + +impl Iterator for NetBufferListIter { + type Item = NetBufferList; + + fn next(&mut self) -> Option { + unsafe { + if let Some(nbl) = self.0.as_mut() { + let current = self.0; + self.0 = nbl.next; + return Some(NetBufferList { + nbl: current, + data: None, + }); + } + None + } + } +} + +/// Host mock of `NetworkAllocator`. No pool to manage; all methods are no-ops. +pub struct NetworkAllocator; + +impl NetworkAllocator { + pub fn new() -> Self { + NetworkAllocator + } + + pub fn free_net_buffer(_nbl: *mut NET_BUFFER_LIST) {} +} + +impl Default for NetworkAllocator { + fn default() -> Self { + Self::new() + } +} + +// Keep the c_void import meaningful: a couple of driver call sites cast the +// layer-data pointer through `*mut c_void`. Provide a tiny helper used by tests +// so the import is not dead. +pub fn as_layer_data(nbl: *mut NET_BUFFER_LIST) -> *mut c_void { + nbl as *mut c_void +} diff --git a/mock_wdk/src/filter_engine/packet.rs b/mock_wdk/src/filter_engine/packet.rs new file mode 100644 index 0000000..a8038b5 --- /dev/null +++ b/mock_wdk/src/filter_engine/packet.rs @@ -0,0 +1,228 @@ +use std::collections::VecDeque; +use std::sync::Mutex; + +use super::callout_data::CalloutData; +use super::net_buffer::{NetBufferList, NET_BUFFER_LIST}; + +/// Host mock of `wdk::filter_engine::packet::InjectInfo`. +/// `compartment_id` is `i32` to match the real `COMPARTMENT_ID` type alias. +pub struct InjectInfo { + pub ipv6: bool, + pub inbound: bool, + pub loopback: bool, + pub interface_index: u32, + pub sub_interface_index: u32, + pub compartment_id: i32, +} + +/// Host mock of `wdk::filter_engine::packet::TransportPacketList`. +/// `net_buffer_list_queue` is public because `id_cache::get_payload` reads it. +#[allow(dead_code)] +pub struct TransportPacketList { + pub net_buffer_list_queue: NetBufferList, + pub(crate) ipv6: bool, + pub(crate) inbound: bool, +} + +/// One packet the driver handed to the injector, as a harness can observe it. +/// `transport` distinguishes the ALE/transport-layer inject (a pended flow's +/// first packet being released) from the network/packet-layer inject (a cloned +/// or redirected IP packet). `loopback` is only meaningful for network injects. +#[derive(Clone)] +pub struct InjectedPacket { + pub ipv6: bool, + pub inbound: bool, + pub loopback: bool, + pub transport: bool, + pub data: Vec, +} + +/// Host mock of `wdk::filter_engine::packet::Injector`. +/// +/// The kernel version hands packets back to the network stack; the mock records +/// them instead, so a harness can drain the queue and — mimicking the OS — feed +/// them back through the packet-layer callouts (see the sim's reinjector). +pub struct Injector { + injected: Mutex>, +} + +impl Injector { + pub fn new() -> Self { + Injector { + injected: Mutex::new(VecDeque::new()), + } + } + + pub fn was_network_packet_injected_by_self( + &self, + nbl: *const NET_BUFFER_LIST, + ipv6: bool, + ) -> bool { + // The real check queries FWPS per injection handle: the v4 and v6 + // network handles are distinct, so a marked packet of the wrong family + // reports NOT_INJECTED. The mock stores the mark (with its family) on + // the NBL itself. + unsafe { + match nbl.as_ref() { + Some(nbl) => matches!(nbl.injected_by_self, Some(family) if family == ipv6), + None => false, + } + } + } + + pub fn was_network_packet_injected_by_self_ale(&self, _nbl: *const NET_BUFFER_LIST) -> bool { + // Transport-handle injection state: packets the mock harness replays at + // the packet layer were network-injected (or fresh), never transport + // ones (a transport re-inject reaches the network layer as + // INJECTED_BY_OTHER, which maps to false). + false + } + + pub fn inject_net_buffer_list( + &self, + net_buffer_list: NetBufferList, + inject_info: InjectInfo, + ) -> Result<(), String> { + // Network injects always carry an owned clone (the driver clones before + // pending); one that lost its bytes is a bug worth failing on. + let Some(data) = net_buffer_list.get_data() else { + return Err("inject_net_buffer_list: no owned packet data".to_string()); + }; + if data.is_empty() { + return Err("inject_net_buffer_list: empty packet".to_string()); + } + self.push(InjectedPacket { + ipv6: inject_info.ipv6, + inbound: inject_info.inbound, + loopback: inject_info.loopback, + transport: false, + data: data.to_vec(), + }); + Ok(()) + } + + pub fn inject_packet_list_transport( + &self, + packet_list: TransportPacketList, + ) -> Result<(), String> { + // ALE defers for outbound TCP connects legitimately carry no packet + // data (the connect had none); nothing to record then. + if let Some(data) = packet_list.net_buffer_list_queue.get_data() { + if !data.is_empty() { + self.push(InjectedPacket { + ipv6: packet_list.ipv6, + inbound: packet_list.inbound, + loopback: false, + transport: true, + data: data.to_vec(), + }); + } + } + Ok(()) + } + + /// Take every packet injected since the last drain, in injection order. + pub fn drain_injected(&self) -> Vec { + match self.injected.lock() { + Ok(mut queue) => queue.drain(..).collect(), + Err(_) => Vec::new(), + } + } + + /// Number of injected packets not yet drained. + pub fn injected_len(&self) -> usize { + self.injected.lock().map(|queue| queue.len()).unwrap_or(0) + } + + fn push(&self, packet: InjectedPacket) { + if let Ok(mut queue) = self.injected.lock() { + queue.push_back(packet); + } + } + + pub fn from_ale_callout( + ipv6: bool, + _callout_data: &CalloutData, + net_buffer_list: NetBufferList, + _remote_ip_slice: &[u8], + inbound: bool, + _interface_index: u32, + _sub_interface_index: u32, + ) -> TransportPacketList { + TransportPacketList { + net_buffer_list_queue: net_buffer_list, + ipv6, + inbound, + } + } +} + +impl Default for Injector { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn info(ipv6: bool, inbound: bool) -> InjectInfo { + InjectInfo { + ipv6, + inbound, + loopback: false, + interface_index: 0, + sub_interface_index: 0, + compartment_id: 0, + } + } + + #[test] + fn network_inject_records_and_drains() { + let injector = Injector::new(); + let nbl = NetBufferList::owned_from_bytes(vec![1, 2, 3]); + injector.inject_net_buffer_list(nbl, info(false, true)).expect("inject"); + assert_eq!(injector.injected_len(), 1); + let packets = injector.drain_injected(); + assert_eq!(packets.len(), 1); + assert_eq!(packets[0].data, vec![1, 2, 3]); + assert!(packets[0].inbound); + assert!(!packets[0].ipv6); + assert!(!packets[0].transport); + assert_eq!(injector.injected_len(), 0); + } + + #[test] + fn network_inject_without_data_fails() { + let injector = Injector::new(); + let nbl = NetBufferList::new(core::ptr::null_mut()); // borrowed, no bytes + assert!(injector.inject_net_buffer_list(nbl, info(false, false)).is_err()); + } + + #[test] + fn transport_inject_without_data_is_ok_and_records_nothing() { + let injector = Injector::new(); + let packet_list = TransportPacketList { + net_buffer_list_queue: NetBufferList::new(core::ptr::null_mut()), + ipv6: false, + inbound: false, + }; + injector.inject_packet_list_transport(packet_list).expect("inject"); + assert_eq!(injector.injected_len(), 0); + } + + #[test] + fn self_inject_mark_honors_family() { + let injector = Injector::new(); + let marked_v4 = NET_BUFFER_LIST::new_box_injected(vec![0u8; 20], 0, false); + let raw = marked_v4.as_ref() as *const NET_BUFFER_LIST; + assert!(injector.was_network_packet_injected_by_self(raw, false)); + assert!(!injector.was_network_packet_injected_by_self(raw, true), "wrong family"); + + let unmarked = NET_BUFFER_LIST::new_box(vec![0u8; 20], 0); + let raw = unmarked.as_ref() as *const NET_BUFFER_LIST; + assert!(!injector.was_network_packet_injected_by_self(raw, false)); + assert!(!injector.was_network_packet_injected_by_self(core::ptr::null(), false)); + } +} diff --git a/mock_wdk/src/interface.rs b/mock_wdk/src/interface.rs new file mode 100644 index 0000000..1a4686b --- /dev/null +++ b/mock_wdk/src/interface.rs @@ -0,0 +1,32 @@ +//! Host mock of `wdk::interface`. Just enough to let the driver's real +//! `DriverEntry` run: bind a [`Driver`] to the caller-provided `DRIVER_OBJECT` +//! so handler registration writes into it. + +use crate::driver::Driver; +use crate::kernel_types::{DEVICE_OBJECT, DRIVER_OBJECT, UNICODE_STRING}; + +/// Mock of `wdk::ffi::WdfObjectAttributes`. The driver only ever passes a null +/// pointer for it, so the mock is an opaque stand-in to type the argument. +pub struct WdfObjectAttributes; + +/// Mock of `wdk::interface::init_driver_object`. +/// +/// The real version creates the WDF driver/device objects via FFI and returns a +/// `Driver` wrapping them. The mock has no WDF: it binds the `Driver` to the +/// caller-owned `DRIVER_OBJECT` (so the subsequent `set_*_fn` calls land in +/// `MajorFunction`) and creates the device object (the IoCreateDevice step), +/// which the filter engine requires for callout registration. +pub fn init_driver_object( + driver_object: *mut DRIVER_OBJECT, + _registry_path: *mut UNICODE_STRING, + _driver_name: &str, + _object_attributes: *mut WdfObjectAttributes, +) -> Result { + let Some(object) = (unsafe { driver_object.as_mut() }) else { + return Err("init_driver_object: null driver object".into()); + }; + if object.DeviceObject.is_null() { + object.DeviceObject = Box::into_raw(Box::new(DEVICE_OBJECT::new())); + } + Ok(Driver::from_driver_object(driver_object)) +} diff --git a/mock_wdk/src/ioqueue.rs b/mock_wdk/src/ioqueue.rs new file mode 100644 index 0000000..43ecb56 --- /dev/null +++ b/mock_wdk/src/ioqueue.rs @@ -0,0 +1,231 @@ +use core::fmt::Display; +use std::collections::VecDeque; +use std::sync::{Condvar, Mutex}; +use std::time::Duration; + +#[derive(Debug)] +pub enum Status { + Uninitialized, + Timeout, + UserAPC, + Abandoned, +} + +impl Display for Status { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Status::Uninitialized => write!(f, "Uninitialized"), + Status::Timeout => write!(f, "Timeout"), + Status::UserAPC => write!(f, "UserAPC"), + Status::Abandoned => write!(f, "Abandoned"), + } + } +} + +/// Host mock of the kernel `IOQueue`, backed by a `VecDeque` guarded by a +/// `Mutex` + `Condvar`. +/// +/// It has two modes, chosen at construction, because the host harness has two +/// very different needs: +/// +/// * **non-blocking** (`new`) — `wait_and_pop` returns `Timeout` immediately when +/// the queue is empty. The synchronous fuzz targets drain the event queue on +/// the same thread that drives the callouts, so a blocking read there would +/// simply hang the fuzzer. This matches the real `pop()` (zero timeout). +/// * **blocking** (`new_blocking`) — `wait_and_pop` blocks until an entry is +/// pushed or the queue is run down, mirroring the kernel `KeRemoveQueue` with a +/// NULL timeout. The full-driver simulation uses this so a user-space reader +/// thread blocks on `Device::read` exactly like the real driver, and `rundown` +/// (shutdown / unload) wakes it with `Abandoned`, which `Device::read` turns +/// into a clean end-of-file. +pub struct IOQueue { + inner: Mutex>, + cond: Condvar, + blocking: bool, +} + +struct Inner { + queue: VecDeque, + // Becomes `false` after `rundown`: the queue is permanently closed and any + // blocked or future `wait_and_pop` returns `Abandoned`. + live: bool, +} + +unsafe impl Sync for IOQueue {} + +// Backstop poll interval for the blocking wait. The `Condvar` notification is the +// primary wakeup; re-checking the predicate every interval is defence-in-depth so +// a missed notification can never wedge a reader forever. +const BLOCKING_POLL: Duration = Duration::from_millis(50); + +impl IOQueue { + /// Non-blocking queue (see type docs). The default for fuzz targets. + pub fn new() -> Self { + Self::with_blocking(false) + } + + /// Blocking queue (see type docs). Used by the full-driver simulation so the + /// user-space reader thread blocks on `Device::read` like the real driver. + pub fn new_blocking() -> Self { + Self::with_blocking(true) + } + + fn with_blocking(blocking: bool) -> Self { + Self { + inner: Mutex::new(Inner { + queue: VecDeque::new(), + live: true, + }), + cond: Condvar::new(), + blocking, + } + } + + pub fn push(&self, entry: T) -> Result<(), Status> { + // Recover from poison: a worker panicking while holding the lock leaves + // the `VecDeque` structurally valid, and wedging the whole queue would + // mask the original failure. + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + if !inner.live { + return Err(Status::Uninitialized); + } + inner.queue.push_back(entry); + drop(inner); + self.cond.notify_one(); + Ok(()) + } + + /// Non-blocking pop. Returns `Timeout` when empty (mirrors the real `pop()` + /// with a zero timeout). + pub fn pop(&self) -> Result { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + inner.queue.pop_front().ok_or(Status::Timeout) + } + + pub fn wait_and_pop(&self) -> Result { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + if !self.blocking { + return inner.queue.pop_front().ok_or(Status::Timeout); + } + loop { + if let Some(value) = inner.queue.pop_front() { + return Ok(value); + } + if !inner.live { + return Err(Status::Abandoned); + } + let (guard, _) = self + .cond + .wait_timeout(inner, BLOCKING_POLL) + .unwrap_or_else(|p| p.into_inner()); + inner = guard; + } + } + + /// Blocks up to `timeout` milliseconds for an entry. Returns `Timeout` if the + /// deadline passes with the queue still empty, or `Abandoned` after rundown. + pub fn pop_timeout(&self, timeout: i64) -> Result { + let dur = if timeout <= 0 { + Duration::ZERO + } else { + Duration::from_millis(timeout as u64) + }; + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(value) = inner.queue.pop_front() { + return Ok(value); + } + if !inner.live { + return Err(Status::Abandoned); + } + let (mut guard, res) = self + .cond + .wait_timeout(inner, dur) + .unwrap_or_else(|p| p.into_inner()); + if let Some(value) = guard.queue.pop_front() { + Ok(value) + } else if !guard.live { + Err(Status::Abandoned) + } else { + let _ = res; + Err(Status::Timeout) + } + } + + /// Closes the queue: drops all pending entries and wakes every blocked + /// waiter with `Abandoned`. The queue must not be used afterwards (mirrors + /// `KeRundownQueue`). + pub fn rundown(&self) { + { + let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner()); + inner.live = false; + inner.queue.clear(); + } + self.cond.notify_all(); + } +} + +impl Default for IOQueue { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::thread; + + #[test] + fn non_blocking_empty_returns_timeout() { + let q: IOQueue = IOQueue::new(); + assert!(matches!(q.wait_and_pop(), Err(Status::Timeout))); + assert!(matches!(q.pop(), Err(Status::Timeout))); + } + + #[test] + fn push_then_pop_fifo() { + let q: IOQueue = IOQueue::new(); + q.push(1).unwrap(); + q.push(2).unwrap(); + assert!(matches!(q.wait_and_pop(), Ok(1))); + assert!(matches!(q.pop(), Ok(2))); + assert!(matches!(q.pop(), Err(Status::Timeout))); + } + + // A blocking waiter must wake when another thread pushes. + #[test] + fn blocking_wait_wakes_on_push() { + let q: Arc> = Arc::new(IOQueue::new_blocking()); + let reader = { + let q = q.clone(); + thread::spawn(move || q.wait_and_pop()) + }; + // Give the reader time to park on the condvar, then push. + thread::sleep(Duration::from_millis(20)); + q.push(42).unwrap(); + assert!(matches!(reader.join().unwrap(), Ok(42))); + } + + // A blocking waiter must wake with `Abandoned` when the queue is run down, + // which is how the simulation releases the reader thread on shutdown. + #[test] + fn blocking_wait_wakes_on_rundown() { + let q: Arc> = Arc::new(IOQueue::new_blocking()); + let reader = { + let q = q.clone(); + thread::spawn(move || q.wait_and_pop()) + }; + thread::sleep(Duration::from_millis(20)); + q.rundown(); + assert!(matches!(reader.join().unwrap(), Err(Status::Abandoned))); + } + + #[test] + fn push_after_rundown_fails() { + let q: IOQueue = IOQueue::new_blocking(); + q.rundown(); + assert!(matches!(q.push(1), Err(Status::Uninitialized))); + assert!(matches!(q.wait_and_pop(), Err(Status::Abandoned))); + } +} diff --git a/mock_wdk/src/irp_helpers.rs b/mock_wdk/src/irp_helpers.rs new file mode 100644 index 0000000..76f31f8 --- /dev/null +++ b/mock_wdk/src/irp_helpers.rs @@ -0,0 +1,275 @@ +// Host mocks of the IRP request wrappers. +// +// They come in two flavours that share one struct: +// * buffer-backed (`from_buffer` / `from_bytes` / `from_parts`) — a fuzz/test +// harness feeds raw byte buffers straight into `Device::read` / `write` / +// device control, with no IRP. +// * IRP-backed (`new`) — mirrors the real wrappers: the buffer is taken from +// the mock `IRP`'s system buffer, and `complete`/`timeout`/etc. write the +// transferred byte count and status back into the IRP so a `DriverConnection` +// can read them, exactly as the kernel completes a request. + +// Status values (i32) the driver reads back via `get_status`. Single source of +// truth in `kernel_types` so the channel client and the driver agree on, e.g., +// the end-of-file code used to stop a reader. +use crate::kernel_types::{ + IRP, STATUS_END_OF_FILE, STATUS_NOT_IMPLEMENTED, STATUS_SUCCESS, STATUS_TIMEOUT, +}; + +pub struct ReadRequest<'a> { + // Present for IRP-backed requests; the transferred count + status are mirrored + // here on completion so a channel client sees them. + irp: Option<&'a mut IRP>, + buffer: &'a mut [u8], + fill_index: usize, + status: i32, +} + +impl<'a> ReadRequest<'a> { + /// Build a read request over a caller-owned output buffer (no IRP). + pub fn from_buffer(buffer: &'a mut [u8]) -> ReadRequest<'a> { + ReadRequest { + irp: None, + buffer, + fill_index: 0, + status: STATUS_SUCCESS, + } + } + + /// Build a read request from a mock IRP (the dispatch path). The output + /// buffer is the IRP's system buffer, sized to `output_len`. + pub fn new(irp: &'a mut IRP) -> ReadRequest<'a> { + let buffer = unsafe { core::slice::from_raw_parts_mut(irp.system_buffer, irp.output_len) }; + ReadRequest { + irp: Some(irp), + buffer, + fill_index: 0, + status: STATUS_SUCCESS, + } + } + + pub fn free_space(&self) -> usize { + self.buffer.len() - self.fill_index + } + + pub fn complete(&mut self) { + self.set_status(STATUS_SUCCESS); + } + + pub fn end_of_file(&mut self) { + self.set_status(STATUS_END_OF_FILE); + } + + pub fn timeout(&mut self) { + self.set_status(STATUS_TIMEOUT); + } + + fn set_status(&mut self, status: i32) { + self.status = status; + if let Some(irp) = self.irp.as_mut() { + irp.information = self.fill_index; + irp.status = status; + } + } + + pub fn get_status(&self) -> i32 { + self.status + } + + pub fn write(&mut self, bytes: &[u8]) -> usize { + let mut bytes_to_write: usize = bytes.len(); + if bytes_to_write > self.free_space() { + bytes_to_write = self.free_space(); + } + for i in 0..bytes_to_write { + self.buffer[self.fill_index + i] = bytes[i]; + } + self.fill_index += bytes_to_write; + bytes_to_write + } + + /// Test helper: the bytes written so far. + pub fn written(&self) -> &[u8] { + &self.buffer[..self.fill_index] + } +} + +pub struct WriteRequest<'a> { + irp: Option<&'a mut IRP>, + buffer: &'a [u8], + status: i32, +} + +impl<'a> WriteRequest<'a> { + /// Build a write request over a caller-supplied input buffer (no IRP). This + /// is the entry point a fuzz target uses to feed arbitrary bytes into + /// `Device::write`. + pub fn from_bytes(buffer: &'a [u8]) -> WriteRequest<'a> { + WriteRequest { + irp: None, + buffer, + status: STATUS_SUCCESS, + } + } + + /// Build a write request from a mock IRP (the dispatch path). The input + /// buffer is the IRP's system buffer, sized to `input_len`. + pub fn new(irp: &'a mut IRP) -> WriteRequest<'a> { + let buffer = unsafe { core::slice::from_raw_parts(irp.system_buffer, irp.input_len) }; + WriteRequest { + irp: Some(irp), + buffer, + status: STATUS_SUCCESS, + } + } + + pub fn get_buffer(&self) -> &[u8] { + self.buffer + } + + pub fn mark_all_as_read(&mut self) { + if let Some(irp) = self.irp.as_mut() { + irp.information = self.buffer.len(); + } + } + + pub fn complete(&mut self) { + self.status = STATUS_SUCCESS; + if let Some(irp) = self.irp.as_mut() { + irp.status = STATUS_SUCCESS; + } + } + + pub fn get_status(&self) -> i32 { + self.status + } +} + +pub struct DeviceControlRequest<'a> { + irp: Option<&'a mut IRP>, + buffer: &'a mut [u8], + fill_index: usize, + control_code: u32, + status: i32, +} + +impl<'a> DeviceControlRequest<'a> { + pub fn from_parts(buffer: &'a mut [u8], control_code: u32) -> DeviceControlRequest<'a> { + DeviceControlRequest { + irp: None, + buffer, + fill_index: 0, + control_code, + status: STATUS_SUCCESS, + } + } + + /// Build a device-control request from a mock IRP (the dispatch path). The + /// output buffer is the IRP's system buffer, sized to `output_len`. + pub fn new(irp: &'a mut IRP) -> DeviceControlRequest<'a> { + let control_code = irp.control_code; + let buffer = unsafe { core::slice::from_raw_parts_mut(irp.system_buffer, irp.output_len) }; + DeviceControlRequest { + irp: Some(irp), + buffer, + fill_index: 0, + control_code, + status: STATUS_SUCCESS, + } + } + + pub fn get_buffer(&self) -> &[u8] { + self.buffer + } + + pub fn write(&mut self, bytes: &[u8]) -> usize { + let mut bytes_to_write: usize = bytes.len(); + if bytes_to_write > self.free_space() { + bytes_to_write = self.free_space(); + } + for i in 0..bytes_to_write { + self.buffer[self.fill_index + i] = bytes[i]; + } + self.fill_index += bytes_to_write; + bytes_to_write + } + + pub fn complete(&mut self) { + self.status = STATUS_SUCCESS; + if let Some(irp) = self.irp.as_mut() { + irp.information = self.fill_index; + irp.status = STATUS_SUCCESS; + } + } + + pub fn not_implemented(&mut self) { + self.status = STATUS_NOT_IMPLEMENTED; + if let Some(irp) = self.irp.as_mut() { + irp.status = STATUS_NOT_IMPLEMENTED; + } + } + + pub fn get_status(&self) -> i32 { + self.status + } + + pub fn get_control_code(&self) -> u32 { + self.control_code + } + + pub fn free_space(&self) -> usize { + self.buffer.len() - self.fill_index + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kernel_types::IRP; + + // A read request built from an IRP writes its bytes into the system buffer and + // reports the transferred count + success back through the IRP. + #[test] + fn read_request_round_trips_through_irp() { + let mut buf = [0u8; 16]; + let mut irp = IRP::for_read(buf.as_mut_ptr(), buf.len()); + { + let mut rr = ReadRequest::new(&mut irp); + assert_eq!(rr.write(&[1, 2, 3]), 3); + rr.complete(); + assert_eq!(rr.get_status(), STATUS_SUCCESS); + } + assert_eq!(irp.information, 3); + assert_eq!(irp.status, STATUS_SUCCESS); + assert_eq!(&buf[..3], &[1, 2, 3]); + } + + // A write request built from an IRP exposes the system buffer as input. + #[test] + fn write_request_reads_irp_input() { + let mut buf = [7u8, 8, 9, 10]; + let mut irp = IRP::for_write(buf.as_mut_ptr(), buf.len()); + let mut wr = WriteRequest::new(&mut irp); + assert_eq!(wr.get_buffer(), &[7, 8, 9, 10]); + wr.mark_all_as_read(); + wr.complete(); + assert_eq!(irp.information, 4); + assert_eq!(irp.status, STATUS_SUCCESS); + } + + // A device-control request reports its control code and writes output back. + #[test] + fn device_control_round_trips_through_irp() { + let mut buf = [0u8; 8]; + let mut irp = IRP::for_ioctl(buf.as_mut_ptr(), 0, buf.len(), 0x1234); + { + let mut cr = DeviceControlRequest::new(&mut irp); + assert_eq!(cr.get_control_code(), 0x1234); + cr.write(&[0xAA, 0xBB]); + cr.complete(); + } + assert_eq!(irp.information, 2); + assert_eq!(irp.status, STATUS_SUCCESS); + assert_eq!(&buf[..2], &[0xAA, 0xBB]); + } +} diff --git a/mock_wdk/src/kernel_types.rs b/mock_wdk/src/kernel_types.rs new file mode 100644 index 0000000..44d31e8 --- /dev/null +++ b/mock_wdk/src/kernel_types.rs @@ -0,0 +1,164 @@ +// These types deliberately keep the `windows_sys` spellings (`DRIVER_OBJECT`, +// `MajorFunction`, ...) so the driver's entry code reads identically in both +// builds. +#![allow(non_camel_case_types, non_snake_case)] + +//! Host mocks of the kernel / Win32 types the driver's entry plumbing names. +//! +//! These mirror `wdk::kernel_types` (which on the real build re-exports the +//! `windows_sys` types) closely enough that the driver's `entry.rs` — +//! `DriverEntry` and the IRP dispatch handlers — compiles and runs unchanged +//! against the mock. They are deliberately minimal: only the fields the driver +//! actually touches exist. + +/// Mirrors `windows_sys` `NTSTATUS` (an `i32`). +pub type NTSTATUS = i32; + +pub const STATUS_SUCCESS: NTSTATUS = 0; +// Real value differs; the simulation only checks `== STATUS_SUCCESS`, so any +// non-zero status means "driver entry failed". +pub const STATUS_FAILED_DRIVER_ENTRY: NTSTATUS = 0xC000_0001u32 as i32; +pub const STATUS_END_OF_FILE: NTSTATUS = 0xC000_0011u32 as i32; +pub const STATUS_TIMEOUT: NTSTATUS = 0x0000_0102; +pub const STATUS_NOT_IMPLEMENTED: NTSTATUS = 0xC000_0002u32 as i32; + +/// The registry path handed to `DriverEntry`. The driver never dereferences it +/// (it is passed straight through to `init_driver_object`), so the mock is an +/// opaque zero-sized stand-in. +pub struct UNICODE_STRING; + +/// WDM device object. The driver's handlers take `&mut DEVICE_OBJECT` but ignore +/// it (they reach the device through the global slot), so the mock carries no +/// state. Each `DriverConnection` owns its own instance to avoid cross-thread +/// aliasing of a shared `&mut DEVICE_OBJECT`. +#[derive(Default)] +pub struct DEVICE_OBJECT; + +impl DEVICE_OBJECT { + pub fn new() -> Self { + DEVICE_OBJECT + } +} + +/// IRP major function dispatch handler. Same shape as the real `MjFnType`, but +/// over the mock `DEVICE_OBJECT`/`IRP`/`NTSTATUS`. +pub type MjFnType = unsafe extern "system" fn(&mut DEVICE_OBJECT, &mut IRP) -> NTSTATUS; + +/// Driver unload callback. Same shape as the real `UnloadFnType`. +pub type UnloadFnType = unsafe extern "system" fn(*const DRIVER_OBJECT); + +// IRP major function codes (WDM values). Only the ones the driver registers are +// needed, but the array is sized to the full WDM range for index safety. +pub const IRP_MJ_CREATE: usize = 0x00; +pub const IRP_MJ_CLOSE: usize = 0x02; +pub const IRP_MJ_READ: usize = 0x03; +pub const IRP_MJ_WRITE: usize = 0x04; +pub const IRP_MJ_DEVICE_CONTROL: usize = 0x0e; +pub const IRP_MJ_CLEANUP: usize = 0x12; +pub const IRP_MJ_MAXIMUM_FUNCTION: usize = 0x1b; + +/// WDM driver object. The real `Driver` registers handlers by writing function +/// pointers into `MajorFunction[IRP_MJ_*]` / `DriverUnload`; the mock `Driver` +/// does the same so a `DriverConnection` can read them back and dispatch exactly +/// like the kernel I/O manager. +/// +/// `DeviceObject` models the WDM field of the same name: null until driver init +/// creates a device (`interface::init_driver_object` here, IoCreateDevice in the +/// kernel). The mock filter engine refuses callout registration without one, +/// like `FwpsCalloutRegister` does. Owned: freed on drop. +pub struct DRIVER_OBJECT { + pub MajorFunction: [Option; IRP_MJ_MAXIMUM_FUNCTION + 1], + pub DriverUnload: Option, + pub DeviceObject: *mut DEVICE_OBJECT, +} + +impl DRIVER_OBJECT { + pub fn new() -> Self { + Self { + MajorFunction: [None; IRP_MJ_MAXIMUM_FUNCTION + 1], + DriverUnload: None, + DeviceObject: core::ptr::null_mut(), + } + } +} + +// The raw `DeviceObject` pointer suppresses the auto impls the struct had before +// it existed. It is written once during init and freed on drop; `DEVICE_OBJECT` +// is a stateless ZST, so cross-thread moves of the owner stay sound. +unsafe impl Send for DRIVER_OBJECT {} +unsafe impl Sync for DRIVER_OBJECT {} + +impl Drop for DRIVER_OBJECT { + fn drop(&mut self) { + if !self.DeviceObject.is_null() { + unsafe { drop(Box::from_raw(self.DeviceObject)) }; + self.DeviceObject = core::ptr::null_mut(); + } + } +} + +impl Default for DRIVER_OBJECT { + fn default() -> Self { + Self::new() + } +} + +/// Host stand-in for the kernel IRP. Models a single buffered request: a system +/// buffer (the `AssociatedIrp.SystemBuffer` equivalent, shared by input and +/// output for buffered I/O) plus the lengths and the completion fields a handler +/// writes back (`information` = bytes transferred, `status` = NTSTATUS). +/// +/// `system_buffer` points at caller-owned storage (a `DriverConnection`'s +/// buffer); the IRP and its request wrapper must not outlive it. +pub struct IRP { + pub system_buffer: *mut u8, + pub input_len: usize, + pub output_len: usize, + pub control_code: u32, + pub information: usize, + pub status: NTSTATUS, +} + +impl IRP { + /// IRP for a read (output-only): the handler fills up to `output_len` bytes. + pub fn for_read(system_buffer: *mut u8, output_len: usize) -> Self { + Self { + system_buffer, + input_len: 0, + output_len, + control_code: 0, + information: 0, + status: STATUS_SUCCESS, + } + } + + /// IRP for a write (input-only): the handler reads `input_len` bytes. + pub fn for_write(system_buffer: *mut u8, input_len: usize) -> Self { + Self { + system_buffer, + input_len, + output_len: 0, + control_code: 0, + information: 0, + status: STATUS_SUCCESS, + } + } + + /// IRP for a device-io-control request (buffered method): input and output + /// share `system_buffer`. + pub fn for_ioctl( + system_buffer: *mut u8, + input_len: usize, + output_len: usize, + control_code: u32, + ) -> Self { + Self { + system_buffer, + input_len, + output_len, + control_code, + information: 0, + status: STATUS_SUCCESS, + } + } +} diff --git a/mock_wdk/src/lib.rs b/mock_wdk/src/lib.rs new file mode 100644 index 0000000..7b5dce3 --- /dev/null +++ b/mock_wdk/src/lib.rs @@ -0,0 +1,58 @@ +//! Host-side mock of the `wdk` crate. +//! +//! Mirrors the module tree and the exact public method signatures the `driver` +//! crate consumes, backed by ordinary host data structures (Vec, UnsafeCell, +//! VecDeque) instead of kernel objects. No `windows_sys`, no FFI, no `#[link]`. + +pub mod connection; +pub mod consts; +pub mod driver; +pub mod filter_engine; +pub mod interface; +pub mod ioqueue; +pub mod irp_helpers; +pub mod kernel_types; +pub mod rw_spin_lock; +pub mod utils; + +// Logging macros. The driver calls these through the renamed crate path +// (`wdk::dbg!`, `wdk::info!`, `wdk::err!`). They expand to a no-op that still +// evaluates the arguments (so there are no "unused variable" warnings and any +// side effects in the format arguments are preserved). The driver's *own* +// `crate::dbg!` / `crate::err!` macros (defined in `driver/src/logger.rs`) are +// unaffected -- only the `wdk::`-qualified calls resolve here. + +#[macro_export] +macro_rules! log { + ($($arg:tt)*) => {{ + let _ = format_args!($($arg)*); + }}; +} + +#[macro_export] +macro_rules! err { + ($($arg:tt)*) => {{ + let _ = format_args!($($arg)*); + }}; +} + +#[macro_export] +macro_rules! dbg { + ($($arg:tt)*) => {{ + let _ = format_args!($($arg)*); + }}; +} + +#[macro_export] +macro_rules! info { + ($($arg:tt)*) => {{ + let _ = format_args!($($arg)*); + }}; +} + +#[macro_export] +macro_rules! warn { + ($($arg:tt)*) => {{ + let _ = format_args!($($arg)*); + }}; +} diff --git a/mock_wdk/src/rw_spin_lock.rs b/mock_wdk/src/rw_spin_lock.rs new file mode 100644 index 0000000..21a7b7e --- /dev/null +++ b/mock_wdk/src/rw_spin_lock.rs @@ -0,0 +1,125 @@ +use core::{ + cell::UnsafeCell, + ops::{Deref, DerefMut}, + sync::atomic::{AtomicIsize, Ordering}, +}; + +/// Host mock of the kernel reader-writer spin lock based `Mutex`. +/// +/// This is a REAL reader-writer spinlock (not a no-op), so the multithreaded +/// host fuzz/stress harness exercises the same mutual exclusion the kernel +/// spinlock provides. Multiple readers may hold it at once; a writer is +/// exclusive. +/// +/// IMPORTANT: the real type is valid when zero-initialized, and the driver +/// relies on that -- `connection_cache::alloc_port_array` allocates arrays of +/// `RCUPort` (which contain `Mutex<()>`) with `alloc_zeroed`. So this mock +/// must also be valid when its bytes are all zero. We back the lock word with an +/// `AtomicIsize` that is `0` (unlocked) when zeroed: +/// * `0` -> unlocked +/// * `-1` -> write-locked (exclusive) +/// * `>0` -> number of active readers (shared) +/// +/// The data lives in an `UnsafeCell`; the lock word is what makes concurrent +/// access sound. No lock-ordering cycles exist in the driver (each operation +/// holds at most one port lock at a time), so this spinlock cannot deadlock. +pub struct Mutex { + lock: AtomicIsize, + data: UnsafeCell, +} + +unsafe impl Sync for Mutex {} +unsafe impl Send for Mutex {} + +impl Mutex { + pub const fn new(data: T) -> Self { + Self { + lock: AtomicIsize::new(0), + data: UnsafeCell::new(data), + } + } + + /// Acquire a shared (reader) lock, spinning until no writer holds it. + pub fn read_lock(&self) -> MutexReadGuard<'_, T> { + loop { + let cur = self.lock.load(Ordering::Relaxed); + if cur >= 0 { + // No writer present; try to register one more reader. + if self + .lock + .compare_exchange_weak(cur, cur + 1, Ordering::Acquire, Ordering::Relaxed) + .is_ok() + { + break; + } + } else { + // A writer holds the lock; back off and retry. + spin(); + } + } + MutexReadGuard { lock: self } + } + + /// Acquire the exclusive (writer) lock, spinning until fully unlocked. + pub fn write_lock(&self) -> MutexWriteGuard<'_, T> { + while self + .lock + .compare_exchange_weak(0, -1, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + // Held by a reader or another writer; back off and retry. + spin(); + } + MutexWriteGuard { lock: self } + } +} + +// Cheap contention back-off. mock_wdk is a std crate, so we can yield to the +// scheduler to avoid pathological spinning when threads > cores. +#[inline] +fn spin() { + core::hint::spin_loop(); + std::thread::yield_now(); +} + +pub struct MutexWriteGuard<'a, T> { + lock: &'a Mutex, +} + +impl<'a, T> Deref for MutexWriteGuard<'a, T> { + type Target = T; + fn deref(&self) -> &Self::Target { + unsafe { &*self.lock.data.get() } + } +} + +impl<'a, T> DerefMut for MutexWriteGuard<'a, T> { + fn deref_mut(&mut self) -> &mut Self::Target { + unsafe { &mut *self.lock.data.get() } + } +} + +impl<'a, T> Drop for MutexWriteGuard<'a, T> { + fn drop(&mut self) { + // Release the exclusive lock. + self.lock.lock.store(0, Ordering::Release); + } +} + +pub struct MutexReadGuard<'a, T> { + lock: &'a Mutex, +} + +impl<'a, T> Deref for MutexReadGuard<'a, T> { + type Target = T; + fn deref(&self) -> &Self::Target { + unsafe { &*self.lock.data.get() } + } +} + +impl<'a, T> Drop for MutexReadGuard<'a, T> { + fn drop(&mut self) { + // Release one reader. + self.lock.lock.fetch_sub(1, Ordering::Release); + } +} diff --git a/mock_wdk/src/utils.rs b/mock_wdk/src/utils.rs new file mode 100644 index 0000000..58daedd --- /dev/null +++ b/mock_wdk/src/utils.rs @@ -0,0 +1,40 @@ +use core::sync::atomic::{AtomicU64, Ordering}; + +// When non-zero, overrides the host clock. Lets state-machine/eviction tests +// drive `get_system_timestamp_ms` deterministically. 0 means "use real clock". +static MOCK_TIME_MS: AtomicU64 = AtomicU64::new(0); + +/// Test helper: pin the mock clock to a fixed millisecond value. +/// Pass 0 to fall back to the real host clock. +pub fn set_mock_time_ms(value: u64) { + MOCK_TIME_MS.store(value, Ordering::SeqCst); +} + +/// Test helper: advance the pinned mock clock by `delta` ms. Only meaningful +/// after `set_mock_time_ms` has pinned the clock to a non-zero value. +pub fn advance_mock_time_ms(delta: u64) { + MOCK_TIME_MS.fetch_add(delta, Ordering::SeqCst); +} + +pub fn get_system_timestamp_ms() -> u64 { + let pinned = MOCK_TIME_MS.load(Ordering::SeqCst); + if pinned != 0 { + return pinned; + } + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +pub fn get_startup_time_ns() -> u64 { + 0 +} + +/// Mock of `wdk::utils::sleep_ms`: the kernel version waits non-alertably and +/// fails above APC_LEVEL. The host has no IRQL, so this just sleeps the thread. +pub fn sleep_ms(milliseconds: u32) -> Result<(), String> { + std::thread::sleep(std::time::Duration::from_millis(u64::from(milliseconds))); + Ok(()) +} diff --git a/protocol/info_diff.bin b/protocol/info_diff.bin new file mode 100644 index 0000000..301de57 Binary files /dev/null and b/protocol/info_diff.bin differ diff --git a/protocol/src/command.rs b/protocol/src/command.rs index 00c7647..6bb31cc 100644 --- a/protocol/src/command.rs +++ b/protocol/src/command.rs @@ -60,29 +60,39 @@ pub struct ConnectionsUpdate { } pub fn parse_type(bytes: &[u8]) -> Option { - FromPrimitive::from_u8(bytes[0]) + // Bounds-checked: an empty command buffer yields `None` instead of panicking + // on `bytes[0]`. + FromPrimitive::from_u8(*bytes.first()?) } -pub fn parse_verdict(bytes: &[u8]) -> &Verdict { +pub fn parse_verdict(bytes: &[u8]) -> Option<&Verdict> { as_type(bytes) } -pub fn parse_update_v4(bytes: &[u8]) -> &UpdateV4 { +pub fn parse_update_v4(bytes: &[u8]) -> Option<&UpdateV4> { as_type(bytes) } -pub fn parse_update_v6(bytes: &[u8]) -> &UpdateV6 { +pub fn parse_update_v6(bytes: &[u8]) -> Option<&UpdateV6> { as_type(bytes) } -pub fn parse_update_info(bytes: &[u8]) -> &ConnectionsUpdate { +pub fn parse_update_info(bytes: &[u8]) -> Option<&ConnectionsUpdate> { as_type(bytes) } -fn as_type(bytes: &[u8]) -> &T { - let ptr: *const u8 = &bytes[0]; - let t_ptr: *const T = ptr as _; - unsafe { t_ptr.as_ref().unwrap() } +/// Reinterpret the start of `bytes` as a `T`. +/// +/// Returns `None` unless the buffer holds at least `size_of::()` bytes, so a +/// short/truncated command from user space can never cause an out-of-bounds +/// read. The command structs are `#[repr(C, packed)]` (alignment 1), so the +/// cast is always correctly aligned. +fn as_type(bytes: &[u8]) -> Option<&T> { + if bytes.len() < core::mem::size_of::() { + return None; + } + let t_ptr: *const T = bytes.as_ptr() as *const T; + unsafe { t_ptr.as_ref() } } #[cfg(test)] @@ -113,7 +123,7 @@ fn test_go_command_file() { panic!("unexpected bytes count") } - assert_eq!(parse_verdict(&buf), &Verdict { id: 1, verdict: 2 }) + assert_eq!(parse_verdict(&buf), Some(&Verdict { id: 1, verdict: 2 })) } CommandType::UpdateV4 => { let mut buf = [0; size_of::()]; @@ -124,14 +134,14 @@ fn test_go_command_file() { assert_eq!( parse_update_v4(&buf), - &UpdateV4 { + Some(&UpdateV4 { protocol: 1, local_address: [1, 2, 3, 4], local_port: 2, remote_address: [2, 3, 4, 5], remote_port: 3, verdict: 4 - } + }) ) } CommandType::UpdateV6 => { @@ -143,7 +153,7 @@ fn test_go_command_file() { assert_eq!( parse_update_v6(&buf), - &UpdateV6 { + Some(&UpdateV6 { protocol: 1, local_address: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], local_port: 2, @@ -152,7 +162,7 @@ fn test_go_command_file() { ], remote_port: 3, verdict: 4 - } + }) ) } CommandType::ClearCache => {} @@ -168,9 +178,9 @@ fn test_go_command_file() { assert_eq!( parse_update_info(&buf), - &ConnectionsUpdate { + Some(&ConnectionsUpdate { timestamp: 1234567890 - } + }) ) } } @@ -179,3 +189,54 @@ fn test_go_command_file() { } } } + +// Regression tests for the bounds-checking fixes. Before the fix, `parse_type` +// panicked on an empty buffer (`bytes[0]`) and `as_type` read `size_of::()` +// bytes out of bounds for a short buffer (UB). These lock the fixes in place. +#[cfg(test)] +mod bounds_tests { + use super::*; + + #[test] + fn parse_type_empty_is_none() { + assert!(parse_type(&[]).is_none()); + } + + #[test] + fn parse_type_valid_and_unknown() { + assert!(matches!(parse_type(&[1]), Some(CommandType::Verdict))); + assert!(parse_type(&[200]).is_none()); + } + + #[test] + fn parse_verdict_short_is_none() { + for n in 0..core::mem::size_of::() { + assert!(parse_verdict(&vec![0u8; n]).is_none(), "len {n} must be None"); + } + assert!(parse_verdict(&vec![0u8; core::mem::size_of::()]).is_some()); + } + + #[test] + fn parse_update_v4_short_is_none() { + for n in 0..core::mem::size_of::() { + assert!(parse_update_v4(&vec![0u8; n]).is_none(), "len {n} must be None"); + } + assert!(parse_update_v4(&vec![0u8; core::mem::size_of::()]).is_some()); + } + + #[test] + fn parse_update_v6_short_is_none() { + for n in 0..core::mem::size_of::() { + assert!(parse_update_v6(&vec![0u8; n]).is_none(), "len {n} must be None"); + } + assert!(parse_update_v6(&vec![0u8; core::mem::size_of::()]).is_some()); + } + + #[test] + fn parse_update_info_short_is_none() { + for n in 0..core::mem::size_of::() { + assert!(parse_update_info(&vec![0u8; n]).is_none(), "len {n} must be None"); + } + assert!(parse_update_info(&vec![0u8; core::mem::size_of::()]).is_some()); + } +} diff --git a/protocol/src/difftest.rs b/protocol/src/difftest.rs new file mode 100644 index 0000000..12fb5a7 --- /dev/null +++ b/protocol/src/difftest.rs @@ -0,0 +1,242 @@ +//! Deterministic Rust <-> Go differential tests of the user/kernel wire format. +//! +//! Every record's field values are derived from its index by [`diff_mix`] (a +//! splitmix64-style hash implemented identically here and in +//! `kext_interface/diff_test.go`). No shared RNG is needed: both languages +//! compute the same expected values from the record position, so any divergence +//! in encoding/decoding (endianness, field order, type width, struct size) +//! surfaces immediately as a mismatch. +//! +//! Two files cross the language boundary: +//! * commands Go writes `go_command_diff.bin` -> Rust reads (`test_go_command_diff`) +//! * info Rust writes `info_diff.bin` -> Go reads (`TestInfoDiffFile`) +//! +//! KEEP THE FORMULAS BELOW IN SYNC WITH kext_interface/diff_test.go. + +use std::fs::File; +use std::io::{Read, Write}; + +use crate::command::{ + parse_type, parse_update_info, parse_update_v4, parse_update_v6, parse_verdict, CommandType, + ConnectionsUpdate, UpdateV4, UpdateV6, Verdict, +}; +use crate::info::{ + connection_end_event_v4_info, connection_end_event_v6_info, connection_info_v4, + connection_info_v6, connection_update_event_v4_info, connection_update_event_v6_info, +}; + +const COMMAND_DIFF_FILE: &str = "../kext_interface/go_command_diff.bin"; +const INFO_DIFF_FILE: &str = "info_diff.bin"; +const INFO_RECORDS: u32 = 6000; + +/// splitmix64-style per-(record, field) value. MUST match `diffMix` in +/// kext_interface/diff_test.go. All arithmetic wraps (matches Go's uint64). +pub(crate) fn diff_mix(i: u32, f: u32) -> u64 { + let mut x = (i as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) + ^ (f as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F); + x ^= x >> 29; + x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9); + x ^= x >> 32; + x +} + +fn b4(i: u32, f: u32) -> [u8; 4] { + let m = diff_mix(i, f).to_le_bytes(); + [m[0], m[1], m[2], m[3]] +} + +fn b16(i: u32, f: u32) -> [u8; 16] { + let a = diff_mix(i, f).to_le_bytes(); + let b = diff_mix(i, f + 0x1000).to_le_bytes(); + let mut out = [0u8; 16]; + out[..8].copy_from_slice(&a); + out[8..].copy_from_slice(&b); + out +} + +fn verdict_val(i: u32, f: u32) -> u8 { + (diff_mix(i, f) % 11) as u8 +} + +fn diff_payload(i: u32) -> Vec { + let len = (diff_mix(i, 9) % 17) as usize; + (0..len).map(|j| diff_mix(i, 100 + j as u32) as u8).collect() +} + +/// Rust writes random-valued Info packets; the Go side reads them back and +/// verifies every field against the same `diff_mix` formula (`TestInfoDiffFile`). +#[test] +fn generate_info_diff_file() { + let mut file = File::create(INFO_DIFF_FILE).expect("create info_diff.bin"); + for i in 0..INFO_RECORDS { + let info = match i % 6 { + 0 => connection_end_event_v4_info( + diff_mix(i, 0), + diff_mix(i, 1) as u8, + diff_mix(i, 2) as u8, + b4(i, 3), + b4(i, 4), + diff_mix(i, 5) as u16, + diff_mix(i, 6) as u16, + diff_mix(i, 7), + diff_mix(i, 8), + diff_mix(i, 9), + diff_mix(i, 10), + ), + 1 => connection_end_event_v6_info( + diff_mix(i, 0), + diff_mix(i, 1) as u8, + diff_mix(i, 2) as u8, + b16(i, 3), + b16(i, 4), + diff_mix(i, 5) as u16, + diff_mix(i, 6) as u16, + diff_mix(i, 7), + diff_mix(i, 8), + diff_mix(i, 9), + diff_mix(i, 10), + ), + 2 => connection_update_event_v4_info( + diff_mix(i, 0) as u8, + b4(i, 1), + b4(i, 2), + diff_mix(i, 3) as u16, + diff_mix(i, 4) as u16, + diff_mix(i, 5), + diff_mix(i, 6), + diff_mix(i, 7), + diff_mix(i, 8), + ), + 3 => connection_update_event_v6_info( + diff_mix(i, 0) as u8, + b16(i, 1), + b16(i, 2), + diff_mix(i, 3) as u16, + diff_mix(i, 4) as u16, + diff_mix(i, 5), + diff_mix(i, 6), + diff_mix(i, 7), + diff_mix(i, 8), + ), + 4 => { + let payload = diff_payload(i); + connection_info_v4( + diff_mix(i, 0), + diff_mix(i, 1), + diff_mix(i, 2) as u8, + diff_mix(i, 3) as u8, + b4(i, 4), + b4(i, 5), + diff_mix(i, 6) as u16, + diff_mix(i, 7) as u16, + diff_mix(i, 8) as u8, + &payload, + ) + } + _ => { + let payload = diff_payload(i); + connection_info_v6( + diff_mix(i, 0), + diff_mix(i, 1), + diff_mix(i, 2) as u8, + diff_mix(i, 3) as u8, + b16(i, 4), + b16(i, 5), + diff_mix(i, 6) as u16, + diff_mix(i, 7) as u16, + diff_mix(i, 8) as u8, + &payload, + ) + } + }; + file.write_all(info.as_bytes()).expect("write info record"); + } +} + +/// Go writes random-valued commands; Rust parses each and verifies every field +/// against the same `diff_mix` formula. Run the Go generator first +/// (`go test -run TestGenerateCommandDiff`). +#[test] +fn test_go_command_diff() { + let mut file = File::open(COMMAND_DIFF_FILE).unwrap_or_else(|e| { + panic!("open {COMMAND_DIFF_FILE}: {e} (run `go test -run TestGenerateCommandDiff` first)") + }); + + let field_cmds = [ + CommandType::Verdict as u8, + CommandType::UpdateV4 as u8, + CommandType::UpdateV6 as u8, + CommandType::GetConnectionsUpdate as u8, + ]; + + let mut i: u32 = 0; + loop { + let mut cmd = [0u8; 1]; + match file.read(&mut cmd) { + Ok(0) => break, + Ok(_) => {} + Err(e) => panic!("record {i}: read command byte: {e}"), + } + let expected_type = field_cmds[(i as usize) % field_cmds.len()]; + assert_eq!(cmd[0], expected_type, "record {i}: command byte"); + + match parse_type(&cmd).expect("valid command type") { + CommandType::Verdict => { + let mut buf = [0u8; core::mem::size_of::()]; + file.read_exact(&mut buf).unwrap(); + let v = parse_verdict(&buf).unwrap(); + let (id, verdict) = (v.id, v.verdict); + assert_eq!(id, diff_mix(i, 0), "record {i}: verdict.id"); + assert_eq!(verdict, verdict_val(i, 1), "record {i}: verdict.verdict"); + } + CommandType::UpdateV4 => { + let mut buf = [0u8; core::mem::size_of::()]; + file.read_exact(&mut buf).unwrap(); + let u = parse_update_v4(&buf).unwrap(); + let (protocol, local, local_port, remote, remote_port, verdict) = ( + u.protocol, + u.local_address, + u.local_port, + u.remote_address, + u.remote_port, + u.verdict, + ); + assert_eq!(protocol, diff_mix(i, 0) as u8, "record {i}: v4 protocol"); + assert_eq!(local, b4(i, 1), "record {i}: v4 local_address"); + assert_eq!(local_port, diff_mix(i, 2) as u16, "record {i}: v4 local_port"); + assert_eq!(remote, b4(i, 3), "record {i}: v4 remote_address"); + assert_eq!(remote_port, diff_mix(i, 4) as u16, "record {i}: v4 remote_port"); + assert_eq!(verdict, verdict_val(i, 5), "record {i}: v4 verdict"); + } + CommandType::UpdateV6 => { + let mut buf = [0u8; core::mem::size_of::()]; + file.read_exact(&mut buf).unwrap(); + let u = parse_update_v6(&buf).unwrap(); + let (protocol, local, local_port, remote, remote_port, verdict) = ( + u.protocol, + u.local_address, + u.local_port, + u.remote_address, + u.remote_port, + u.verdict, + ); + assert_eq!(protocol, diff_mix(i, 0) as u8, "record {i}: v6 protocol"); + assert_eq!(local, b16(i, 1), "record {i}: v6 local_address"); + assert_eq!(local_port, diff_mix(i, 2) as u16, "record {i}: v6 local_port"); + assert_eq!(remote, b16(i, 3), "record {i}: v6 remote_address"); + assert_eq!(remote_port, diff_mix(i, 4) as u16, "record {i}: v6 remote_port"); + assert_eq!(verdict, verdict_val(i, 5), "record {i}: v6 verdict"); + } + CommandType::GetConnectionsUpdate => { + let mut buf = [0u8; core::mem::size_of::()]; + file.read_exact(&mut buf).unwrap(); + let c = parse_update_info(&buf).unwrap(); + let ts = c.timestamp; + assert_eq!(ts, diff_mix(i, 0), "record {i}: timestamp"); + } + other => panic!("record {i}: unexpected command type {}", other as u8), + } + i += 1; + } + assert!(i > 0, "no command records read from {COMMAND_DIFF_FILE}"); +} diff --git a/protocol/src/lib.rs b/protocol/src/lib.rs index e55fb31..537a9ac 100644 --- a/protocol/src/lib.rs +++ b/protocol/src/lib.rs @@ -3,3 +3,7 @@ extern crate alloc; pub mod command; pub mod info; + +// Cross-language (Rust<->Go) differential tests of the wire format. +#[cfg(test)] +mod difftest; diff --git a/sim/Cargo.lock b/sim/Cargo.lock new file mode 100644 index 0000000..de0131b --- /dev/null +++ b/sim/Cargo.lock @@ -0,0 +1,377 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[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 = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +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 = "driver" +version = "0.1.0" +dependencies = [ + "mock_wdk", + "num", + "num-derive", + "num-traits", + "protocol", + "smoltcp", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mock_wdk" +version = "0.1.0" + +[[package]] +name = "nexufend-sim" +version = "0.0.0" +dependencies = [ + "ciborium", + "crossbeam-channel", + "driver", + "mock_wdk", + "serde", + "serde_bytes", + "serde_json", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e98dc3b890f6c23a0f9d3d491a2823d0dea0fa656302a13dd225fa924112a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "protocol" +version = "0.1.0" +dependencies = [ + "num", + "num-derive", + "num-traits", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "smoltcp" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f73d40463bba65efc9adc6370b56df76d563cc46e2482bba58351b4afb7535e" +dependencies = [ + "bitflags", + "byteorder", + "cfg-if", + "heapless", + "managed", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/sim/Cargo.toml b/sim/Cargo.toml new file mode 100644 index 0000000..6a4391b --- /dev/null +++ b/sim/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "nexufend-sim" +version = "0.0.0" +publish = false +edition = "2021" + +# Real-world userspace simulation harness. A host binary that loads the *real* +# driver over `mock_wdk` (via `driver::sim::Simulation`) and exposes it on a +# Windows named pipe (or, on Unix, a Unix domain socket) so the real Go agent +# can connect to it exactly as it would to the kernel device. The libFuzzer +# targets under driver/fuzz remain the coverage-guided path and are untouched. + +[[bin]] +name = "zf-sim" +path = "src/main.rs" + +[dependencies] +# The driver, built against the host mock WDK (same as driver/fuzz). +driver = { path = "../driver", default-features = false, features = ["mock"] } +# For the user-space connection's status constants (STATUS_SUCCESS / EOF). This +# resolves to the same `mock_wdk` instance the driver links, so the +# `DriverConnection` returned by `Simulation::connect` is the matching type. +mock_wdk = { path = "../mock_wdk" } + +# --- Scenario-file executor (see src/scenario.rs) -------------------------- +# A scenario is a CBOR sequence (`.cbor`) or JSON Lines (`.json`, one object per +# line, for debugging) of per-command records; it is decoded as a lazy stream and +# replayed against the driver by a worker pool. All pure host crates -- the mock +# build stays windows-sys-free. +ciborium = "0.2" +serde = { version = "1", features = ["derive"] } +serde_bytes = "0.11" +serde_json = "1" +crossbeam-channel = "0.5" diff --git a/sim/src/dns.rs b/sim/src/dns.rs new file mode 100644 index 0000000..b1e8c3f --- /dev/null +++ b/sim/src/dns.rs @@ -0,0 +1,199 @@ +//! Live DNS resolution for the high-level `dns` scenario op. +//! +//! Unlike the rest of the harness -- which fabricates packets so a run stays +//! offline and reproducible -- this module performs a REAL DNS query against the +//! machine's default resolver (or an explicit one) and hands back the actual +//! query and response datagrams that crossed the socket. The `dns` op feeds those +//! real bytes to the driver's callouts, so a domain-aware policy sees genuine DNS +//! traffic. Touching the network makes `dns` records non-deterministic and +//! dependent on connectivity; a failed lookup is reported, never silently faked. +//! +//! The OS default resolver is read via `GetNetworkParams` (iphlpapi) on Windows -- +//! raw FFI in the same spirit as the named-pipe server, pulling in no +//! `windows-sys` -- and from `/etc/resolv.conf` on Unix. + +use std::net::{IpAddr, SocketAddr, UdpSocket}; +use std::time::Duration; + +/// A completed lookup: the server actually queried, the first address of the +/// requested family (if the reply carried one), and the raw query/response +/// datagrams exactly as they went over the wire. +pub struct Resolution { + pub resolver: IpAddr, + pub resolved: Option, + pub query: Vec, + pub response: Vec, +} + +/// Per-attempt timeout waiting for the resolver to answer. +const RECV_TIMEOUT: Duration = Duration::from_secs(2); +/// How many times to (re)send the query before giving up. +const ATTEMPTS: usize = 2; + +/// Resolve `domain` for real. `want_v6` selects the record type (AAAA vs A), and +/// hence the family of `resolved`; the query itself travels to `resolver` (the +/// explicit override, else the OS default) over whatever family that server is. +/// Returns the wire bytes on success, or a human-readable reason on failure. +pub fn resolve( + domain: &str, + want_v6: bool, + resolver: Option, +) -> Result { + if domain.is_empty() { + return Err("`domain` must not be empty".into()); + } + let resolver = match resolver { + Some(ip) => ip, + None => os_default_resolver().ok_or_else(|| { + "no OS default DNS server found; set `resolver` to query one explicitly".to_string() + })?, + }; + + let query = crate::scenarios::dns_query_for(query_id(domain), domain, want_v6); + + let bind = if resolver.is_ipv6() { "[::]:0" } else { "0.0.0.0:0" }; + let socket = UdpSocket::bind(bind).map_err(|e| format!("bind UDP socket: {e}"))?; + socket + .connect(SocketAddr::new(resolver, 53)) + .map_err(|e| format!("connect to resolver {resolver}:53: {e}"))?; + socket + .set_read_timeout(Some(RECV_TIMEOUT)) + .map_err(|e| format!("set socket timeout: {e}"))?; + + let mut buf = [0u8; 1500]; + let mut last = format!("resolver {resolver} did not answer"); + for _ in 0..ATTEMPTS { + socket + .send(&query) + .map_err(|e| format!("send query to {resolver}: {e}"))?; + match socket.recv(&mut buf) { + // A matching reply echoes our transaction id in the first two bytes. + Ok(n) if n >= 12 && buf[..2] == query[..2] => { + let response = buf[..n].to_vec(); + let resolved = crate::scenarios::dns_answer_ip(&response, want_v6) + .and_then(|bytes| ip_from_bytes(&bytes)); + return Ok(Resolution { + resolver, + resolved, + query, + response, + }); + } + Ok(_) => last = format!("resolver {resolver} sent an unexpected reply"), + Err(e) => last = format!("no reply from {resolver}: {e}"), + } + } + Err(last) +} + +/// Parse a 4- or 16-byte address into an [`IpAddr`]; `None` for any other width. +pub fn ip_from_bytes(bytes: &[u8]) -> Option { + match bytes.len() { + 4 => Some(IpAddr::from(<[u8; 4]>::try_from(bytes).ok()?)), + 16 => Some(IpAddr::from(<[u8; 16]>::try_from(bytes).ok()?)), + _ => None, + } +} + +/// A DNS transaction id derived from the name (FNV-1a), so a query is stable for +/// a given domain without needing a random source. +fn query_id(domain: &str) -> u16 { + let mut h = 0xcbf2_9ce4_8422_2325u64; + for b in domain.bytes() { + h = (h ^ b as u64).wrapping_mul(0x0000_0100_0000_01b3); + } + (h ^ (h >> 32)) as u16 +} + +// ---- OS default resolver (Windows, GetNetworkParams) --------------------------- + +// FIXED_INFO / IP_ADDR_STRING as laid out by iptypes.h. `#[repr(C)]` reproduces +// the C padding, so reading `dns_server_list` lands on the bytes the API wrote. +// The server addresses arrive as NUL-terminated ASCII (e.g. "192.168.1.1"). + +#[cfg(windows)] +#[repr(C)] +struct IpAddrString { + next: *mut IpAddrString, + ip_address: [u8; 16], // IP_ADDRESS_STRING.String ("255.255.255.255\0") + ip_mask: [u8; 16], + context: u32, +} + +#[cfg(windows)] +#[repr(C)] +struct FixedInfo { + host_name: [u8; 132], // MAX_HOSTNAME_LEN + 4 + domain_name: [u8; 132], // MAX_DOMAIN_NAME_LEN + 4 + current_dns_server: *mut IpAddrString, + dns_server_list: IpAddrString, + node_type: u32, + scope_id: [u8; 260], // MAX_SCOPE_ID_LEN + 4 + enable_routing: u32, + enable_proxy: u32, + enable_dns: u32, +} + +#[cfg(windows)] +#[link(name = "iphlpapi")] +extern "system" { + fn GetNetworkParams(fixed_info: *mut FixedInfo, out_buf_len: *mut u32) -> u32; +} + +#[cfg(windows)] +const ERROR_SUCCESS: u32 = 0; +#[cfg(windows)] +const ERROR_BUFFER_OVERFLOW: u32 = 111; + +/// The machine's primary configured DNS server, or `None` if none can be read. +/// Walks `DnsServerList`, skipping any unspecified (0.0.0.0) placeholder entry. +#[cfg(windows)] +fn os_default_resolver() -> Option { + unsafe { + // First call sizes the buffer (extra servers chain inside it). + let mut len: u32 = 0; + let ret = GetNetworkParams(std::ptr::null_mut(), &mut len); + if ret != ERROR_BUFFER_OVERFLOW && ret != ERROR_SUCCESS { + return None; + } + let needed = len.max(std::mem::size_of::() as u32); + // Over-align to 8 (FIXED_INFO holds pointers) by backing it with u64s. + let mut backing = vec![0u64; (needed as usize + 7) / 8]; + let info = backing.as_mut_ptr() as *mut FixedInfo; + len = needed; + if GetNetworkParams(info, &mut len) != ERROR_SUCCESS { + return None; + } + // The list nodes point into `backing`, which outlives this walk. + let mut node: *const IpAddrString = &(*info).dns_server_list; + while !node.is_null() { + if let Some(ip) = parse_ascii_ip(&(*node).ip_address) { + if !ip.is_unspecified() { + return Some(ip); + } + } + node = (*node).next; + } + None + } +} + +// ---- OS default resolver (Unix, /etc/resolv.conf) ----------------------------- + +/// The first usable `nameserver` in `/etc/resolv.conf`, or `None` if there is +/// none. A zone-scoped IPv6 entry (`fe80::1%eth0`) doesn't parse as `IpAddr` and +/// is skipped. +#[cfg(unix)] +fn os_default_resolver() -> Option { + let conf = std::fs::read_to_string("/etc/resolv.conf").ok()?; + conf.lines() + .filter_map(|line| line.trim().strip_prefix("nameserver")) + .filter_map(|rest| parse_ascii_ip(rest.trim().as_bytes())) + .find(|ip| !ip.is_unspecified()) +} + +/// Parse a NUL-terminated ASCII address string (as `GetNetworkParams` fills in). +fn parse_ascii_ip(bytes: &[u8]) -> Option { + let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + std::str::from_utf8(&bytes[..end]).ok()?.parse().ok() +} diff --git a/sim/src/main.rs b/sim/src/main.rs new file mode 100644 index 0000000..79f4a8f --- /dev/null +++ b/sim/src/main.rs @@ -0,0 +1,288 @@ +//! Real-world userspace simulation: a *fake driver* the real Go agent connects +//! to over a Windows named pipe or, on Unix, a Unix domain socket (see [`pipe`]). +//! +//! Loads the real driver over `mock_wdk` ([`driver::sim::Simulation`]), exposes +//! it on the endpoint named by `ZF_SIM_PIPE` (default `ZenithFence`, i.e. +//! `\\.\pipe\ZenithFence` on Windows; a full `\\.\pipe\...` path is used as is), +//! waits for an agent to connect, and bridges the pipe to the driver's +//! user-space channel ([`DriverConnection`] read/write). A producer injects fake +//! OS network events through the driver's callout entry points; the agent reads +//! the resulting events and writes verdicts back, exactly as it does against the +//! kernel device. +//! +//! Threads (genuine concurrency, all funnelling to the one global `Device`): +//! - producer ("kernel"): runs callouts that pend packets and emit events +//! - event pump: blocks on `DriverConnection::read`, ships bytes to the pipe +//! - command pump: reads command messages from the pipe, applies them +//! +//! With no scenario file the daemon serves agents one after another until the +//! user stops it (Ctrl+C): each agent session gets a freshly loaded driver and +//! seeded-random traffic, and when the agent disconnects the driver is unloaded +//! and the daemon waits for the next agent -- the way the agent's Start/Stop load +//! and unload the kernel driver. A broken invariant ends the process via the +//! monitor thread's non-zero `process::exit`. `--any-verdict` / +//! `ZF_SIM_ANY_VERDICT` lets a real agent decide verdicts by its own policy (see +//! [`producer::VerdictPolicy`]). Given a scenario file (positional arg or +//! `ZF_SIM_SCENARIO`, see [`scenario`]) the daemon instead serves one agent, +//! replays that file on a worker pool and exits when it finishes. Either way the +//! OS reclaims the driver, the pumps, and the pipe. + +mod dns; +mod pipe; +mod producer; +mod rng; +mod scenario; +mod scenarios; + +use std::process::ExitCode; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; + +use driver::fuzz_api::{filter_engine_snapshot, Layer}; +use driver::sim::Simulation; +use mock_wdk::kernel_types::{STATUS_END_OF_FILE, STATUS_SUCCESS}; + +use pipe::PipeServer; +use producer::VerdictPolicy; + +/// Logical endpoint name when `ZF_SIM_PIPE` is unset; mapped per OS by [`pipe::endpoint`]. +const DEFAULT_PIPE_NAME: &str = "ZenithFence"; + +fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + + // `zf-sim --emit-samples [dir]` writes the reference scenario files and exits + // without loading the driver -- handy for authoring / regenerating fixtures. + if args.get(1).map(String::as_str) == Some("--emit-samples") { + let dir = args.get(2).map(String::as_str).unwrap_or("./sim/scenarios"); + return match scenario::emit_samples(dir) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("[sim] --emit-samples failed: {err}"); + ExitCode::FAILURE + } + }; + } + + // A scenario file (first non-flag arg or ZF_SIM_SCENARIO) switches the traffic + // source from seeded-random to deterministic file replay. Verdicts still come + // from the Go agent over the pipe -- only the injection source changes. Finding + // the first non-flag arg lets the path sit before or after flags like --loop. + let scenario_path = std::env::var("ZF_SIM_SCENARIO") + .ok() + .or_else(|| args.iter().skip(1).find(|a| !a.starts_with("--")).cloned()); + + // --loop / ZF_SIM_LOOP replays the scenario on repeat, restarting from the top + // each time it finishes, until the process is stopped (Ctrl+C). The env var is + // how the agent-spawned daemon receives it (like ZF_SIM_SCENARIO); the flag is + // for running the daemon directly. + let looping = args.iter().any(|a| a == "--loop") || std::env::var_os("ZF_SIM_LOOP").is_some(); + + // --any-verdict / ZF_SIM_ANY_VERDICT: the random producer accepts whatever + // verdict the agent decides (a real agent with its own policy) instead of + // kext_tester's fixed rules. + let policy = if args.iter().any(|a| a == "--any-verdict") + || std::env::var_os("ZF_SIM_ANY_VERDICT").is_some() + { + VerdictPolicy::Any + } else { + VerdictPolicy::Expected + }; + + let sim = match load_driver() { + Ok(sim) => sim, + Err(code) => return code, + }; + + // The agent-spawned daemon inherits ZF_SIM_PIPE from the agent, so both + // sides agree on the endpoint. + let pipe_name = std::env::var("ZF_SIM_PIPE").unwrap_or_else(|_| DEFAULT_PIPE_NAME.into()); + let endpoint = pipe::endpoint(&pipe_name); + println!("[sim] creating endpoint {endpoint}"); + let server = match PipeServer::create(&endpoint) { + Ok(server) => Arc::new(server), + Err(err) => { + eprintln!("[sim] creating endpoint {endpoint} failed: {err}"); + return ExitCode::FAILURE; + } + }; + + let Some(path) = scenario_path else { + return serve_agents(sim, &server, policy); + }; + + if let Err(code) = wait_for_agent(&server) { + return code; + } + // The scenario modes serve one agent; the pumps run until the process exits. + let _pumps = Pumps::spawn(&sim, &server, Arc::new(AtomicBool::new(false))); + + // A scenario file replays deterministically on a worker pool (verdicts still + // come from the agent over the pipe), once or -- with --loop / ZF_SIM_LOOP -- + // restarting from the top until stopped. `sim` and the pumps stay alive across + // the call because `main` does not return until it finishes. + if looping { + // Loop until the user stops the process, resetting the driver between + // passes so each is an independent scenario. Diverges: `run_loop` prints + // the accumulated totals on Ctrl+C and exits; the OS reclaims `sim` and + // the pumps. + println!("[sim] looping scenario `{path}` until stopped (Ctrl+C) ..."); + scenario::run_loop(&path); + } + println!("[sim] replaying scenario `{path}` ..."); + let summary = scenario::run(&path); + summary.print(); + sim.shutdown(); + if summary.ok() { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } +} + +/// Random-traffic mode: serve agents one after another until the process is +/// stopped. Each session mirrors the agent's Start/Stop of the kernel driver: the +/// producer runs while the agent is connected; once it disconnects the driver is +/// shut down and unloaded, and a fresh one is loaded for the next agent. +fn serve_agents(mut sim: Simulation, server: &Arc, policy: VerdictPolicy) -> ExitCode { + loop { + if let Err(code) = wait_for_agent(server) { + return code; + } + + let stop = Arc::new(AtomicBool::new(false)); + let pumps = Pumps::spawn(&sim, server, Arc::clone(&stop)); + println!("[sim] producing fake network traffic ..."); + producer::drive(policy, stop); + + // Teardown order is load-bearing (see driver::sim): run down the event + // queue so the event pump wakes, join the pumps, only then unload. + sim.shutdown(); + pumps.join(); + drop(sim); + server.disconnect(); + println!("[sim] agent disconnected; driver unloaded"); + + sim = match load_driver() { + Ok(sim) => sim, + Err(code) => return code, + }; + } +} + +/// Load the real driver over mock_wdk and check it registered like a kernel +/// load would. +fn load_driver() -> Result { + println!("[sim] loading driver over mock_wdk ..."); + let sim = Simulation::start().map_err(|status| { + eprintln!("[sim] driver_entry failed: {status:#x}"); + ExitCode::FAILURE + })?; + + // Loading-fidelity oracle: the driver must have committed the filter engine + // with the production callout set, like a real kernel load would. + if let Err(err) = check_registration() { + eprintln!("[sim] FAIL: {err}"); + return Err(ExitCode::FAILURE); + } + println!("[sim] filter engine committed; all callouts registered"); + Ok(sim) +} + +fn wait_for_agent(server: &PipeServer) -> Result<(), ExitCode> { + println!("[sim] waiting for agent to connect ..."); + if let Err(err) = server.wait_for_client() { + eprintln!("[sim] waiting for agent failed: {err}"); + return Err(ExitCode::FAILURE); + } + println!("[sim] agent connected"); + Ok(()) +} + +/// The two threads bridging the pipe and the driver's user-space channel. Both +/// set `stop` once the agent is gone. +struct Pumps { + events: JoinHandle<()>, + commands: JoinHandle<()>, +} + +impl Pumps { + fn spawn(sim: &Simulation, server: &Arc, stop: Arc) -> Self { + // Event pump: driver events -> pipe. Its own connection, blocking reads; + // ends when the driver's event queue is run down or the agent is gone. + let mut evt_conn = sim.connect(); + let evt_pipe = Arc::clone(server); + let evt_stop = Arc::clone(&stop); + let events = thread::spawn(move || loop { + let mut buf = [0u8; 4096]; + let (n, status) = evt_conn.read(&mut buf); + if status == STATUS_END_OF_FILE || status != STATUS_SUCCESS { + break; + } + if n == 0 { + continue; + } + if evt_pipe.write_all(&buf[..n]).is_err() { + evt_stop.store(true, Ordering::Relaxed); + break; + } + }); + + // Command pump: pipe -> driver. One command message per read; ends when + // the agent disconnects. + let mut cmd_conn = sim.connect(); + let cmd_pipe = Arc::clone(server); + let commands = thread::spawn(move || { + let mut buf = [0u8; 512]; + loop { + match cmd_pipe.read_message(&mut buf) { + Ok(0) => continue, + Ok(n) => { + cmd_conn.write(&buf[..n]); + } + Err(_) => break, + } + } + stop.store(true, Ordering::Relaxed); + }); + + Self { events, commands } + } + + fn join(self) { + let _ = self.commands.join(); + let _ = self.events.join(); + } +} + +/// Assert the driver's load registered exactly the production callout set, on +/// the expected layers, inside a committed filter-engine transaction. +fn check_registration() -> Result<(), String> { + let snapshot = filter_engine_snapshot().ok_or("no device installed after driver_entry")?; + if !snapshot.committed { + return Err("filter engine transaction not committed".into()); + } + let expected = [ + Layer::AleAuthConnectV4, + Layer::AleAuthRecvAcceptV4, + Layer::AleAuthConnectV6, + Layer::AleAuthRecvAcceptV6, + Layer::AleEndpointClosureV4, + Layer::AleEndpointClosureV6, + Layer::AleResourceReleaseV4, + Layer::AleResourceReleaseV6, + Layer::OutboundIppacketV4, + Layer::InboundIppacketV4, + Layer::OutboundIppacketV6, + Layer::InboundIppacketV6, + ]; + let layers: Vec = snapshot.callouts.iter().map(|(_, layer, _)| *layer).collect(); + if layers != expected { + return Err(format!("registered callout layers mismatch: {layers:?}")); + } + if snapshot.callouts.iter().any(|(_, _, filter_id)| *filter_id == 0) { + return Err("a callout has no registered filter".into()); + } + Ok(()) +} diff --git a/sim/src/pipe/mod.rs b/sim/src/pipe/mod.rs new file mode 100644 index 0000000..497c682 --- /dev/null +++ b/sim/src/pipe/mod.rs @@ -0,0 +1,48 @@ +//! The daemon's agent-facing endpoint, one implementation per OS behind the same +//! `PipeServer` API (`create`, `wait_for_client`, `write_all`, `read_message`, +//! `disconnect`). One client is served at a time; after `disconnect` the server +//! can `wait_for_client` again for the next one: +//! +//! * Windows: an overlapped, message-mode named pipe (`\\.\pipe\`). +//! * Unix: a stream Unix domain socket. Stream sockets don't preserve message +//! boundaries, so on this transport the agent prefixes each command with a +//! `u32` little-endian length, which restores "one command message = one +//! `Device::write`". The event direction stays an unframed byte stream on both. +//! +//! [`endpoint`] maps the logical name (`ZF_SIM_PIPE`) to the OS address. The Go +//! side (`kext_interface/sim_conn*.go`) applies the same rule. + +#[cfg(unix)] +mod unix; +#[cfg(windows)] +mod windows; + +#[cfg(unix)] +pub use unix::PipeServer; +#[cfg(windows)] +pub use windows::PipeServer; + +/// The OS address for the logical endpoint `name`: `name` itself if it is a +/// full pipe path (`\\.\pipe\...`), else `\\.\pipe\`. +#[cfg(windows)] +pub fn endpoint(name: &str) -> String { + const PREFIX: &str = r"\\.\pipe\"; + if name.get(..PREFIX.len()).is_some_and(|p| p.eq_ignore_ascii_case(PREFIX)) { + return name.to_string(); + } + format!("{PREFIX}{name}") +} + +/// The OS address for the logical endpoint `name`: `name` itself if it is a +/// path (contains `/`), else `$XDG_RUNTIME_DIR/.sock`, falling back to +/// `/tmp/.sock`. +#[cfg(unix)] +pub fn endpoint(name: &str) -> String { + if name.contains('/') { + return name.to_string(); + } + match std::env::var("XDG_RUNTIME_DIR") { + Ok(dir) if !dir.is_empty() => format!("{}/{name}.sock", dir.trim_end_matches('/')), + _ => format!("/tmp/{name}.sock"), + } +} diff --git a/sim/src/pipe/unix.rs b/sim/src/pipe/unix.rs new file mode 100644 index 0000000..fed7fb3 --- /dev/null +++ b/sim/src/pipe/unix.rs @@ -0,0 +1,107 @@ +//! Unix domain socket server: the Unix counterpart of the Windows named pipe. +//! +//! A `SOCK_STREAM` socket from `std` (no extra dependencies). Reads and writes +//! run on separate threads over separate fds (`try_clone`), the same concurrency +//! the overlapped pipe provides on Windows. +//! +//! Stream sockets don't keep message boundaries, but the driver needs one command +//! per `Device::write` (one IRP per `WriteFile` in the kernel). So on this +//! transport each agent command arrives as a frame: a `u32` little-endian length, +//! then that many bytes. `SOCK_SEQPACKET` would keep the boundaries without +//! framing, but it truncates any message larger than the reader's buffer, and the +//! agent deliberately reads events through a small (1 KiB) buffer. Events are +//! therefore sent as a plain byte stream, exactly like the pipe's byte-mode reads. + +use std::io::{self, Read, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::sync::{Arc, Mutex, MutexGuard}; + +/// The current client's stream, shared with the pump blocked on it. +type Slot = Mutex>>; + +pub struct PipeServer { + path: String, + listener: UnixListener, + // Set by `wait_for_client` for each client; the reader and writer are + // separate fds so the command and event pumps never contend on one handle. + reader: Slot, + writer: Slot, +} + +impl PipeServer { + /// Bind the socket at `path`, replacing a stale socket file left by a + /// daemon that was killed before it could clean up. + pub fn create(path: &str) -> io::Result { + match std::fs::remove_file(path) { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err), + } + let listener = UnixListener::bind(path)?; + Ok(Self { + path: path.to_string(), + listener, + reader: Mutex::new(None), + writer: Mutex::new(None), + }) + } + + /// Block until a client (the Go agent) connects, replacing the previous + /// client's streams. + pub fn wait_for_client(&self) -> io::Result<()> { + let (stream, _) = self.listener.accept()?; + let writer = stream.try_clone()?; + *lock(&self.reader) = Some(Arc::new(stream)); + *lock(&self.writer) = Some(Arc::new(writer)); + Ok(()) + } + + /// Write all `bytes` to the socket (event stream → agent). + pub fn write_all(&self, bytes: &[u8]) -> io::Result<()> { + let writer = lock(&self.writer).clone().ok_or_else(not_connected)?; + let mut writer = &*writer; + writer.write_all(bytes) + } + + /// Read one length-prefixed command frame into `buf` (agent → command + /// channel). Returns the byte count, or `Err` once the client disconnects or + /// sends a frame that doesn't fit `buf`. + pub fn read_message(&self, buf: &mut [u8]) -> io::Result { + let reader = lock(&self.reader).clone().ok_or_else(not_connected)?; + let mut reader = &*reader; + let mut len = [0u8; 4]; + reader.read_exact(&mut len)?; + let len = u32::from_le_bytes(len) as usize; + if len > buf.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("command frame of {len} bytes exceeds {}-byte buffer", buf.len()), + )); + } + reader.read_exact(&mut buf[..len])?; + Ok(len) + } + + /// Force the connection closed, ending both pumps, and forget the client so + /// the next [`wait_for_client`](Self::wait_for_client) starts clean. + pub fn disconnect(&self) { + if let Some(stream) = lock(&self.reader).take() { + let _ = stream.shutdown(std::net::Shutdown::Both); + } + lock(&self.writer).take(); + } +} + +impl Drop for PipeServer { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +fn lock(slot: &Slot) -> MutexGuard<'_, Option>> { + slot.lock().unwrap_or_else(|p| p.into_inner()) +} + +fn not_connected() -> io::Error { + io::Error::new(io::ErrorKind::NotConnected, "no agent connected") +} diff --git a/sim/src/pipe/windows.rs b/sim/src/pipe/windows.rs new file mode 100644 index 0000000..1071c99 --- /dev/null +++ b/sim/src/pipe/windows.rs @@ -0,0 +1,235 @@ +//! Minimal Windows named-pipe server with overlapped (asynchronous) I/O. +//! +//! Raw `kernel32` FFI (no `windows-sys`/`windows` dependency, so it is immune to +//! the version churn in those crates and pulls nothing extra into the build). +//! +//! The handle **must** be overlapped: the daemon reads commands on one thread and +//! writes events on another, and on a *synchronous* pipe handle Windows +//! serializes all I/O — a blocked `ReadFile` (waiting for a command) would block +//! every concurrent `WriteFile` (sending an event), which deadlocks the pipeline +//! (no events out -> no verdicts in -> read never unblocks). With overlapped I/O +//! each operation carries its own OVERLAPPED + event and completes independently, +//! so reads and writes run concurrently. +//! +//! The pipe is **message** type so each agent `WriteFile` (one per command) +//! arrives as a single message on our read side — one message maps to one +//! `Device::write`, exactly like one IRP per `WriteFile` in the kernel. The agent +//! reads events in byte mode (its default), so the byte-oriented event parser is +//! unaffected by message boundaries. + +use std::ffi::c_void; +use std::io; +use std::ptr; + +type Handle = *mut c_void; +type Bool = i32; +type Dword = u32; + +#[repr(C)] +struct Overlapped { + internal: usize, + internal_high: usize, + offset: u32, + offset_high: u32, + h_event: Handle, +} + +#[link(name = "kernel32")] +extern "system" { + fn CreateNamedPipeW( + name: *const u16, + open_mode: Dword, + pipe_mode: Dword, + max_instances: Dword, + out_buffer_size: Dword, + in_buffer_size: Dword, + default_timeout: Dword, + security_attributes: *mut c_void, + ) -> Handle; + fn ConnectNamedPipe(pipe: Handle, overlapped: *mut Overlapped) -> Bool; + fn DisconnectNamedPipe(pipe: Handle) -> Bool; + fn ReadFile( + file: Handle, + buffer: *mut u8, + to_read: Dword, + read: *mut Dword, + overlapped: *mut Overlapped, + ) -> Bool; + fn WriteFile( + file: Handle, + buffer: *const u8, + to_write: Dword, + written: *mut Dword, + overlapped: *mut Overlapped, + ) -> Bool; + fn GetOverlappedResult( + file: Handle, + overlapped: *mut Overlapped, + transferred: *mut Dword, + wait: Bool, + ) -> Bool; + fn CreateEventW( + attributes: *mut c_void, + manual_reset: Bool, + initial_state: Bool, + name: *const u16, + ) -> Handle; + fn CloseHandle(handle: Handle) -> Bool; + fn GetLastError() -> Dword; +} + +const FILE_FLAG_OVERLAPPED: Dword = 0x4000_0000; +const PIPE_ACCESS_DUPLEX: Dword = 0x0000_0003; +const PIPE_TYPE_MESSAGE: Dword = 0x0000_0004; +const PIPE_READMODE_MESSAGE: Dword = 0x0000_0002; +const PIPE_WAIT: Dword = 0x0000_0000; +const PIPE_UNLIMITED_INSTANCES: Dword = 255; +const INVALID_HANDLE_VALUE: isize = -1; +const ERROR_IO_PENDING: Dword = 997; +// A client connected between CreateNamedPipe and ConnectNamedPipe; not an error. +const ERROR_PIPE_CONNECTED: Dword = 535; + +pub struct PipeServer { + handle: Handle, +} + +// The handle is read on the command-pump thread and written on the event-pump +// thread concurrently. With overlapped I/O (each op carries its own OVERLAPPED) +// this is sound; the main thread calls `disconnect`. +unsafe impl Send for PipeServer {} +unsafe impl Sync for PipeServer {} + +impl PipeServer { + /// Create the overlapped, message-mode pipe server (e.g. `\\.\pipe\ZenithFence`). + pub fn create(name: &str) -> io::Result { + let wide: Vec = name.encode_utf16().chain(std::iter::once(0)).collect(); + let handle = unsafe { + CreateNamedPipeW( + wide.as_ptr(), + PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, + PIPE_UNLIMITED_INSTANCES, + 64 * 1024, + 64 * 1024, + 0, + ptr::null_mut(), + ) + }; + if handle as isize == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + Ok(Self { handle }) + } + + /// Block until a client (the Go agent) connects. + pub fn wait_for_client(&self) -> io::Result<()> { + let event = new_event()?; + let mut ov = zeroed_overlapped(event); + let ok = unsafe { ConnectNamedPipe(self.handle, &mut ov) }; + let result = if ok != 0 { + Ok(()) + } else { + match unsafe { GetLastError() } { + ERROR_PIPE_CONNECTED => Ok(()), + ERROR_IO_PENDING => self.wait(&mut ov).map(|_| ()), + err => Err(io::Error::from_raw_os_error(err as i32)), + } + }; + unsafe { CloseHandle(event) }; + result + } + + /// Write all `bytes` to the pipe (event stream → agent). + pub fn write_all(&self, bytes: &[u8]) -> io::Result<()> { + let mut offset = 0; + while offset < bytes.len() { + let n = self.overlapped(|ov| unsafe { + WriteFile( + self.handle, + bytes[offset..].as_ptr(), + (bytes.len() - offset) as Dword, + ptr::null_mut(), + ov, + ) + })?; + if n == 0 { + return Err(io::Error::new(io::ErrorKind::WriteZero, "pipe wrote 0 bytes")); + } + offset += n; + } + Ok(()) + } + + /// Read one command message (agent → command channel). Returns the byte + /// count, or `Err` once the client disconnects. + pub fn read_message(&self, buf: &mut [u8]) -> io::Result { + let len = buf.len() as Dword; + let ptr = buf.as_mut_ptr(); + self.overlapped(|ov| unsafe { ReadFile(self.handle, ptr, len, ptr::null_mut(), ov) }) + } + + /// Force the connection closed: completes both a pending server `ReadFile` + /// and the agent's `ReadFile` with an error, so each side's loop ends. Also + /// readies the instance for the next [`wait_for_client`](Self::wait_for_client). + pub fn disconnect(&self) { + unsafe { + DisconnectNamedPipe(self.handle); + } + } + + /// Run an overlapped op (started by `start`) to completion and return the + /// number of bytes transferred. + fn overlapped(&self, start: impl FnOnce(*mut Overlapped) -> Bool) -> io::Result { + let event = new_event()?; + let mut ov = zeroed_overlapped(event); + let ok = start(&mut ov); + let result = if ok != 0 { + self.wait(&mut ov) + } else { + match unsafe { GetLastError() } { + ERROR_IO_PENDING => self.wait(&mut ov), + err => Err(io::Error::from_raw_os_error(err as i32)), + } + }; + unsafe { CloseHandle(event) }; + result + } + + /// Block on an overlapped op's completion; returns bytes transferred. + fn wait(&self, ov: &mut Overlapped) -> io::Result { + let mut transferred: Dword = 0; + let ok = unsafe { GetOverlappedResult(self.handle, ov, &mut transferred, 1) }; + if ok != 0 { + Ok(transferred as usize) + } else { + Err(io::Error::last_os_error()) + } + } +} + +impl Drop for PipeServer { + fn drop(&mut self) { + unsafe { + CloseHandle(self.handle); + } + } +} + +fn new_event() -> io::Result { + let event = unsafe { CreateEventW(ptr::null_mut(), 1, 0, ptr::null()) }; + if event.is_null() { + Err(io::Error::last_os_error()) + } else { + Ok(event) + } +} + +fn zeroed_overlapped(event: Handle) -> Overlapped { + Overlapped { + internal: 0, + internal_high: 0, + offset: 0, + offset_high: 0, + h_event: event, + } +} diff --git a/sim/src/producer.rs b/sim/src/producer.rs new file mode 100644 index 0000000..2f552e8 --- /dev/null +++ b/sim/src/producer.rs @@ -0,0 +1,677 @@ +//! Fake OS-side network traffic. +//! +//! Drives semi-realistic flows through the real driver callout entry points, +//! the way the OS does: ALE authorization first (which pends and emits an event +//! the agent answers over the pipe), then data packets through the IP packet +//! layer, then endpoint closure and resource release. The default run generates +//! seeded random flows ([`crate::scenarios::random_flow`]) until the agent +//! disconnects. +//! +//! Concurrency mirrors the kernel: several producer workers classify flows at +//! the same time (WFP classify callbacks fire on many threads), and one +//! reinjector thread models the OS re-presenting driver-injected packets to the +//! packet-layer callouts (network injects re-enter marked injected-by-self; +//! outbound transport injects descend the stack unmarked; inbound transport +//! injects re-indicate upward and never pass the network layer again). +//! +//! Everything is reproducible from the fixed [`SEED`]: just rerun. +//! +//! Verdicts are judged per [`VerdictPolicy`]: against kext_tester's fixed rules +//! by default, or -- for a real agent with its own policy -- accepting any +//! verdict and checking only that the driver enforces the one it got. + +use std::collections::BTreeMap; +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use driver::fuzz_api::{ + self as api, CalloutResult, Direction, Verdict, FWP_ACTION_BLOCK, FWP_ACTION_PERMIT, +}; + +use crate::rng::{mix, SplitMix64}; +use crate::scenarios::{self, Expect, FlowPlan, Tuple}; + +const VERDICT_POLL: Duration = Duration::from_millis(5); +const REINJECT_POLL: Duration = Duration::from_millis(2); +/// How often the monitor checks whether the run was stopped. +const STOP_POLL: Duration = Duration::from_millis(50); + +// ---- Hardcoded run parameters ------------------------------------------------ + +/// Fixed run seed. The traffic is fully reproducible across runs -- rerun to +/// reproduce a failure. The RNG stream is infinite, so one seed still yields +/// endless distinct flows. +const SEED: u64 = 0x0BAD_C0DE_5EED_1234; + +/// Producer worker threads (WFP classify fires on many threads at once). Kept +/// small so each worker's disjoint local-port window -- 2048 ports starting at +/// `20000 + worker * 2048` -- stays inside a u16. +const THREADS: usize = 4; + +/// Per-flow budget for the agent's verdict and any mid-burst upgrades before the +/// flow is counted as a timeout/failure. +const FLOW_TIMEOUT: Duration = Duration::from_secs(15); + +/// How often the monitor thread prints a liveness line and checks invariants. +const HEARTBEAT: Duration = Duration::from_secs(5); + +// ---- Summary ------------------------------------------------------------------ + +#[derive(Default)] +pub struct Summary { + pub flows_ok: AtomicU64, + pub packets: AtomicU64, + pub bytes: AtomicU64, + /// Network-injected packets that re-entered the packet layer marked + /// injected-by-self and were permitted (the driver's self-inject branch). + pub loopback_permits: AtomicU64, + /// Outbound transport injects replayed down the stack and permitted. + pub transport_replays: AtomicU64, + pub verdict_timeouts: AtomicU64, + pub by_scenario: Mutex>, + pub failures: Mutex>, +} + +impl Summary { + fn fail(&self, msg: String) { + // Attach a stack trace when RUST_BACKTRACE is set (`Backtrace::capture` + // is a no-op otherwise), so `RUST_BACKTRACE=1` pinpoints the failing site. + let backtrace = std::backtrace::Backtrace::capture(); + let msg = if backtrace.status() == std::backtrace::BacktraceStatus::Captured { + format!("{msg}\n stack:\n{backtrace}") + } else { + msg + }; + if let Ok(mut failures) = self.failures.lock() { + // Cap the list; one broken invariant tends to repeat per flow. + if failures.len() < 64 { + failures.push(msg); + } + } + } + + fn count_scenario(&self, name: &'static str) { + if let Ok(mut map) = self.by_scenario.lock() { + *map.entry(name).or_insert(0) += 1; + } + } + + /// No failures recorded. Verdict timeouts are recorded as failures only + /// under [`VerdictPolicy::Expected`] (see [`Run::verdict_timeout`]). + pub fn is_ok(&self) -> bool { + self.failures.lock().map(|f| f.is_empty()).unwrap_or(false) + } + + /// Compact one-line liveness snapshot for the periodic monitor. + pub fn heartbeat(&self) { + println!( + "[sim] alive: flows ok={} | packets={} bytes={} | self-inject={} transport={} | pended={} timeouts={}", + self.flows_ok.load(Ordering::Relaxed), + self.packets.load(Ordering::Relaxed), + self.bytes.load(Ordering::Relaxed), + self.loopback_permits.load(Ordering::Relaxed), + self.transport_replays.load(Ordering::Relaxed), + api::packet_cache_len(), + self.verdict_timeouts.load(Ordering::Relaxed), + ); + } + + pub fn print(&self) { + println!( + "[sim] flows ok={} | packets={} bytes={} | self-inject permits={} transport replays={} | verdict timeouts={}", + self.flows_ok.load(Ordering::Relaxed), + self.packets.load(Ordering::Relaxed), + self.bytes.load(Ordering::Relaxed), + self.loopback_permits.load(Ordering::Relaxed), + self.transport_replays.load(Ordering::Relaxed), + self.verdict_timeouts.load(Ordering::Relaxed), + ); + if let Ok(map) = self.by_scenario.lock() { + let list: Vec = map.iter().map(|(name, n)| format!("{name}={n}")).collect(); + println!("[sim] scenarios: {}", list.join(" ")); + } + if let Ok(failures) = self.failures.lock() { + for failure in failures.iter() { + eprintln!("[sim] FAILURE: {failure}"); + } + } + } +} + +/// How the producer judges the verdicts the agent hands out. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VerdictPolicy { + /// kext_tester's fixed rules ([`FlowPlan::expect`]): a different verdict, or + /// none within [`FLOW_TIMEOUT`], is a failure. + Expected, + /// A real agent deciding by its own policy: any verdict is legal and + /// [`FlowPlan::expect`] is only a hint. Checks that the driver enforces the + /// verdict it got remain fatal; a verdict that never comes is only counted. + Any, +} + +/// Shared state of one [`drive`] run. +struct Run { + summary: Summary, + policy: VerdictPolicy, + stop: Arc, +} + +impl Run { + fn any_verdict(&self) -> bool { + self.policy == VerdictPolicy::Any + } + + fn stopped(&self) -> bool { + self.stop.load(Ordering::Relaxed) + } + + fn verdict_timeout(&self, msg: String) { + self.summary.verdict_timeouts.fetch_add(1, Ordering::Relaxed); + if !self.any_verdict() { + self.summary.fail(msg); + } + } +} + +// ---- Driving --------------------------------------------------------------------- + +/// Generate random flows across [`THREADS`] workers until `stop` is set, then +/// join every producer thread and return. A broken invariant ends the process via +/// the monitor's non-zero `process::exit`. Each worker owns a disjoint IP/port +/// window and a private RNG stream, and runs one flow fully -- ALE authorization, +/// the agent's verdict, data bursts, closure, release -- before starting the +/// next, so a worker can never outpace the agent it waits on. +pub fn drive(policy: VerdictPolicy, stop: Arc) { + println!( + "[sim] producer: seed=0x{SEED:016x} threads={THREADS} verdicts={policy:?}; generating random flows until the agent disconnects" + ); + + let run = Arc::new(Run { + summary: Summary::default(), + policy, + stop, + }); + + let mut threads = Vec::new(); + + // Reinjector: models the OS re-presenting driver-injected packets. + { + let run = Arc::clone(&run); + threads.push(thread::spawn(move || reinject_loop(&run))); + } + + // Monitor: periodic liveness line + fail-fast on the first broken invariant. + { + let run = Arc::clone(&run); + threads.push(thread::spawn(move || monitor_loop(&run))); + } + + threads.extend((0..THREADS).map(|worker| { + let run = Arc::clone(&run); + thread::spawn(move || { + let mut rng = SplitMix64::new(mix(SEED ^ worker as u64)); + let mut counter: u64 = 0; + while !run.stopped() { + // Keep the sequence inside the worker's 2048-port window; a + // flow closes and releases its port before that port recurs, + // so the reuse is always safe. + let seq = (counter % 2048) as u16; + let plan = scenarios::random_flow(&mut rng, worker, seq); + let deadline = Instant::now() + FLOW_TIMEOUT; + run_flow(&plan, &run, deadline); + counter = counter.wrapping_add(1); + } + }) + })); + + for thread in threads { + let _ = thread.join(); + } + run.summary.print(); +} + +/// Print a liveness line every [`HEARTBEAT`]; the moment an invariant breaks, +/// dump the full summary and exit non-zero. The seed is fixed, so a failing run +/// reproduces exactly on the next launch. +fn monitor_loop(run: &Run) { + let mut last = Instant::now(); + while !run.stopped() { + thread::sleep(STOP_POLL); + if last.elapsed() < HEARTBEAT { + continue; + } + last = Instant::now(); + run.summary.heartbeat(); + if !run.summary.is_ok() { + run.summary.print(); + eprintln!("[sim] FAIL: traffic invariant violated (seed=0x{SEED:016x}); exiting"); + std::process::exit(1); + } + } +} + +// ---- One flow's life ------------------------------------------------------------ + +fn run_flow(plan: &FlowPlan, run: &Run, deadline: Instant) { + let summary = &run.summary; + summary.count_scenario(plan.name); + + // 1. ALE authorization: connect (outbound) or accept (inbound). + let result = run_ale(plan, false); + let mut ok = match plan.expect { + Expect::ImmediateAccept => { + // Inbound loopback: ALE itself accepts, nothing pends. + let permitted = result.action == FWP_ACTION_PERMIT; + if !permitted { + summary.fail(format!("{}: inbound loopback not permitted at ALE", plan.describe())); + } + permitted + } + Expect::NoVerdict => true, // ICMP: ALE not involved for the packet path below + Expect::Accepted | Expect::Blocked => { + // New TCP/UDP connection: the original classify is absorbed and the + // flow pends until the agent's verdict. + if !result.absorb { + summary.fail(format!("{}: new connection did not pend at ALE", plan.describe())); + } + result.absorb + } + }; + + // 2. Wait for the agent's verdict (like the OS holding the pended classify). + if ok && matches!(plan.expect, Expect::Accepted | Expect::Blocked) { + match wait_verdict(plan, run, deadline) { + // Any verdict is legal; the data phase checks it is enforced. + Some(_) if run.any_verdict() => {} + Some(verdict) => { + let expected_block = plan.expect == Expect::Blocked; + let got_block = verdict == Verdict::PermanentBlock as u8; + let accept_ish = + verdict == Verdict::Accept as u8 || verdict == Verdict::PermanentAccept as u8; + if expected_block && !got_block { + summary.fail(format!("{}: expected block, got verdict {verdict}", plan.describe())); + ok = false; + } else if !expected_block && !accept_ish { + summary.fail(format!("{}: expected accept, got verdict {verdict}", plan.describe())); + ok = false; + } + } + None if run.stopped() => return, + None => { + run.verdict_timeout(format!("{}: verdict timed out", plan.describe())); + if !run.any_verdict() { + return; + } + // The agent left the flow pending; the application gives up and + // closes the socket. + ok = false; + } + } + } + + // 3. Data packets. + if ok && run.any_verdict() && plan.expect != Expect::NoVerdict { + ok &= run_bursts_any_verdict(plan, run); + } else if ok { + match plan.expect { + Expect::Blocked => { + // The OS wouldn't send more on a blocked outbound flow, but a + // leftover packet must be blocked (and not absorbed) here. + if let Some(burst) = plan.bursts.first() { + let result = run_packet(plan, burst.direction, &burst.payload); + if result.action != FWP_ACTION_BLOCK || result.absorb { + summary.fail(format!( + "{}: blocked flow's packet not hard-blocked (action={:#x} absorb={})", + plan.describe(), result.action, result.absorb + )); + ok = false; + } + } + } + Expect::NoVerdict => { + // ICMP: every packet pends on its own; the agent resolves each + // and the accepted clone comes back via the reinjector. + for burst in &plan.bursts { + let result = run_packet(plan, burst.direction, &burst.payload); + if !result.absorb { + summary.fail(format!("{}: ICMP packet did not pend", plan.describe())); + ok = false; + break; + } + count_packet(summary, &burst.payload); + } + } + Expect::Accepted | Expect::ImmediateAccept => { + ok &= run_bursts(plan, run, deadline); + } + } + } + + // 4. Endpoint closure + resource release (the socket goes away). + if plan.expect != Expect::NoVerdict { + close_flow(plan); + } + if plan.release_port { + release_port(plan); + } + + if ok { + summary.flows_ok.fetch_add(1, Ordering::Relaxed); + } + + // 5. Optional port-reuse follow-up: a fresh flow on the just-released port. + if let Some(follow_up) = scenarios::reuse_follow_up(plan) { + if !run.stopped() { + run_flow(&follow_up, run, deadline); + } + } +} + +/// Data exchange for an authorized flow. A permanent accept permits packets +/// outright; a temporary Accept (inbound loopback's initial state) pends each +/// packet until the agent upgrades the verdict, so both outcomes are legal -- +/// what's illegal is a hard block or a missing action. +fn run_bursts(plan: &FlowPlan, run: &Run, deadline: Instant) -> bool { + let summary = &run.summary; + for (i, burst) in plan.bursts.iter().enumerate() { + if plan.reauth_after == Some(i) && !reauthorize(plan, summary) { + return false; + } + + let verdict = current_verdict(plan); + let result = run_packet(plan, burst.direction, &burst.payload); + if verdict == Some(Verdict::PermanentAccept as u8) { + if result.action != FWP_ACTION_PERMIT { + summary.fail(format!( + "{}: burst {i} not permitted under PermanentAccept (action={:#x})", + plan.describe(), result.action + )); + return false; + } + } else if result.absorb { + // Temporary verdict: the packet pended; wait for the agent to + // upgrade the flow before sending more (the pended clone itself is + // delivered by injection, observed by the reinjector). + if wait_for(run, deadline, || { + current_verdict(plan).filter(|&v| v == Verdict::PermanentAccept as u8) + }) + .is_none() + { + if !run.stopped() { + run.verdict_timeout(format!("{}: burst {i} verdict timed out", plan.describe())); + } + return false; + } + } else if result.action != FWP_ACTION_PERMIT { + summary.fail(format!( + "{}: burst {i} neither permitted nor pended (action={:#x})", + plan.describe(), result.action + )); + return false; + } + count_packet(summary, &burst.payload); + } + true +} + +/// Data exchange under [`VerdictPolicy::Any`]: whatever verdict the agent gave, +/// each packet must get the action the driver owes that verdict. Temporary +/// verdicts pend the packet for a per-packet answer, so nothing waits for an +/// upgrade; a pended packet is resolved by the agent and the reinjector. +fn run_bursts_any_verdict(plan: &FlowPlan, run: &Run) -> bool { + let summary = &run.summary; + for (i, burst) in plan.bursts.iter().enumerate() { + let verdict = current_verdict(plan); + // Reauthorizing is only defined for a flow the agent accepted for good. + if plan.reauth_after == Some(i) + && verdict == Some(Verdict::PermanentAccept as u8) + && !reauthorize(plan, summary) + { + return false; + } + + let result = run_packet(plan, burst.direction, &burst.payload); + let enforced = match verdict { + Some(v) if v == Verdict::PermanentAccept as u8 => result.action == FWP_ACTION_PERMIT, + Some(v) if v == Verdict::PermanentBlock as u8 => { + result.action == FWP_ACTION_BLOCK && !result.absorb + } + Some(v) + if v == Verdict::PermanentDrop as u8 + || v == Verdict::Undeterminable as u8 + || v == Verdict::Failed as u8 => + { + result.action == FWP_ACTION_BLOCK && result.absorb + } + // Temporary and redirect verdicts: the driver decides per packet. + _ => true, + }; + if !enforced { + summary.fail(format!( + "{}: burst {i} not enforced under verdict {verdict:?} (action={:#x} absorb={})", + plan.describe(), result.action, result.absorb + )); + return false; + } + count_packet(summary, &burst.payload); + } + true +} + +fn reauthorize(plan: &FlowPlan, summary: &Summary) -> bool { + // Mid-flow reauthorization classify (as the OS does on policy change): the + // connection exists with a permanent verdict, so ALE must decide directly. + let result = run_ale(plan, true); + if result.action != FWP_ACTION_PERMIT { + summary.fail(format!( + "{}: reauthorization of an accepted flow not permitted (action={:#x})", + plan.describe(), result.action + )); + return false; + } + true +} + +// ---- Callout adapters ---------------------------------------------------------- + +fn run_ale(plan: &FlowPlan, reauthorize: bool) -> CalloutResult { + match (plan.tuple, plan.direction) { + (Tuple::V4(t), Direction::Outbound) => { + api::run_ale_connect_v4_tuple(t, reauthorize, plan.process_id, &plan.first_payload) + } + (Tuple::V4(t), Direction::Inbound) => { + api::run_ale_accept_v4_tuple(t, reauthorize, plan.process_id, &plan.first_payload) + } + (Tuple::V6(t), Direction::Outbound) => { + api::run_ale_connect_v6_tuple(t, reauthorize, plan.process_id, &plan.first_payload) + } + (Tuple::V6(t), Direction::Inbound) => { + api::run_ale_accept_v6_tuple(t, reauthorize, plan.process_id, &plan.first_payload) + } + } +} + +fn run_packet(plan: &FlowPlan, direction: Direction, payload: &[u8]) -> CalloutResult { + match plan.tuple { + Tuple::V4(t) => api::run_packet_v4_tuple(t, direction, payload), + Tuple::V6(t) => api::run_packet_v6_tuple(t, direction, payload), + } +} + +fn close_flow(plan: &FlowPlan) { + match plan.tuple { + Tuple::V4(t) => api::run_endpoint_close_v4_tuple(t, plan.process_id), + Tuple::V6(t) => api::run_endpoint_close_v6_tuple(t, plan.process_id), + } +} + +fn release_port(plan: &FlowPlan) { + match plan.tuple { + Tuple::V4(_) => api::run_resource_release_v4_port(plan.tuple.protocol(), plan.tuple.local_port()), + Tuple::V6(_) => api::run_resource_release_v6_port(plan.tuple.protocol(), plan.tuple.local_port()), + } +} + +fn current_verdict(plan: &FlowPlan) -> Option { + match plan.tuple { + Tuple::V4(t) => api::verdict_for_key_v4(t), + Tuple::V6(t) => api::verdict_for_key_v6(t), + } +} + +fn wait_verdict(plan: &FlowPlan, run: &Run, deadline: Instant) -> Option { + wait_for(run, deadline, || { + current_verdict(plan).filter(|&v| v != Verdict::Undecided as u8) + }) +} + +/// Poll `cond` until it yields `Some`, the deadline passes, or the run is +/// stopped (check [`Run::stopped`] to tell the last apart from a timeout). +fn wait_for(run: &Run, deadline: Instant, mut cond: impl FnMut() -> Option) -> Option { + loop { + if let Some(value) = cond() { + return Some(value); + } + if Instant::now() >= deadline || run.stopped() { + return None; + } + thread::sleep(VERDICT_POLL); + } +} + +fn count_packet(summary: &Summary, payload: &[u8]) { + summary.packets.fetch_add(1, Ordering::Relaxed); + summary.bytes.fetch_add(payload.len() as u64, Ordering::Relaxed); +} + +// ---- Reinjector ------------------------------------------------------------------- +// +// Models what the OS does with packets the driver injects: +// * network injects re-enter the packet-layer callouts carrying the FWPS +// injection state -- the driver must recognize itself and permit; +// * outbound transport injects (a released ALE defer) descend the full stack +// and hit the outbound packet layer as INJECTED_BY_OTHER, i.e. unmarked; +// * inbound transport injects re-indicate upward from the transport layer and +// never traverse the network layer again. + +fn reinject_loop(run: &Run) { + let summary = &run.summary; + while !run.stopped() { + let injected = api::drain_injected(); + if injected.is_empty() { + thread::sleep(REINJECT_POLL); + continue; + } + for packet in injected { + if packet.transport { + if packet.inbound { + continue; // re-indicated upward, skips the network layer + } + let result = if packet.ipv6 { + api::run_packet_bytes_v6(&packet.data, Direction::Outbound, false) + } else { + api::run_packet_bytes_v4(&packet.data, Direction::Outbound, false) + }; + // kext_tester only hands out permanent verdicts, so by the time + // the defer was released the flow is permanently accepted. A + // real agent may hold a temporary verdict (the replay pends + // again) or have changed it since, so any action is legal then. + if result.action == FWP_ACTION_PERMIT { + summary.transport_replays.fetch_add(1, Ordering::Relaxed); + } else if !run.any_verdict() { + summary.fail(format!( + "transport re-inject not permitted: {} (action={:#x} absorb={})", + describe_injected(&packet), + result.action, + result.absorb + )); + } + } else { + let direction = if packet.inbound { Direction::Inbound } else { Direction::Outbound }; + let result = if packet.ipv6 { + api::run_packet_bytes_v6(&packet.data, direction, true) + } else { + api::run_packet_bytes_v4(&packet.data, direction, true) + }; + if result.action == FWP_ACTION_PERMIT { + summary.loopback_permits.fetch_add(1, Ordering::Relaxed); + } else { + summary.fail(format!( + "self-injected packet not permitted: {} (action={:#x} absorb={})", + describe_injected(&packet), + result.action, + result.absorb + )); + } + } + } + } +} + +/// Best-effort one-line identity for a drained injected packet: parses the IP +/// header for the 5-tuple, falling back to flags + length when the bytes are not +/// a parsable IP packet (e.g. a raw transport segment). +fn describe_injected(packet: &api::InjectedPacket) -> String { + let kind = if packet.transport { "transport" } else { "network" }; + let dir = if packet.inbound { "inbound" } else { "outbound" }; + let loopback = if packet.loopback { " loopback" } else { "" }; + match describe_ip_packet(&packet.data, packet.ipv6) { + Some(tuple) => format!("{tuple} [{kind} {dir}{loopback} len={}]", packet.data.len()), + None => format!( + " [{kind} {dir}{loopback} len={}]", + if packet.ipv6 { "v6" } else { "v4" }, + packet.data.len(), + ), + } +} + +/// Pull "PROTO src=addr:port dst=addr:port" out of a raw IP packet, or `None` if +/// the header is too short or its version nibble does not match `ipv6`. +fn describe_ip_packet(data: &[u8], ipv6: bool) -> Option { + if ipv6 { + if data.len() < 40 || data[0] >> 4 != 6 { + return None; + } + let proto = data[6]; + let src = Ipv6Addr::from(<[u8; 16]>::try_from(&data[8..24]).ok()?); + let dst = Ipv6Addr::from(<[u8; 16]>::try_from(&data[24..40]).ok()?); + Some(match ports_at(data, 40, proto) { + Some((s, d)) => { + format!("{} src=[{src}]:{s} dst=[{dst}]:{d}", scenarios::proto_label(proto)) + } + None => format!("{} src=[{src}] dst=[{dst}]", scenarios::proto_label(proto)), + }) + } else { + if data.len() < 20 || data[0] >> 4 != 4 { + return None; + } + let ihl = (data[0] & 0x0f) as usize * 4; + if ihl < 20 { + return None; + } + let proto = data[9]; + let src = Ipv4Addr::new(data[12], data[13], data[14], data[15]); + let dst = Ipv4Addr::new(data[16], data[17], data[18], data[19]); + Some(match ports_at(data, ihl, proto) { + Some((s, d)) => format!("{} src={src}:{s} dst={dst}:{d}", scenarios::proto_label(proto)), + None => format!("{} src={src} dst={dst}", scenarios::proto_label(proto)), + }) + } +} + +/// The source/destination ports at `offset` for TCP/UDP, if the bytes are there. +fn ports_at(data: &[u8], offset: usize, proto: u8) -> Option<(u16, u16)> { + if !matches!(proto, 6 | 17) { + return None; + } + let src = data.get(offset..offset + 2)?; + let dst = data.get(offset + 2..offset + 4)?; + Some(( + u16::from_be_bytes([src[0], src[1]]), + u16::from_be_bytes([dst[0], dst[1]]), + )) +} diff --git a/sim/src/rng.rs b/sim/src/rng.rs new file mode 100644 index 0000000..660820e --- /dev/null +++ b/sim/src/rng.rs @@ -0,0 +1,36 @@ +//! Tiny deterministic PRNG (SplitMix64) so the traffic generator is fully +//! reproducible from one printed seed, with no external dependencies. + +pub struct SplitMix64 { + state: u64, +} + +impl SplitMix64 { + pub fn new(seed: u64) -> Self { + SplitMix64 { state: seed } + } + + pub fn next_u64(&mut self) -> u64 { + self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15); + mix(self.state) + } + + /// Uniform-ish value in `[lo, hi)`. `hi` must be greater than `lo`. + pub fn range(&mut self, lo: u64, hi: u64) -> u64 { + lo + self.next_u64() % (hi - lo) + } + + /// True with probability `percent`/100. + pub fn chance(&mut self, percent: u64) -> bool { + self.range(0, 100) < percent + } +} + +/// The SplitMix64 finalizer, also used standalone to derive per-worker seeds +/// from the run seed. +pub fn mix(x: u64) -> u64 { + let mut z = x; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} diff --git a/sim/src/scenario.rs b/sim/src/scenario.rs new file mode 100644 index 0000000..612ab67 --- /dev/null +++ b/sim/src/scenario.rs @@ -0,0 +1,1352 @@ +//! CBOR scenario-file executor. +//! +//! Replays an authored scenario file against the driver instead of the seeded +//! random [`crate::producer`]. The file is either **CBOR** (`.cbor`: a sequence +//! of record maps per RFC 8742, compact) or **JSON Lines** (`.json`: one record +//! object per line / NDJSON, human-readable for debugging) -- chosen by extension. +//! Both are decoded as a LAZY STREAM: exactly one record is parsed at a time and +//! dispatched before the next is read, so the whole file is never buffered and a +//! scenario can be arbitrarily long. Each record carries an absolute timestamp +//! (`ts`, microseconds since scenario start) and one command. +//! +//! Execution is *global-timeline dispatch*: a single scheduler thread reads the +//! file in order, sleeps the delta between consecutive records' timestamps, then +//! hands each command to the next free worker in a pool. Workers run the matching +//! [`driver::fuzz_api`] callout entry point immediately, so records that share a +//! timestamp (delta 0) are injected in parallel -- genuine OS threads hammering +//! the one global device, which is what surfaces concurrency bugs. +//! +//! Verdicts are out of scope here: the real Go agent still answers them over the +//! pipe (see [`crate::main`]); this module only *injects* the scenario. +//! +//! Each record is a flat map/object so it is trivial to author from any language +//! -- Python CBOR: `cbor2.dump({"ts": 0, "op": "ale_connect", ...}, f)` per record; +//! or just hand-write one JSON object per line. Only the fields relevant to `op` +//! need be present; the rest default. `ts` and `op` are always required. Byte +//! fields (`local`/`remote`/`payload`/`bytes`/`resolver`) are CBOR byte strings, +//! and arrays of byte values in JSON. +//! +//! ## High-level `dns` op +//! +//! Beyond the one-callout-per-record ops above, `dns` is a *live* macro op: one +//! record names a `domain` and, at replay time, performs a REAL DNS lookup +//! against the machine's default resolver (the OS-configured server, auto-detected; +//! override with `resolver`), then expands into the callout sequence that lookup +//! is made of -- ALE connect (UDP, carrying the real query so a domain-aware policy +//! sees it on the pended event), the real query datagram outbound, the real +//! response datagram inbound, then endpoint close + resource release. Set +//! `tcp_connect` to append a TCP session (`tcp_port`, default 443) to the address +//! the response actually resolved to. `v6` asks for AAAA and connects over IPv6; +//! the query itself reaches the resolver over whatever family that server uses. +//! The sub-steps run through [`execute`] like hand-authored records, so they +//! validate and tally identically. Unlike every other op this one touches the +//! network: it is non-deterministic, needs connectivity, and a failed lookup is +//! recorded as a failure rather than faked. Because the lookup blocks on the +//! network (tens of ms) on its worker, space `dns` records by more than that in +//! `ts` (milliseconds) if you want them paced distinctly -- tighter deltas just +//! overlap on the worker pool. See [`run_dns`]. + +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::{self, BufReader, BufWriter, ErrorKind, Write}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread; +use std::time::Duration; + +use crossbeam_channel::unbounded; +use serde::{Deserialize, Serialize}; + +use driver::fuzz_api::{ + self as api, CalloutResult, Direction, TupleSpecV4, TupleSpecV6, FWP_ACTION_BLOCK, + FWP_ACTION_PERMIT, +}; + +use crate::rng::mix; +use crate::scenarios; + +/// One scenario command ("line"). See the module docs for the field vocabulary. +#[derive(Debug, Default, PartialEq, Serialize, Deserialize)] +struct Record { + /// Miliseconds since scenario start (absolute point on the timeline). + ts: u64, + /// Command selector: `ale_connect` | `ale_accept` | `packet` | + /// `packet_bytes` | `endpoint_close` | `resource_release` | `dns`. + op: String, + /// Address family: `false` => IPv4, `true` => IPv6. For `dns`, selects the + /// record type asked for (AAAA vs A) and the family of the `tcp_connect`. + #[serde(default, skip_serializing_if = "is_default")] + v6: bool, + /// IP protocol number (6 TCP, 17 UDP, 1 ICMP, ...). + #[serde(default, skip_serializing_if = "is_default")] + proto: u8, + /// Local address, as a byte string (4 bytes for v4, 16 for v6). + #[serde(default, with = "serde_bytes", skip_serializing_if = "Vec::is_empty")] + local: Vec, + #[serde(default, skip_serializing_if = "is_default")] + lport: u16, + /// Remote address, as a byte string (4 bytes for v4, 16 for v6). + #[serde(default, with = "serde_bytes", skip_serializing_if = "Vec::is_empty")] + remote: Vec, + #[serde(default, skip_serializing_if = "is_default")] + rport: u16, + /// Process id carried into `ale_*` / `endpoint_close` callouts. + #[serde(default, skip_serializing_if = "is_default")] + pid: u64, + /// `ale_*`: reauthorization flag. + #[serde(default, skip_serializing_if = "is_default")] + reauth: bool, + /// `packet` / `packet_bytes`: direction, 0 = Outbound, 1 = Inbound. + #[serde(default, skip_serializing_if = "is_default")] + dir: u8, + /// `ale_*` / `packet`: upper-layer payload a structured packet is built around. + #[serde(default, with = "serde_bytes", skip_serializing_if = "Vec::is_empty")] + payload: Vec, + /// `packet_bytes`: full raw packet bytes, starting at the IP header. + #[serde(default, with = "serde_bytes", skip_serializing_if = "Vec::is_empty")] + bytes: Vec, + /// `packet_bytes`: mark the NBL as network-injected by the driver itself. + #[serde(default, skip_serializing_if = "is_default")] + injected_by_self: bool, + /// `resource_release`: the local port to release. + #[serde(default, skip_serializing_if = "is_default")] + port: u16, + + // ---- High-level `dns` op ---- + /// `dns`: the domain to look up for real. A non-empty `domain` is all a `dns` + /// record needs; every other field below has a default. It is resolved live + /// against the default resolver, and the address that comes back is what + /// `tcp_connect` then dials -- see [`run_dns`]. + #[serde(default, skip_serializing_if = "is_default")] + domain: String, + /// `dns`: the DNS server to query, as a byte string (4 bytes for v4, 16 for + /// v6). Defaults to the machine's OS-configured default resolver. + #[serde(default, with = "serde_bytes", skip_serializing_if = "Vec::is_empty")] + resolver: Vec, + /// `dns`: after the lookup, also open a TCP session to the address the + /// response resolved to. + #[serde(default, skip_serializing_if = "is_default")] + tcp_connect: bool, + /// `dns`: destination port for `tcp_connect` (defaults to 443). + #[serde(default, skip_serializing_if = "is_default")] + tcp_port: u16, +} + +/// `skip_serializing_if` predicate: omit a field that still holds its zero value, +/// so emitted sample files only carry the fields that matter for each `op`. +fn is_default(v: &T) -> bool { + *v == T::default() +} + +/// An explicit 5-tuple resolved from a record, ready for the `*_tuple` runners. +enum Tuple { + V4(TupleSpecV4), + V6(TupleSpecV6), +} + +/// Build the 5-tuple from a record, validating the address widths for the family. +fn tuple(rec: &Record) -> Result { + let bad = |field: &str, got: usize, want: usize| { + format!( + "op `{}`: {field} address must be {want} bytes, got {got}", + rec.op + ) + }; + if rec.v6 { + let local: [u8; 16] = rec + .local + .as_slice() + .try_into() + .map_err(|_| bad("local", rec.local.len(), 16))?; + let remote: [u8; 16] = rec + .remote + .as_slice() + .try_into() + .map_err(|_| bad("remote", rec.remote.len(), 16))?; + Ok(Tuple::V6(TupleSpecV6 { + protocol: rec.proto, + local, + local_port: rec.lport, + remote, + remote_port: rec.rport, + })) + } else { + let local: [u8; 4] = rec + .local + .as_slice() + .try_into() + .map_err(|_| bad("local", rec.local.len(), 4))?; + let remote: [u8; 4] = rec + .remote + .as_slice() + .try_into() + .map_err(|_| bad("remote", rec.remote.len(), 4))?; + Ok(Tuple::V4(TupleSpecV4 { + protocol: rec.proto, + local, + local_port: rec.lport, + remote, + remote_port: rec.rport, + })) + } +} + +fn direction(dir: u8) -> Direction { + if dir == 0 { + Direction::Outbound + } else { + Direction::Inbound + } +} + +/// Delta between the previous timestamp and this one, in microseconds. The first +/// record (no previous) is 0; a non-monotonic timestamp clamps to 0 via +/// `saturating_sub`, so a mis-ordered file never panics or sleeps backwards. +fn delta_milis(prev: Option, ts: u64) -> u64 { + prev.map_or(0, |p| ts.saturating_sub(p)) +} + +/// Run one command against the driver via the real callout entry points. +fn execute(rec: &Record, sum: &ScenarioSummary) { + let result: Option = match rec.op.as_str() { + "ale_connect" => match tuple(rec) { + Ok(Tuple::V4(s)) => Some(api::run_ale_connect_v4_tuple( + s, + rec.reauth, + rec.pid, + &rec.payload, + )), + Ok(Tuple::V6(s)) => Some(api::run_ale_connect_v6_tuple( + s, + rec.reauth, + rec.pid, + &rec.payload, + )), + Err(e) => return sum.fail(e), + }, + "ale_accept" => match tuple(rec) { + Ok(Tuple::V4(s)) => Some(api::run_ale_accept_v4_tuple( + s, + rec.reauth, + rec.pid, + &rec.payload, + )), + Ok(Tuple::V6(s)) => Some(api::run_ale_accept_v6_tuple( + s, + rec.reauth, + rec.pid, + &rec.payload, + )), + Err(e) => return sum.fail(e), + }, + "packet" => match tuple(rec) { + Ok(Tuple::V4(s)) => Some(api::run_packet_v4_tuple( + s, + direction(rec.dir), + &rec.payload, + )), + Ok(Tuple::V6(s)) => Some(api::run_packet_v6_tuple( + s, + direction(rec.dir), + &rec.payload, + )), + Err(e) => return sum.fail(e), + }, + "packet_bytes" => { + let d = direction(rec.dir); + Some(if rec.v6 { + api::run_packet_bytes_v6(&rec.bytes, d, rec.injected_by_self) + } else { + api::run_packet_bytes_v4(&rec.bytes, d, rec.injected_by_self) + }) + } + "endpoint_close" => { + match tuple(rec) { + Ok(Tuple::V4(s)) => api::run_endpoint_close_v4_tuple(s, rec.pid), + Ok(Tuple::V6(s)) => api::run_endpoint_close_v6_tuple(s, rec.pid), + Err(e) => return sum.fail(e), + } + None + } + "resource_release" => { + if rec.v6 { + api::run_resource_release_v6_port(rec.proto, rec.port); + } else { + api::run_resource_release_v4_port(rec.proto, rec.port); + } + None + } + // Macro op: performs a real DNS lookup, then injects the callout sequence + // (+ optional TCP session) through this same `execute`. Tallies its own + // sub-steps, so return before the single-result tally below. + "dns" => return run_dns(rec, sum), + other => return sum.fail(format!("unknown op `{other}`")), + }; + sum.tally(&rec.op, result); +} + +// ---- High-level `dns` op -------------------------------------------------------- +// +// A `dns` record is a convenience macro: it names a `domain`, resolves it for +// real against the OS default resolver (see [`crate::dns`]), and expands, inline +// on the worker, into the low-level records that lookup is made of -- built around +// the actual query/response bytes -- then runs each through `execute`. Nothing +// here waits for verdicts (that stays the agent's job over the pipe, as for every +// op) -- it only injects the sequence a real lookup produced. + +const TCP: u8 = 6; +const UDP: u8 = 17; + +/// Resolve a `dns` record's domain live, build the callout sequence from the real +/// query/response bytes, run each sub-step through [`execute`], then tally the +/// lookup (and any TCP session). A bad record, an unreachable resolver, or a +/// domain with no address of the requested family is recorded as a failure -- +/// this op depends on the network, so those are real, reportable outcomes. +fn run_dns(rec: &Record, sum: &ScenarioSummary) { + if rec.domain.is_empty() { + return sum.fail("op `dns`: `domain` must not be empty".into()); + } + let resolver = match rec.resolver.len() { + 0 => None, + 4 | 16 => crate::dns::ip_from_bytes(&rec.resolver), + n => { + return sum.fail(format!( + "op `dns`: resolver address must be 4 or 16 bytes, got {n}" + )) + } + }; + + // The one network-touching step: a real query against the default resolver. + let resolution = match crate::dns::resolve(&rec.domain, rec.v6, resolver) { + Ok(resolution) => resolution, + Err(e) => return sum.fail(format!("op `dns`: lookup of `{}` failed: {e}", rec.domain)), + }; + + let records = match build_dns_records(rec, &resolution) { + Ok(records) => records, + Err(e) => return sum.fail(e), + }; + for r in &records { + execute(r, sum); + } + sum.dns_lookups.fetch_add(1, Ordering::Relaxed); + if rec.tcp_connect { + sum.tcp_sessions.fetch_add(1, Ordering::Relaxed); + } +} + +/// Build the low-level records a completed lookup expands into, around the real +/// wire bytes in `res` -- ALE connect (carrying the actual query so the agent sees +/// the domain on the pended event), the query datagram outbound, the response +/// datagram inbound, then endpoint close + resource release -- plus, when +/// `tcp_connect` is set, a follow-on TCP session to the address the response +/// resolved to. Each sub-flow's family follows the address it actually uses (the +/// resolver for the lookup, the resolved host for the connect), so a v6 record can +/// legitimately query a v4 resolver and then connect over IPv6. The local port is +/// a per-domain ephemeral so distinct domains stay distinct connection keys. +/// +/// Pure and device-free (it only shapes records from `res`), so it is unit tested +/// directly; [`run_dns`] is the part that does the live lookup and injection. +fn build_dns_records(rec: &Record, res: &crate::dns::Resolution) -> Result, String> { + let lport = if rec.lport != 0 { + rec.lport + } else { + derived_port(mix_domain(&rec.domain, rec.ts)) + }; + + let resolver = ip_to_bytes(res.resolver); + let resolver_v6 = res.resolver.is_ipv6(); + let dns_local = local_for(&rec.local, resolver_v6); + + let mut out = Vec::with_capacity(if rec.tcp_connect { 10 } else { 5 }); + + // --- DNS lookup over UDP to resolver:53, carrying the real query/response --- + // ALE connect carries the query, so a domain policy can decide on the pend. + let mut connect = sub_record( + "ale_connect", + resolver_v6, + UDP, + &dns_local, + lport, + &resolver, + 53, + ); + connect.pid = rec.pid; + connect.payload = res.query.clone(); + out.push(connect); + // The query datagram at the packet layer, then the resolver's real response. + let mut query_pkt = sub_record("packet", resolver_v6, UDP, &dns_local, lport, &resolver, 53); + query_pkt.dir = 0; // outbound + query_pkt.payload = res.query.clone(); + out.push(query_pkt); + let mut response_pkt = sub_record("packet", resolver_v6, UDP, &dns_local, lport, &resolver, 53); + response_pkt.dir = 1; // inbound + response_pkt.payload = res.response.clone(); + out.push(response_pkt); + // Socket teardown (release keys on protocol + local port, not the tuple). + let mut close = sub_record( + "endpoint_close", + resolver_v6, + UDP, + &dns_local, + lport, + &resolver, + 53, + ); + close.pid = rec.pid; + out.push(close); + out.push(release_record(resolver_v6, UDP, lport)); + + // --- Optional TCP session to the address the lookup resolved to --- + if rec.tcp_connect { + let resolved = res.resolved.ok_or_else(|| { + format!( + "op `dns`: `{}` did not resolve to a{} address to connect to", + rec.domain, + if rec.v6 { "n IPv6" } else { " IPv4" } + ) + })?; + let remote = ip_to_bytes(resolved); + let resolved_v6 = resolved.is_ipv6(); + let tcp_local = local_for(&rec.local, resolved_v6); + let tcp_port = if rec.tcp_port != 0 { rec.tcp_port } else { 443 }; + // TCP vs the UDP lookup are distinct keys (protocol + remote differ), so + // reusing the same local port for both is fine. + let mut connect = sub_record( + "ale_connect", + resolved_v6, + TCP, + &tcp_local, + lport, + &remote, + tcp_port, + ); + connect.pid = rec.pid; + out.push(connect); // an outbound TCP connect carries no data + let mut hello = sub_record( + "packet", + resolved_v6, + TCP, + &tcp_local, + lport, + &remote, + tcp_port, + ); + hello.dir = 0; // outbound: the ClientHello is the first packet + hello.payload = scenarios::tls_client_hello(); + out.push(hello); + let mut reply = sub_record( + "packet", + resolved_v6, + TCP, + &tcp_local, + lport, + &remote, + tcp_port, + ); + reply.dir = 1; // inbound + reply.payload = vec![0x16; 128]; // ServerHello-ish handshake bytes + out.push(reply); + let mut close = sub_record( + "endpoint_close", + resolved_v6, + TCP, + &tcp_local, + lport, + &remote, + tcp_port, + ); + close.pid = rec.pid; + out.push(close); + out.push(release_record(resolved_v6, TCP, lport)); + } + Ok(out) +} + +/// One low-level tuple-bearing record for the `dns` expansion; caller then fills +/// in `dir`/`payload`/`pid` as the sub-step needs. +fn sub_record( + op: &str, + v6: bool, + proto: u8, + local: &[u8], + lport: u16, + remote: &[u8], + rport: u16, +) -> Record { + Record { + op: op.to_string(), + v6, + proto, + local: local.to_vec(), + lport, + remote: remote.to_vec(), + rport, + ..Default::default() + } +} + +/// A `resource_release` record (keys on protocol + local port, no tuple). +fn release_record(v6: bool, proto: u8, port: u16) -> Record { + Record { + op: "resource_release".to_string(), + v6, + proto, + port, + ..Default::default() + } +} + +/// The local client address for a sub-flow of the given family: the record's +/// `local` override when it is set to a matching-width address, else a stable +/// default. A `dns` record's ephemeral local *port* is what keeps distinct +/// domains on distinct connection keys, so a shared local address is fine. +fn local_for(provided: &[u8], v6: bool) -> Vec { + let want = if v6 { 16 } else { 4 }; + if provided.len() == want { + provided.to_vec() + } else { + default_local(v6) + } +} + +/// The default local client address for the family (documentation ranges). +fn default_local(v6: bool) -> Vec { + if v6 { + // 2001:db8::1 (documentation prefix). + vec![0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1] + } else { + vec![10, 0, 0, 1] + } +} + +/// An [`IpAddr`](std::net::IpAddr) as the 4- or 16-byte string a record carries. +fn ip_to_bytes(ip: std::net::IpAddr) -> Vec { + match ip { + std::net::IpAddr::V4(a) => a.octets().to_vec(), + std::net::IpAddr::V6(a) => a.octets().to_vec(), + } +} + +/// Fold a domain (salted by the record timestamp, so repeats of the same name +/// stay distinct) into a well-mixed 64-bit value seeding the DNS transaction id, +/// the local port, and any synthesized address -- all deterministic, so a +/// scenario replays identically. +fn mix_domain(domain: &str, ts: u64) -> u64 { + let mut h = 0xcbf2_9ce4_8422_2325u64 ^ ts; // FNV-1a offset basis, salted by ts + for b in domain.bytes() { + h = (h ^ b as u64).wrapping_mul(0x0000_0100_0000_01b3); // FNV-1a prime + } + mix(h) +} + +/// An ephemeral local port (49152-65151) derived from the domain hash. +fn derived_port(h: u64) -> u16 { + 49152 + (h % 16000) as u16 +} + +// ---- Summary ------------------------------------------------------------------ + +/// Counters gathered across the run, plus any failures (validation errors, parse +/// errors, worker panics). Shared behind an `Arc` across the worker pool. +#[derive(Default)] +pub struct ScenarioSummary { + dispatched: AtomicU64, + executed: AtomicU64, + pended: AtomicU64, + permitted: AtomicU64, + blocked: AtomicU64, + /// High-level `dns` records expanded, and TCP sessions appended to them. + dns_lookups: AtomicU64, + tcp_sessions: AtomicU64, + by_op: Mutex>, + failures: Mutex>, +} + +impl ScenarioSummary { + fn tally(&self, op: &str, result: Option) { + self.executed.fetch_add(1, Ordering::Relaxed); + *self + .by_op + .lock() + .unwrap() + .entry(op.to_string()) + .or_insert(0) += 1; + if let Some(r) = result { + if r.absorb { + self.pended.fetch_add(1, Ordering::Relaxed); + } + if r.action == FWP_ACTION_PERMIT { + self.permitted.fetch_add(1, Ordering::Relaxed); + } else if r.action == FWP_ACTION_BLOCK { + self.blocked.fetch_add(1, Ordering::Relaxed); + } + } + } + + fn fail(&self, msg: String) { + eprintln!("[scenario] FAIL: {msg}"); + self.failures.lock().unwrap().push(msg); + } + + /// True if the scenario replayed with no validation errors or worker panics. + pub fn ok(&self) -> bool { + self.failures.lock().unwrap().is_empty() + } + + pub fn print(&self) { + println!( + "[scenario] dispatched={} executed={} pended={} permitted={} blocked={}", + self.dispatched.load(Ordering::Relaxed), + self.executed.load(Ordering::Relaxed), + self.pended.load(Ordering::Relaxed), + self.permitted.load(Ordering::Relaxed), + self.blocked.load(Ordering::Relaxed), + ); + let dns = self.dns_lookups.load(Ordering::Relaxed); + let tcp = self.tcp_sessions.load(Ordering::Relaxed); + if dns > 0 || tcp > 0 { + println!("[scenario] dns lookups={dns} tcp sessions={tcp}"); + } + for (op, n) in self.by_op.lock().unwrap().iter() { + println!("[scenario] {op}: {n}"); + } + let failures = self.failures.lock().unwrap(); + if failures.is_empty() { + println!("[scenario] OK: no failures"); + } else { + println!("[scenario] {} FAILURE(S):", failures.len()); + for f in failures.iter() { + println!("[scenario] - {f}"); + } + } + } +} + +// ---- Executor ----------------------------------------------------------------- + +/// Replay a CBOR scenario file. Never panics on I/O -- file and parse errors are +/// recorded as failures on the returned summary. Worker panics (a driver +/// invariant blowing up, which is exactly what we hunt) are caught at join and +/// recorded too. Worker count comes from `ZF_SIM_THREADS` (default 4). +pub fn run(path: &str) -> Arc { + let summary = Arc::new(ScenarioSummary::default()); + run_into(path, &summary, true); + summary +} + +/// The heart of both [`run`] and [`run_loop`]: stream `path` and dispatch every +/// record into `summary` on a worker pool, so a caller can accumulate across +/// runs. `announce` prints the one-line "streaming ..." banner (the loop +/// suppresses it, keeping per-iteration output quiet). Never panics on I/O. +fn run_into(path: &str, summary: &Arc, announce: bool) { + let threads = std::env::var("ZF_SIM_THREADS") + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|&n| n > 0) + .unwrap_or(4); + + // Open a streaming record iterator (codec chosen by extension). Records are + // parsed and dispatched one at a time -- the whole file is never held in + // memory, and a scenario can be arbitrarily long. + let records = match open_records(path) { + Ok(records) => records, + Err(e) => { + summary.fail(e); + return; + } + }; + + // Scheduler -> worker pool. One sender (this thread); each worker owns a + // clone of the receiver, so whichever worker is free takes the next command. + let (tx, rx) = unbounded::(); + let workers: Vec<_> = (0..threads) + .map(|i| { + let rx = rx.clone(); + let sum = Arc::clone(summary); + thread::Builder::new() + .name(format!("scenario-worker-{i}")) + .spawn(move || { + for rec in rx.iter() { + execute(&rec, &sum); + } + }) + .expect("spawn scenario worker") + }) + .collect(); + drop(rx); // only the workers hold receivers now + + if announce { + println!("[scenario] streaming `{path}` on {threads} worker(s) ..."); + } + let mut prev: Option = None; + for item in records { + let rec = match item { + Ok(rec) => rec, + Err(e) => { + summary.fail(e); + break; + } + }; + let delta = delta_milis(prev, rec.ts); + if delta > 0 { + thread::sleep(Duration::from_millis(delta)); + } + prev = Some(rec.ts); + summary.dispatched.fetch_add(1, Ordering::Relaxed); + if tx.send(rec).is_err() { + summary.fail("all workers exited before the scenario finished".into()); + break; + } + } + drop(tx); // close the channel: workers drain the queue, then exit + + for (i, w) in workers.into_iter().enumerate() { + if w.join().is_err() { + summary.fail(format!( + "worker {i} panicked (driver invariant violated -- see backtrace above)" + )); + } + } +} + +// ---- Loop mode ---------------------------------------------------------------- +// +// `run_loop` replays a file forever, resetting the driver between passes so each +// is an INDEPENDENT scenario (never a continuation carrying the previous pass's +// cached verdicts), and accumulating one running total that is printed only when +// the process is stopped (Ctrl+C) -- never per iteration. + +/// The running total across every loop pass, published for [`ctrl_handler`]. +static LOOP_TOTAL: OnceLock> = OnceLock::new(); +/// Passes started so far, so the Ctrl+C summary can report the count. +static LOOP_ITERS: AtomicU64 = AtomicU64::new(0); + +/// Replay `path` on repeat until the process is stopped. Diverges: on Ctrl+C the +/// [`ctrl_handler`] prints the accumulated totals once and exits. Between passes +/// the driver's per-connection state is wiped (see [`api::reset_device_state`]), +/// so an iteration re-pends everything instead of inheriting the last pass's +/// verdicts -- each pass is the same scenario run afresh, not a continuation. +pub fn run_loop(path: &str) -> ! { + let total = Arc::new(ScenarioSummary::default()); + let _ = LOOP_TOTAL.set(Arc::clone(&total)); + install_ctrl_handler(); + loop { + LOOP_ITERS.fetch_add(1, Ordering::Relaxed); + // api::reset_device_state(); + // println!("[scenario] --- iteration {n} ---"); + run_into(path, &total, false); + } +} + +/// Ctrl+C: print the loop's running totals once, then exit. Reached from the +/// OS-specific hook installed by [`install_ctrl_handler`]. +fn report_and_exit() -> ! { + let code = match LOOP_TOTAL.get() { + Some(total) => { + println!( + " +[scenario] stopped after {} iteration(s) -- totals:", + LOOP_ITERS.load(Ordering::Relaxed) + ); + total.print(); + if total.ok() { + 0 + } else { + 1 + } + } + None => 0, + }; + let _ = io::stdout().flush(); + std::process::exit(code); +} + +/// Windows: `SetConsoleCtrlHandler` (raw FFI, like the pipe server). The handler +/// runs on its own thread, so it can report and exit directly. +#[cfg(windows)] +fn install_ctrl_handler() { + unsafe extern "system" fn ctrl_handler(_ctrl_type: u32) -> i32 { + report_and_exit() + } + + #[link(name = "kernel32")] + extern "system" { + fn SetConsoleCtrlHandler( + handler: Option i32>, + add: i32, + ) -> i32; + } + unsafe { + SetConsoleCtrlHandler(Some(ctrl_handler), 1); + } +} + +/// Unix: a `SIGINT` handler. Printing and exiting aren't async-signal-safe, so +/// the handler only raises a flag and a watcher thread does the reporting. +#[cfg(unix)] +fn install_ctrl_handler() { + use std::sync::atomic::AtomicBool; + + static INTERRUPTED: AtomicBool = AtomicBool::new(false); + + extern "C" fn on_sigint(_signum: i32) { + INTERRUPTED.store(true, Ordering::SeqCst); + } + + extern "C" { + fn signal(signum: i32, handler: extern "C" fn(i32)) -> usize; + } + const SIGINT: i32 = 2; + + unsafe { + signal(SIGINT, on_sigint); + } + std::thread::spawn(|| loop { + if INTERRUPTED.load(Ordering::SeqCst) { + report_and_exit(); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + }); +} + +/// A lazy stream of records; each `next()` parses exactly one record from the +/// underlying reader, so the file is never fully buffered. +type RecordStream = Box>>; + +/// Open a scenario file as a [`RecordStream`], choosing the codec by extension: +/// * `.json` => **JSON Lines** (one record object per line / whitespace-separated +/// JSON values), decoded with serde_json's streaming `StreamDeserializer`. +/// NOTE: this is NDJSON, deliberately NOT a single JSON array (arrays can't be +/// streamed a value at a time). +/// * anything else (e.g. `.cbor`) => a CBOR sequence (RFC 8742) of record maps, +/// read one top-level item at a time until EOF. +fn open_records(path: &str) -> Result { + let file = File::open(path).map_err(|e| format!("cannot open scenario `{path}`: {e}"))?; + let reader = BufReader::new(file); + let is_jsonl = std::path::Path::new(path) + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("jsonl")); + if is_jsonl { + let iter = serde_json::Deserializer::from_reader(reader) + .into_iter::() + .map(|r| r.map_err(|e| format!("JSON parse error: {e}"))); + Ok(Box::new(iter)) + } else { + let mut reader = reader; + let iter = std::iter::from_fn(move || { + match ciborium::from_reader::(&mut reader) { + Ok(rec) => Some(Ok(rec)), + // A clean boundary between items (or a truncated tail) reports EOF. + Err(ciborium::de::Error::Io(e)) if e.kind() == ErrorKind::UnexpectedEof => None, + Err(e) => Some(Err(format!("CBOR parse error: {e}"))), + } + }); + Ok(Box::new(iter)) + } +} + +// ---- Sample scenarios --------------------------------------------------------- + +/// Write the reference sample scenarios into `dir`, creating it if needed. They +/// double as living documentation of the wire format and as round-trip fixtures. +pub fn emit_samples(dir: &str) -> io::Result<()> { + fs::create_dir_all(dir)?; + for (name, records) in [ + ("basic_v4", basic_v4()), + ("mixed_v4v6_parallel", mixed_v4v6_parallel()), + ("icmp_burst", icmp_burst()), + ("dns_and_connect", dns_flows()), + ] { + write_cbor(&format!("{dir}/{name}.cbor"), &records)?; + write_json(&format!("{dir}/{name}.jsonl"), &records)?; + } + Ok(()) +} + +/// Write records as a CBOR sequence (compact; for real runs and Python authoring). +fn write_cbor(path: &str, records: &[Record]) -> io::Result<()> { + let mut out = BufWriter::new(File::create(path)?); + for rec in records { + ciborium::into_writer(rec, &mut out) + .map_err(|e| io::Error::new(ErrorKind::Other, format!("CBOR encode: {e}")))?; + } + out.flush()?; + println!("[scenario] wrote {} records -> {path}", records.len()); + Ok(()) +} + +/// Write records as JSON Lines (one compact record object per line; NDJSON) -- +/// streamable and diff/grep-friendly. Byte fields render as arrays of byte values. +fn write_json(path: &str, records: &[Record]) -> io::Result<()> { + let mut out = BufWriter::new(File::create(path)?); + for rec in records { + let line = serde_json::to_string(rec) + .map_err(|e| io::Error::new(ErrorKind::Other, format!("JSON encode: {e}")))?; + out.write_all(line.as_bytes())?; + out.write_all(b"\n")?; + } + out.flush()?; + println!("[scenario] wrote {} records -> {path}", records.len()); + Ok(()) +} + +/// Base record with just the timeline point and the op set; the rest defaults. +fn at(ts: u64, op: &str) -> Record { + Record { + ts, + op: op.to_string(), + ..Default::default() + } +} + +/// One outbound TCP flow to 1.1.1.1:443: authorize, two data packets, close, release. +fn basic_v4() -> Vec { + let local = vec![10, 0, 0, 5]; + let remote = vec![1, 1, 1, 1]; + vec![ + Record { + proto: 6, + local: local.clone(), + lport: 40001, + remote: remote.clone(), + rport: 443, + pid: 1000, + payload: b"hello".to_vec(), + ..at(0, "ale_connect") + }, + Record { + proto: 6, + dir: 0, + local: local.clone(), + lport: 40001, + remote: remote.clone(), + rport: 443, + payload: b"GET / HTTP/1.1\r\n\r\n".to_vec(), + ..at(5_000, "packet") + }, + Record { + proto: 6, + dir: 1, + local: local.clone(), + lport: 40001, + remote: remote.clone(), + rport: 443, + payload: b"HTTP/1.1 200 OK\r\n".to_vec(), + ..at(15_000, "packet") + }, + Record { + proto: 6, + local: local.clone(), + lport: 40001, + remote: remote.clone(), + rport: 443, + pid: 1000, + ..at(50_000, "endpoint_close") + }, + Record { + proto: 6, + port: 40001, + ..at(50_100, "resource_release") + }, + ] +} + +/// Three connections authorized at the same instant (delta 0 => injected in +/// parallel on different workers), staggered data, then simultaneous closes. +fn mixed_v4v6_parallel() -> Vec { + let v6l = vec![0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]; + let v6r = vec![ + 0x26, 0x06, 0x47, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x64, + ]; + vec![ + Record { + proto: 6, + local: vec![10, 0, 0, 1], + lport: 40001, + remote: vec![1, 1, 1, 1], + rport: 443, + pid: 1001, + ..at(0, "ale_connect") + }, + Record { + proto: 6, + local: vec![10, 0, 0, 2], + lport: 40002, + remote: vec![8, 8, 8, 8], + rport: 53, + pid: 1002, + ..at(0, "ale_connect") + }, + Record { + v6: true, + proto: 6, + local: v6l.clone(), + lport: 40003, + remote: v6r.clone(), + rport: 443, + pid: 1003, + ..at(0, "ale_connect") + }, + Record { + proto: 6, + dir: 0, + local: vec![10, 0, 0, 1], + lport: 40001, + remote: vec![1, 1, 1, 1], + rport: 443, + payload: b"GET /".to_vec(), + ..at(1_000, "packet") + }, + Record { + proto: 6, + dir: 0, + local: vec![10, 0, 0, 2], + lport: 40002, + remote: vec![8, 8, 8, 8], + rport: 53, + payload: vec![0x12, 0x34], + ..at(1_000, "packet") + }, + Record { + v6: true, + proto: 6, + dir: 0, + local: v6l.clone(), + lport: 40003, + remote: v6r.clone(), + rport: 443, + payload: b"GET /".to_vec(), + ..at(2_000, "packet") + }, + Record { + proto: 6, + local: vec![10, 0, 0, 1], + lport: 40001, + remote: vec![1, 1, 1, 1], + rport: 443, + pid: 1001, + ..at(10_000, "endpoint_close") + }, + Record { + proto: 6, + local: vec![10, 0, 0, 2], + lport: 40002, + remote: vec![8, 8, 8, 8], + rport: 53, + pid: 1002, + ..at(10_000, "endpoint_close") + }, + Record { + v6: true, + proto: 6, + local: v6l, + lport: 40003, + remote: v6r, + rport: 443, + pid: 1003, + ..at(10_000, "endpoint_close") + }, + ] +} + +/// A burst of 32 ICMP packets all stamped ts 0, so they are dispatched +/// back-to-back and pend concurrently across the pool -- a small concurrency probe. +fn icmp_burst() -> Vec { + (0..32u8) + .map(|i| Record { + proto: 1, + dir: 0, + local: vec![10, 0, 1, i], + remote: vec![10, 0, 1, 254], + payload: vec![8, 0, 0, 0], + ..at(0, "packet") + }) + .collect() +} + +/// High-level `dns` records: resolve a real domain, optionally opening a TCP +/// session to the resolved address. Each performs a live lookup at replay time +/// and expands into a full ALE/packet/close/release callout sequence (see +/// [`run_dns`]) -- the "set a domain and go" authoring path. Being live, these +/// need connectivity; the domains below are chosen to actually resolve. +fn dns_flows() -> Vec { + // `ts` is milliseconds since scenario start, and the scheduler paces DISPATCH + // to it. A live `dns` lookup then blocks on the network for tens of ms on a + // worker, so records spaced tighter than that would overlap on the pool rather + // than run in sequence. These are 1 s apart (1000 ms) so each lookup finishes + // well before the next is dispatched -- the timeline is plainly visible even + // against real DNS latency. + vec![ + // Bare lookup against the machine's default resolver. + Record { + domain: "example.com".to_string(), + ..at(0, "dns") + }, + // +1 s: lookup via an explicit resolver (1.1.1.1), then a TLS session on + // 443 to the address it resolved to. + Record { + domain: "cloudflare.com".to_string(), + resolver: vec![1, 1, 1, 1], + tcp_connect: true, + tcp_port: 443, + pid: 4242, + ..at(1000, "dns") + }, + // +2 s: AAAA lookup (via the default resolver), then connect over IPv6. + Record { + v6: true, + domain: "google.com".to_string(), + tcp_connect: true, + ..at(2000, "dns") + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn encode(records: &[Record]) -> Vec { + let mut buf = Vec::new(); + for r in records { + ciborium::into_writer(r, &mut buf).unwrap(); + } + buf + } + + fn decode(mut bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + loop { + match ciborium::from_reader::(&mut bytes) { + Ok(r) => out.push(r), + Err(ciborium::de::Error::Io(e)) if e.kind() == ErrorKind::UnexpectedEof => break, + Err(e) => panic!("decode error: {e}"), + } + } + out + } + + #[test] + fn record_sequence_roundtrips() { + for records in [basic_v4(), mixed_v4v6_parallel(), icmp_burst(), dns_flows()] { + let back = decode(&encode(&records)); + assert_eq!(records, back); + } + } + + #[test] + fn json_lines_roundtrips() { + for records in [basic_v4(), mixed_v4v6_parallel(), icmp_burst(), dns_flows()] { + // Encode as JSON Lines, then stream-decode it back one value at a time. + let mut jsonl = String::new(); + for r in &records { + jsonl.push_str(&serde_json::to_string(r).unwrap()); + jsonl.push('\n'); + } + let back: Vec = serde_json::Deserializer::from_str(&jsonl) + .into_iter::() + .map(|r| r.unwrap()) + .collect(); + assert_eq!(records, back); + } + } + + #[test] + fn delta_is_monotonic_zero_and_clamped() { + assert_eq!(delta_milis(None, 100), 0); // first record: no previous + assert_eq!(delta_milis(Some(100), 250), 150); // forward delta + assert_eq!(delta_milis(Some(250), 250), 0); // equal ts => parallel, no sleep + assert_eq!(delta_milis(Some(250), 100), 0); // non-monotonic => clamped + } + + #[test] + fn tuple_validates_address_width() { + let mut r = at(0, "packet"); + r.proto = 6; + r.local = vec![10, 0, 0, 1]; + r.remote = vec![1, 1, 1, 1]; + assert!(matches!(tuple(&r), Ok(Tuple::V4(_)))); + r.remote = vec![0; 16]; // v4 with a 16-byte address is rejected + assert!(tuple(&r).is_err()); + + let mut r6 = at(0, "packet"); + r6.v6 = true; + r6.local = vec![0; 16]; + r6.remote = vec![0; 16]; + assert!(matches!(tuple(&r6), Ok(Tuple::V6(_)))); + r6.local = vec![0; 4]; // v6 with a 4-byte address is rejected + assert!(tuple(&r6).is_err()); + } + + /// A completed lookup with the given resolver/resolved addresses and wire + /// bytes -- lets the pure record builder be tested with no network. + fn fake_resolution( + resolver: &str, + resolved: Option<&str>, + query: Vec, + response: Vec, + ) -> crate::dns::Resolution { + crate::dns::Resolution { + resolver: resolver.parse().unwrap(), + resolved: resolved.map(|s| s.parse().unwrap()), + query, + response, + } + } + + #[test] + fn dns_builds_the_lookup_sequence_from_wire_bytes() { + let rec = Record { + domain: "example.com".to_string(), + ..at(0, "dns") + }; + let query = crate::scenarios::dns_query_for(0x1234, "example.com", false); + let response = + crate::scenarios::dns_response_for(0x1234, "example.com", &[93, 184, 216, 34]); + let res = fake_resolution( + "8.8.8.8", + Some("93.184.216.34"), + query.clone(), + response.clone(), + ); + let recs = build_dns_records(&rec, &res).unwrap(); + + let ops: Vec<&str> = recs.iter().map(|r| r.op.as_str()).collect(); + assert_eq!( + ops, + [ + "ale_connect", + "packet", + "packet", + "endpoint_close", + "resource_release" + ] + ); + + // Every lookup step is UDP to the resolver we actually queried, on :53, + // from one per-domain ephemeral local port off a stable client address. + let lport = derived_port(mix_domain("example.com", 0)); + for r in &recs[..4] { + assert!(!r.v6); + assert_eq!(r.proto, UDP); + assert_eq!(r.remote, vec![8, 8, 8, 8]); + assert_eq!(r.rport, 53); + assert_eq!(r.lport, lport); + assert_eq!(r.local, vec![10, 0, 0, 1]); + } + // The real query goes out; the real response comes back in. + assert_eq!(recs[1].dir, 0); + assert_eq!(recs[2].dir, 1); + assert_eq!(recs[0].payload, query); // pended connect carries the query + assert_eq!(recs[1].payload, query); + assert_eq!(recs[2].payload, response); + // Release frees this flow's UDP local port. + let release = recs.last().unwrap(); + assert_eq!(release.proto, UDP); + assert_eq!(release.port, lport); + } + + #[test] + fn dns_tcp_connect_dials_the_resolved_answer() { + let rec = Record { + domain: "api.example.org".to_string(), + tcp_connect: true, + tcp_port: 8443, + pid: 4242, + ..at(0, "dns") + }; + let query = crate::scenarios::dns_query_for(0x1, "api.example.org", false); + let response = + crate::scenarios::dns_response_for(0x1, "api.example.org", &[203, 0, 113, 7]); + let res = fake_resolution("8.8.8.8", Some("203.0.113.7"), query, response); + let recs = build_dns_records(&rec, &res).unwrap(); + assert_eq!(recs.len(), 10, "5 lookup steps + 5 TCP session steps"); + + let tcp = &recs[5..]; + let ops: Vec<&str> = tcp.iter().map(|r| r.op.as_str()).collect(); + assert_eq!( + ops, + [ + "ale_connect", + "packet", + "packet", + "endpoint_close", + "resource_release" + ] + ); + // The session dials exactly the address the lookup resolved to. + for r in &tcp[..4] { + assert_eq!(r.proto, TCP); + assert_eq!(r.remote, vec![203, 0, 113, 7]); + assert_eq!(r.rport, 8443); + } + // pid rides the ALE connect and the endpoint close (the packet-layer + // callout ignores it, so the datagram records leave it default). + assert_eq!(tcp[0].pid, 4242); + assert_eq!(tcp[3].pid, 4242); + // An outbound TCP connect carries no data; the ClientHello is the first packet. + assert!(tcp[0].payload.is_empty()); + assert_eq!(tcp[1].dir, 0); + assert_eq!(tcp[1].payload, crate::scenarios::tls_client_hello()); + assert_eq!(tcp[2].dir, 1); + } + + #[test] + fn dns_v6_record_can_query_v4_and_connect_over_v6() { + // A v6 record asks for AAAA, but the query still reaches whatever family + // the resolver is -- here a v4 server -- and only the connect is v6. + let rec = Record { + v6: true, + domain: "google.com".to_string(), + tcp_connect: true, + ..at(0, "dns") + }; + let query = crate::scenarios::dns_query_for(0x1, "google.com", true); + let v6 = [0x26, 0x06, 0x47, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x64]; + let response = crate::scenarios::dns_response_for(0x1, "google.com", &v6); + let res = fake_resolution("8.8.8.8", Some("2606:4700::64"), query, response); + let recs = build_dns_records(&rec, &res).unwrap(); + assert_eq!(recs.len(), 10); + + // The lookup sub-flow follows the (v4) resolver we queried. + assert!(!recs[0].v6); + assert_eq!(recs[0].remote, vec![8, 8, 8, 8]); + assert_eq!(recs[0].local.len(), 4); + // The connect sub-flow follows the (v6) resolved address. + let tcp = &recs[5..]; + assert!(tcp[0].v6); + assert_eq!( + tcp[0].remote, + "2606:4700::64" + .parse::() + .unwrap() + .octets() + ); + assert_eq!(tcp[0].local.len(), 16); + assert_eq!(tcp[0].rport, 443); + } + + #[test] + fn dns_tcp_connect_needs_a_resolved_address() { + // The resolver answered, but with no address of the requested family. + let query = crate::scenarios::dns_query_for(0x1, "nope.invalid", false); + let res = fake_resolution("8.8.8.8", None, query, vec![0u8; 12]); + + let want_connect = Record { + domain: "nope.invalid".to_string(), + tcp_connect: true, + ..at(0, "dns") + }; + assert!(build_dns_records(&want_connect, &res) + .unwrap_err() + .contains("did not resolve")); + + // Without tcp_connect a missing address is fine -- just the lookup steps. + let lookup_only = Record { + domain: "nope.invalid".to_string(), + ..at(0, "dns") + }; + assert_eq!(build_dns_records(&lookup_only, &res).unwrap().len(), 5); + } +} diff --git a/sim/src/scenarios.rs b/sim/src/scenarios.rs new file mode 100644 index 0000000..6876e05 --- /dev/null +++ b/sim/src/scenarios.rs @@ -0,0 +1,800 @@ +//! Traffic scenarios: what the fake OS "does" on the network. +//! +//! A [`FlowPlan`] is one connection's whole life, mirroring how the OS drives +//! WFP: resource assignment -> ALE authorization (pends, the agent decides) -> +//! data packets through the IP packet layer -> endpoint closure -> resource +//! release. +//! +//! The default run generates seeded random flows from weighted templates +//! ([`random_flow`]) and never stops. The hand-authored deterministic scenarios +//! (DNS, TLS, blocked remote, loopback, v6, ICMP, reauth, port reuse) each live +//! as their own function in the [`deterministic`] module, currently unwired -- +//! kept for when we decide how to schedule them alongside the random loop. +//! +//! Payload realism: first packets carry real-looking application bytes (DNS +//! query/response, TLS ClientHello, HTTP GET). Payloads that reach the agent +//! (the pended first packet) are kept small (<= 256 bytes) because the agent +//! reads events through a 1 KiB pipe buffer. + +use std::net::{Ipv4Addr, Ipv6Addr}; + +use driver::fuzz_api::{Direction, TupleSpecV4, TupleSpecV6}; + +use crate::rng::SplitMix64; + +const TCP: u8 = 6; +const UDP: u8 = 17; + +/// A short human label for an IP protocol number, for diagnostics. +pub fn proto_label(protocol: u8) -> String { + match protocol { + 1 => "ICMP".to_string(), + 6 => "TCP".to_string(), + 17 => "UDP".to_string(), + 58 => "ICMPv6".to_string(), + other => format!("proto{other}"), + } +} + +/// What the agent's policy (kext_tester `handleInfo`) is expected to decide. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Expect { + /// Verdict lands asynchronously as Accept/PermanentAccept. + Accepted, + /// Outbound to 1.1.1.1: the agent replies PermanentBlock. + Blocked, + /// Inbound loopback: ALE itself sets Accept and permits, no pend. + ImmediateAccept, + /// Not connection-tracked (ICMP): every packet pends on its own, no + /// connection verdict ever lands in the cache. + NoVerdict, +} + +#[derive(Clone, Copy, Debug)] +pub enum Tuple { + V4(TupleSpecV4), + V6(TupleSpecV6), +} + +impl Tuple { + pub fn protocol(&self) -> u8 { + match self { + Tuple::V4(t) => t.protocol, + Tuple::V6(t) => t.protocol, + } + } + + pub fn local_port(&self) -> u16 { + match self { + Tuple::V4(t) => t.local_port, + Tuple::V6(t) => t.local_port, + } + } + + /// "PROTO local=addr:port remote=addr:port" for diagnostics (v6 bracketed). + pub fn endpoints(&self) -> String { + match self { + Tuple::V4(t) => format!( + "{} local={}:{} remote={}:{}", + proto_label(t.protocol), + Ipv4Addr::from(t.local), + t.local_port, + Ipv4Addr::from(t.remote), + t.remote_port, + ), + Tuple::V6(t) => format!( + "{} local=[{}]:{} remote=[{}]:{}", + proto_label(t.protocol), + Ipv6Addr::from(t.local), + t.local_port, + Ipv6Addr::from(t.remote), + t.remote_port, + ), + } + } +} + +/// One data packet after authorization. +#[derive(Clone, Debug)] +pub struct Burst { + pub direction: Direction, + pub payload: Vec, +} + +fn out(payload: Vec) -> Burst { + Burst { + direction: Direction::Outbound, + payload, + } +} + +fn inb(payload: Vec) -> Burst { + Burst { + direction: Direction::Inbound, + payload, + } +} + +/// One connection's planned life. +#[derive(Clone, Debug)] +pub struct FlowPlan { + /// Scenario label for the summary (template name for random flows). + pub name: &'static str, + pub tuple: Tuple, + /// Direction of the authorization: Outbound = ALE connect, Inbound = ALE accept. + pub direction: Direction, + pub process_id: u64, + /// Payload carried by the ALE authorization packet (reaches the agent). + pub first_payload: Vec, + /// Data packets exchanged once the flow is authorized. + pub bursts: Vec, + pub expect: Expect, + /// Fire a reauthorization ALE classify after this many bursts. + pub reauth_after: Option, + /// Report the local port released to the resource-release callout at the end. + pub release_port: bool, + /// After closing, open a follow-up flow reusing the local port against a + /// different remote (same-key reuse would hit the ended-connection cache + /// entry; see the connection-cache stale-lookup semantic). + pub follow_up_reuse: bool, +} + +impl FlowPlan { + /// One-line identity for failure messages: scenario, 5-tuple, direction, pid. + pub fn describe(&self) -> String { + format!( + "{} [{} {} pid={}]", + self.name, + self.tuple.endpoints(), + self.direction, + self.process_id, + ) + } +} + +fn v4(protocol: u8, local: [u8; 4], lport: u16, remote: [u8; 4], rport: u16) -> Tuple { + Tuple::V4(TupleSpecV4 { + protocol, + local, + local_port: lport, + remote, + remote_port: rport, + }) +} + +fn v6(protocol: u8, local16: u8, lport: u16, remote16: u8, rport: u16) -> Tuple { + let mut local = [0u8; 16]; + local[0] = 0x20; + local[1] = 0x01; + local[15] = local16; + let mut remote = [0u8; 16]; + remote[0] = 0x26; + remote[1] = 0x06; + remote[15] = remote16; + Tuple::V6(TupleSpecV6 { + protocol, + local, + local_port: lport, + remote, + remote_port: rport, + }) +} + +// ---- Application payloads --------------------------------------------------- + +/// Encode a domain as a DNS QNAME: each dot-separated label prefixed by its +/// length byte, terminated by a zero (root) byte. Labels are clamped to the +/// 63-byte label limit and empty labels (e.g. a trailing dot) are dropped. +fn encode_qname(domain: &str) -> Vec { + let mut out = Vec::with_capacity(domain.len() + 2); + for label in domain.split('.').filter(|l| !l.is_empty()) { + let bytes = label.as_bytes(); + let len = bytes.len().min(63); + out.push(len as u8); + out.extend_from_slice(&bytes[..len]); + } + out.push(0); // root label terminates the name + out +} + +/// A real-shaped DNS query for `domain`: a standard recursion-desired query with +/// one question, type A (`v6` false) or AAAA (`v6` true) in class IN. +pub fn dns_query_for(id: u16, domain: &str, v6: bool) -> Vec { + let qname = encode_qname(domain); + let qtype: u16 = if v6 { 28 } else { 1 }; // AAAA / A + let mut p = Vec::with_capacity(12 + qname.len() + 4); + p.extend_from_slice(&id.to_be_bytes()); + p.extend_from_slice(&[0x01, 0x00]); // flags: RD + p.extend_from_slice(&[0, 1, 0, 0, 0, 0, 0, 0]); // QD=1, AN/NS/AR=0 + p.extend_from_slice(&qname); + p.extend_from_slice(&qtype.to_be_bytes()); + p.extend_from_slice(&[0, 1]); // class IN + p +} + +/// A matching DNS response for `domain`: the question echoed back plus one +/// answer carrying `ip` -- a 4-byte A record or a 16-byte AAAA record (the +/// record type follows `ip`'s width). +pub fn dns_response_for(id: u16, domain: &str, ip: &[u8]) -> Vec { + let v6 = ip.len() == 16; + let mut p = dns_query_for(id, domain, v6); + p[2] = 0x81; // QR + RD + p[3] = 0x80; // RA + p[7] = 1; // AN=1 + p.extend_from_slice(&[0xC0, 0x0C]); // answer name: pointer to the question + let qtype: u16 = if v6 { 28 } else { 1 }; + p.extend_from_slice(&qtype.to_be_bytes()); + p.extend_from_slice(&[0, 1]); // class IN + p.extend_from_slice(&[0, 0, 0, 0x3C]); // TTL 60s + p.extend_from_slice(&(ip.len() as u16).to_be_bytes()); // RDLENGTH + p.extend_from_slice(ip); // RDATA + p +} + +/// A real-shaped DNS query: header + QD for `example.com` A IN. +pub fn dns_query(id: u16) -> Vec { + dns_query_for(id, "example.com", false) +} + +/// A matching DNS response: query echo + one A answer (93.184.216.34). +pub fn dns_response(id: u16) -> Vec { + dns_response_for(id, "example.com", &[93, 184, 216, 34]) +} + +/// Read the first answer of the requested family out of a DNS response: the A +/// record (`want_v6 == false`, 4 bytes) or AAAA (`want_v6 == true`, 16 bytes). +/// Walks the real message -- header counts, the question section, then each +/// answer -- following/skipping name compression, so it copes with CNAME chains +/// and multi-record replies from a live resolver, not just our own fabricated +/// ones. Returns `None` if no answer of that family is present or the buffer is +/// malformed. This is how a resolved address flows from the response into a +/// follow-on connection, the way a client reads the answer and dials it. +pub fn dns_answer_ip(response: &[u8], want_v6: bool) -> Option> { + if response.len() < 12 { + return None; + } + let qdcount = u16::from_be_bytes([response[4], response[5]]) as usize; + let ancount = u16::from_be_bytes([response[6], response[7]]) as usize; + + let mut i = 12; // past the fixed header + for _ in 0..qdcount { + i = skip_name(response, i)?; + i = i.checked_add(4)?; // QTYPE (2) + QCLASS (2) + } + + let (want_type, want_len) = if want_v6 { (28u16, 16usize) } else { (1u16, 4usize) }; + for _ in 0..ancount { + i = skip_name(response, i)?; + let rtype = u16::from_be_bytes([*response.get(i)?, *response.get(i + 1)?]); + // After the name: TYPE (2) + CLASS (2) + TTL (4), then RDLENGTH (2), RDATA. + let rdlen = u16::from_be_bytes([*response.get(i + 8)?, *response.get(i + 9)?]) as usize; + let rdata = i.checked_add(10)?; + if rtype == want_type && rdlen == want_len { + return Some(response.get(rdata..rdata.checked_add(rdlen)?)?.to_vec()); + } + i = rdata.checked_add(rdlen)?; + } + None +} + +/// Advance past a DNS name starting at `i`, returning the index just after it. +/// Handles the three label forms: the zero root label ends the name, a `0xC0` +/// compression pointer is two bytes and also ends it, and a normal label is a +/// length byte plus that many octets. `None` on a truncated or reserved name. +fn skip_name(msg: &[u8], mut i: usize) -> Option { + loop { + let len = *msg.get(i)?; + match len & 0xC0 { + 0x00 => { + if len == 0 { + return Some(i + 1); + } + i = i.checked_add(1 + len as usize)?; + } + 0xC0 => { + msg.get(i + 1)?; // the pointer's second byte must exist + return Some(i + 2); + } + _ => return None, // 0x40 / 0x80 are reserved label types + } + } +} + +/// The first bytes of a TLS 1.2/1.3 ClientHello record, truncated to fit the +/// agent's pipe read buffer when it rides a pended packet. +pub fn tls_client_hello() -> Vec { + let mut p = vec![ + 0x16, 0x03, 0x01, 0x00, 0xF8, // record: handshake, TLS1.0, len + 0x01, 0x00, 0x00, 0xF4, // ClientHello, len + 0x03, 0x03, // TLS1.2 + ]; + p.extend((0u8..32).map(|i| i.wrapping_mul(7))); // "random" + p.resize(200, 0xA5); // extensions padding + p +} + +pub fn http_get() -> Vec { + b"GET / HTTP/1.1\r\nHost: example.com\r\nUser-Agent: zf-sim\r\nAccept: */*\r\n\r\n".to_vec() +} + +pub fn http_response() -> Vec { + let mut p = + b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 512\r\n\r\n".to_vec(); + p.resize(p.len() + 512, b'x'); + p +} + +// ---- Deterministic scenarios (currently unwired) ----------------------------- +// +// One hand-authored flow per interesting driver path, each its own function. +// Nothing schedules them yet; they wait here until we decide how to interleave +// them with the random loop. They use local addresses (10.0.1.x, loopback) +// distinct from the random flows' 10.1.x, so their connection keys never collide +// (a shared key would coalesce two flows into one connection). +#[allow(dead_code)] +pub mod deterministic { + use super::*; + + const ICMP: u8 = 1; + + fn icmp_echo(seq: u16) -> Vec { + let mut p = vec![8, 0, 0, 0]; // type 8 (echo), code 0, checksum 0 + p.extend_from_slice(&[0, 1]); // identifier + p.extend_from_slice(&seq.to_be_bytes()); + p.extend_from_slice(b"abcdefghijklmnopqrstuvwabcdefghi"); // classic 32-byte ping + p + } + + /// DNS query over UDP to a public resolver; the verdict lands asynchronously. + pub fn dns_udp() -> FlowPlan { + FlowPlan { + name: "dns-udp", + tuple: v4(UDP, [10, 0, 1, 10], 54321, [8, 8, 8, 8], 53), + direction: Direction::Outbound, + process_id: 0, + first_payload: dns_query(0x1234), + bursts: vec![inb(dns_response(0x1234))], + expect: Expect::Accepted, + reauth_after: None, + release_port: true, + follow_up_reuse: false, + } + } + + /// Outbound HTTPS: TLS handshake, then application data both ways. + pub fn https_tcp() -> FlowPlan { + FlowPlan { + name: "https-tcp", + tuple: v4(TCP, [10, 0, 1, 11], 49152, [93, 184, 216, 34], 443), + direction: Direction::Outbound, + process_id: 0, + first_payload: Vec::new(), // outbound TCP connect carries no data + bursts: vec![ + out(tls_client_hello()), + inb(vec![0x16; 1400]), // ServerHello + cert chain + inb(vec![0x16; 1400]), + out(vec![0x14; 51]), // ChangeCipherSpec/Finished + inb(vec![0x17; 900]), // application data + out(vec![0x17; 120]), + ], + expect: Expect::Accepted, + reauth_after: None, + release_port: true, + follow_up_reuse: false, + } + } + + /// Plain HTTP GET / 200 OK. + pub fn http_get_flow() -> FlowPlan { + FlowPlan { + name: "http-get", + tuple: v4(TCP, [10, 0, 1, 12], 49200, [203, 0, 113, 80], 80), + direction: Direction::Outbound, + process_id: 0, + first_payload: Vec::new(), + bursts: vec![out(http_get()), inb(http_response())], + expect: Expect::Accepted, + reauth_after: None, + release_port: false, + follow_up_reuse: false, + } + } + + /// Outbound to 1.1.1.1: the agent replies PermanentBlock; the packet layer + /// must hard-block. + pub fn blocked_cloudflare() -> FlowPlan { + FlowPlan { + name: "blocked-cloudflare", + tuple: v4(TCP, [10, 0, 1, 13], 49300, [1, 1, 1, 1], 443), + direction: Direction::Outbound, + process_id: 0, + first_payload: Vec::new(), + bursts: vec![out(tls_client_hello())], // must be blocked at the packet layer + expect: Expect::Blocked, + reauth_after: None, + release_port: true, + follow_up_reuse: false, + } + } + + /// Loopback client half. + pub fn loopback_out() -> FlowPlan { + FlowPlan { + name: "loopback-out", + tuple: v4(TCP, [127, 0, 0, 1], 49400, [127, 0, 0, 1], 8080), + direction: Direction::Outbound, + process_id: 0, + first_payload: Vec::new(), + bursts: vec![out(http_get()), inb(http_response())], + expect: Expect::Accepted, + reauth_after: None, + release_port: false, + follow_up_reuse: false, + } + } + + /// Loopback server half: ALE accepts inbound loopback immediately. + pub fn loopback_in() -> FlowPlan { + FlowPlan { + name: "loopback-in", + tuple: v4(TCP, [127, 0, 0, 1], 8080, [127, 0, 0, 1], 49401), + direction: Direction::Inbound, + process_id: 0, + first_payload: http_get(), + bursts: vec![inb(http_get()), out(http_response())], + expect: Expect::ImmediateAccept, + reauth_after: None, + release_port: false, + follow_up_reuse: false, + } + } + + /// IPv6 HTTPS. + pub fn https_v6() -> FlowPlan { + FlowPlan { + name: "https-v6", + tuple: v6(TCP, 0x10, 49500, 0x63, 443), + direction: Direction::Outbound, + process_id: 0, + first_payload: Vec::new(), + bursts: vec![out(tls_client_hello()), inb(vec![0x17; 1200])], + expect: Expect::Accepted, + reauth_after: None, + release_port: true, + follow_up_reuse: false, + } + } + + /// IPv6 DNS. + pub fn dns_v6() -> FlowPlan { + FlowPlan { + name: "dns-v6", + tuple: v6(UDP, 0x11, 49600, 0x88, 53), + direction: Direction::Outbound, + process_id: 0, + first_payload: dns_query(0x77AA), + bursts: vec![inb(dns_response(0x77AA))], + expect: Expect::Accepted, + reauth_after: None, + release_port: false, + follow_up_reuse: false, + } + } + + /// ICMP echo: not connection-tracked, so every packet pends on its own. + pub fn icmp_echo_flow() -> FlowPlan { + FlowPlan { + name: "icmp-echo", + tuple: v4(ICMP, [10, 0, 1, 14], 0, [203, 0, 113, 7], 0), + direction: Direction::Outbound, + process_id: 0, + first_payload: Vec::new(), + bursts: vec![out(icmp_echo(1)), out(icmp_echo(2))], + expect: Expect::NoVerdict, + reauth_after: None, + release_port: false, + follow_up_reuse: false, + } + } + + /// Mid-flow reauthorization: an accepted connection is re-classified by ALE. + pub fn reauth_mid_flow() -> FlowPlan { + FlowPlan { + name: "reauth-mid-flow", + tuple: v4(TCP, [10, 0, 1, 15], 49700, [198, 51, 100, 23], 443), + direction: Direction::Outbound, + process_id: 0, + first_payload: Vec::new(), + bursts: vec![ + out(tls_client_hello()), + inb(vec![0x17; 800]), + out(vec![0x17; 90]), + ], + expect: Expect::Accepted, + reauth_after: Some(2), + release_port: false, + follow_up_reuse: false, + } + } + + /// Port reuse: release the local port, then reopen it against a new remote + /// (see [`super::reuse_follow_up`]). + pub fn port_reuse() -> FlowPlan { + FlowPlan { + name: "port-reuse", + tuple: v4(TCP, [10, 0, 1, 16], 49800, [198, 51, 100, 40], 443), + direction: Direction::Outbound, + process_id: 0, + first_payload: Vec::new(), + bursts: vec![out(vec![0x17; 200])], + expect: Expect::Accepted, + reauth_after: None, + release_port: true, + follow_up_reuse: true, + } + } + + /// Inbound TCP to a local listener (non-loopback). + pub fn inbound_tcp() -> FlowPlan { + FlowPlan { + name: "inbound-tcp", + tuple: v4(TCP, [10, 0, 1, 17], 8443, [198, 51, 100, 77], 52344), + direction: Direction::Inbound, + process_id: 0, + first_payload: tls_client_hello(), + bursts: vec![inb(vec![0x17; 600]), out(vec![0x17; 300])], + expect: Expect::Accepted, + reauth_after: None, + release_port: false, + follow_up_reuse: false, + } + } +} + +/// The follow-up flow for `follow_up_reuse`: same local port, different remote +/// (a fresh key, like a new socket bound to the just-released port). +pub fn reuse_follow_up(plan: &FlowPlan) -> Option { + if !plan.follow_up_reuse { + return None; + } + let Tuple::V4(t) = plan.tuple else { + return None; + }; + let mut remote = t.remote; + remote[3] = remote[3].wrapping_add(1); + Some(FlowPlan { + name: "port-reuse(2)", + tuple: Tuple::V4(TupleSpecV4 { remote, ..t }), + direction: Direction::Outbound, + process_id: plan.process_id, + first_payload: Vec::new(), + bursts: vec![out(vec![0x17; 150])], + expect: Expect::Accepted, + reauth_after: None, + release_port: true, + follow_up_reuse: false, + }) +} + +// ---- Random flows -------------------------------------------------------------- + +/// A seeded random flow for `worker`. Cross-worker keys stay disjoint via the +/// per-worker local-port window (`20000 + worker*2048 + seq`, `seq < 2048`). +/// Within a worker that window is reused every 2048 flows, so a flow's *remote* +/// address must carry enough entropy that the reused (port, local) pair still +/// yields a fresh key -- otherwise the driver treats the repeat as an existing +/// connection and permits it without pending. The v4 remote is 3 random octets; +/// the v6 remote is a random 64-bit interface id for the same reason. Templates +/// are weighted toward the common real-world mix; the costly/special paths +/// (blocked, loopback, ICMP) stay in the deterministic set. +pub fn random_flow(rng: &mut SplitMix64, worker: usize, seq: u16) -> FlowPlan { + let local_port = 20000 + (worker as u16) * 2048 + seq; + let local = [10, 1, worker as u8 + 1, rng.range(2, 250) as u8]; + // Public-ish remote. Steer clear of the ranges the driver special-cases, so + // these templates only exercise the normal pend path: 1.1.1.1 & co (first + // octet < 11), 127.0.0.0/8 loopback (an inbound loopback connection is + // accepted at ALE *without pending* -- that path is covered by the + // deterministic loopback scenarios), and multicast/reserved (>= 224). + let first_octet = { + // [11, 222] with 127 skipped, so no loopback remote is ever produced. + let octet = rng.range(11, 222) as u8; + if octet < 127 { + octet + } else { + octet + 1 + } + }; + let remote = [ + first_octet, + rng.range(0, 255) as u8, + rng.range(0, 255) as u8, + rng.range(1, 254) as u8, + ]; + let process_id = 0; //2000 + rng.range(0, 12) * 111; + + let roll = rng.range(0, 100); + if roll < 25 { + // DNS lookup + let id = rng.next_u64() as u16; + FlowPlan { + name: "rnd-dns", + tuple: v4(UDP, local, local_port, remote, 53), + direction: Direction::Outbound, + process_id, + first_payload: dns_query(id), + bursts: vec![inb(dns_response(id))], + expect: Expect::Accepted, + reauth_after: None, + release_port: rng.chance(50), + follow_up_reuse: false, + } + } else if roll < 55 { + // HTTPS session with a variable-size exchange + let mut bursts = vec![out(tls_client_hello())]; + for _ in 0..rng.range(1, 8) { + let size = rng.range(1, 1400) as usize; + if rng.chance(60) { + bursts.push(inb(vec![0x17; size])); + } else { + bursts.push(out(vec![0x17; size])); + } + } + FlowPlan { + name: "rnd-https", + tuple: v4(TCP, local, local_port, remote, 443), + direction: Direction::Outbound, + process_id, + first_payload: Vec::new(), + bursts, + expect: Expect::Accepted, + reauth_after: None, + release_port: rng.chance(30), + follow_up_reuse: false, + } + } else if roll < 70 { + // Plain HTTP + FlowPlan { + name: "rnd-http", + tuple: v4(TCP, local, local_port, remote, 80), + direction: Direction::Outbound, + process_id, + first_payload: Vec::new(), + bursts: vec![out(http_get()), inb(http_response())], + expect: Expect::Accepted, + reauth_after: if rng.chance(20) { Some(1) } else { None }, + release_port: false, + follow_up_reuse: false, + } + } else if roll < 80 { + // Generic UDP exchange (QUIC-ish sizes) + let mut bursts = Vec::new(); + for _ in 0..rng.range(1, 5) { + let size = rng.range(40, 1200) as usize; + if rng.chance(50) { + bursts.push(inb(vec![0x40; size])); + } else { + bursts.push(out(vec![0x40; size])); + } + } + FlowPlan { + name: "rnd-udp", + tuple: v4( + UDP, + local, + local_port, + remote, + rng.range(1024, 65000) as u16, + ), + direction: Direction::Outbound, + process_id, + first_payload: vec![0x40; rng.range(24, 200) as usize], + bursts, + expect: Expect::Accepted, + reauth_after: None, + release_port: rng.chance(50), + follow_up_reuse: false, + } + } else if roll < 90 { + // IPv6 HTTPS. The v6 address is only one meaningful byte per side, so + // once a worker's local-port window wraps (every 2048 flows) those keys + // start repeating -- and a repeated key looks like an existing + // connection to the driver, which permits it without pending ("did not + // pend at ALE"). Give the remote a wide random interface id (like the v4 + // remote's random octets) so a reused (port, local) still makes a fresh + // key; put the worker in the local address to mirror v4's isolation. + let mut local6 = [0u8; 16]; + local6[0] = 0x20; + local6[1] = 0x01; + local6[8] = worker as u8; + local6[15] = rng.range(1, 250) as u8; + let mut remote6 = [0u8; 16]; + remote6[0] = 0x26; + remote6[1] = 0x06; + remote6[8..16].copy_from_slice(&rng.next_u64().to_be_bytes()); + FlowPlan { + name: "rnd-v6", + tuple: Tuple::V6(TupleSpecV6 { + protocol: TCP, + local: local6, + local_port, + remote: remote6, + remote_port: 443, + }), + direction: Direction::Outbound, + process_id, + first_payload: Vec::new(), + bursts: vec![ + out(tls_client_hello()), + inb(vec![0x17; rng.range(100, 1400) as usize]), + ], + expect: Expect::Accepted, + reauth_after: None, + release_port: false, + follow_up_reuse: false, + } + } else { + // Inbound TCP (a listener receiving a connection): the agent accepts + // inbound immediately, so these are cheap. + FlowPlan { + name: "rnd-inbound", + tuple: v4( + TCP, + local, + local_port, + remote, + rng.range(1024, 65000) as u16, + ), + direction: Direction::Inbound, + process_id, + first_payload: vec![0x17; rng.range(1, 250) as usize], + bursts: vec![inb(vec![0x17; rng.range(1, 1000) as usize])], + expect: Expect::Accepted, + reauth_after: None, + release_port: false, + follow_up_reuse: false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn answer_ip_reads_a_and_aaaa() { + let a = dns_response_for(0x1234, "example.com", &[93, 184, 216, 34]); + assert_eq!(dns_answer_ip(&a, false), Some(vec![93, 184, 216, 34])); + assert_eq!(dns_answer_ip(&a, true), None); // no AAAA in an A reply + + let v6 = [0x26, 0x06, 0x47, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x64]; + let aaaa = dns_response_for(0x1234, "example.com", &v6); + assert_eq!(dns_answer_ip(&aaaa, true), Some(v6.to_vec())); + assert_eq!(dns_answer_ip(&aaaa, false), None); + } + + #[test] + fn answer_ip_skips_cname_and_follows_compression() { + // A real-shaped reply for www.example.com: a CNAME answer, then the A. + // The question qname sits at offset 12, so a pointer to the "example.com" + // suffix is 0xC010 and to the full name 0xC00C. + let mut m = Vec::new(); + m.extend_from_slice(&0xABCDu16.to_be_bytes()); // id + m.extend_from_slice(&[0x81, 0x80]); // response, RD+RA + m.extend_from_slice(&[0, 1, 0, 2, 0, 0, 0, 0]); // QD=1, AN=2 + m.extend_from_slice(b"\x03www\x07example\x03com\x00"); // qname @ offset 12 + m.extend_from_slice(&[0, 1, 0, 1]); // A, IN + // Answer 1: CNAME www.example.com -> example.com (rdata is a pointer). + m.extend_from_slice(&[0xC0, 0x0C, 0, 5, 0, 1, 0, 0, 0, 60, 0, 2, 0xC0, 0x10]); + // Answer 2: A example.com -> 1.2.3.4. + m.extend_from_slice(&[0xC0, 0x10, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 1, 2, 3, 4]); + + assert_eq!(dns_answer_ip(&m, false), Some(vec![1, 2, 3, 4])); + assert_eq!(dns_answer_ip(&m, true), None); + } + + #[test] + fn answer_ip_rejects_malformed() { + assert_eq!(dns_answer_ip(&[], false), None); + assert_eq!(dns_answer_ip(&[0; 8], false), None); // shorter than a header + } +} diff --git a/tools/godoc-gen/go.mod b/tools/godoc-gen/go.mod new file mode 100644 index 0000000..113a7dc --- /dev/null +++ b/tools/godoc-gen/go.mod @@ -0,0 +1,3 @@ +module zenithfence/tools/godoc-gen + +go 1.20 diff --git a/tools/godoc-gen/main.go b/tools/godoc-gen/main.go new file mode 100644 index 0000000..c5cbc82 --- /dev/null +++ b/tools/godoc-gen/main.go @@ -0,0 +1,380 @@ +// Command godoc-gen generates docs/LLMs/GODOC.md, a Markdown reference of the +// Go user-space code (kext_interface, kext_tester) optimized for LLM +// consumption. It walks the repository, parses every non-generated Go package, +// and writes its exported symbols (variables, constants, types, and function +// signatures) together with their doc comments. For `package main` packages, +// which export nothing, all declarations are included instead. +// +// Usage: +// +// go run ./tools/godoc-gen [repo-root] +// just gen-llm-docs +package main + +import ( + "bytes" + "fmt" + "go/ast" + "go/doc" + "go/format" + "go/parser" + "go/token" + "io" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// outputFile is the output path relative to the repository root. +const outputFile = "docs/LLMs/GODOC.md" + +// skipDirs lists directory names that are skipped during the directory walk. +// These are either non-Go projects, build output, or the doc tools themselves. +var skipDirs = map[string]bool{ + "vendor": true, + "testdata": true, + "target": true, + "tools": true, + "libs": true, + "npcap": true, + "release": true, +} + +func main() { + // Resolve the repository root from the optional first argument. + root := "." + if len(os.Args) > 1 { + root = os.Args[1] + } + + absRoot, err := filepath.Abs(root) + if err != nil { + fatalf("resolving root path: %v", err) + } + + // Walk the directory tree and collect package documentation. + var pkgs []packageInfo + err = filepath.WalkDir(absRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + return nil + } + + // Skip hidden directories and known non-Go directories. + if path != absRoot && (strings.HasPrefix(d.Name(), ".") || skipDirs[d.Name()]) { + return filepath.SkipDir + } + + // Parse the Go package at this directory, if any. + info, ok := parsePackageDir(path) + if ok { + pkgs = append(pkgs, info) + } + return nil + }) + if err != nil { + fatalf("walking directory tree: %v", err) + } + + // Sort packages by import path for deterministic, readable output. + sort.Slice(pkgs, func(i, j int) bool { + return pkgs[i].importPath < pkgs[j].importPath + }) + + outPath := filepath.Join(absRoot, outputFile) + if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { + fatalf("creating output directory: %v", err) + } + written, skipped, err := writeFile(outPath, pkgs) + if err != nil { + fatalf("writing %s: %v", outputFile, err) + } + fmt.Fprintf(os.Stderr, "wrote %s (%d packages, %d skipped)\n", outPath, written, skipped) +} + +// writeFile writes all packages in pkgs to outPath. +// Returns the number of packages written and skipped (no documented content). +func writeFile(outPath string, pkgs []packageInfo) (written, skipped int, err error) { + var relevant []packageInfo + for _, pkg := range pkgs { + if packageHasContent(pkg.pkg) { + relevant = append(relevant, pkg) + } else { + skipped++ + } + } + + f, err := os.Create(outPath) + if err != nil { + return 0, skipped, err + } + defer f.Close() + + // Write the file header. + fmt.Fprintln(f, "# ZenithFence — Go Package Reference") + fmt.Fprintln(f) + fmt.Fprintln(f, "> Auto-generated by `go run ./tools/godoc-gen .` (`just gen-llm-docs`) — do not edit by hand. ") + fmt.Fprintln(f, "> Excludes generated files (`Code generated` header), test files, and non-Go directories. ") + fmt.Fprintln(f, "> Library packages: exported symbols only. `package main`: all declarations. Function bodies are stripped.") + fmt.Fprintln(f) + fmt.Fprintln(f, "---") + fmt.Fprintln(f) + + // Write one section per package. + for _, pkg := range relevant { + writePackage(f, pkg) + } + + return len(relevant), skipped, nil +} + +// packageInfo holds the parsed documentation for a single Go package. +type packageInfo struct { + importPath string + dir string + fset *token.FileSet + pkg *doc.Package +} + +// moduleLine matches the module directive of a go.mod file. +var moduleLine = regexp.MustCompile(`(?m)^module\s+(\S+)`) + +// importPathFor returns the import path of dir by locating the nearest go.mod +// at or above it. Returns false when dir is not inside a Go module. +func importPathFor(dir string) (string, bool) { + for modDir := dir; ; modDir = filepath.Dir(modDir) { + data, err := os.ReadFile(filepath.Join(modDir, "go.mod")) + if err == nil { + m := moduleLine.FindSubmatch(data) + if m == nil { + return "", false + } + rel, err := filepath.Rel(modDir, dir) + if err != nil { + return "", false + } + if rel == "." { + return string(m[1]), true + } + return string(m[1]) + "/" + filepath.ToSlash(rel), true + } + if parent := filepath.Dir(modDir); parent == modDir { + return "", false + } + } +} + +// parsePackageDir parses the Go package at dir and returns its documentation. +// Returns (_, false) when the directory has no non-generated, non-test Go files. +func parsePackageDir(dir string) (packageInfo, bool) { + fset := token.NewFileSet() + + // Parse all Go files in the directory, skipping test files. + astPkgs, err := parser.ParseDir(fset, dir, func(fi fs.FileInfo) bool { //nolint:staticcheck // parser.ParseDir is deprecated but adequate for this internal doc generator + return !strings.HasSuffix(fi.Name(), "_test.go") + }, parser.ParseComments) + if err != nil || len(astPkgs) == 0 { + return packageInfo{}, false + } + + importPath, ok := importPathFor(dir) + if !ok { + return packageInfo{}, false + } + + // Process each AST package found in the directory (normally just one). + for _, astPkg := range astPkgs { + // Skip the test-only package variant (e.g., package foo_test). + if strings.HasSuffix(astPkg.Name, "_test") { + continue + } + + // Remove files that were auto-generated — they add noise without insight. + dropGeneratedFiles(astPkg) + if len(astPkg.Files) == 0 { + continue + } + + // Exported symbols only, except for main packages which export nothing. + var mode doc.Mode + if astPkg.Name == "main" { + mode = doc.AllDecls + } + dpkg := doc.New(astPkg, importPath, mode) + return packageInfo{importPath: importPath, dir: dir, fset: fset, pkg: dpkg}, true + } + + return packageInfo{}, false +} + +// dropGeneratedFiles removes any file whose comments contain "Code generated" +// from the AST package. +func dropGeneratedFiles(pkg *ast.Package) { //nolint:staticcheck // ast.Package is deprecated but adequate for this internal doc generator +outer: + for name, f := range pkg.Files { + for _, cg := range f.Comments { + for _, c := range cg.List { + if strings.Contains(c.Text, "Code generated") { + delete(pkg.Files, name) + continue outer + } + } + } + } +} + +// writePackage writes the full Markdown section for one package. +func writePackage(w io.Writer, info packageInfo) { + dp := info.pkg + fset := info.fset + + // Package heading, source files, and package-level doc comment. + fmt.Fprintf(w, "## `%s`\n\n", info.importPath) + fmt.Fprintf(w, "Files: %s\n\n", sourceFiles(fset, info.dir)) + if dp.Doc != "" { + fmt.Fprintf(w, "%s\n\n", strings.TrimSpace(dp.Doc)) + } + + // Variables — package-level var declarations. + if len(dp.Vars) > 0 { + fmt.Fprintln(w, "### Variables") + fmt.Fprintln(w) + for _, v := range dp.Vars { + writeDecl(w, fset, v.Doc, v.Decl) + } + } + + // Constants — package-level const declarations. + if len(dp.Consts) > 0 { + fmt.Fprintln(w, "### Constants") + fmt.Fprintln(w) + for _, c := range dp.Consts { + writeDecl(w, fset, c.Doc, c.Decl) + } + } + + // Types — structs, interfaces, and type aliases, with methods grouped under them. + if len(dp.Types) > 0 { + fmt.Fprintln(w, "### Types") + fmt.Fprintln(w) + for _, t := range dp.Types { + writeType(w, fset, t) + } + } + + // Functions — top-level functions not associated with any type. + if len(dp.Funcs) > 0 { + fmt.Fprintln(w, "### Functions") + fmt.Fprintln(w) + for _, fn := range dp.Funcs { + writeFunc(w, fset, fn) + } + } + + fmt.Fprintln(w, "---") + fmt.Fprintln(w) +} + +// sourceFiles lists the repo-relative files of the package, so the reader can +// jump from a symbol to its source. +func sourceFiles(fset *token.FileSet, dir string) string { + var names []string + fset.Iterate(func(f *token.File) bool { + if filepath.Dir(f.Name()) == dir { + names = append(names, "`"+filepath.Base(f.Name())+"`") + } + return true + }) + sort.Strings(names) + return strings.Join(names, ", ") +} + +// writeDecl writes a var or const declaration block with its doc comment. +func writeDecl(w io.Writer, fset *token.FileSet, docText string, decl *ast.GenDecl) { + if docText != "" { + fmt.Fprintf(w, "%s\n\n", strings.TrimSpace(docText)) + } + fmt.Fprintln(w, "```go") + fmt.Fprintln(w, strings.TrimRight(formatNode(fset, decl), "\n")) + fmt.Fprintln(w, "```") + fmt.Fprintln(w) +} + +// writeType writes a type declaration, its constructor functions, and its methods. +func writeType(w io.Writer, fset *token.FileSet, t *doc.Type) { + fmt.Fprintf(w, "#### `%s`\n\n", t.Name) + if t.Doc != "" { + fmt.Fprintf(w, "%s\n\n", strings.TrimSpace(t.Doc)) + } + + fmt.Fprintln(w, "```go") + fmt.Fprintln(w, strings.TrimRight(formatNode(fset, t.Decl), "\n")) + fmt.Fprintln(w, "```") + fmt.Fprintln(w) + + // Constants and variables of this type (e.g. enum-like iota blocks). + for _, c := range t.Consts { + writeDecl(w, fset, c.Doc, c.Decl) + } + for _, v := range t.Vars { + writeDecl(w, fset, v.Doc, v.Decl) + } + + // Constructor functions and other funcs that return this type. + for _, fn := range t.Funcs { + writeFunc(w, fset, fn) + } + + // Methods on this type (and its pointer receiver). + for _, fn := range t.Methods { + writeFunc(w, fset, fn) + } +} + +// writeFunc writes a function or method signature and its doc comment. +func writeFunc(w io.Writer, fset *token.FileSet, fn *doc.Func) { + if fn.Doc != "" { + fmt.Fprintf(w, "%s\n\n", strings.TrimSpace(fn.Doc)) + } + fmt.Fprintln(w, "```go") + fmt.Fprintln(w, strings.TrimRight(formatFuncSig(fset, fn.Decl), "\n")) + fmt.Fprintln(w, "```") + fmt.Fprintln(w) +} + +// formatNode formats an AST node as canonical Go source using go/format. +func formatNode(fset *token.FileSet, node any) string { + var buf bytes.Buffer + if err := format.Node(&buf, fset, node); err != nil { + return fmt.Sprintf("/* format error: %v */", err) + } + return buf.String() +} + +// formatFuncSig formats a function declaration as its signature only, without +// the body. +func formatFuncSig(fset *token.FileSet, fn *ast.FuncDecl) string { + sig := &ast.FuncDecl{ + Recv: fn.Recv, + Name: fn.Name, + Type: fn.Type, + } + return formatNode(fset, sig) +} + +// packageHasContent reports whether a package has anything worth writing. +func packageHasContent(dp *doc.Package) bool { + return dp.Doc != "" || len(dp.Vars) > 0 || len(dp.Consts) > 0 || + len(dp.Types) > 0 || len(dp.Funcs) > 0 +} + +// fatalf prints an error message to stderr and exits with status 1. +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "godoc-gen: "+format+"\n", args...) + os.Exit(1) +} diff --git a/tools/rustdoc-gen/Cargo.lock b/tools/rustdoc-gen/Cargo.lock new file mode 100644 index 0000000..7ecc884 --- /dev/null +++ b/tools/rustdoc-gen/Cargo.lock @@ -0,0 +1,58 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustdoc-gen" +version = "0.1.0" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" diff --git a/tools/rustdoc-gen/Cargo.toml b/tools/rustdoc-gen/Cargo.toml new file mode 100644 index 0000000..bff03f7 --- /dev/null +++ b/tools/rustdoc-gen/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rustdoc-gen" +version = "0.1.0" +edition = "2021" +publish = false +description = "Generates docs/LLMs/RUSTDOC-*.md item references for LLM assistants" + +# Standalone tool, not part of any workspace. +[workspace] + +[dependencies] +syn = { version = "2", features = ["full", "parsing", "printing"] } +prettyplease = "0.2" +quote = "1" +proc-macro2 = "1" diff --git a/tools/rustdoc-gen/src/main.rs b/tools/rustdoc-gen/src/main.rs new file mode 100644 index 0000000..c150b7a --- /dev/null +++ b/tools/rustdoc-gen/src/main.rs @@ -0,0 +1,489 @@ +//! Generates per-component Markdown item references under `docs/LLMs/`, +//! optimized for LLM consumption: one section per Rust source file listing every +//! item (constants, macros, types with their full definitions, impl blocks, +//! functions, extern blocks) with its doc comment and signature, but without +//! function bodies. Test code (`#[test]`, `#[cfg(test)]`) is skipped. +//! +//! Private items are included on purpose: most of the interesting logic in the +//! driver lives in crate-private functions, and that is exactly what an +//! assistant working on the code needs to find. +//! +//! Usage: +//! +//! ```text +//! cargo run --manifest-path tools/rustdoc-gen/Cargo.toml -- [repo-root] +//! just gen-llm-docs +//! ``` + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; + +use syn::{parse_quote, Attribute, Expr, ImplItem, Item, Lit, Meta, TraitItem, Type, Visibility}; + +/// A crate whose sources are documented. +struct Crate { + /// Crate directory relative to the repository root. + dir: &'static str, + /// Source roots relative to `dir`, walked recursively. + roots: &'static [&'static str], +} + +/// A group of crates written to a single output file. +struct Group { + title: &'static str, + output: &'static str, + crates: &'static [Crate], +} + +const GROUPS: &[Group] = &[ + Group { + title: "Driver", + output: "docs/LLMs/RUSTDOC-driver.md", + crates: &[ + Crate { dir: "driver", roots: &["src"] }, + Crate { dir: "driver/fuzz", roots: &["src", "fuzz_targets"] }, + ], + }, + Group { + title: "Protocol", + output: "docs/LLMs/RUSTDOC-protocol.md", + crates: &[Crate { dir: "protocol", roots: &["src"] }], + }, + Group { + title: "WDK Bindings", + output: "docs/LLMs/RUSTDOC-wdk.md", + crates: &[Crate { dir: "wdk", roots: &["src"] }], + }, + Group { + title: "Mock WDK (host test double for wdk)", + output: "docs/LLMs/RUSTDOC-mock_wdk.md", + crates: &[Crate { dir: "mock_wdk", roots: &["src"] }], + }, + Group { + title: "Simulation & Release Tools", + output: "docs/LLMs/RUSTDOC-tools.md", + crates: &[ + Crate { dir: "sim", roots: &["src"] }, + Crate { dir: "release", roots: &["src"] }, + ], + }, +]; + +fn main() { + let root = std::env::args().nth(1).unwrap_or_else(|| ".".into()); + let root = fs::canonicalize(&root).unwrap_or_else(|e| fatal(&format!("resolving {root}: {e}"))); + + for group in GROUPS { + let mut out = String::new(); + writeln!(out, "# ZenithFence — Rust Item Reference: {}\n", group.title).unwrap(); + out.push_str("> Auto-generated by `cargo run --manifest-path tools/rustdoc-gen/Cargo.toml -- .` (`just gen-llm-docs`) — do not edit by hand. \n"); + out.push_str("> One section per source file: module docs, constants, macros, full type definitions, impl blocks, functions and extern blocks. \n"); + out.push_str("> Includes private items. Function bodies, long initializers and test code are stripped.\n\n---\n\n"); + + let mut files = 0; + for krate in group.crates { + for src_root in krate.roots { + let mut paths = Vec::new(); + collect_rs(&root.join(krate.dir).join(src_root), &mut paths); + paths.sort(); + for path in paths { + let rel = path.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"); + document_file(&mut out, &path, &rel); + files += 1; + } + } + } + + let out_path = root.join(group.output); + fs::create_dir_all(out_path.parent().unwrap()).unwrap(); + fs::write(&out_path, out).unwrap_or_else(|e| fatal(&format!("writing {}: {e}", group.output))); + eprintln!("wrote {} ({files} files)", group.output); + } +} + +/// Recursively collects `.rs` files under `dir`. +fn collect_rs(dir: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + if path.is_dir() { + if !name.starts_with('.') && name != "target" { + collect_rs(&path, out); + } + } else if name.ends_with(".rs") { + out.push(path); + } + } +} + +/// Writes the section(s) for one source file. +fn document_file(out: &mut String, path: &Path, rel: &str) { + let source = match fs::read_to_string(path) { + Ok(s) => s, + Err(e) => fatal(&format!("reading {rel}: {e}")), + }; + match syn::parse_file(&source) { + Ok(file) => document_items(out, &format!("`{rel}`"), &docs_of(&file.attrs), &file.items), + Err(e) => { + writeln!(out, "## `{rel}`\n\n_Could not be parsed: {e}_\n\n---\n").unwrap(); + } + } +} + +/// Writes one Markdown section for a module's items, then one section per +/// inline (non-test) submodule. +fn document_items(out: &mut String, heading: &str, module_docs: &str, items: &[Item]) { + let mut reexports = Vec::new(); + let mut consts = Vec::new(); + let mut macros = Vec::new(); + let mut types: Vec<(String, String, String)> = Vec::new(); // (name, docs, code) + let mut inherent: BTreeMap> = BTreeMap::new(); // type -> [(docs, code)] + let mut trait_impls = Vec::new(); + let mut funcs = Vec::new(); + let mut externs = Vec::new(); + let mut submodules = Vec::new(); + + for item in items { + if is_test(item_attrs(item)) { + continue; + } + match item { + Item::Use(u) if !matches!(u.vis, Visibility::Inherited) => { + let mut u = u.clone(); + clean_attrs(&mut u.attrs, false); + reexports.push(render(Item::Use(u))); + } + Item::Const(_) | Item::Static(_) => consts.push((docs_of(item_attrs(item)), render_value(item))), + Item::Macro(m) => { + if let Some(ident) = &m.ident { + macros.push((docs_of(&m.attrs), format!("macro_rules! {ident} {{ /* ... */ }}"))); + } + } + Item::Struct(_) | Item::Enum(_) | Item::Union(_) | Item::Type(_) | Item::Trait(_) | Item::TraitAlias(_) => { + types.push((item_name(item), docs_of(item_attrs(item)), render_type(item))); + } + Item::Impl(imp) if imp.trait_.is_some() => trait_impls.push(render_trait_impl(imp)), + Item::Impl(imp) => { + if let Some(code) = render_inherent_impl(imp) { + inherent.entry(type_name(&imp.self_ty)).or_default().push((docs_of(&imp.attrs), code)); + } + } + Item::Fn(f) => { + let mut f = f.clone(); + clean_attrs(&mut f.attrs, false); + f.block = Box::new(parse_quote!({})); + funcs.push((docs_of(item_attrs(item)), strip_bodies(&render(Item::Fn(f))))); + } + Item::ForeignMod(fm) => { + let mut fm = fm.clone(); + clean_attrs(&mut fm.attrs, false); + for fi in &mut fm.items { + if let syn::ForeignItem::Fn(f) = fi { + clean_attrs(&mut f.attrs, true); + } else if let syn::ForeignItem::Static(s) = fi { + clean_attrs(&mut s.attrs, true); + } + } + externs.push((docs_of(&fm.attrs), render(Item::ForeignMod(fm)))); + } + Item::Mod(m) => { + if let Some((_, sub)) = &m.content { + submodules.push((format!("{heading} — `mod {}`", m.ident), docs_of(&m.attrs), sub.clone())); + } + } + _ => {} + } + } + + let empty = reexports.is_empty() + && consts.is_empty() + && macros.is_empty() + && types.is_empty() + && inherent.is_empty() + && trait_impls.is_empty() + && funcs.is_empty() + && externs.is_empty(); + if !(empty && module_docs.is_empty()) { + writeln!(out, "## {heading}\n").unwrap(); + if !module_docs.is_empty() { + writeln!(out, "{module_docs}\n").unwrap(); + } + if !reexports.is_empty() { + out.push_str("### Re-exports\n\n"); + code_block(out, &reexports.concat()); + } + if !consts.is_empty() { + out.push_str("### Constants & statics\n\n"); + documented_blocks(out, &consts); + } + if !macros.is_empty() { + out.push_str("### Macros\n\n"); + documented_blocks(out, ¯os); + } + if !types.is_empty() { + out.push_str("### Types\n\n"); + for (name, docs, code) in &types { + writeln!(out, "#### `{name}`\n").unwrap(); + prose(out, docs); + code_block(out, code); + if let Some(impls) = inherent.remove(name) { + documented_blocks(out, &impls); + } + } + } + if !inherent.is_empty() { + out.push_str("### Impl blocks (types defined elsewhere)\n\n"); + for (name, impls) in &inherent { + writeln!(out, "#### `{name}`\n").unwrap(); + documented_blocks(out, impls); + } + } + if !trait_impls.is_empty() { + out.push_str("### Trait implementations\n\n"); + code_block(out, &trait_impls.join("\n")); + } + if !funcs.is_empty() { + out.push_str("### Functions\n\n"); + documented_blocks(out, &funcs); + } + if !externs.is_empty() { + out.push_str("### Extern blocks\n\n"); + documented_blocks(out, &externs); + } + out.push_str("---\n\n"); + } + + for (heading, docs, items) in submodules { + document_items(out, &heading, &docs, &items); + } +} + +fn prose(out: &mut String, docs: &str) { + if !docs.is_empty() { + writeln!(out, "{docs}\n").unwrap(); + } +} + +fn code_block(out: &mut String, code: &str) { + writeln!(out, "```rust\n{}\n```\n", code.trim_end()).unwrap(); +} + +fn documented_blocks(out: &mut String, entries: &[(String, String)]) { + for (docs, code) in entries { + prose(out, docs); + code_block(out, code); + } +} + +/// Renders a single item as formatted Rust source. +fn render(item: Item) -> String { + prettyplease::unparse(&syn::File { shebang: None, attrs: Vec::new(), items: vec![item] }) +} + +/// Renders a const/static, eliding the initializer when it does not fit on one short line. +fn render_value(item: &Item) -> String { + let mut item = item.clone(); + match &mut item { + Item::Const(c) => clean_attrs(&mut c.attrs, false), + Item::Static(s) => clean_attrs(&mut s.attrs, false), + _ => unreachable!(), + } + let full = render(item.clone()); + if full.trim_end().lines().count() == 1 && full.len() <= 120 { + return full; + } + let placeholder: Expr = parse_quote!(__ELIDED__); + match &mut item { + Item::Const(c) => c.expr = Box::new(placeholder), + Item::Static(s) => s.expr = Box::new(placeholder), + _ => unreachable!(), + } + render(item).replace("__ELIDED__", "...") +} + +/// Renders a type definition with field/variant docs kept and default trait method bodies removed. +fn render_type(item: &Item) -> String { + let mut item = item.clone(); + match &mut item { + Item::Struct(s) => { + clean_attrs(&mut s.attrs, false); + s.fields.iter_mut().for_each(|f| clean_attrs(&mut f.attrs, true)); + } + Item::Enum(e) => { + clean_attrs(&mut e.attrs, false); + for v in &mut e.variants { + clean_attrs(&mut v.attrs, true); + v.fields.iter_mut().for_each(|f| clean_attrs(&mut f.attrs, true)); + } + } + Item::Union(u) => { + clean_attrs(&mut u.attrs, false); + u.fields.named.iter_mut().for_each(|f| clean_attrs(&mut f.attrs, true)); + } + Item::Type(t) => clean_attrs(&mut t.attrs, false), + Item::TraitAlias(t) => clean_attrs(&mut t.attrs, false), + Item::Trait(t) => { + clean_attrs(&mut t.attrs, false); + for ti in &mut t.items { + match ti { + TraitItem::Fn(f) => { + clean_attrs(&mut f.attrs, true); + f.default = None; + } + TraitItem::Const(c) => clean_attrs(&mut c.attrs, true), + TraitItem::Type(ty) => clean_attrs(&mut ty.attrs, true), + _ => {} + } + } + } + _ => unreachable!(), + } + render(item) +} + +/// Renders an inherent impl block with method signatures only. None if it has no items left. +fn render_inherent_impl(imp: &syn::ItemImpl) -> Option { + let mut imp = imp.clone(); + clean_attrs(&mut imp.attrs, false); + imp.items.retain(|ii| !is_test(impl_item_attrs(ii)) && !matches!(ii, ImplItem::Macro(_))); + if imp.items.is_empty() { + return None; + } + for ii in &mut imp.items { + match ii { + ImplItem::Fn(f) => { + clean_attrs(&mut f.attrs, true); + f.block = parse_quote!({}); + } + ImplItem::Const(c) => clean_attrs(&mut c.attrs, true), + ImplItem::Type(t) => clean_attrs(&mut t.attrs, true), + _ => {} + } + } + Some(strip_bodies(&render(Item::Impl(imp)))) +} + +/// Renders a trait impl as a one-line header, e.g. `unsafe impl Sync for Foo;`. +fn render_trait_impl(imp: &syn::ItemImpl) -> String { + let mut imp = imp.clone(); + clean_attrs(&mut imp.attrs, false); + imp.items.clear(); + let s = strip_bodies(&render(Item::Impl(imp))); + s.trim_end().trim_end_matches(';').to_string() + "\n" +} + +/// Replaces the empty `{}` bodies left after stripping with `;`. +fn strip_bodies(code: &str) -> String { + let mut lines: Vec = Vec::new(); + for line in code.lines() { + if line.trim() == "{}" { + if let Some(prev) = lines.last_mut() { + prev.push(';'); + continue; + } + } + match line.strip_suffix(" {}") { + Some(head) => lines.push(format!("{head};")), + None => lines.push(line.to_string()), + } + } + lines.join("\n") + "\n" +} + +/// Extracts the `///` / `//!` doc comment text from attributes. +fn docs_of(attrs: &[Attribute]) -> String { + let mut lines = Vec::new(); + for attr in attrs { + if !attr.path().is_ident("doc") { + continue; + } + if let Meta::NameValue(nv) = &attr.meta { + if let Expr::Lit(lit) = &nv.value { + if let Lit::Str(s) = &lit.lit { + let v = s.value(); + for line in v.lines() { + lines.push(line.strip_prefix(' ').unwrap_or(line).trim_end().to_string()); + } + } + } + } + } + lines.join("\n").trim().to_string() +} + +/// Drops attributes that are noise for a reader, optionally keeping doc comments. +fn clean_attrs(attrs: &mut Vec, keep_docs: bool) { + const KEEP: &[&str] = &["derive", "repr", "cfg", "no_mangle", "export_name", "link", "unsafe"]; + attrs.retain(|a| { + let p = a.path(); + (keep_docs && p.is_ident("doc")) || KEEP.iter().any(|k| p.is_ident(k)) + }); +} + +/// True for `#[test]` items and `#[cfg(test)]` items/modules. +fn is_test(attrs: &[Attribute]) -> bool { + attrs.iter().any(|a| { + a.path().is_ident("test") + || (a.path().is_ident("cfg") + && matches!(&a.meta, Meta::List(l) if l.tokens.to_string().trim() == "test")) + }) +} + +fn item_attrs(item: &Item) -> &[Attribute] { + match item { + Item::Const(i) => &i.attrs, + Item::Enum(i) => &i.attrs, + Item::ExternCrate(i) => &i.attrs, + Item::Fn(i) => &i.attrs, + Item::ForeignMod(i) => &i.attrs, + Item::Impl(i) => &i.attrs, + Item::Macro(i) => &i.attrs, + Item::Mod(i) => &i.attrs, + Item::Static(i) => &i.attrs, + Item::Struct(i) => &i.attrs, + Item::Trait(i) => &i.attrs, + Item::TraitAlias(i) => &i.attrs, + Item::Type(i) => &i.attrs, + Item::Union(i) => &i.attrs, + Item::Use(i) => &i.attrs, + _ => &[], + } +} + +fn impl_item_attrs(item: &ImplItem) -> &[Attribute] { + match item { + ImplItem::Const(i) => &i.attrs, + ImplItem::Fn(i) => &i.attrs, + ImplItem::Type(i) => &i.attrs, + ImplItem::Macro(i) => &i.attrs, + _ => &[], + } +} + +fn item_name(item: &Item) -> String { + match item { + Item::Struct(i) => i.ident.to_string(), + Item::Enum(i) => i.ident.to_string(), + Item::Union(i) => i.ident.to_string(), + Item::Type(i) => i.ident.to_string(), + Item::Trait(i) => i.ident.to_string(), + Item::TraitAlias(i) => i.ident.to_string(), + _ => unreachable!(), + } +} + +/// The last path segment of an impl's self type, used to attach impls to their type. +fn type_name(ty: &Type) -> String { + match ty { + Type::Path(p) => p.path.segments.last().map(|s| s.ident.to_string()).unwrap_or_default(), + other => quote::quote!(#other).to_string(), + } +} + +fn fatal(msg: &str) -> ! { + eprintln!("rustdoc-gen: {msg}"); + std::process::exit(1) +} diff --git a/wdk/src/ioqueue.rs b/wdk/src/ioqueue.rs index c483619..2e0178d 100644 --- a/wdk/src/ioqueue.rs +++ b/wdk/src/ioqueue.rs @@ -100,6 +100,14 @@ impl IOQueue { } } + /// Mirror of the host mock's blocking constructor. The kernel queue already + /// blocks in `wait_and_pop` (NULL timeout), so this is just an alias for + /// `new`; the parameter exists so `Device::new_with_blocking_reads` can name + /// one constructor across both builds. + pub fn new_blocking() -> Self { + Self::new() + } + /// Pushes new entry of any type. pub fn push(&self, entry: T) -> Result<(), Status> { let kqueue = self.kernel_queue.get(); diff --git a/wdk/src/lib.rs b/wdk/src/lib.rs index 1abdbe4..149052a 100644 --- a/wdk/src/lib.rs +++ b/wdk/src/lib.rs @@ -19,6 +19,17 @@ pub mod utils; #[allow(dead_code)] pub mod ffi; +/// Re-exports of the kernel / Win32 types the driver's entry plumbing names +/// (`DriverEntry`, the IRP dispatch handlers). The driver refers to them through +/// `wdk::kernel_types::*` so the host mock can provide API-compatible stand-ins +/// under the same path, letting the same `entry.rs` compile in both builds. +pub mod kernel_types { + pub use windows_sys::Wdk::Foundation::{DEVICE_OBJECT, DRIVER_OBJECT, IRP}; + pub use windows_sys::Win32::Foundation::{ + NTSTATUS, STATUS_FAILED_DRIVER_ENTRY, STATUS_SUCCESS, UNICODE_STRING, + }; +} + // Needed by the linker for legacy reasons. Not important for rust. #[cfg(not(test))] #[export_name = "_fltused"]