with one click
rust-unsafe
// Rust unsafe guidance for FFI, raw pointers, transmute, UnsafeCell, ioctl/tun, and safe wrappers.
// Rust unsafe guidance for FFI, raw pointers, transmute, UnsafeCell, ioctl/tun, and safe wrappers.
[HINT] Download the complete skill directory including SKILL.md and all related files
| name | rust-unsafe |
| description | Rust unsafe guidance for FFI, raw pointers, transmute, UnsafeCell, ioctl/tun, and safe wrappers. |
Guide agents through writing, reviewing, and auditing unsafe Rust in RIPDPI's 23 native crates. The dominant unsafe patterns are JNI FFI, Linux ioctl/tun device operations, and signal handling.
#![forbid(unsafe_code)]Pure-logic crates MUST carry #![forbid(unsafe_code)] at the crate root. Currently enforced in: ripdpi-failure-classifier, ripdpi-ipfrag, ripdpi-desync, ripdpi-session, ripdpi-config, ripdpi-packets. When creating a new crate that has no FFI or OS-level calls, add the attribute. When reviewing, verify it has not been removed without justification.
*const T, *mut T)extern "C" / extern "system")Send, Sync)Every JNI entry point uses #[unsafe(no_mangle)] (Rust 2024 syntax) and extern "system":
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_poyka_ripdpi_core_Tun2SocksNativeBindings_jniCreate(
env: EnvUnowned<'_>,
_thiz: JObject,
config_json: JString,
) -> jlong {
tunnel_create_entry(env, config_json)
}
Unwinding across extern "system" is UB. All JNI entry points MUST catch panics. The project uses EnvUnowned::with_env + Outcome:
pub(crate) fn proxy_create_entry(mut env: EnvUnowned<'_>, config_json: JString) -> jlong {
match env.with_env(move |env| -> jni::errors::Result<jlong> {
Ok(create_session(env, config_json))
}).into_outcome() {
Outcome::Ok(handle) => handle,
Outcome::Err(err) => { /* throw Java exception, return 0 */ }
Outcome::Panic(payload) => { /* throw Java exception with panic message, return 0 */ }
}
}
Rule: Never write a bare extern "system" fn body without with_env/catch_unwind wrapping.
These take ownership of a raw JNI local reference. Safety invariants:
// Safety: `raw` is a valid jstring local ref returned by the JVM;
// null-checked above; consumed exactly once.
let string = unsafe { JString::from_raw(env, raw) };
Used in tests to convert a raw JNIEnv pointer. The resulting EnvUnowned must not outlive the Env it was derived from:
// Safety: env pointer is valid for the lifetime of the Env borrow.
unsafe { EnvUnowned::from_raw(env.get_raw()) }
JavaVM::from_raw(vm.get_raw()) clones the VM handle without incrementing a refcount. The resulting handle is a plain pointer copy; the JVM retains ownership.
Every call MUST have a // SAFETY: comment documenting:
JavaVM is held by a 'static OnceCell, so the pointer is valid for program lifetime).attach_current_thread, which is thread-safe on the JVM side. No mutation of VM state occurs through the clone.// SAFETY: `vm` is held by the static `JVM: OnceCell<JavaVM>` in lib.rs, so its
// raw pointer is valid for program lifetime. `JavaVM::from_raw` copies the
// pointer only; the JVM manages its own lifetime.
let vm_clone = unsafe { JavaVM::from_raw(vm.get_raw()) };
Anchor: native/rust/crates/ripdpi-android/src/vpn_protect.rs:58-60 (currently lacks a formal SAFETY: block — flag in review).
When receiving a file descriptor from Java (e.g., TUN fd from VpnService), always dup before taking ownership:
// Safety: BorrowedFd does not take ownership; dup creates an independent fd.
let owned_fd = unsafe { nix::unistd::dup(BorrowedFd::borrow_raw(tun_fd)) };
libc::ifreq is a plain C struct with no Rust-level invariants. All-zero bytes is a valid representation:
// Safety: ifreq is a plain C struct; all-zero bytes is valid.
let mut ifr: libc::ifreq = unsafe { mem::zeroed() };
ifr.ifr_name = self.make_ifr_name();
Do not use mem::zeroed() for types with Rust invariants (bool, enum, NonNull, references).
Each ioctl call needs a safety comment documenting: (1) fd validity, (2) struct field validity, (3) which ioctl number and what it does:
// Safety: sock is a valid AF_INET/SOCK_DGRAM fd; &ifr has ifr_name set and
// ifru_mtu populated; SIOCSIFMTU (0x8922) sets the interface MTU.
let res = unsafe { libc::ioctl(sock.as_raw_fd(), libc::SIOCSIFMTU, &ifr as *const _) };
if res < 0 {
return Err(TunnelError::Ioctl(format!("SIOCSIFMTU: {}", std::io::Error::last_os_error())));
}
ifreq.ifr_ifru is a C union. Access is unsafe because Rust cannot guarantee which variant was last written. Always zero-initialize first, then write-before-read:
unsafe {
ifr.ifr_ifru.ifru_flags = IFF_TUN | IFF_NO_PI;
if multi_queue {
ifr.ifr_ifru.ifru_flags |= IFF_MULTI_QUEUE;
}
}
Casting sockaddr to sockaddr_in is valid because they are layout-compatible (both start with sa_family_t). Document this in the safety comment:
// Safety: sockaddr_in is layout-compatible with sockaddr; we set sin_family
// and sin_addr which are the fields the kernel reads for SIOCSIFADDR.
unsafe {
let sin = &mut ifr.ifr_ifru.ifru_addr as *mut _ as *mut libc::sockaddr_in;
(*sin).sin_family = libc::AF_INET as libc::sa_family_t;
(*sin).sin_addr.s_addr = libc::htonl(u32::from(addr));
}
The Linux platform module (native/rust/crates/ripdpi-runtime/src/platform/linux.rs, 83 unsafe blocks — the largest concentration in the workspace) wraps raw libc::setsockopt / getsockopt / recvmsg for kernel-specific options unavailable in socket2: TCP_INFO, TCP_MD5SIG, TCP_FASTOPEN_CONNECT, SO_ORIGINAL_DST, IP_RECVTTL, and CMSG-carrying recvmsg.
The canonical wrapper idiom — zeroed::<T>() + cast-to-*mut-T for variadic kernel structs:
/// # Safety
/// `fd` must be a live socket descriptor; `T` must match the kernel's expected
/// output layout for the given `level`/`name` combination.
unsafe fn getsockopt_raw<T>(
fd: libc::c_int,
level: libc::c_int,
name: libc::c_int,
) -> io::Result<(T, libc::socklen_t)> {
let mut val: T = unsafe { zeroed() };
let mut len = size_of::<T>() as libc::socklen_t;
let rc = unsafe { libc::getsockopt(fd, level, name, (&mut val as *mut T).cast(), &mut len) };
if rc == 0 { Ok((val, len)) } else { Err(io::Error::last_os_error()) }
}
Rule: every new syscall wrapper in this module MUST:
# Safety rustdoc block on the unsafe fn signature listing the fd-validity and layout-match invariants.zeroed() only for plain C structs (no Rust-level invariants — no bool, enum, NonNull, or references).&mut val as *mut T via .cast() rather than as *mut _ — the method preserves pointer provenance and plays well with Miri's Tree Borrows checker.io::Error::last_os_error() — never discard errno.Last audited: <date> against socket2 <ver> header when adding new wrappers. The header signals a human has reconciled the kernel-ABI-vs-socket2 boundary; bumping the date on each audit is mandatory.Anchors:
native/rust/crates/ripdpi-runtime/src/platform/linux.rs:46-60 — setsockopt_raw referencenative/rust/crates/ripdpi-runtime/src/platform/linux.rs:69-83 — getsockopt_raw reference// Safety: Ignoring SIGPIPE is async-signal-safe. The previous handler is
// discarded; we don't need to restore it.
let _ = unsafe { signal(Signal::SIGPIPE, SigHandler::SigIgn) };
Call ignore_sigpipe() exactly once from JNI_OnLoad. On Android, ART does not ignore SIGPIPE for native code; writing to a closed socket delivers SIGPIPE and terminates the process.
| From | To | Safe? | Preferred alternative |
|---|---|---|---|
u32 | f32 | Yes | f32::from_bits(u) |
[u8; 4] | u32 | Yes | u32::from_ne_bytes(arr) |
&T | *const T | Yes | ptr as *const T |
Box<T> | *mut T | Yes | Box::into_raw(b) |
&'a T | &'b T (longer lifetime) | No | Restructure lifetimes |
u8 | bool | No unless 0/1 | Match on value |
u8 | MyEnum | No unless valid tag | MyEnum::try_from(u) |
Vec<T> | Vec<U> | No | Manual conversion |
When reviewing an unsafe block:
// Safety: comment explaining which invariant is upheld?extern "system" JNI: is the body wrapped in with_env/catch_unwind?from_raw): is the raw ref valid and consumed exactly once?mem::zeroed(): is the type a plain C struct with no Rust invariants?Send/Sync impl: is thread safety actually guaranteed?MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test) — Tree Borrows is the formal aliasing model published at PLDI 2025 and is now the recommended default. It permits more valid unsafe patterns than Stacked Borrows, so code that failed the older model may pass now.Drop::drop implementation contain .unwrap(), .expect(), or any call that can panic? → move to an explicit close()/flush() method returning Result.#[no_mangle] or #[export_name] symbol collide with an identically-named symbol in another cdylib crate loaded simultaneously?Legitimate (already present):
- JNI FFI exports (#[unsafe(no_mangle)], extern "system")
- JNI object construction (JString::from_raw, EnvUnowned::from_raw)
- Linux TUN device (ioctl, mem::zeroed, union field access, raw fd)
- Signal handling (ignore_sigpipe)
Should NOT need unsafe:
- Pure packet parsing / protocol logic -> use #![forbid(unsafe_code)]
- Configuration / session management -> use #![forbid(unsafe_code)]
- Anything a safe crate (nix, jni) already wraps
Drop may never runSeverity: CRITICAL for public unsafe APIs
mem::forget is a safe function. ManuallyDrop::new is safe. Any public unsafe API that relies on a RAII guard running its Drop for soundness is unsound — a caller can mem::forget the guard.
Concrete rule: if your unsafe code establishes a safety invariant via a guard's destructor (e.g., "the raw pointer in slot X is valid because the guard keeps the allocation alive"), the invariant must be stated in the # Safety section AND the API must be designed so forgetting the guard is either impossible or benign.
The correct designs:
thread::spawn requires 'static — no guard needed, lifetime enforces safety.thread::scope uses a closure + join inside the scope before returning — the scope itself is the guard, and its address is captured by the running threads, so forgetting it is prevented by the borrow checker.select! can be dropped at any .await. If your future holds a guard, cancellation may drop it without Drop running if the task itself is mem::forget-ed by the executor. Design cancel-safe futures to not rely on Drop for correctness.Reference: crabbook/raii_and_memory_safety.md
unsafe breaks local reasoningSeverity: CRITICAL
A single unsafe block anywhere in the call graph (including in dependencies) can invalidate type invariants codebase-wide. You cannot reason locally about safety just by looking at a single function.
Concrete example: flatbuffers calls str::from_utf8_unchecked internally. If the buffer contains invalid UTF-8, the resulting str violates Rust's invariants. Calling .chars() on that str — safe code — triggers a panic or UB depending on the version. The unsafe is in the dep; the observable failure is in your safe code.
Action items when auditing:
rg 'from_utf8_unchecked\|from_raw_parts\|String::from_raw_parts' native/rust/ --type rust -n — every hit needs a SAFETY comment tracing back to where the invariant is established.cargo deny check — flag any dep with a known unsafe-soundness advisory.unsafe may transit through your API surface, document the assumed invariant in your own # Safety section.Reference: crabbook/unsafe_is_unsafe.md
unsafe impl Sync/Send checklistSeverity: CRITICAL
Manually implementing Sync or Send for a type is a promise to the compiler that you guarantee thread safety. Blanket impls on the inner type's fields can silently break this promise.
Failure mode: you wrap T in a newtype and write unsafe impl Sync for MyWrapper<T> {}. Later, T gains an inner Rc<i32> field. Rc is !Send + !Sync, but the blanket impl on Debug/Clone/Display doesn't stop you from sending MyWrapper<Rc<i32>> across threads — the compiler accepts it, but a double-free or data race follows at runtime (SIGABRT under TSan).
Checklist before writing unsafe impl Sync for T / unsafe impl Send for T:
Sync/Send or document why your wrapper maintains the invariant despite the field not being so.T (especially blanket impls from Debug, Clone, Display). None of them should allow shared access to non-Sync inner state.static_assertions::assert_impl_all! or static_assertions::assert_not_impl_all! test to catch regressions if inner types change.unsafe impl with a // SAFETY: comment listing the fields audited and why the invariant holds.Reference: crabbook/send_and_sync.md
ManuallyDrop + from_raw_parts reference fabrication (caution)Severity: HIGH — use only as last resort
It is possible to fabricate a &String from a &str via ManuallyDrop<String> + String::from_raw_parts, exploiting the fact that String is layout-compatible with (ptr, len, cap) and its bytes overlap str. This technique is:
String was never "owned" by the caller, so from_raw_parts creates a pointer with wrong provenance.String's internal layout or allocator breaks it silently.impl AsRef<str> or &str instead of &String everywhere.The only context where this pattern may appear is legacy FFI where a C caller passes a pointer and length pair and a &String is required. In that case: document the full invariant, test under Miri with Tree Borrows (MIRIFLAGS="-Zmiri-tree-borrows"), and gate the call with cfg(not(miri)) if Miri rejects it.
Reference: crabbook/crafting_reference_to_owned.md
#[no_mangle] and #[link_section] symbol collision (edition 2024 hazard)Severity: CRITICAL
In Rust 2024, #[no_mangle], #[export_name = "..."], and #[link_section = "..."] must be written as #[unsafe(no_mangle)], #[unsafe(export_name = "...")], and #[unsafe(link_section = "...")]. The unsafe wrapper acknowledges a soundness risk that predates the edition: if two compilation units export the same unmangled symbol, the linker silently picks one, causing the wrong function to be called — a soundness bug with no compile-time diagnostic.
In RIPDPI: all four -android cdylib crates (ripdpi-android, ripdpi-tunnel-android, ripdpi-warp-android, ripdpi-relay-android) export Java_* symbols. The JNI naming convention (Java_<pkg>_<class>_<method>) provides natural uniqueness, but any #[no_mangle] on a non-JNI symbol (e.g., a C-API entry point, a test export, or a cbindgen-generated symbol) must be audited for uniqueness across all cdylib crates.
Audit check: rg '#\[no_mangle\]|#\[export_name' native/rust/ --type rust -n — for every hit, verify: (a) the symbol name is unique across all crates that may be loaded simultaneously, and (b) the 2024 unsafe() wrapper form is used after edition migration.
Reference: Edition Guide — Unsafe Attributes, RFC 3325.
Drop::drop during unwinding aborts the processSeverity: CRITICAL
If a panic is already in progress (stack unwinding), and a Drop implementation panics while being called, Rust immediately aborts the process — this is a "double panic". Unlike a first panic, a double panic cannot be caught by std::panic::catch_unwind. There is no recovery path.
This is a latent hazard in any RAII guard whose drop() performs fallible cleanup: flushing a buffer, sending a shutdown packet, committing a transaction, closing a socket gracefully. Any .unwrap(), .expect(), or ? (via a method that panics on error) inside drop() is a double-panic bomb that fires only when there is already an error in flight — exactly the worst time.
// DANGEROUS: double-panic if called during unwind
impl Drop for BufferedWriter {
fn drop(&mut self) {
self.flush().unwrap(); // panics -> process abort if already unwinding
}
}
// CORRECT: log-and-discard in drop(), expose an explicit close() for Result
impl Drop for BufferedWriter {
fn drop(&mut self) {
if let Err(e) = self.flush() {
tracing::error!("flush on drop failed: {e}");
}
}
}
impl BufferedWriter {
pub fn close(mut self) -> Result<()> {
self.flush()?; // returns error to caller instead of panicking
}
}
Rule: drop() MUST NOT panic. Move all fallible cleanup to an explicit close() / commit() / flush() method that returns Result. Leave drop() as a silent best-effort fallback with error logging only.
Reference: Rustonomicon — Unwinding, Ferrous Systems — Drop, Panic and Abort.
rust-sanitizers-miri -- Miri is the essential tool for testing unsafe coderust-ffi -- FFI patterns, bindgen, cbindgenrust-debugging -- debugging panics in unsafe codememory-model -- aliasing and memory ordering in unsafeFor detailed reference patterns, see references/unsafe-patterns.md.