How-to guides for contributors: porting a plugin, proving it matches python, measuring it, and
working without exhausting the machine. Every change keeps four rules: no dependencies
([dependencies] stays empty), stdout byte-identical to python volatility3, no panic, hang or
unbounded memory on a malformed image, and no code copied from vol-rs.
Applies to fastvol 0.1.0 and volatility3 2.28.2.
| File | What it gives you |
|---|---|
| src/objects/README-API.md | python to Rust cheat-sheet for layers, objects, symbols, scanning and plugin helpers |
src/plugins/mod.rs |
The Plugin trait, Requirement, Config, the registry |
src/renderers/mod.rs |
Column, ColType, Value, RowSink |
src/plugins/windows/pslist.rs, kpcrs.rs |
Small, complete plugins to copy from |
The scripts in bench/scripts/ find the repository from their own location. The data they read
is not in git and goes in the checkout: a linked worktree uses the main checkout's, found through
git, and FASTVOL_DATA=<DIR> points the scripts at another directory. They expect, in that
directory:
| Path | Content |
|---|---|
volatility3/ |
A checkout of python volatility3 2.28.2 |
bench/venv/ |
CPython 3.14 with capstone, yara-python, pycryptodome and pefile |
bench/ref/ |
Reference outputs: py/ and pyargs/ for the main Windows image, win1809/, linux/<image>/, mac/<image>/ |
testdata/ |
Test images and ISF files, described in testdata/README.md |
testdata/images/windows/memory-dirty.raw |
The main Windows test image, x64 build 22000, 5 GiB; a symlink is fine |
The directory the scripts use, $DATA in the commands below:
DATA=${FASTVOL_DATA:-$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")}The benchmarks against vol-rs run vol-rs from PATH, or the binary in VOLRS. The python
version matters: references were made with CPython 3.14.7, and CPython 3.13 prints some values
differently.
The machine is shared by many agents and services, and systemd-oomd kills the entire terminal session, not only the process that used the memory. These rules are mandatory:
- Build and test only through
bench/scripts/cargo.sh. It allows at most 5 cargo runs on the machine, each capped at 6 GiB, and.cargo/config.tomllimits each build to 6 jobs. - Run anything heavy through
bench/scripts/limit.sh [-m <MEM>] <COMMAND>: python volatility3, benchmarks, and tests or tools that touch the multi-GB images. It starts the command in its own systemd scope with a hard memory cap, 8G by default, and allows 4 such jobs at a time. - Run at most one python volatility3 process per agent. The reference generators in
bench/scripts/py_refs*.shandpy_args_refs.shrun 2 in parallel by default, bounded bylimit.sh; setPAR=1when other agents are working, or a higherPARon an idle machine. - Never read a memory image into a
Vec. Map it or read ranges. /tmpis in RAM. Put anything over about 50 MB intestdata/scratch/.
bench/scripts/cargo.sh build --profile fast # target/fast/fvol, for iterating
bench/scripts/cargo.sh build --release # target/release/fvol, for timing
bench/scripts/cargo.sh test --profile fast # all unit tests
bench/scripts/cargo.sh test --profile fast pslist # tests whose name contains "pslist"Unit tests must stay fast. Tests that need a large image or a reference library are marked
#[ignore] and read their inputs from environment variables named in their doc comments, such
as FASTVOL_BENCH_IMG. Run one with
bench/scripts/cargo.sh test --profile fast <NAME> -- --ignored.
-
Read the python plugin in
volatility3/volatility3/framework/plugins/, and the extension classes and helpers it calls. Some plugins also live involatility3/volatility3/plugins/. -
Create the file under
src/plugins/<os>/, mirroring python's layout, for examplesrc/plugins/windows/malware/orsrc/plugins/linux/tracing/. Start it with the derivation header that every ported file carries://! windows.kpcrs.KPCRs (python `plugins/windows/kpcrs.py`): the `_KPCR` of every processor. //! //! Derived from Volatility 3 (Volatility Software License 1.0).
-
Implement
Plugin. The name is python's full dotted name, the description is the first paragraph of the class docstring, andrequirements()lists only the command-line options, in python's order, with python's names, help texts and defaults:pub struct PsList; impl Plugin for PsList { fn name(&self) -> &'static str { "windows.pslist.PsList" } fn description(&self) -> &'static str { "Lists the processes present in a particular windows memory image." } fn requirements(&self) -> Vec<Requirement> { vec![ Requirement::flag("physical", "Display physical offsets instead of virtual"), Requirement::new("pid", "Process ID to include (all other processes are excluded)", ReqKind::ListInt).optional(), Requirement::flag("dump", "Extract listed processes"), ] } fn run(&self, ctx: &Context, cfg: &Config, out: &mut dyn RowSink) -> Result<()> { let k = ctx.windows_kernel()?; out.begin(vec![Column::new("PID", ColType::Int) /* ... */])?; // ... Ok(()) } }
Kernel, layers and symbol tables come from the
Contextwhen you ask for them; python's hidden requirements, such as the kernel module, are not declared. python's complete requirement lists, hidden ones included, are data insrc/plugins/pyreqs.tsv, from which--save-configandtimeliner --record-configwrite python's configuration. Regenerate it when python gains a plugin; unit tests check that every registered plugin is in it and that the options you declare have python's kinds and defaults:bench/scripts/limit.sh -m 4G bench/venv/bin/python bench/scripts/py_plugin_reqs.py > src/plugins/pyreqs.tsv -
Register it with one line in the group's
register()insrc/plugins/<os>/mod.rs, and apub modline. A deprecated python alias, such aswindows.malfind.Malfindforwindows.malware.malfind.Malfind, is registered too and shares the implementation. -
Port the behaviour, including failures. Where python catches
InvalidAddressExceptionand skips, skip; where it rendersUnreadableValue, emitValue::Unreadable; where python would raise, return the error after the rows python printed. Never panic on garbage memory. The API cheat-sheet shows the Rust form of each python idiom. -
Write files like python. Get output files from
ctx.create_output_file(name), which applies python's naming, de-duplication and permissions. -
Add
timeline()if the python class implementsTimeLinerInterface. -
Make it fast. Use the scanning primitives and
util::parhelpers, which keep python's order, resolve struct members once withFieldin hot loops, and avoid per-row allocations. A two-phase scanner can join the scan cache by implementingcache_query(); see the scan cache section of the API cheat-sheet for what that description must guarantee.
Put helpers that other plugins will need in src/symbols/<os>/ or in a helper module next to the
plugin, with doc comments, and add them to src/objects/README-API.md.
Compare one plugin on the main Windows image with its reference:
bench/scripts/compare.sh -b $PWD/target/fast/fvol windows.pslist.PsListOK windows.pslist.PsList 3ms
On a difference it prints DIFF, the paths of both outputs and the first lines of the diff.
Arguments after the plugin name are passed through:
bench/scripts/compare.sh -b $PWD/target/fast/fvol windows.pslist.PsList --pid 4The stored references were made without arguments, so for options, run python yourself and diff the outputs:
bench/scripts/limit.sh -m 8G bench/venv/bin/python volatility3/vol.py \
-q -f <IMAGE> -o <DIR> windows.pslist.PsList --pid 4 --dump > py.txt
target/fast/fvol -q -f <IMAGE> -o <DIR2> windows.pslist.PsList --pid 4 --dump > rs.txt
cmp py.txt rs.txt && diff -r <DIR> <DIR2>To compare on another image, set IMG and REF, and pass symbol directories in GLOBAL_ARGS:
IMG=$DATA/testdata/images/linux/rsvol-noble-6.8.0-139.elf \
REF=$DATA/bench/ref/linux/rsvol-noble-6.8.0-139-elf/linux.pslist.PsList.txt \
GLOBAL_ARGS="-s $DATA/testdata/symbols" \
bench/scripts/compare.sh -b $PWD/target/fast/fvol linux.pslist.PsListPlugins whose python output order is random from run to run are listed in
bench/nondeterministic.txt; compare.sh accepts them when the sorted outputs match and prints
OK~. The Symbols line of windows.info.Info names the kernel ISF python's identifier cache
lists last, which depends on the cache's history when the same ISF is in several symbol
directories; when only that line differs from the reference, compare.sh checks it against a
live python run (NO_LIVE_SYMBOLS=1 turns that off).
To check --save-config for many plugins at once, bench/scripts/py_save_configs.py runs
python's command line for each case of a list (one plugin and its options per line) and stops
each plugin right after python wrote the configuration, so only kernel discovery is paid for.
Compare its <case>.json files with fastvol's --save-config output for the same cases.
Before a merge, run every reference with a private cache, first cold and then warm:
bench/scripts/gates.sh $PWD/target/release/fvolwindows cold: == OK=98 DIFF=0 MISSING=0
windows warm: == OK=98 DIFF=0 MISSING=0
nix cold: == TOTAL OK=275 DIFF=0 MISSING=0
nix warm: == TOTAL OK=275 DIFF=0 MISSING=0
gates.sh runs check_all.sh, the 98 no-argument plugins on the main Windows image, and
check_nix.sh, the no-argument plugins on the four Linux images and the macOS image. Outputs and
the cache go to testdata/scratch/gates/ unless you pass another directory as the second
argument. Each script also runs on its own:
bench/scripts/check_all.sh -b $PWD/target/release/fvol
bench/scripts/check_nix.sh -b $PWD/target/release/fvol linuxThe Windows 10 1809 image is not part of gates.sh. Check it with a loop over the same plugin
list:
while read -r p; do
IMG=$DATA/testdata/images/windows/rsvol-win10-x64-17763-imagery.raw \
REF=$DATA/bench/ref/win1809/$p.txt \
OUTDIR=$DATA/testdata/scratch/out1809 \
bench/scripts/compare.sh -b $PWD/target/release/fvol "$p"
done < bench/win_noarg.txtOther checks:
| Area | Command |
|---|---|
| Web UI against the CLI | bench/web/e2e.sh |
| CLI fixtures from python | bench/venv/bin/python bench/scripts/cli_fixtures.py, then cargo.sh test --profile fast cli |
| Renderer fixtures from python | bench/venv/bin/python bench/scripts/render_fixtures.py, then cargo.sh test --profile fast render |
| YARA engine vs yara-python | bench/scripts/limit.sh -m 4G bench/venv/bin/python bench/scripts/yara_diff.py |
| Disassembler vs capstone | bench/scripts/disasm_diff.py gen once, then bench/scripts/disasm_check_all.sh |
| Options x renderers sweep | bench/venv/bin/python bench/scripts/sweep.py gen, then sweep.py py (python, cached), sweep.py rs -b <BIN> and sweep.py report -v |
To make new references, use bench/scripts/py_refs.sh for the Windows image,
py_refs_linux.sh and py_refs_mac.sh with IMG and NAME set for the others, and
py_args_refs.sh for the argument cases in bench/args_cases.txt.
No plugin may panic, hang, exhaust memory or run away on a malformed
image; it must degrade the way python does (skip the bad object, render UnreadableValue, or, where
python itself raises an uncaught exception, mirror python's exit code and partial output). The fuzz
driver bench/scripts/fuzz_images.py (standard-library python, helper modules fuzz_geom.py,
fuzz_targets.py, fuzz_mutate.py, fuzz_cmds.py) proves and enforces this.
It builds corrupted copies of the real test images without copying them: cp --reflink=auto
shares every extent with the base on btrfs, then targeted in-place writes, hole punches and
truncation cost only the changed blocks (about 10 MB per mutant of a 2 GB image). It corrupts the
structures that matter — page-table entries, kernel objects (_EPROCESS/task_struct/hives/
drivers/files/...) and their linked-list pointers (made cyclic), kernel symbols, container headers
(ELF/LiME/crash/...), plus generic damage (zeroed/garbage pages, huge counts, truncation) — locating
them by parsing the image geometry, walking the page tables and reading the ISF. It then runs every
plugin of the image's OS on each mutant through limit.sh (memory cap -m, default 4G) with a
per-run timeout of max(--min-timeout, --mult × the clean run time), and classifies each run:
| Class | Meaning | Bug? |
|---|---|---|
OK |
exit 0 | no |
ERROR |
other clean exit (a python-style error/traceback) | no |
PYRAISE |
a panic! whose message names a python exception — fastvol's intended emulation of python's uncaught raise; the CLI catches it and exits 1 exactly as python does (stderr text does not affect parity) |
no |
PANIC |
a Rust-internal panic (index/unwrap/overflow/slice/unreachable/stack overflow) | yes |
HANG |
killed by the per-run timeout | yes |
OOM |
killed by the memory cgroup cap | yes |
SIGNAL |
died by another signal | yes |
RUNAWAY |
stdout exceeded the output cap | yes |
Distinguishing PYRAISE from PANIC matters: fastvol deliberately emulates python's uncaught
exceptions by panicking with the exception's message, so panicked at in stderr is not by itself a
bug. Only a Rust-internal fault (which means the plugin diverged from python instead of skipping the
object) or a hang/OOM/signal/runaway is a bug.
Workflow (all scratch lives on disk under testdata/scratch/fuzz/, never /tmp, and clean mutants
are deleted immediately — only mutants that produced a bug are kept, with a .mutlog.json that
reproduces them):
# 0. a release binary the driver will use (default: testdata/scratch/fuzz/bin/vol-base)
cp target/release/fvol testdata/scratch/fuzz/bin/vol-base
# 1. find structures to corrupt in a clean image (runs a few clean plugins, walks page tables)
bench/scripts/fuzz_images.py targets win1809 # -> testdata/scratch/fuzz/targets/win1809.json
# 2. record clean-image times + output hashes (used for per-run timeouts)
bench/scripts/fuzz_images.py baseline win1809 # -> testdata/scratch/fuzz/baseline/win1809.json
# 3. campaign: N mutants x every plugin case; keeps only mutants that produced a bug
bench/scripts/fuzz_images.py campaign win1809 --mutants 30 --par 2 --mem 4G
# 4. summarise, and reproduce/inspect one kept mutant
bench/scripts/fuzz_images.py report testdata/scratch/fuzz/results/win1809.jsonl
bench/scripts/fuzz_images.py rerun testdata/scratch/fuzz/kept/win1809-s7.img.mutlog.json --keep
# 5. confirm we degrade the SAME way as python on a sample (python via limit.sh, <=2 at a time)
bench/scripts/fuzz_images.py pycompare testdata/scratch/fuzz/results/win1809.jsonl --limit 20
# 6. the small synthetic container fixtures (tests/fixtures/containers)
bench/scripts/fuzz_images.py containersBase image names for targets/baseline/campaign: win10 (the 5 GiB main Windows image),
win1809, noble-elf, noble-lime, jammy-elf, jammy-lime, mac. Bugs found by a campaign are
reduced to small crafted-input unit tests next to the code they fix, so they never regress. Two the
driver has found so far, both cases where python's own lazy enumeration would run forever on the
corrupted count, so fastvol bounds it and stops instead of hanging/OOMing:
- a corrupted
kallsyms_num_symsmadelinux.kallsyms.Kallsymsloop up to ~1.8 billion times (python does the same unboundedrange(num_syms)); guarded insrc/symbols/linux/kallsyms.rs(MAX_PLAUSIBLE_SYMS), testtests_robustness::huge_num_syms_does_not_hang. - corrupted page tables (a garbage page read as a page table) made the translation walk enumerate a
practically unbounded mapped space, which every scanning plugin and
windows.memmapcollected into memory and OOMed on (python streams it lazily and hangs instead); bounded insrc/layers/scan.rs(MAX_SCAN_CHUNKS, the chunk list) andsrc/layers/intel.rs(MAX_MAPPING_RUNS, onemapping_with_targetscall), testscan::tests::scan_chunk_list_is_bounded_on_corrupt_layer.
Run the driver through limit.sh yourself only when you invoke a subcommand that does not already
wrap its own image work — the campaign, baseline and pycompare all wrap every image run internally.
For quick measurements during development:
| Question | Command |
|---|---|
| Faster than vol-rs on every plugin? | bench/scripts/bench_vs_volrs.sh [-b <BIN>] [-n <RUNS>] [<LIST>] |
| Cold and warm start | bench/scripts/coldbench.py |
| Fixed cost of a CLI invocation | bench/scripts/cli_startup.sh <BIN> [<N>] |
| Library throughput vs the C references | bench/scripts/refbench.sh |
| Where the time goes in one run | FASTVOL_TRACE=1 target/release/fvol ... |
Use FASTVOL_CACHE=<EMPTY_DIR> to measure a cold run without touching your own cache. Always
time release builds, through limit.sh, and keep the numbers before and after each
optimization. A cold run replays python's identifier cache, so its time depends on whether that
cache already covers the -s directories (milliseconds) or not (every ISF is read, like python's
own update): compare binaries interleaved in one coldbench.py invocation and pin the cache with
--py-cache (its docstring has the details).
The README's numbers and chart come from bench/scripts/bench_local.py, which times fastvol,
vol-rs and python on one machine (its docstring describes the method). After a new run, write the
report to bench/local/, redraw the chart from its summary.json, and then copy the figures into
the README's Performance section:
python3 bench/scripts/bench_local.py <RUN_DIR>
python3 bench/scripts/bench_local_report.py <RUN_DIR>
python3 docs/assets/build.py --png-
Work on your own branch or worktree and commit often, with conventional commit messages such as
feat(windows): port psscan. -
Change only the files you own, plus one-line registrations.
-
Keep
[dependencies]inCargo.tomlempty. -
Before handing over, merge the latest main and make sure the release build, the unit tests and the parity gates pass:
main=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)") # the main checkout git fetch "$main" main && git merge FETCH_HEAD bench/scripts/cargo.sh build --release bench/scripts/cargo.sh test --profile fast bench/scripts/gates.sh $PWD/target/release/fvol
GitHub Actions (.github/workflows/, also runnable by Forgejo Actions):
| Workflow | Runs on | What it does |
|---|---|---|
ci.yml |
every push to any branch, pull requests | warning-free build, unit tests and a smoke test of fvol on x86-64 (stable Rust and the minimum version, 1.95) and on a native arm64 runner; checks that docs/assets matches python3 docs/assets/build.py |
release.yml |
tags vX.Y.Z (or by hand, as a dry run) |
runs ci.yml, builds static fat-LTO binaries for any x86-64 CPU, for x86-64-v3 (AVX2) and for any arm64 CPU, and publishes them as a GitHub release with SHA-256 sums and the commits since the previous tag |
CI cannot run the parity gates: they need GBs of memory images and python reference outputs that
are not in the repository. Tests that use those files are #[ignore]d or skip themselves, so the
unit tests pass on a clean checkout. Run the gates locally before a release.
To release:
bench/scripts/gates.sh $PWD/target/release/fvol # the parity gates, locally
bench/scripts/release.sh 0.2.0 # unit tests, version bump, commit, tag vX.Y.Z
git push origin main v0.2.0 # starts release.ymlrelease.sh refuses a dirty tree, a branch other than main, an existing tag or a version that
is not greater than the current one. It sets the version in Cargo.toml, Cargo.lock and the
"Applies to fastvol X" line of every doc. It does not push.