diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 838de026..8e281324 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -111,6 +111,13 @@ jobs: - name: Format run: cargo fmt --all --check + # rust-safety WP2c: three textual gates for classes no lint covers — unsafe fn markers + # carrying no contract, panic across an extern boundary (an abort since 1.81), and + # process-global safe APIs (env::set_var & co, count-ratcheted). Pure grep/awk, no cargo. + # Both failure modes were demonstrated before this became blocking (planted instances). + - name: Unsafe-hygiene grep gates + run: sh scripts/ci/check-unsafe-hygiene.sh + - name: Clippy (deny warnings) run: cargo clippy --workspace --all-targets --locked -- -D warnings diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index 98480361..42ec297d 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -9,7 +9,7 @@ use punktfunk_core::config::{CompositorPref, GamepadPref, Mode}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use super::{hex32, jni_guard, parse_hex32, SessionHandle}; +use super::{hex32, jni_guard, lock_recover, parse_hex32, SessionHandle}; /// Machine token of the most recent `nativeConnect`/`nativePair` failure, taken (and cleared) /// by `nativeTakeLastError` so Kotlin can render a cause-specific message instead of the old @@ -41,7 +41,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeTakeLastErr env: JNIEnv<'local>, _this: JObject<'local>, ) -> jni::sys::jstring { - let token = std::mem::take(&mut *LAST_ERROR.lock().unwrap()); + let token = std::mem::take(&mut *lock_recover(&LAST_ERROR)); match env.new_string(token) { Ok(s) => s.into_raw(), Err(_) => JObject::null().into_raw(), diff --git a/clients/android/native/src/session/mod.rs b/clients/android/native/src/session/mod.rs index 9875d4a5..f38dac1e 100644 --- a/clients/android/native/src/session/mod.rs +++ b/clients/android/native/src/session/mod.rs @@ -45,6 +45,15 @@ pub(crate) fn jni_guard(default: T, f: impl FnOnce() -> T) -> T { }) } +/// Poison-recovering lock for the JNI entry points that are NOT behind [`jni_guard`]: a +/// `.lock().unwrap()` there turns a poisoned mutex into a panic across the `extern "system"` +/// boundary — an abort of the whole app on Rust ≥ 1.81 (the panic-in-extern grep gate's class). +/// The slots behind these mutexes are plane-thread handles and last-value caches; whatever a +/// poisoned writer left is still valid to inspect or replace. +pub(crate) fn lock_recover(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + /// A live session behind the `jlong` handle: the connector + the decode thread it feeds. pub(crate) struct SessionHandle { // Read only by the android decode path (`nativeStartVideo` → `crate::decode`); on the host diff --git a/clients/android/native/src/session/planes.rs b/clients/android/native/src/session/planes.rs index 198ea461..5a62288a 100644 --- a/clients/android/native/src/session/planes.rs +++ b/clients/android/native/src/session/planes.rs @@ -8,7 +8,7 @@ use jni::objects::JString; use jni::sys::{jboolean, jdoubleArray, jintArray, jlong, jsize, jstring}; use jni::JNIEnv; -use super::{jni_guard, SessionHandle}; +use super::{jni_guard, lock_recover, SessionHandle}; /// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature, /// isTv, presentPriority, smoothBuffer)` — wrap the SurfaceView's `Surface` as an `ANativeWindow` @@ -48,7 +48,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo( .filter(|s| !s.is_empty()); // SAFETY: live handle per the nativeConnect/nativeClose contract. let h = unsafe { &*(handle as *const SessionHandle) }; - let mut guard = h.video.lock().unwrap(); + let mut guard = lock_recover(&h.video); if guard.is_some() { return; // already streaming } @@ -222,7 +222,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats( } // SAFETY: live handle per the nativeConnect/nativeClose contract. let h = unsafe { &*(handle as *const SessionHandle) }; - if h.video.lock().unwrap().is_none() { + if lock_recover(&h.video).is_none() { return std::ptr::null_mut(); // not streaming → no stats } let snap = h @@ -385,7 +385,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartAudio( } // SAFETY: live handle per the nativeConnect/nativeClose contract. let h = unsafe { &*(handle as *const SessionHandle) }; - let mut guard = h.audio.lock().unwrap(); + let mut guard = lock_recover(&h.audio); if guard.is_some() { return; // already playing } @@ -434,7 +434,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartMic( } // SAFETY: live handle per the nativeConnect/nativeClose contract. let h = unsafe { &*(handle as *const SessionHandle) }; - let mut guard = h.mic.lock().unwrap(); + let mut guard = lock_recover(&h.mic); if let Some(m) = guard.as_ref() { return m.session_id(); // already capturing — same stream, same session } @@ -516,7 +516,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud speaker != 0, ) { Some(p) => { - *h.pad_audio.lock().unwrap() = Some(p); + *lock_recover(&h.pad_audio) = Some(p); 1 } None => 0, @@ -629,6 +629,6 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeMicActive( } // SAFETY: live handle per the nativeConnect/nativeClose contract. let h = unsafe { &*(handle as *const SessionHandle) }; - jboolean::from(h.mic.lock().unwrap().is_some()) + jboolean::from(lock_recover(&h.mic).is_some()) }) } diff --git a/clients/windows/src/deeplink.rs b/clients/windows/src/deeplink.rs index 87ea4a40..eb06bdde 100644 --- a/clients/windows/src/deeplink.rs +++ b/clients/windows/src/deeplink.rs @@ -176,7 +176,13 @@ unsafe extern "system" fn wnd_proc( let slice = unsafe { std::slice::from_raw_parts(cds.lpData as *const u16, len) }; let url = String::from_utf16_lossy(slice); tracing::debug!(%url, "link from another instance"); - INBOX.lock().unwrap().push(url); + // Poison-recover, never unwrap: a panic out of a window procedure is an abort since + // Rust 1.81, and the inbox is a plain Vec that stays valid whatever a poisoned + // writer left behind. + INBOX + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(url); return LRESULT(1); } } diff --git a/crates/pf-encode/src/enc/libav.rs b/crates/pf-encode/src/enc/libav.rs index 1954aa6e..35acb531 100644 --- a/crates/pf-encode/src/enc/libav.rs +++ b/crates/pf-encode/src/enc/libav.rs @@ -48,6 +48,8 @@ impl AvBuffer { /// allocator returns on failure (so the `is_null` check every caller used to open-code happens /// once, here). /// + // unsafe-fn-no-op-ok: contract-deferring constructor (`Vec::set_len` shape) — the body is + // safe; the ownership transfer promised here is what Drop/as_ptr later rely on. /// # Safety /// `p` must be null, or a live `AVBufferRef` whose ownership passes to the returned value — /// nothing else may unref it. diff --git a/crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs index 510ad56d..4a7a287a 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs @@ -81,6 +81,8 @@ pub(crate) trait VdisplayDriver: Send + Sync { /// The monitor is NOT departed; the caller CCD-forces the freshly-advertised mode afterwards. /// The default errs so a backend without support routes to the re-arrival fallback. /// + // unsafe-fn-no-op-ok: trait method — the "dev is live" contract binds every impl; this + // default body is a stub that bails. /// # Safety /// `dev` must be the live control handle. unsafe fn update_modes(&self, dev: HANDLE, key: &MonitorKey, mode: Mode) -> Result<()> { @@ -114,6 +116,7 @@ mod tests { fn open(&self, _reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)> { anyhow::bail!("fake driver has no control device") } + // unsafe-fn-no-op-ok: signature mandated by the trait; test stub. unsafe fn add_monitor( &self, _dev: HANDLE, @@ -125,9 +128,11 @@ mod tests { ) -> Result { anyhow::bail!("fake driver adds no monitors") } + // unsafe-fn-no-op-ok: signature mandated by the trait; test stub. unsafe fn remove_monitor(&self, _dev: HANDLE, _key: &MonitorKey) -> Result<()> { Ok(()) } + // unsafe-fn-no-op-ok: signature mandated by the trait; test stub. unsafe fn ping(&self, _dev: HANDLE) -> Result<()> { Ok(()) } diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index d90a53c7..48cd44c4 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -53,6 +53,16 @@ use std::os::raw::c_char; use std::panic::AssertUnwindSafe; use std::ptr; +/// Poison-recovering lock for the C ABI surface. `.lock().unwrap()` inside an `extern "C"` fn +/// turns a poisoned mutex (some other thread panicked mid-write) into a panic across the C +/// boundary — an abort since Rust 1.81, exactly the class the panic-in-extern grep gate exists +/// for. The slots behind these mutexes are plain last-value caches (frame/audio/cursor/clip), so +/// whatever a poisoned writer left behind is still structurally valid data to overwrite or hand +/// out; recovering the guard is strictly better than aborting the embedding application. +fn lock_recover(m: &std::sync::Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + /// Opaque session handle. Pointer-only from C. pub struct PunktfunkSession { inner: Session, @@ -471,8 +481,7 @@ pub unsafe extern "C" fn punktfunk_client_poll_frame( } match s.inner.poll_frame() { Ok(frame) => { - s.last_frame = Some(frame); - let f = s.last_frame.as_ref().unwrap(); + let f = s.last_frame.insert(frame); // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the // matching `#[repr(C)]` type, written once by value. unsafe { @@ -2249,9 +2258,8 @@ pub unsafe extern "C" fn punktfunk_connection_next_au( .next_frame(std::time::Duration::from_millis(timeout_ms as u64)) { Ok(frame) => { - let mut slot = c.last.lock().unwrap(); - *slot = Some(frame); - let f = slot.as_ref().unwrap(); + let mut slot = lock_recover(&c.last); + let f = slot.insert(frame); // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the // matching `#[repr(C)]` type, written once by value. unsafe { @@ -2314,9 +2322,8 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio( .next_audio(std::time::Duration::from_millis(timeout_ms as u64)) { Ok(pkt) => { - let mut slot = c.last_audio.lock().unwrap(); - *slot = Some(pkt); - let p = slot.as_ref().unwrap(); + let mut slot = lock_recover(&c.last_audio); + let p = slot.insert(pkt); // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the // matching `#[repr(C)]` type, written once by value. unsafe { @@ -2467,7 +2474,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm( Ok(pkt) => pkt, Err(e) => return e.status(), }; - let mut state = c.audio_pcm.lock().unwrap(); + let mut state = lock_recover(&c.audio_pcm); match state.decode_packet(&pkt.data, pkt.seq, channels) { // Nothing to hand out this call: a DTX silence marker with no loss owed before it. Ok(0) => PunktfunkStatus::NoFrame, @@ -3072,9 +3079,8 @@ pub unsafe extern "C" fn punktfunk_connection_next_cursor_shape( .next_cursor_shape(std::time::Duration::from_millis(timeout_ms as u64)) { Ok(shape) => { - let mut slot = c.last_cursor_shape.lock().unwrap(); - *slot = Some(shape); - let sh = slot.as_ref().unwrap(); + let mut slot = lock_recover(&c.last_cursor_shape); + let sh = slot.insert(shape); // SAFETY: per the ABI contract - `out` is a caller-owned writable slot of the // matching `#[repr(C)]` type, written once by value. unsafe { @@ -4053,7 +4059,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_clipboard( .next_clip(std::time::Duration::from_millis(timeout_ms as u64)) { Ok(ev) => { - let mut slot = c.last_clip.lock().unwrap(); + let mut slot = lock_recover(&c.last_clip); let out_ev = build_clip_event(ev, &mut slot); // SAFETY: per the ABI contract - a caller-owned out-param, non-null on this path, // written once by value. @@ -4065,7 +4071,7 @@ pub unsafe extern "C" fn punktfunk_connection_next_clipboard( // traffic is sporadic, so without this a one-off 50 MiB paste stays resident // for the rest of the session (there is no other release entry point). The // borrow contract already says `out` data is valid only until the next call. - *c.last_clip.lock().unwrap() = None; + *lock_recover(&c.last_clip) = None; e.status() } } diff --git a/packaging/windows/drivers/pf-umdf-util/src/wdf.rs b/packaging/windows/drivers/pf-umdf-util/src/wdf.rs index d72f11c8..033869b6 100644 --- a/packaging/windows/drivers/pf-umdf-util/src/wdf.rs +++ b/packaging/windows/drivers/pf-umdf-util/src/wdf.rs @@ -29,6 +29,8 @@ pub struct Request(WDFREQUEST); impl Request { /// Wrap the raw request handed to the current framework callback. /// + // unsafe-fn-no-op-ok: contract-deferring constructor — the body only wraps the handle; every + // later `complete`/`forward` call trusts the framework-liveness promised here. /// # Safety /// `raw` must be the live, framework-provided `WDFREQUEST` of the callback invocation this is /// called from (WDF owns handle validity; a forged/dangling handle is framework UB). diff --git a/scripts/ci/check-unsafe-hygiene.sh b/scripts/ci/check-unsafe-hygiene.sh new file mode 100755 index 00000000..3fa03d4c --- /dev/null +++ b/scripts/ci/check-unsafe-hygiene.sh @@ -0,0 +1,211 @@ +#!/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: ` 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: ` directly +# above the fn. +# +# C. Safe-but-process-global APIs: `env::set_var`/`remove_var`, `sigaction`, `setlocale`, +# `set_current_dir`. Each is safe to call 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. Edition 2024 makes `env::set_var` unsafe; until that +# migration this count-ratchet is the control. 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: ' 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: ' 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' +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:2 +crates/pf-encode/src/enc/linux/nvenc_cuda.rs:49 +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"