Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Undeclared Reads

Status: researched (2026-10-06), not started — deferred. This chapter records what was found while considering a check for files a tool reads without them being declared inputs. Pick it up from here.

The problem

rsconstruct does not declare dependencies; it discovers them. Processors find their inputs by scanning, analyzers add what source files reference (#include, import, @use, template includes), and dep_inputs / dep_auto add the rest. When all of that misses a file a tool actually reads, nothing notices: the file is not part of the product’s cache key, editing it rebuilds nothing, and the build stays green with a stale result.

A check would run each tool, see which files it opened, and report any file inside the project that is not among the product’s inputs.

How other build systems handle it

Two families: prevent undeclared reads by running each action where only its declared inputs exist, or observe what each action reads and compare it with what it declared.

Prevent — sandboxes

  • Bazel runs every action in a sandbox containing only its declared inputs. On Linux, linux-sandbox builds a private mount namespace with each input bind-mounted or symlinked into a fresh execution root (network off by default); on macOS, sandbox-exec profiles; elsewhere, a weaker symlink-forest sandbox. An undeclared file is simply absent, so the tool fails with “not found” — Bazel never sees the read itself. Remote execution gives the same guarantee: only declared inputs are uploaded to the worker.
  • Buck2 gets hermeticity chiefly from remote execution, with the same only-declared-inputs property. Local execution is, as far as we know, not sandboxed by default, so a local Buck2 build can read an undeclared file unnoticed; the guarantee holds where builds run remotely, typically CI.
  • Pants runs each process in a temporary directory into which only its declared inputs are materialized.
  • Please isolates each process with Linux namespaces.
  • Nix builds in a namespace sandbox holding only the declared store paths, without network.

Observe — tracing

  • tup watches every file a command opens (a FUSE filesystem on Linux, library interposition elsewhere) and fails the build when a command read a file it did not declare, or wrote one it did not declare. The closest match to what rsconstruct would want.
  • BuildXL (Microsoft) intercepts every file access of every process — Detours on Windows; on Linux, LD_PRELOAD interposition backed by ptrace for static binaries — and uses the observations both to enforce declarations and for caching.
  • fabricate.py and similar tools run commands under strace and infer dependencies from what was opened, rather than checking declarations.
  • Ninja / Make take dependency lists from the compiler (-MD, deps = gcc) and trust them; nothing is enforced.

Which family fits rsconstruct

Observation. Sandboxing works for Bazel because a BUILD file declares every input of every action before it runs. rsconstruct runs tools in the real working tree, at real paths, where they find config by walking up directories, and many read files nobody would think to declare — Sphinx’s conf.py imports, book.toml, Cargo.lock, node_modules, a whole .venv. A sandbox would break most processors until every such file was declared, inverting the design (discovery over declaration).

Tracing leaves tools running exactly as now and adds a report on the side, so it can be opt-in and used in CI, as tup uses it. It also catches the read even when the undeclared file exists; a sandbox only fails when the file is missing from it.

Mechanisms considered

MechanismSeesCostNotes
strace -f -e trace=openat,open,execve around each tool runevery syscall, including static binaries and Go programstraced runs are much slower; needs the strace toolLinux only — on macOS the mode would fail with a clear “needs strace” error, consistent with the platform rules (no #[cfg]). Leading candidate.
LD_PRELOAD shim intercepting open/openatdynamically linked programs onlyfastMisses static binaries, Go, direct syscalls: it would under-report silently, against strict-by-default. Also a .so to build and ship.
fanotify, in-processall opens under the projectno external toolAttributing an open to the product that caused it needs the process tree; unprivileged fanotify is limited on older kernels; Linux-only code to keep in platform.rs.
ptrace / seccomp user-notify, in-processevery syscallmoderatePrecise, but a substantial amount of Linux-specific code.

Open questions

  1. Mechanism. strace is the leading candidate (complete, simple to attach — tool runs already go through central helpers in src/processor/mod.rs), at the price of slow traced runs and a tool dependency for this mode.
  2. What a finding does. Fail the product with a message naming the file (consistent with how a missing tool is handled), or warn. Failing needs an escape hatch: a per-processor field listing reads that are known and accepted (e.g. a tool’s own config file outside the project).
  3. What counts. Only reads inside the project tree, excluding .rsconstruct/ and the tool’s own installation; reads of a product’s own outputs and of declared output directories are expected.
  4. Batch runs. A batched invocation reads files for many products at once; a read is undeclared only if no product of the batch declares it.

Suggested next step

Before designing the feature, measure the noise: trace one build of a few fleet repositories with strace by hand and count undeclared project-file reads per processor. If most processors read nothing undeclared, the check can fail by default with a small allow-list; if many do (config files, caches), it starts as a report.

See also Suggestions (“Sandboxed execution”) and Strictness.