footprint · a guide for whoever continues it

Owning the codebase

A reading path through the code. The README says what footprint does and how to run it, and .stuff/MODEL.md says why it is built this way. This page shows where each thing lives, how one command moves through the crates, which rules hold the case together, and how to change it without breaking them.

Every surface stays brief. More depth is one click away: ▸ expandable blocks open in place, cards marked open ↗ open a panel, and any teal mono link opens that file's declarations from the reference atlas. Press / to filter chapters, Esc to close a panel.

0source files
0lines, no tests
0types
0functions
0tables
0schema version
0tests
Part I · Orientation

01How to read this guide

Three sittings, with the repository open beside the page.

①Shape and runtime

Chapters 02–10: the crates and which way they depend, the words the code uses, what one command does from main to the terminal, what runs in parallel, what the case database holds, and the CLI and TUI surfaces.

②The product and its riskiest part

Chapters 11–25: how collectors discover and read evidence, the time model, browsers and system logs, availability and fallbacks, state and diff, safety. Then the case itself: identities, dedup, outside tools, verdicts, correlation, staging and export.

③Working on it

Chapters 26–37: options, errors, tests, recipes, invariants, the plan, then the atlas, the two maps and SQLite, the library the whole tool leans on. Chapter 38 asks twelve questions to check yourself.

✓ The fastest way in

Two functions map everything else. run_collect is the composition root: it builds the environment, picks the collectors from registry, discovers sources, runs them on workers and writes the case. main in the CLI is the dispatcher: one match sends each subcommand to the function that answers it. Read those two, and every other file is a detail of one of their lines.

02The shape of the thing

One Rust binary over one SQLite file, in nine crates that only depend downward. Click a layer for what lives there.

crates/cli · crates/tui
The footprint binary: clap commands and the interactive terminal UI. Neither touches SQL.
▼ calls
Read model and runner — crates/query
Everything a frontend reads (timeline, detail, state, diff, sources, coverage) and run_collect, the composition root.
▼ drives
Domain — crates/collect · crates/correlate · crates/render
Collectors that read evidence; passes that link it; formats that print or export it. Collectors never touch the store.
▼ write and read
Case store — crates/store
The SQLite case: schema v2, ingest with dedup, filters in SQL, links, state, IOCs.
Shared vocabulary — crates/core · test data — crates/fixtures
Records, kinds, the time model and the deny-list, usable everywhere; synthetic evidence for tests only.

Where the lines are

Non-test lines per crate. Click a bar to open that crate in the atlas.

What each crate may depend on Cargo.toml
CrateDepends on (workspace)Notable outside crates
footprint (cli)query, tui, render, correlate, store, coreclap
fp-tuiquery, render, store, coreratatui, crossterm
fp-querycollect, correlate, store, corechrono, libc (signals), tempfile
fp-collectcorerusqlite, regex, flate2, quick-xml, sha2, libc
fp-correlatestore, corerusqlite, sha2
fp-rendercorrelate, store, coresha2, chrono
fp-storecorerusqlite (bundled-full)
fp-core—serde, chrono, thiserror
⬇Dependencies point down

cli → tui → query → collect, correlate, render → store → core. A collector that wanted the store would have to go through the runner; that is the point. The single writer (chapter 06) depends on it.

🧪Where the tests live

Unit tests sit at the bottom of each file in mod tests. Scenario tests that run the real binary live in crates/cli/tests; the read model and the TUI have their own tests/. Every test builds its evidence in a temp dir; the scenario tests use fp-fixtures for a whole synthetic machine.

03Vocabulary

The words the code uses, each with the symbol that carries it. Click one for what it is, where it is stored and the rule that matters.

Part II · Runtime

04Boot, run, shut down

A command starts, does one thing and exits. Only collect has a real lifecycle, and run_collect is where it lives.

What run_collect does

Click a step for its detail.

1 · argumentsWindow and collectorsparse times, filter the registry
2 · caseEnv, case, run rowopen 0600, arm signals, temp dir
3 · discoverSource rowssequential, per user where needed
4 · collectWorkers + one writerparallel reads, single ingest
5 · finishCorrelate, close the runsix passes, errors recorded

Passes that run after every collect

footprint has no scheduler and never runs in the background. The nearest thing to periodic jobs is this fixed list, run once at the end of each collect (and the IOC pass again after ioc add and ioc import).

PassWhenDoesFile
chainsevery collectlinks redirects and navigations inside one browser profilechains.rs
downloadsevery collectstats and hashes files the downloads point at (deny-listed files never hashed)downloads.rs
installevery collectreads a .deb's identity with the host's dpkg-deb -finstall.rs
anchoringevery collectgives untimed shell lines a derived timeanchoring.rs
permissionsevery collectjoins visits and site permissionspermissions.rs
iocsevery collect, ioc add, ioc importdeletes and rebuilds every ioc_match linkioc.rs

Run and shutdown

▶Run

The CLI's collect spawns run_collect on a thread and prints errors and warnings as CollectEvents arrive, then the one-line summary: new events, already known, states, artifacts, sources, failures. The TUI starts the same function from its Collect view.

■Shutdown

Ctrl-C or SIGTERM sets a flag (on_signal). Workers skip every job not yet started, the main thread records them as not collected: interrupted, correlation still runs, and the temp dir is removed. The guard restores the previous handlers when the run ends.

Why the order matters collect

Each step produces what the next one needs. The source rows are inserted during discovery, before any worker exists, so every instance already has the id its staging directory is named after (src-<id>), and workers never touch the store. The signal handler is armed only after the case is open, so a Ctrl-C during argument parsing behaves like any other program. Correlation runs after the last ingest because its passes join across sources: a download link needs the file's stat and the install log at once. finish_run comes last so a run without a finish time means the process died mid-way.

⚠ The second Ctrl-C skips the cleanup

A second signal calls _exit(130) from the handler, because nothing else is async-signal-safe. Destructors don't run, so the footprint-* staging directory under the temp dir stays behind, with copies of browser history in it. It is 0700 and yours, but delete it by hand. One Ctrl-C always cleans up.

05One command's run

footprint list --since 20:40 --kind web.*, from argv to the terminal. Click a step for its code.

main.rsParseclap, then --tz
main.rs · schema.rsOpenan existing case only
query/time.rsFiltertimes to UTC µs
timeline.rs · schema.rsQueryevery filter in SQL
main.rs · renderPrintzone, sanitize, format

The dispatch table

SubcommandHandlerReads or writes through
collectcollectrun_collect on a thread, progress over a channel
sourcessourcesfp_query::sources, coverage
listlisttimeline, then print_rows or print_grouped
showshowevent_detail
searchsearchfp_query::search (FTS5)
tracetracetrace_tree, render_trace
state, diffstate, diffstate_records, diff_runs
iociocStore::insert_ioc, correlate_iocs, check_iocs
exportexportexport_case
explainexplainfp_query::explain (no case needed)
tuifp_tui::runall of the above through App
◆ The quiet rules every command keeps

A read command never creates a case. A case of another schema version is refused. Every evidence string reaches the terminal through sanitize. Times are compared in UTC µs and only rendered in a zone. --group-by lifts the limit until after grouping, so --limit 10 means ten groups.

Read the real dispatch, trimmed main.rs

06Concurrency model

Readers in parallel, one writer. Collectors never share state and never see the store.

main threaddiscovery · source rows workers1–8 threads · one job each collect_only · catch_unwind a Sink per instance staging src-<id>/ (0700) checks INTERRUPTEDbefore each job main threadthe single writerstamp owner · ingesta transaction per sourcethen correlate progressCollectEvent channel→ CLI lines · TUI view case.dbSQLite, 0600busy_timeout 5 sa TUI may read meanwhile
✍One writer

Only the main thread holds the Store. Workers return a Sink; the main thread ingests it in one transaction.

⚑The interrupt flag

An AtomicBool the signal handler sets; workers read it before each job.

🛡A panic is one source's error

catch_unwind around every collect: hostile evidence can crash a parser, not the run.

⏳SQLite's own lock

Two processes on one case wait up to five seconds for each other instead of failing.

What may run at the same time on one case? reasoning aid
ActivitySerialised by
Two collectors reading two sourcesnothing needed: separate Sinks, separate staging dirs
Two profiles of one browserseparate src-<id> dirs, so History-wal files never mix
Ingest of two sourcesthe main thread: one at a time
A TUI reading while a CLI collect writesSQLite's lock, with a 5 s busy timeout
Two collects on one caseSQLite's lock per transaction; the dedup key keeps an event from being stored twice, and each run has its own rows
Correlation and a readerSQLite's lock; each pass inserts its links in one transaction (anchoring's time updates are single statements)

07State: the case and what lives in memory

Everything lasting is in one SQLite file. Everything else lives for one run and is thrown away.

The 8 tables

Seven tables and the FTS5 index over events. Click a group for every field, read from the code.

The same tables as an interactive map: chapter 33.

#Versions, not migrations

SCHEMA_VERSION is 2. A case of another version is refused with "start a new case". A change of shape bumps the number; there is no in-place upgrade.

🔒The case is private

Created 0600 before SQLite opens it; under sudo it is chowned to SUDO_UID. It holds history, so it never leaves the machine.

⏱Numbers and units

Event times are i64 UTC µs; file times in artifacts are ns; sizes are bytes, shown in IEC units (MiB). JSON columns end in _json.

What lives only for one run

FamilyHoldsFile
staging dir footprint-*/src-<id>/copies of live databases with their -wal/-journal, opened read-onlyutils.rs
Sink per instanceevents, states, artifacts and warnings until the writer ingests themcollector.rs
PkgVerifier cachesthe root's dpkg file index (built once), one verify report per packagepersist.rs
INTERRUPTEDthe signal flag workers readinterrupt.rs
TUI Appthe loaded page of the timeline, filters, the selected view, collect progressapp.rs
Lose the staging dir entirely — what happens? failure drill

Nothing that lasts. The copies are rebuilt on the next run from the originals, which were never touched. A collector whose copy vanished mid-read fails its instance with the I/O error; the source is stored as error: … and the run goes on. Lose the case file instead and everything is gone except the evidence itself: a new collect rebuilds the timeline, but state history (and so diff) starts over.

Part III · The interface layer

08Patterns you'll reuse

Six shapes recur in every command and view. Copy them; don't invent a seventh.

Rule: evidence is hostile, so no field from the case reaches a terminal without sanitize. It drops ANSI escape sequences, control characters and the bidi overrides that make evil‮fdp.exe display as evilexe.pdf. The CLI wraps it as clean; the TUI truncates and sanitizes in truncate.

Rule: every listing offers --format table|jsonl|csv. A table is for eyes, JSON Lines for jq, CSV for spreadsheets, where each cell goes through escape_csv, which also defuses cells that start like a formula.

Rule: store and compare in UTC µs; render with format_time and the Tz from the global --tz. Never format a time any other way: an order_only event must print (untimed), not 1970.

Rule: long work reports through CollectEvent on a channel; the frontend decides what to show. The CLI prints errors and warnings to stderr and the summary to stdout; the TUI turns the same events into its Collect view.

Rule: a view of an unbounded table pages with timeline_page: ask for one row more than the page, and the extra row says whether there are more. Offsets are pushed into SQL, so a page is always full.

Rule: an action that fails in the TUI becomes the status line; it never ends the session. Every key handler returns Result, and the event loop catches it. A bad filter time is caught earlier still, in FilterDraft::apply, with the bad text quoted back.

09Commands, views and the registry

16 commands and subcommands, and eight TUI views over the same read model. Click a card for its flow and files.

⬇ collect

discover and read sources into the case

☰ sources

status per source, and coverage

◷ list · search

the merged timeline and FTS5

🔎 show

one event, its provenance and links

⤳ trace

where a file or URL came from

▣ state · diff

how things were, and what changed

⚑ ioc · export

indicators, reports

▦ tui · explain

the interactive UI; scanner false positives

The TUI's keys, per view app.rs
WhereKeys
everywhere? help, q quit; 1–8 switch view, except from detail and trace (Esc first); none of these while a field is being edited
timeline↑ ↓ PgUp PgDn move (the next page loads at the end), Enter detail, r trace the row, i or / edit the filter bar, t local/UTC
detail↑ ↓ links, Enter jump to the linked event, r trace this event, Esc back
trace↑ ↓ rows, Enter open the event behind a row, Esc back
state, diff, ioc, collectk kind filter (state), e run ids (diff), Enter opens a hit (ioc), the collect form takes the window and starts the run

The exact bindings are in App::on_key and the per-view handlers beside it; draw_help lists them in the app.

The registry

Every routing name in the code, read from the atlas: subcommands (variants of a #[derive(Subcommand)] enum), collector kinds (the string each Collector::kind returns) and event kinds (EventKind::as_str). Filter it:

ValueFamilyDeclared byFileDoc or arguments

10A first investigation

What a new user does, in order, and what each step needs from the code.

1
Build: cargo build --release. The toolchain is pinned in rust-toolchain.toml; SQLite is bundled, so there is nothing else to install.
2
Collect a window: footprint collect --since -2d. The case ./footprint.db is created 0600; a second run adds only what is new.
3
See what answered: footprint sources --coverage. A gap names what was missing and what would have filled it (journal groups, root for /root).
4
Read the timeline: footprint list --since 20:40 --until 21:30 --kind web.*,pkg.*,priv.sudo, then show ID for one row's provenance.
5
Follow a file back: footprint trace ~/Downloads/x.deb: the page, the redirects, the download, the install, each edge with its confidence.
6
Mark what is bad: footprint ioc add domain evil.example; hits appear in every list. Then export --format html --redact to share.
Input rules worth knowing flags
  • Times (time::parse): 2026-10-16T20:40, 2026-10-16 20:40:05, 20:40 (today), 2026-10-16, -24h, +30m, -2w, yesterday, @1792172917. clap accepts a leading - for these flags.
  • Kinds: exact (pkg.install) or a family with a trailing * (web.*); comma-separated or repeated.
  • Sources: in collect, a kind or a family prefix (chromium, pkg, shell.history) or all; in list, also an app or app/profile (Brave/Default).
  • Hosts match their subdomains: --host zoom.us includes cdn.zoom.us, not notzoom.us.
  • Zones: --tz renders (local, UTC, +03:30); --assume-tz interprets logs that store local time. They are different things.
  • The case is -c PATH, global; read commands never create one.
Part IV · Collection

11Collectors: discover, then collect

24 collectors behind one small trait. Discovery is cheap and says what exists; collection reads one instance and may fail alone.

Envhomes · root discovercheap, no parsing SourceInstance+ availability collectone instance Sink present only others: their status is final
🔭discover

Find instances and set their availability. No parsing, no copying.

📥collect

Read one instance into the Sink; warn for what was skipped.

🧱new_event

The one constructor: kind, subject, summary, stamp, provenance.

🩺Probe

Tools by absolute path under the root, the distro, journal access.

👥Per user

Which collectors read one home, and how the owner is stamped.

📇The registry

Every collector, in the order discovery runs.

Which collector answers which question matrix
QuestionPrimaryFallbacks
What sites, and how did I get there?chromium.history, firefox.history—
Where did this file come from?browser downloads, dlmgr.xdmdlmgr.xattr, shell curl/wget, file times (fs.statx)
What was installed?pkg.dpkg, pkg.aptpkg.pacman, pkg.dnf, pkg.zypper, pkg.snap, pkg.flatpak
Who used root, who logged in?auth.journaldauth.log, auth.wtmp
What was typed?shell.history—
What was opened?desktop.recentdesktop.kde; desktop.kactivity and desktop.zeitgeist detect-only
What will run at boot or login?persist.snapshot (state)—

12The time model

Every source counts time its own way. One function turns them all into UTC µs, keeps the raw value, and treats an overflow as an error.

TimeFormatCountsUsed by
WebKitMicrosµs since 1601-01-01 UTCChromium History, Preferences
PRTimeµs since 1970 UTCFirefox places, permissions
UnixMillisms since 1970Firefox download endTime (modern)
JournaldRealtimeµs since 1970journald __REALTIME_TIMESTAMP
DotNetBinary100 ns ticks since year 1, kind in the top 2 bitsXDM downloads.db
UnixSeconds, BashHashEpoch, ZshExtendedseconds since 1970wtmp, bash #epoch, zsh : epoch:0;, fish
FileTimens since 1970statx, xattr downloads (UnixNanos is the same unit, unused today)
LocalNaive(fmt)wall-clock text, no zonedpkg.log, apt history, auth.log, flatpak
Rfc3339text with an offsetpacman, snap --abs-time, xbel, flatpak (some versions)
Noneno time: order_onlybash without timestamps, REPLs

A log that writes local wall time (2026-10-16 21:08:00) is converted with the offset the zone had at that date, not today's: resolve_local asks chrono's local zone for that instant. A time skipped by a DST jump has no instant and is an error; one repeated by a fall-back resolves to the earlier instant.

--assume-tz +03:30 (or UTC) overrides the host zone for a fixed offset, which is what you want for a disk image from another machine. The event's ts_tz_assumed says which was used.

TimePrecision says how far to trust the instant: exact (sub-second from the source), second (whole seconds, or derived by anchoring), minute, day, or order_only (no instant; ts_utc is 0 and the attrs carry the position in the file).

Timestamps come from evidence, so a crafted one may be anything. scale multiplies with checked_mul, epochs are subtracted with checked_sub, and an overflow is TimeError::OutOfRange, never a panic in debug or a wrap in release. File times from statx saturate (file_time_ns): ext4 can store dates past what i64 ns holds.

Worked example: one Chromium visit WebKit → UTC → screen
  1. visits.visit_time is 13436645597000000: µs since 1601-01-01.
  2. Minus WEBKIT_EPOCH_US (11 644 473 600 000 000) gives 1792171997000000 µs since 1970: 2026-10-16 17:33:17 UTC. That is ts_utc; ts_raw keeps the 1601 number and ts_raw_format says webkit_micros.
  3. footprint list --tz +03:30 prints 2026-10-16 21:03:17; --tz UTC prints 17:33:17. The stored value never changes.
  4. The window is pushed into the SQL the other way round: webkit_window adds the epoch to --since with saturating_add.

13Browsers and download managers

The main evidence: what was visited, how one page led to the next, what was downloaded, and what a site was allowed to do.

The copy is read; the original is named in the provenance, with the hash of what was read.

Chrome, Chromium, Brave, Edge, Vivaldi and Opera, with their beta and dev channels, native (~/.config), flatpak (~/.var/app/<id>/config) and snap roots (BROWSERS, SNAP_ROOTS). Every Default and Profile N is an instance (list_profiles).

  • Visits (collect_visits): the transition's qualifier bits decide web.redirect ([3xx] server, [client] client) or web.visit; from_visit is kept for chains.
  • Downloads (collect_downloads) with their URL chain; an empty target path means "not saved", and no artifact.
  • Searches from keyword search terms.
  • Preferences and Secure Preferences: real permission types only (PERMISSION_TYPES), changes and one-time uses as events, settings as state; extensions as state, with component extensions never reported as installs.

Firefox, Zen and LibreWolf, native, XDG (~/.config/mozilla), snap and flatpak (ROOTS). Profiles are named from profiles.ini (parse_profiles_ini): default-release, not the random directory prefix; a directory missing from the ini falls back to its prefix.

  • Visits from moz_historyvisits; visit types 5 and 6 are redirects.
  • Downloads are two moz_annos rows per place (destination URI and metadata), keyed by place_id so the locator is stable. endTime is ms in modern Firefox and µs in old ones; PRTIME_FLOOR tells them apart.
  • Permissions from permissions.sqlite, extensions from extensions.json. cookies.sqlite, logins.json and key4.db are on the deny-list.

XDM keeps every download in ~/.xdman/downloads.db, including the ones a browser handed over and never recorded. The schema is checked first (table_has_columns), the old Java XDM is Unsupported, and only the columns in COLUMNS are selected, because the same table stores site and proxy passwords. Times are .NET binary.

The schema peek opens the live file with mode=ro&immutable=1 (open_immutable), with the path percent-encoded for the URI.

When no browser recorded a download, the file may have: browsers and some tools set user.xdg.origin.url and referrer.url. XattrDownloadCollector reads them in the download directories from XDG_DOWNLOAD_DIR (download_dirs) and ~/Downloads.

uGet, Motrix/aria2, KGet and JDownloader are detected only (KNOWN_MANAGERS) and reported unsupported until a parser is verified on real data.

Why redirects matter more than visits the lure

The scenario the tool was built for is a link in an email that bounced through two redirectors to a fake download page. A redirect row carries both ends (from → to [3xx]) and its from_visit, so the chain pass (chapter 23) can link each hop to the one before it. trace walks those links backwards from the download to the first page, which is how "which email link started this?" gets an answer.

14System evidence: packages, privilege, shells

What the machine itself wrote down: what was installed, who used root, and what was typed.

The invoking user is before : , USER= is the target, and COMMAND= runs to the end of the line.

DpkgCollector reads dpkg.log and every rotation (.1, .2.gz, through with_rotations); one event per package action. AptCollector reads apt/history.log blocks: Start-Date (two spaces between date and time), Commandline, Requested-By, then one event per Install/Upgrade/Remove line, explicit packages first.

A line that won't parse is skipped and counted in one warning per file (warn_skipped).

pacman, dnf/rpm and zypper are logs, read like dpkg. snap and flatpak keep no log, so their own tools are run by absolute path with a fixed argv and the C locale (run_listing): snap changes --abs-time (the verb leads the summary, snap_change_kind) and flatpak history (syslog-style times without a year, parse_flatpak_time). A tool that fails is an error, never an empty result.

AuthCollector runs journalctl --output=json once with OR-ed matches (MATCHES): sudo, su, pkexec, the ssh daemon under both unit names, and systemd-logind. sudo lines become priv.sudo, pam_* lines auth.session, ssh logins auth.ssh, logind sessions auth.login (parse_logind_session, both message formats).

The window goes to journalctl as @epoch seconds and is then applied exactly; a non-UTF-8 message arrives as a byte array and is decoded lossily. Outside adm/systemd-journal you see only your own entries, and the source's note says so.

AuthLogCollector reads /var/log/auth.log* and secure* whenever they exist, with the same sudo and ssh parsers as the journal; it is what answers on systems without a journal. Where both exist, a sudo shows up twice, once per witness. Their timestamps have no year, so parse_log_timestamp rolls a date that would be in the future back a year.

WtmpCollector reads wtmp/btmp: fixed 384-byte glibc records, with the timestamp read as a 32- or 64-bit timeval (record_tv_sec).

ShellCollector knows bash, zsh, fish, and the python, psql, mysql, sqlite3 and node REPLs (HISTORIES), plus $HISTFILE and $ZDOTDIR.

  • bash: #epoch lines time the entry after them, which may span lines (parse_bash); without them every line is order_only.
  • zsh: read as bytes and unmetafied (unmetafy), \-continued lines joined.
  • fish: entries start at - cmd:, with when: and paths:.
  • libedit REPLs escape bytes as octal (\040).

Every command is redacted before it is stored (redact_secrets) and flagged when it looks dangerous (DANGEROUS: a download piped to a shell, base64 -d, eval, /dev/tcp, chmod +x, an inline interpreter, a typed curl/wget/git clone…). A typed download also names its URL: first_url fills subject_host, so --host and domain IOCs see it — the shell-history rung of the downloads fallback chain.

Desktop, filesystem and the rest smaller collectors
Part V · The rules around collection

15Availability, coverage and fallbacks

An app that isn't installed is a fact about the machine, not an error. Every source ends a run with one status, and coverage turns those statuses into an answer.

🧭Who sets which status

Discovery sets the first five; the runner sets partial, empty and error after collecting.

🗺Coverage

Six categories, each Covered, Partial or Gap, with the sources behind the answer.

🪜Fallback chains

When the primary source is missing, a weaker one answers, and says it is weaker.

Reading a coverage report example

A Gap in Privilege with the note "journald: user entries only (not in adm or systemd-journal group)" means sudo lines exist but weren't visible: run as root, or add the user to adm. A Partial in Browser history usually means the window was empty for a browser that is installed. An unsupported note is a parser the project hasn't verified yet, not a failure.

16State, persistence and diff

Events say what happened; state says how things are. Each run keeps a snapshot, and diff shows what changed between two.

SystemPersistSnapshot records, under the root: ld.so.preload, uid-0 accounts and login shells, sudoers, root's SSH keys, cron (system and spool), systemd units, autostart entries, profile and rc files, PAM modules, udev and network hooks, apt hooks, and SUID/SGID binaries. UserPersistSnapshot records, per home: authorized keys, user units, autostart, rc files and git hooks in nested repositories.

Files are queued as Pending and resolved in one batch by flush_pending: owners first, then one state record each with size, times, sha256, owner, integrity and flags.

FlagRaised when
unownedno package owns a system file and the package database could be read
hash_mismatchthe package manager's verify says mismatch or missing
mtime_in_windowthe file changed inside the collect window
not_regular_filea FIFO or device sits where a config file belongs
world_writable_pathan rc file puts a world-writable directory on PATH
uid0_non_rootan account other than root has uid 0
unowned_suid, unowned_pam_modulea SUID binary or a PAM module no package owns

Only with --with-live, and only on the live host: processes from /proc with flags for binaries that are deleted, in /tmp-like dirs or hidden dirs (proc_flags); listening and connected sockets with their owner, kernel sockets labeled (socket_state follows tcp_states.h); kernel modules not owned by a package, found in the running kernel's tree; the tainted flag.

Only with --with-scanners: clamscan (homes and temp dirs), rkhunter and chkrootkit, each run by absolute path with a fixed argv and its output capped (MAX_SCAN_OUTPUT). Findings become scan.finding state.

Scanners have well-known false positives. explain matches a finding against EXPLANATIONS and prints why it is harmless and a read-only command that proves it, such as dpkg -S /usr/bin/lwp-request.

diff_runs loads both runs' state, keys it by (kind, key), and reports Added, Removed or Changed with a one-line detail (changed_summary). Keys are chosen so the same thing has the same key in every run: a unit file's path, an extension's profile|id, a socket's address and owner.

Why state is per run but events are not design

An event happened once; seeing it again in a later run is the same event, so the dedup key ignores the run. State is a reading taken at a moment; the same unit file read twice is two readings, and the difference between them is the point. That is why state is keyed (run_id, kind, key) and upserts within a run, while events is keyed by content.

17Safety: secrets, hostile evidence, output

The tool reads what an attacker may have written, on a machine that holds secrets, and prints to a terminal. Four guards, each in one place.

🔐Secrets are never opened

Cookies, saved logins, keys, wallets, .env: is_secret decides, guard refuses.

☣Evidence is hostile

Only regular files, size caps, checked arithmetic, panics contained, nothing executed.

✱Typed passwords are masked

Before storage: PASSWORD '…', TOKEN=…, user:pass@, Authorization:.

🖨Output can't attack its reader

Terminal escapes stripped, CSV formulas defused, the HTML report forbids fetches.

Refusals, as the user reads them table
SituationWhat is printed or stored
a deny-listed filerefusing to open a secret file: … (the source errors; nothing is read)
a FIFO or device as evidence… is not a regular file; in the persistence snapshot, a not_regular_file flag
a file over the capover the 268435456-byte limit
a timestamp out of rangetimestamp out of range: … for that line or row
a panicking parsersource status error: collector panicked: …
a case of another schemacase file schema vN is not supported (expected v2); start a new case
Part VI · The case: from evidence to trusted links

18The map and its laws

The riskiest part is the path from a byte in someone's history file to a line in a report someone will act on. A bug here costs trust: a wrong time, a missing link, a duplicate, or a secret where it shouldn't be.

Evidence — the user's files, logs and tools
Never written, locked or vacuumed. Copied, then read.
▼ read through the guarded helpers into a Sink
Collectors — crates/collect
Raw time kept beside UTC; provenance on every record; secrets masked.
▼ an Outcome per instance, to the single writer
Ingest — Store::ingest
One transaction per source; the dedup key keeps re-runs idempotent.
▼ after every collect
Correlation — crates/correlate
Links with a relation, a confidence and a why; derived times marked derived.
▼ read by
Reads and exports — crates/query · crates/render
Filters in SQL, times in a chosen zone, everything sanitized, exports optionally redacted.

The laws

①Evidence is never modified

Live databases are copied with their side files and opened read-only; XDM's schema peek is immutable=1.

②Every fact has provenance

File, sha256 of what was read, and a locator precise enough to find the same row again.

③A re-run adds only what is new

The dedup key is built from the source's identity and the event's content, never from run-local ids.

④UTC inside, raw kept

Times are compared in UTC µs; the raw value and its format are stored beside them.

⑤Nothing claims more than it knows

Derived times and heuristic links say so; "unverified" is never printed as "ok".

⑥No secret enters the case

Deny-listed files are never opened; typed passwords are masked before storage.

19Identities and keys

Two strings decide whether a re-run duplicates the case: the source's identity and the event's dedup key.

⟳Rotation shares an identity

dpkg.log.1 and dpkg.log.2.gz have the identity of dpkg.log, so a line that moved into a rotation is still known.

⚠Renaming changes identity

A new app name or profile label (Firefox profiles named from profiles.ini) makes every event of that source new once. Such a change needs a new case.

Locators, per source what makes a row findable
SourceLocatorStable because
Chromium visitsvisits.id=Nrow ids never change in a history file
Firefox downloadsmoz_annos.place_id=Nkeyed by place, not by query order
XDMdownloads.id=Nthe table's key
journaldthe entry's __CURSORjournald's own stable cursor
dpkg, apt, auth.logline N of the filewith the rotation-stripped identity; a line moved by rotation keeps its time and subject
timed shell historywhen=EPOCHline numbers shift when the shell truncates its history
untimed shell historyline Nthe best there is; a truncated history can re-number lines
snap, flatpakchange id=N, history CHANGE APPthe tools' own ids

The insert itself:

⚠ Never join on source_id across runs

An event keeps the source_id of the run that first stored it. A pass that matches events by source_id misses everything a later run added; match by the history file (source_kind and prov_file) instead, as the chain and anchoring passes do.

20The outside tools it runs

Some evidence only a tool can read. Every tool is the host's, run by absolute path with a fixed argv, no shell, stdin closed, and a failure is an error, never an empty result.

/usr/bin/journalctl --output=json --no-pager --quiet [--directory DIR] [--since=@S] [--until=@S] MATCHES…. Streamed line by line; stderr read on a thread so a full pipe can't deadlock; a bad entry is skipped and counted. On an image, --directory points at the image's journal.

Ownership on a Debian root comes from the root's own var/lib/dpkg/info/*.list (no tool at all, so it works on images). rpm and pacman answer ownership by batches of OWNER_BATCH paths (query_owners). Integrity is one verify run per package, live host only (verify_report): dpkg --verify, rpm -V, pacman -Qkk.

snap changes --abs-time and flatpak history --columns=time,change,application, both under LC_ALL=C through run_listing. Their paths come from Probe::tool_path at discovery.

Correlation reads a downloaded package's name with DPKG_DEB -f FILE Package (inspect_deb): only a regular file named *.deb, never extracted, never installed.

SCANNERS: clamscan --infected --no-summary -r over homes and temp dirs, rkhunter --check --sk --rwo, chkrootkit -q. Output capped at 64 MiB.

Why never a shell, and never $PATH the threat

The tool runs on a machine that may be compromised. A $PATH lookup can be redirected by a planted ~/.local/bin/dpkg; a shell would interpret a crafted file name. So every tool is found by absolute path under the root (Probe::tool_path) or is a constant (/usr/bin/journalctl, /usr/bin/dpkg-deb), arguments are passed as a vector, and stdin is closed. Output is parsed as untrusted text, capped when it can be large.

ToolWhenOn an imageIf it fails
journalctlauth.journaldreads the image's journal dirsource error with its stderr
dpkg --verify, rpm -V, pacman -Qkkpersistence snapshotnot run: unverifiedunverified, never ok
rpm -qf, pacman -Qoqpersistence snapshotnot run: ownership unknownowner unknown for that batch
snap, flatpakpkg.snap, pkg.flatpakthe image's tool path is probedsource error
dpkg-debinstall correlationthe host's tool on the image's fileno package name; filename prefix used
scannersonly with --with-scannerslive hostsource error

21How it computes ownership and integrity

Five verdicts, and the rule that the tool never says ok when it didn't check.

VerdictMeans
oka verify ran for the owning package and named no problem for this file
mismatchthe checksum or size differs (dpkg/rpm flag column has 5 or S)
missingthe package lists the file and it is gone
unverifiedno verify could run (an image, no tool) or the tool couldn't read the file (?)
unownedthe package database was readable and no package owns the path
Worked example: the same SUID file, on the host and on an image both branches

Live host. The SUID scan walks /usr/bin (on merged-/usr systems /bin is a link to it) and finds /usr/bin/su. The root is /, so PkgVerifier::new finds dpkg and keeps its path. The dpkg index (built once from /var/lib/dpkg/info/*.list) may list the file as /bin/su; usr_merge_alias tries that alias and finds util-linux. dpkg --verify util-linux runs once, reports nothing for the file, and the verdict is ok.

Mounted image. The same file under --root /mnt/img. The index comes from /mnt/img/var/lib/dpkg/info, so the owner is still util-linux. No tool is kept for an image (the host's dpkg would check the host), so integrity returns unverified. An image without a dpkg database can't resolve owners at all, and a file with no owner is then unverified, not unowned (resolves_owners).

⚠ What went wrong before

The review found every SUID binary reported unowned (the /bin vs /usr/bin alias), sudoers reported as a hash mismatch when dpkg only lacked permission to read it (??????????), "ok" printed when no check ran, and images judged by the host's database. Each verdict now has exactly one way to be produced.

22One collect, start to finish

From the user's flags to a closed run, with every way it can refuse.

1
Refuse early: bad times, an inverted window, an empty selection, --all-users without root. Nothing is created yet.
2
Open the case 0600, chown to SUDO_UID, arm signals, insert the run, make the staging dir.
3
Discover every selected collector, per user where needed; a sources row each.
4
Collect the present instances on workers; ingest each Sink on the main thread; set its status.
5
Correlate, record errors, finish the run, drop the staging dir, print the summary.
Every refusal, as the user reads it table
CauseMessageWhere
unparsable timeunrecognized time: …parse_datetime
a time skipped by DST… does not exist in the local time zonenaive_to_utc_us
inverted window--since is after --untilrun_collect
nothing selectedno source kind matches --sources/--excluderun_collect
--all-users as a user--all-users requires root privilegesbuild_env
a case of another schemacase file schema vN is not supported…Store::init
a bad --tzunknown time zone `…` (use local, UTC, or ±HH:MM)parse_tz
What "new" and "already in the case" mean the summary line

run 2: 5 new events (26174 already in the case), 828 states, 13815 artifacts from 28 sources. New and known come from the insert's row count (1 or 0) per event; states and artifacts are counted as written, because both upsert. A second run over the same window should show almost nothing new: what remains is real activity since the first run.

23Correlation and the life of a source

A source moves through a few states in one run; correlation then runs over everything the case holds, old and new.

discovered not installed unsupported unreadable · empty present → job collected error present partial empty Ok(sink) Err · panic · interrupted rows warnings nothing
⚓A command typed many times

Which sudo apt update was this history line?

📁A path on an image

The download says /home/u/x.deb; the file is under --root.

📦Three witnesses of one install

sudo, apt and the shell all name the file. Which links?

⚑An IOC removed

Its links must go too.

Why the passes run in this order dependencies

Chains first: they only read visits. Downloads next, because they create and fill the artifacts the install pass needs. Installs before anchoring, because both read sudo and apt records but anchoring changes shell lines' times. Permissions read visits only. IOCs last, so they see every artifact the download pass just created.

24Staging: copy, then read

A browser holds its database open and locked. footprint never touches it: it copies the file and its side files into a private directory, and reads the copy.

~/.config/…/History+ History-wal · in use footprint-*/src-7/History · History-wal (0700) Connection READ_ONLYon the copy · sha256 of the copy removed when the run ends copy_db_set open_snapshot
📜The WAL carries the recent rows

Copying only History loses what the browser hasn't checkpointed.

🗂One directory per instance

src-<id>, 0700, so two profiles' History-wal never mix.

🔏Read-only, and immutable for peeks

The copy opens read-only; a schema peek at a live original uses immutable=1.

🧹Cleanup

The whole staging dir goes when the run ends, or on the first Ctrl-C.

What can still go wrong with a copy limits
  • The browser can write while the copy runs; the copy may then be torn. SQLite reads what it can, and a broken copy fails that source with SQLite's error, never the original.
  • A database larger than 4 GiB is refused rather than copied.
  • A side file swapped for a FIFO is refused (regular_file); the main file is checked the same way.
  • The provenance hash is of the copy that was read, which is what a reader can check against.

25Getting it out: export and redaction

The reverse of collecting: the case leaves as a file someone else will read. It goes out filtered, in a chosen zone, and with secrets hashed if asked.

1
Filter with the same flags as list; export_case reads with list_events_detailed.
2
Render times in the zone of the global --tz (ExportTime).
3
Redact subject, summary and attrs with redact_text and redact_json when --redact is set.
4
Write the file with mode 0600 — an overwritten file's old, permissive mode is tightened, not kept: JSON Lines, CSV or a standalone HTML report.
FormatHoldsFor
jsonl (render_jsonl)every field of ExportEvent, attrs includedscripts, jq, another tool
csv (render_csv)the columns a person scans, formulas defusedspreadsheets
html (render_html)metrics, IOC detections, the runs, a filterable timeline; no networka report to hand over
Two kinds of redaction don't mix them up
At collectionAt export
Functionredact_secretsredact_url, redact_text, redact_json
Applies toshell/REPL commands, journald messagesevery exported URL and credential
Replaces with*** (gone for good)<sha256:16 hex> (comparable, not readable)
Changes the caseyes: the secret never enters itno: only the exported copy
Part VII · Working on it

26Options, environment and defaults

footprint has no config file. Everything is a flag, a few environment variables it reads, or a constant in the code.

FlagDefaultDoes
-c, --case PATHfootprint.dbthe case file; global
--tz ZONElocalrender times in local, UTC or ±HH:MM; global, honoured by every read command, tui and export
--since, --untilno boundthe window (chapter 10 lists the forms)
--sources, --excludeall, nonekinds or families to read or skip
--home DIRthe invoking user's homerepeatable; each home is read as its owner
--all-usersoffevery passwd user with uid ≥ 1000 and a real shell; root only
--root DIR/where system paths live: a mounted image
--assume-tz ZONEthe host zone at each datethe offset of logs that store local time
--with-live, --with-scannersoffopt-in collectors

All defined in Cli and CollectArgs.

VariableRead byEffect
SUDO_UIDsudo_invoker, hand_case_to_sudo_uidunder sudo, read the invoking user's home and give the case to them
USEREnv::current, the run rowthe user name when not under sudo
HISTFILE, ZDOTDIR, SHELL, HOMEShellCollectorwhere the current user's shell history lives and which format it is; only for the home of the running user
NO_COLORfp_tui::runany value turns colours off
LC_ALL=Cset, not read: run_listingpackage tools print English month names and verbs
ConstantValueWhy
SCHEMA_VERSION2other versions are refused
MAX_TEXT_BYTES256 MiBtext evidence read whole, gunzip included
MAX_DB_BYTES4 GiBSQLite evidence copied
MAX_SCAN_OUTPUT64 MiBscanner output kept
PAGE_SIZE500rows per TUI page
SAME_RUN_US60 sidentical sudo/apt records closer than this are one run
INSTALL_WINDOW_US120 sa package log line follows its install command within this
PRTIME_FLOOR1014Firefox endTime above it is µs, below it ms
OWNER_BATCHpaths per rpm -qfkeeps the argv far below the kernel limit
workers1–8available_parallelism, capped, never more than the jobs

27Errors, warnings and what the user sees

There is no log file and no metrics: a run's health is stored in the case itself, per source, and printed by sources.

⚠Warnings make a source partial

sink.warn(…) for what was skipped (bad lines counted once per file, an unreadable side file). The joined warnings become the source's note, and the CLI prints them as warning: label: ….

✖Errors fail one source

An Err or a panic stores error: … on that source and adds a line to the run's errors_json. The run exits 0 and says , N failed.

⛔Refusals stop the command

Bad arguments, a missing or foreign case, no selected collector: anyhow errors with context, printed by main, exit 1, before anything is written.

📐Conventions

No .unwrap() outside tests (CI greps for it). Errors carry the path or tool in their context (with_context). The TUI shows an error in its status line and keeps running. Comments say why, in lowercase.

28Tests

189 tests, all hermetic: every one builds its evidence in a temp dir and never reads the host.

TierWhereWhat it proves
unit (147)mod tests at the bottom of each source fileparsers on real formats, time conversion, verdicts, filters, sanitizing
mutation smokecrates/collect/src/smoke.rs (test-only)400 corrupted variants per parser: events or an error, never a panic
scenariocrates/cli/teststhe real binary over a synthetic machine: fake Zoom, state and diff, IOC and export, Firefox, a git hook, several users
read modelcrates/query/teststimeline, paging, detail and coverage over a collected case
TUIcrates/tui/testsApp driven by keys, and frames rendered into a test backend at 80×24
CI.github/workflows/ci.ymlfmt, check, clippy -D warnings, the no-unwrap grep, all tests on the pinned toolchain
⚠ Never read the host in a test

A test that reads /proc, the host's wtmp, its dpkg database or $SHELL passes on one machine and fails on the next. Build a synthetic root and pass it as --root or Env::test. External tools are faked with a small script written into the temp dir (utils::script_tool, copied so exec never races a writer).

A test reads like an investigation

🧪The fixture machine

One function writes a whole synthetic home and root in the real formats.

⚖Rules that keep tests honest

Real formats, fixed zones, no host, a regression test per fix.

🧰Common helpers

setup, collect, events, footprint.

🎲Mutation rounds

Deterministic corruption of seed files, without nightly fuzzing.

29Recipes

The common changes, file by file.

1 · Add a collector collect
  1. A module in crates/collect/src/ with a unit struct implementing Collector: a kind like family.name, a discover that sets availability, a collect that reads through read_evidence or copy_db_set.
  2. Build events with new_event, a Stamp, and an Origin with a stable locator.
  3. per_user() → true if it reads one home.
  4. Register it in registry, and add its kind to a category in CATEGORIES.
  5. Unit tests on the real format; a seed in the smoke tests; a fixture if a scenario should cover it.
2 · Add an event kind core
  1. A variant in EventKind and its string in as_str, as family.action so family.* filters work.
  2. If correlation should see it, add it to the kinds the pass reads (get_events_by_kinds).
  3. The TUI and the HTML report pick badges by prefix; check both.
3 · Add a persistence location state
  1. A collect_* method on SystemPersistSnapshot or UserPersistSnapshot that queues files with emit_file_state; call it from run.
  2. Use a kind under persist. and a key that is the same in every run (the path), so diff works.
  3. Read contents only through open_regular/read_evidence.
4 · Add a command or a flag cli
  1. A variant in Command with a doc comment (it becomes --help) and an args struct.
  2. One arm in main and one function. Read through fp_query, open with Store::open, print through clean, offer --format, take the global tz.
  3. If the TUI should have it, add it to the read model first, then to App.
  4. A scenario test in crates/cli/tests; the README's command table.
5 · Add a correlation pass correlate
  1. A module in crates/correlate/src/ with a correlate_* function returning the number of links inserted.
  2. Idempotent: links are unique on (src, rel, dst); if the pass must forget old links, own a relation and delete_links it first.
  3. Join events across runs by file (source_kind, prov_file), never by source_id.
  4. Call it from correlate in the right order and count it in CorrelateStats; teach trace the relation if it belongs in a provenance tree.
6 · Change the schema store
  1. Edit SCHEMA, the row type and its from_row, and the inserts.
  2. Bump SCHEMA_VERSION. Old cases are refused; that is a breaking change for users and needs the owner's approval and a ! commit.
  3. Extend the store's tests (re-run idempotency, refusal of the old version).

30Invariants and gotchas

What must never break, and the traps that broke it before.

🚫No write, lock or vacuum of evidence

Copy with side files, open the copy read-only. Never Connection::open a live original.

🔑No read outside the guarded helpers

read_evidence, open_regular, copy_db_set. A plain File::open skips the deny-list and blocks on a FIFO.

🆔Locators and identities are stable

A locator built from a row index of an unordered query duplicated every re-run once.

✍Only the main thread writes

Collectors fill Sinks; the runner ingests. Never hand a Store to a worker.

🕰Raw time is kept, local time resolved at its date

Never apply today's offset to an old local time; never compare local strings in SQL.

🧾Tools by absolute path, fixed argv

Nothing from the evidence becomes an argument except as a path, and nothing is ever run through a shell.

🖨Sanitize everything printed

Including errors that quote evidence, and every TUI cell.

🧪The tool excludes its own footprint

The staging dir and the case files are skipped by the filesystem walk; otherwise a run records itself.

Gotchas that cost a day each from the history
SymptomCause
snap source empty on a real machinethe collector parsed a format invented from docs; snap changes has no kind column
every SUID binary "unowned"/bin vs /usr/bin under merged /usr
a 9-minute collectone dpkg-query and one dpkg --verify per file instead of per batch and per package
duplicates on every re-runa locator built from a row index of an unordered query
a redirect without its chaina pass joined on source_id, which differs per run
logins recorded under a strange user namelogind changed its message format in systemd 255
◆ The principle

Every line footprint prints may end up in a report someone acts on. When in doubt, say less and say how sure you are: unverified over ok, derived over direct, untimed over a guessed time.

31Where it stands; what's next

M0–M6 and the full review that followed are done; new work comes from the ideas list.

M0 core ✓→M1 chromium ✓→M2 system sources ✓→M2++ coverage ✓→M3 correlation ✓→M4 state ✓→M5 IOC, export, TUI ✓→M6 firefox, live ✓→M6+ review ✓→ideas, picked by the owner
💡What's next

Candidates in .stuff/IDEAS.md: declarative collectors (TOML specs), time ranges for lines between two anchors, flagging packages installed from a local file, counting independent witnesses per event, Trash and thumbnail-cache evidence, network configuration as state.

🔌Where they plug in

A new source is a collector (recipe 1); ranges change correlate_anchoring and add a precision; witnesses are a correlation pass plus a column in the read model; network state is a persistence location (recipe 3).

Part VIII · Reference

32The reference atlas

Every folder, file, type and function, read straight from the code, with a purpose note per file and crate. Browse the tree, or search every declaration at once.

Sources: 55 non-test files under crates/*/src, parsed with syn. Line counts leave out #[cfg(test)] modules and items; the “tests” figure is the number of #[test] functions in the file's own test module. Test-only files (smoke.rs, crates/*/tests) aren't listed. Enum variants are consts; methods show their owner, with the trait for trait impls; route entries are the registry of chapter 09. Counts include private names.

33The data model, as a map

Every table of the case, starting from runs and growing outward to what each run writes. Each box shows the fields that matter; click it for all of them. Pick a flow to watch the rows a feature writes, step by step.

🧷Declared, not enforced

sources.run_id, events.run_id, events.source_id and artifacts.first_seen_run say REFERENCES, but SQLite only enforces that with PRAGMA foreign_keys, which footprint never sets. links point at rows by type and id, with no constraint at all.

⚡Staging is not a table

Copies of browser databases live only in the run's temp dir and are gone when it ends. The case keeps what was read, with the hash of the copy.

🧮Links are computed

Every links row is derived from events, artifacts and state by a correlation pass; delete them all and the next collect rebuilds them (except anchored times, which stay on the events).

Reading the map in two minutes orientation
  1. The middle column is a run's life: runs, the sources it discovered, and the events they produced, with the FTS index beside them.
  2. To the right, what a run keeps besides events: state per run, and artifacts by path.
  3. Bottom, what correlation adds: links between any two rows, and the user's iocs.
  4. Left, the dashed boxes are not tables: the evidence a source points at, and the staging dir it was copied to.

34The type map

The Rust types that carry the work, about forty of them. Each arrow is a function turning one into the next: hover a box or follow a flow to see its name, and click the name to read it. Boxes marked ⛁ are shapes the case stores.

🔁Values, not handles

Collectors return plain records in a Sink; the store returns plain rows (EventRow, EventDetail). Nothing outside the store holds a connection.

🧱Raw beside normalized

A Stamp becomes an Event with both its raw time and its UTC time; neither is ever thrown away on the way to the case.

35Where the rest is written

This page is about the code. The other documents hold the why and the plan.

What footprint does, every command, safety, limitations, roadmap. The only public document.

The design: time model, every source and its format, correlation, trace, safety rules, lessons from real machines.

The plan, milestone by milestone, with commit references and every bug found on the way.

Code style, comments, tests, the safety rules for working on a forensic tool, and the commit format.

Ideas for later, with review notes. Nothing there is promised.

⚙CI and toolchain

.github/workflows/ci.yml and rust-toolchain.toml (1.97.1). This guide's own sources are in .stuff/guide/tools/codebase.

Part IX · Wrapping up: SQLite

36What SQLite is to footprint

SQLite is both what footprint reads and what it writes: most evidence is somebody else's SQLite file, and the case is footprint's own. It is not a server and holds no state outside its files. The project uses rusqlite 0.34 with SQLite 3.49.1 bundled and compiled in (bundled-full), so FTS5 is always there and the host's SQLite never matters.

evidence DBsHistory · places.sqlitedownloads.db (XDM) staging copy+ -wal / -journalopened READ_ONLY collectorsSELECT, schema checkedonly listed columns Storeone connectiona transaction per source case.db0600 · user_version 2events_fts (FTS5) XDM originalschema peek onlymode=ro&immutable=1 copy Sink
📦Bundled, with FTS5

The same SQLite on every machine, compiled into the binary.

🔏Read-only and immutable opens

How a database is read without being touched.

📜WAL and side files

Why a copy needs its -wal, and what the case writes beside itself.

🔎PRAGMAs as probes

user_version and table_info before anything is trusted.

37How footprint uses it, and inspecting a case

Four crates use rusqlite; only two of them may run SQL against the case.

1
store owns the case: one Connection, opened NO_MUTEX with a 5 s busy timeout, every query in schema.rs.
2
collect opens evidence copies read-only and runs SELECTs on them; it never sees the case.
3
correlate works through Store, and inserts a placeholder artifact through store.conn() in the download pass.
4
fixtures writes evidence databases for tests, including one held open in WAL mode.

Connection (one per database), Statement from prepare or prepare_cached, Rows/Row with typed get, Transaction from conn.transaction(), OpenFlags, and params!/params_from_iter for bound values. The store maps rows into its own types (EventRow, EventDetail…) and never returns a Row.

chromium_history creates the tables Chromium has (HISTORY_SCHEMA) and, with hold_wal, switches to WAL, turns autocheckpoint off and returns the open connection, so the test copies a database whose rows are only in the WAL, as on a running browser. xdm_db and firefox_profile do the same for XDM and Firefox.

  • prepare_cached for statements run per row (ingest); prepare for one-off reads.
  • Bind every value with ?N; build SQL text only from constants (build_events_query adds clauses, never values).
  • ON CONFLICT … DO NOTHING for idempotent inserts, DO UPDATE SET … COALESCE to fill gaps without erasing.
  • LIKE … ESCAPE '\' with escape_like whenever user text meets LIKE.

Use -readonly; a case is evidence of an investigation too. For anything a user will see, prefer the commands: they sanitize.

  • Never open a live evidence database without staging it, and never read-write: a write, a lock or an automatic checkpoint changes evidence.
  • Never VACUUM anything that isn't the case.
  • A query that names columns of someone else's database is preceded by table_has_columns.
  • SQL outside crates/store and the collectors' evidence reads is a design break; the one store.conn() use in correlation is the only exception today.
  • FTS5 query syntax reaches SQLite as typed in search; a syntax error is SQLite's message.
The case's own SQL, in one place schema.rs

Every table is in SCHEMA; every insert is a constant beside it (INSERT_EVENT, INSERT_STATE, INSERT_ARTIFACT).

✖ The one rule

Never point Connection::open at the user's original evidence. Copy it with copy_db_set into the run's staging dir and open the copy with open_snapshot. Opening the original, even to read, can take a lock, write a -shm or trigger a checkpoint in the browser's file.

38Check yourself

Twelve questions. Each answer is a few lines of code you have now read.

A second collect over the same window reports “0 new events”. Which two strings made that possible?
SourceInstance::identity (kind, app, profile and path with the rotation suffix stripped) and Event::dedup_key built from it plus kind, time, locator and subject. events.dedup_key is UNIQUE and the insert is ON CONFLICT DO NOTHING.
Why are source rows inserted during discovery, before any worker runs?
So every instance already has its id, which names its staging dir (stage_dir), and so workers never need the store: only the main thread writes.
A parser panics on a crafted history file. What happens to the run?
collect_only catches it with catch_unwind; that source is stored as error: collector panicked: …, the run's errors list it, and every other source is collected and correlated as usual.
A FIFO is planted as /etc/cron.d/backup. Why doesn't the snapshot hang, and what does the user see?
emit_state_entry hashes only regular files and flags anything else not_regular_file; content reads go through open_regular, which opens non-blocking and checks the type on the descriptor.
footprint state says unverified for a file on a mounted image. Why not ok?
PkgVerifier keeps no tool when the root isn't /, because the host's dpkg --verify would check the host. integrity returns unverified whenever no report could be produced.
Where does --host zoom.us become SQL, and why doesn't it match notzoom.us?
build_events_query: subject_host = 'zoom.us' OR subject_host LIKE '%.zoom.us', with the host passed through escape_like. The dot before the suffix is what excludes notzoom.us.
Five identical sudo apt update lines are in a bash history without timestamps; the journal holds two. What gets anchored?
correlate_anchoring walks the file newest first: the newest line takes the newest run, the next one the older run, and the three oldest lines stay untimed. Each link's why says run k of 2 identical; placed by history order.
A redirect was collected today; the visit it came from was collected last week. Why is the chain still linked?
correlate_navigation_chains keys visits by their history file (source_kind, prov_file) and visit id, not by the run-local source_id, which differs between the two runs.
An IOC is deleted. When do its [IOC: …] badges and links disappear?
The badges at once: timeline matches IOCs per row at read time. The links on the next IOC pass, which correlate_iocs starts by deleting every ioc_match link.
What keeps an apt log line written at 21:08 local time from shifting by an hour when read in winter?
TimeFormat::LocalNaive goes through resolve_local, which asks the zone for the offset in effect at that date, not today's. --assume-tz replaces the zone with a fixed offset for images from elsewhere.
Under sudo footprint collect, whose shell history is read, and who owns the case?
sudo_invoker uses SUDO_UID and passwd to make the invoking user the target, so their home is read; hand_case_to_sudo_uid chowns the case to them.
A CSV export has a summary starting with =HYPERLINK(…). What does the spreadsheet show?
The text, not a link: escape_csv prefixes cells starting with = + - @, tab or CR with ', then quotes the cell.

39What could be built next

The full list with rationale lives in .stuff/IDEAS.md; it moves into TASKS.md when work starts. These are the standouts, ★ marked there too.

🧾★ Clean-bill report

Most tools report what they found. footprint verdict would report what was checked and found nothing, per attack stage, with the coverage behind each claim: "No execution evidence: shell history, sudo log, dpkg log and download dirs checked."

🔗★ Kill-chain view

Map events onto stages — lure → delivery → execution → persistence → access/exfil — and show where a chain stops and why.

👀★ Lure and lookalike detection

Offline: edit distance, homoglyphs and brand-in-subdomain against a data-driven brand list, plus first-seen hosts and webmail-click redirect chains.

🕵★ inspect: pre-flight check

footprint inspect <repo|archive> — static only, never executes: live git hooks, lifecycle scripts, typosquats, curl|sh, obfuscated JS. A risk summary before you check out job material.

🧮★ Corroboration count

Per event, how many independent sources agree (sudo journal, apt block, dpkg lines, bash line, ctime) — and which expected witnesses are missing.

📦★ Local-file installs

Flag every package installed from a file (apt install ./x.deb, dpkg -i) — exactly how the fake-Zoom SDK got in — and pair it with the trace.

🗑★ Deleted-but-recoverable evidence

Trash .trashinfo files, thumbnail-cache tEXt chunks, and carving SQLite free pages — on the staging copy only, so the original is never touched.

🧩Extensibility

Declarative TOML collectors (same deny-list and guarded helpers enforced by the engine), an IOC exchange format, and footprint pack: a self-contained, verifiable evidence archive.

🧭Investigation aids

verify (re-hash sources against the case), baselines and drift, story mode (a plain-language narrative with every sentence linked to events), clock-skew and history-tampering hints, extension permission drift.

🚫Still out of scope

No telemetry, no cloud upload, no online reputation by default; secret contents are never read, not even "just to check".

Generated 2026-10-31 at commit 1248128 from .stuff/guide/tools/codebase. Rebuild at every docs update.