chore(safety): three unsafe-hygiene grep gates, blocking in ci.yml (WP2c gates)
scripts/ci/check-unsafe-hygiene.sh — textual gates for three classes no lint covers: A. unsafe fn markers carrying no contract. unsafe_op_in_unsafe_fn forces real ops into blocks, so an unsafe fn with no `unsafe` in its body is a marker with no contract (db659809found two by hand). Contract-deferring fns (Vec::set_len shape) waive with `// unsafe-fn-no-op-ok: <reason>`; fenced files and `unsafe extern "ABI" fn` (signature-mandated markers) are skipped structurally. B. unwrap/expect/panic! inside extern "C"/"system" bodies — an abort since Rust 1.81, not linted, not fuzzable (8b98d0b3). catch_unwind bodies are exempt; `// panic-in-extern-ok: <reason>` waives a deliberate abort. C. Safe-but-process-global APIs (env::set_var/remove_var, sigaction, setlocale, set_current_dir) — the972af299environ race lived in a file with zero occurrences of the word `unsafe`. Per-file count ratchet with the baseline in the script; any increase or new file fails. Making gate B clean on main surfaced 14 real instances of exactly its class — `.lock().unwrap()` in unguarded extern fns, where a poisoned mutex aborts the embedding process: six punktfunk-core abi.rs entry points (poll_frame, next_au, next_audio, next_audio_pcm, next_cursor_shape, next_clipboard), seven Android JNI entry points, and the Windows client's deeplink wnd_proc. All fixed with poison-recovering locks (the slots are last-value caches, valid whatever a poisoned writer left) and Option::insert for the set-then-unwrap shape; punktfunk-core's 203 lib tests pass. Gate A's findings were six genuine contract-deferring fns — waived with reasons, not fixed, because the markers are correct. Gate-of-the-gate: all three shown to FAIL on deliberately planted instances (marker fn, panicking extern callback, env::set_var in an unlisted file) and to run clean on the tree, before the ci.yml step made them blocking.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -45,6 +45,15 @@ pub(crate) fn jni_guard<T>(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<T>(m: &Mutex<T>) -> 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
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<AddedMonitor> {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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<T>(m: &std::sync::Mutex<T>) -> 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Executable
+211
@@ -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: <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 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: <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: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"
|
||||
Reference in New Issue
Block a user