Files
punktfunk/scripts/ci/check-unsafe-hygiene.sh
T
enricobuehler f373dffb5e chore: migrate the main workspace and pf-vkhdr-layer to edition 2024 (WP20)
The safety half of the rust-safety programme's §8.4: `std::env::set_var`/`remove_var` are
`unsafe fn` in edition 2024, converting the class of bug the programme found the hard way
(the 972af299 environ data race lived in a file with ZERO occurrences of the word
`unsafe`) from invisible to counted and compiler-enforced.

Manifests: [workspace.package] edition 2021→2024, rust-version 1.82→1.85 (the pinned
toolchain is 1.96.0, so no toolchain bump — only the declared floor rises); the 13 crates
pinning `edition = "2021"` literally now inherit it (Trap 1: the root bump alone reaches
only `edition.workspace = true` crates and would have left pf-encode/pf-capture/pf-inject
et al. on 2021 while reading as complete); pf-driver-proto's stale rust-version 1.82 pin
now inherits; pf-vkhdr-layer (a separate workspace, inherits nothing) bumped to 2024. The
four vendored crates (fec-rs, cros-codecs, usbip-sim, the patched ndk) stay on 2021
deliberately — upstream code stays pristine. The excluded usbip-poc standalone PoC is
untouched.

Mechanical, done textually across ALL cfg branches so no platform's half is left behind
(Trap 3 — 44% of the host's unsafe is Windows-only and a one-platform `cargo fix` misses
it): 148 `#[no_mangle]` → `#[unsafe(no_mangle)]` (83 in abi.rs); 12 bare extern blocks →
`unsafe extern`; `gen` is a reserved keyword, so pf-vdisplay's generation stamps
(registry.rs, windows/manager.rs) and the WinUI shell's animation counters rename
gen → generation (internal identifiers only, no serde/wire surface); two
match-ergonomics patterns take the compiler's suggested reference form.

env mutation: every `set_var`/`remove_var` site (20 files) now sits in an `unsafe` block
whose SAFETY comment states the real serialization argument (pf-vdisplay's ENV_LOCK,
CONFIG_DIR_TEST_LOCK, ART_ROOTS_LOCK, vkdecode's gpu_lock, the `--test-threads=1`
contracts of the hardware spikes, or single-threaded startup). Two genuine hazards
surfaced en route — exactly the WP3b-class finds this migration exists to make visible —
and are fixed here:
- windows/service.rs spawned the network-profile warner thread BEFORE `load_host_env()`,
  so a child-spawning thread (child spawn snapshots the env block) was live while
  `set_var` ran in a loop; the load now precedes the spawn.
- pf-console-ui's `fake_home()` re-set HOME outside its OnceLock on EVERY call, so two
  parallel tests could race the write; the set now happens exactly once inside
  `get_or_init`.

cbindgen (Trap 2): 0.29.4 parses `#[unsafe(no_mangle)]` — verified empirically; the
header regenerates byte-identical. The ci.yml drift check could never catch "failed to
regenerate" (build.rs demotes a cbindgen failure to a warning and writes nothing, leaving
the checked-in header untouched and the diff clean), so the step now first asserts the
"punktfunk-core: wrote" line and the absence of "cbindgen failed" (sh -e safe: no `!`
pipeline, no tee-masked exit).

rustfmt: style_edition pinned to 2021 at the root — edition 2024 would otherwise flip the
style edition and reformat ~370 untouched files inside this same commit, burying the
migration diff. The drivers workspace pins its already-current 2024 style. Adopting the
2024 style tree-wide is its own future one-line-plus-reformat commit.

Census: the primary metric moves UP BY DESIGN — 2435 → 2453 operations, unsafe blocks
1534 → 1577, and env_set_var is now a counted category (45 ops). The newly counted env
sites are a truer number, not a regression; baseline snapshot saved as punktfunk-planning
design/rust-safety-census-baseline-2026-08-12-edition-2024.txt. Gate C's env ratchet is
now compiler-enforced (the hygiene-script header says so); the two shrunk file counts
(nvenc_cuda 49→2 via the test helpers, shell/tests 2→1) are lowered in the same commit
per the gate's own rule.

Drop order (the semantic change most likely to bite this codebase): the migration lint
`-W tail-expr-drop-order` reports zero findings on the macOS-visible halves of
pf-encode / pf-zerocopy / pf-capture / pf-frame; the Linux and Windows halves run the
same lint on the gate boxes. The four #[ignore]d alloc/drop-cycle tests on the hardware
boxes remain owed, as before this change.
2026-08-12 16:12:35 +02:00

214 lines
9.5 KiB
Bash
Executable File

#!/bin/sh
# Unsafe-hygiene grep gates (rust-safety programme §4 WP2c). Three classes no lint covers:
#
# A. `unsafe fn` whose body contains no unsafe operation. Because the workspaces deny
# `unsafe_op_in_unsafe_fn`, every real unsafe op inside an `unsafe fn` sits in an explicit
# `unsafe {}` block — so an `unsafe fn` with no `unsafe` in its body is a marker carrying no
# contract (db659809 found two by hand, with call-site SAFETY proofs describing FFI the fns
# no longer performed). A contract-DEFERRING fn (`set_len` shape: safe body, the danger is in
# later safe code trusting the argument) is legitimate — waive it with a comment line
# `// unsafe-fn-no-op-ok: <why the marker still carries a contract>` above the fn (doc lines
# may sit between). Two classes are skipped structurally: files carrying
# `#![allow(unsafe_op_in_unsafe_fn)]` (the fenced GPU/FFI backends — there the premise that
# ops are forced into blocks does not hold), and `unsafe extern "ABI" fn` definitions (loader
# / framework callbacks, where the unsafe marker is dictated by the PFN type they must match,
# not by a caller contract; gate B still covers their bodies).
#
# B. `unwrap`/`expect`/`panic!` inside an `extern "C"` / `extern "system"` fn body. Panic across
# an `extern` boundary is an abort since Rust 1.81 — not a diagnostic, not a sanitizer
# finding, not fuzzable (8b98d0b3: an ETW callback's `RING.lock().unwrap()` aborted the host
# on a poisoned lock). A body that routes through `catch_unwind` (the abi.rs pattern) is
# exempt; otherwise waive a deliberate abort with `// panic-in-extern-ok: <reason>` directly
# above the fn.
#
# C. Safe-but-process-global APIs: `env::set_var`/`remove_var`, `sigaction`, `setlocale`,
# `set_current_dir`. Each is (or was) callable without `unsafe` and unsound (or racy) from a
# live multithreaded process — the 972af299 environ data race lived in a file with ZERO
# occurrences of the word `unsafe`, invisible to the census. Since the edition-2024
# migration the env pair is `unsafe fn` (compiler-enforced, SAFETY proof per site, counted
# by the census); this ratchet stays for the still-safe APIs (`sigaction`, `setlocale`,
# `set_current_dir`) and as a growth brake on env mutation generally. The baseline below
# enumerates today's debt per file; ANY increase (or a new file) fails. Shrink a file's
# count? Lower its baseline in the same commit.
#
# All three gates were shown to FAIL on deliberately planted instances before being made blocking
# (the gate-of-the-gate rule that caught cd72f77a's `0 * SLOT`).
#
# Textual gates, so textual limits: string literals containing `unsafe {` and macro-generated fns
# are invisible; nested `unsafe fn` items inside another fn's body attribute their blocks to the
# outer fn. Both classes are rare here and covered by review.
set -u
cd "$(dirname "$0")/../.." || exit 2
fail=0
tmp="${TMPDIR:-/tmp}/unsafe-hygiene.$$"
mkdir -p "$tmp"
trap 'rm -rf "$tmp"' EXIT
# Tracked, non-vendored Rust sources.
git ls-files '*.rs' | grep -v '/vendor/' > "$tmp/files"
# ---------------------------------------------------------------- gate A
awk '
function reset() { state = 0; has_unsafe = 0; depth = 0 }
FNR == 1 { reset(); fenced = 0; waive_next = 0 }
/^#!\[allow\(unsafe_op_in_unsafe_fn\)\]$/ { fenced = 1 }
{
line = $0
sub(/^[ \t]+/, "", line)
is_comment = (line ~ /^\/\//)
if (is_comment && line ~ /unsafe-fn-no-op-ok:/) { waive_next = 1 }
}
fenced { next }
# fn-definition start: a plain `unsafe fn` outside a comment — not a type alias, not an
# `unsafe extern "ABI" fn` (signature-mandated markers; see the header)
state == 0 && !is_comment && /(^|[ \t(])unsafe[ \t]+fn[ \t]+[A-Za-z_]/ \
&& $0 !~ /^[ \t]*type[ \t]/ && $0 !~ /=[ \t]*unsafe/ {
state = 1; sig_file = FILENAME; sig_line = FNR
name = $0; sub(/.*fn[ \t]+/, "", name); sub(/[^A-Za-z0-9_].*/, "", name)
waived = waive_next
}
state == 1 {
# declaration (trait method / extern block) ends before a body opens
if ($0 ~ /;/ && $0 !~ /{/) { reset(); next }
if ($0 ~ /{/) {
state = 2
# count braces via gsub (returns the count, leaves the line unchanged) — the
# empty-separator split() alternative is a gawk extension mawk lacks
t = $0; opens = gsub(/\{/, "{", t); t = $0; closes = gsub(/\}/, "}", t)
depth = opens - closes
if (opens > 0 && depth == 0) { # one-line body
if ($0 ~ /unsafe[ \t]*{[^}]*}[^}]*}/ || $0 ~ /unsafe impl/) has_unsafe = 1
if (!has_unsafe && !waived) { print sig_file ":" sig_line ": unsafe fn `" name "` has no unsafe operation in its body"; bad = 1 }
reset()
}
next
}
next
}
state == 2 {
if (!is_comment && ($0 ~ /unsafe[ \t]*{/ || $0 ~ /unsafe impl/)) has_unsafe = 1
t = $0; opens = gsub(/\{/, "{", t); t = $0; closes = gsub(/\}/, "}", t)
depth += opens - closes
if (depth <= 0) {
if (!has_unsafe && !waived) { print sig_file ":" sig_line ": unsafe fn `" name "` has no unsafe operation in its body"; bad = 1 }
reset()
}
}
!is_comment { waive_next = 0 }
END { exit bad ? 1 : 0 }
' $(cat "$tmp/files") > "$tmp/gate_a" 2>&1
if [ -s "$tmp/gate_a" ]; then
echo "GATE A — unsafe fn markers carrying no contract (waive a contract-deferring fn with"
echo " '// unsafe-fn-no-op-ok: <reason>' on the line above):"
cat "$tmp/gate_a"
fail=1
fi
# ---------------------------------------------------------------- gate B
awk '
function reset() { state = 0; depth = 0; guarded = 0; nhit = 0 }
FNR == 1 { reset(); waive_next = 0 }
{
line = $0
sub(/^[ \t]+/, "", line)
is_comment = (line ~ /^\/\//)
if (is_comment && line ~ /panic-in-extern-ok:/) { waive_next = 1 }
}
state == 0 && !is_comment && /extern[ \t]+"(C|system)"[ \t]+fn[ \t]+[A-Za-z_]/ \
&& $0 !~ /^[ \t]*type[ \t]/ && $0 !~ /=[ \t]*(unsafe[ \t]+)?extern/ {
state = 1; sig_file = FILENAME; sig_line = FNR
name = $0; sub(/.*fn[ \t]+/, "", name); sub(/[^A-Za-z0-9_].*/, "", name)
waived = waive_next
}
state == 1 {
if ($0 ~ /;/ && $0 !~ /{/) { reset(); next }
if ($0 ~ /{/) {
state = 2
t = $0; opens = gsub(/\{/, "{", t); t = $0; closes = gsub(/\}/, "}", t)
depth = opens - closes
if (depth == 0) reset()
next
}
next
}
state == 2 {
if ($0 ~ /catch_unwind/) guarded = 1
if (!is_comment && ($0 ~ /\.unwrap\(\)/ || $0 ~ /\.expect\(/ || $0 ~ /(^|[^a-zA-Z0-9_])panic!/)) {
nhit++; hitline[nhit] = FILENAME ":" FNR
}
t = $0; opens = gsub(/\{/, "{", t); t = $0; closes = gsub(/\}/, "}", t)
depth += opens - closes
if (depth <= 0) {
if (nhit > 0 && !guarded && !waived) {
for (i = 1; i <= nhit; i++)
print hitline[i] ": unwrap/expect/panic! reachable in extern fn `" name "` (no catch_unwind)"
bad = 1
}
reset()
}
}
!is_comment { waive_next = 0 }
END { exit bad ? 1 : 0 }
' $(cat "$tmp/files") > "$tmp/gate_b" 2>&1
if [ -s "$tmp/gate_b" ]; then
echo "GATE B — panic across an extern boundary aborts the process since Rust 1.81. Route the"
echo " body through catch_unwind (see punktfunk-core abi.rs) or waive a deliberate"
echo " abort with '// panic-in-extern-ok: <reason>' on the line above the fn:"
cat "$tmp/gate_b"
fail=1
fi
# ---------------------------------------------------------------- gate C
# Baseline: per-file count of process-global-API mentions (call sites AND comments — the grep is
# the contract; keep it dumb and stable). Regenerate a line with:
# grep -c 'env::set_var\|env::remove_var\|sigaction\|setlocale\|set_current_dir' <file>
cat > "$tmp/gate_c_baseline" <<'BASELINE'
clients/linux/src/app.rs:1
clients/linux/src/spawn.rs:1
clients/session/src/main.rs:4
crates/pf-console-ui/src/screens/settings.rs:1
crates/pf-console-ui/src/shell/tests.rs:1
crates/pf-encode/src/enc/linux/nvenc_cuda.rs:2
crates/pf-encode/src/enc/linux/worker.rs:1
crates/pf-encode/src/enc/windows/nvenc.rs:4
crates/pf-inject/src/inject/linux/steam_gadget.rs:5
crates/pf-vdisplay/src/lib.rs:1
crates/pf-vdisplay/src/vdisplay/routing.rs:4
crates/pf-vdisplay/src/vdisplay/session.rs:10
crates/pf-vkdecode/tests/common/mod.rs:1
crates/pf-vkdecode/tests/gpu_parity.rs:5
crates/pf-win-display/src/win_display.rs:2
crates/punktfunk-core/src/quic/endpoint.rs:2
crates/punktfunk-host/src/identity.rs:3
crates/punktfunk-host/src/library/art.rs:4
crates/punktfunk-host/src/mgmt/tests.rs:3
crates/punktfunk-host/src/native.rs:4
crates/punktfunk-host/src/windows/service.rs:1
BASELINE
: > "$tmp/gate_c"
while IFS= read -r f; do
n=$(grep -c 'env::set_var\|env::remove_var\|sigaction\|setlocale\|set_current_dir' "$f")
[ "$n" -eq 0 ] && continue
base=$(grep -F "$f:" "$tmp/gate_c_baseline" | head -1 | awk -F: '{print $NF}')
base=${base:-0}
if [ "$n" -gt "$base" ]; then
echo "$f: $n process-global-API mentions (baseline $base)" >> "$tmp/gate_c"
fi
done < "$tmp/files"
if [ -s "$tmp/gate_c" ]; then
echo "GATE C — env::set_var/remove_var, sigaction, setlocale, set_current_dir are safe to"
echo " call and unsound from a live multithreaded process (972af299). Fix the new"
echo " call site (a per-call env override belongs in Command::env; a handler install"
echo " belongs behind Once at startup) rather than raising the baseline:"
cat "$tmp/gate_c"
fail=1
fi
if [ "$fail" -eq 0 ]; then
echo "unsafe-hygiene: all three gates clean"
fi
exit "$fail"