pixelflux/nvgpufilter.rs
1/*
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5 */
6
7//! Multi-GPU NVENC `GET_ATTACHED_IDS` / `GET_PROBED_IDS` ioctl filter — it exists so a container
8//! handed only a subset of the host's GPUs can still open an NVENC session.
9//!
10//! The problem: on NVIDIA driver 570-595, `libnvidia-encode` / `libcuda` / `libnvcuvid` enumerate
11//! every *host* GPU via the RM `GET_ATTACHED_IDS` ioctl and try to peer-init each one — including
12//! GPUs the container never exposed. A GPU whose `/dev/nvidiaX` node is absent or unopenable then makes
13//! `nvEncOpenEncodeSessionEx` fail with UNSUPPORTED_DEVICE, so the session cannot open at all even
14//! though a perfectly usable GPU is right there in the container.
15//!
16//! The fix is to strip the unreachable GPUs out of that enumeration response before the libraries
17//! act on it. It GOT-patches `ioctl` in those NVIDIA libraries *only* — deliberately not an
18//! LD_PRELOAD object, which would shadow every `ioctl` in the process and need its own recursion
19//! guard. Because this crate's own GOT is left untouched, the wrapper's inner `ioctl` still resolves
20//! to the real libc instead of re-entering itself.
21//!
22//! Everything hinges on at least one host GPU being hidden from the container, since that is the
23//! only situation the bug arises in: on 565-or-before / 610-or-later drivers (enumeration already
24//! correct) and whenever the container can see every host GPU, the strict-subset rule downstream
25//! makes the whole filter a no-op.
26
27// Walking the ELF tables and repointing GOT slots is raw-pointer work from
28// end to end, so the safety contract is carried by the function signatures
29// rather than by a block around each dereference.
30#![allow(unsafe_op_in_unsafe_fn)]
31
32use libc::{c_char, c_int, c_long, c_ulong, c_void};
33use std::ffi::CStr;
34use std::sync::Once;
35use std::sync::atomic::{AtomicPtr, AtomicU64, Ordering};
36
37/// The ioctl command number (`_IOC_NR`, bits 0-7) that identifies the RM control escape —
38/// the low byte the request gate keys on to recognize `NV_RM_CONTROL_REQUEST`. Kept as a named
39/// constant only for documentation and the `ioc_nr_extracts_low_byte` unit test, hence
40/// `#[allow(dead_code)]`.
41#[allow(dead_code)]
42const NV_ESC_RM_CONTROL: c_ulong = 0x2A;
43/// The RM control ioctl the gate recognizes: `_IOWR('F'=0x46, NR=0x2A,
44/// sizeof(NVOS54_PARAMETERS)=32)`, encoding DIR=READ|WRITE, TYPE=0x46, NR=0x2A, SIZE=0x20.
45///
46/// Matching is by DIR|TYPE|NR with the encoded `_IOC_SIZE` deliberately masked off (via
47/// `ioc_no_size`): a driver whose `NVOS54_PARAMETERS` has a different `sizeof` encodes a different
48/// size and would slip past an exact-request compare, yet it is still the very same command.
49/// Matching the NR alone would be too loose and pinning the exact encoded size too tight, so the
50/// gate keys on DIR|TYPE|NR and validates the true per-command parameter layout separately via
51/// `ctrl.params_size`. This is the deliberate middle ground.
52const NV_RM_CONTROL_REQUEST: c_ulong = 0xC020_462A;
53/// Locates the `_IOC_SIZE` field so it can be cleared for size-agnostic request matching:
54/// the 14-bit encoded parameter size lives at bit 16 of an ioctl request (`asm-generic/ioctl.h`).
55/// Paired with `IOC_SIZEMASK` by `ioc_no_size`.
56const IOC_SIZESHIFT: u32 = 16;
57/// 14-bit mask covering the `_IOC_SIZE` field (`(1 << 14) - 1`); shifted by `IOC_SIZESHIFT`
58/// and cleared from a request by `ioc_no_size` so the driver's param-struct size cannot affect the
59/// match.
60const IOC_SIZEMASK: c_ulong = 0x3FFF;
61/// Identifies the attached-GPU enumeration response the filter rewrites — RM control command
62/// `NV0000_CTRL_CMD_GPU_GET_ATTACHED_IDS`, matched against `NvRmControlParams::cmd`.
63const GPU_GET_ATTACHED_IDS: u32 = 0x0201;
64/// Identifies the probed-GPU enumeration response the filter rewrites — RM control command
65/// `NV0000_CTRL_CMD_GPU_GET_PROBED_IDS`, matched against `NvRmControlParams::cmd`.
66const GPU_GET_PROBED_IDS: u32 = 0x0214;
67/// The RM ABI caps attached GPUs at 32, so this is the fixed `gpuIds` array capacity in both
68/// enumeration params structs — and the bound the filter's scans and scratch buffers are sized to.
69const MAX_ATTACHED_GPUS: usize = 32;
70/// Marks the end of the live ids: the RM fills unused `gpuIds` slots with this sentinel, so
71/// the first `INVALID_GPU_ID` terminates the populated prefix the filter scans and rewrites.
72const INVALID_GPU_ID: u32 = 0xFFFF_FFFF;
73/// The `params_size` that marks an ATTACHED response and tells it apart from PROBED: both
74/// structs open with the same `gpuIds[32]`, so only the exact total size (here `gpuIds[32]` =
75/// `4 * MAX_ATTACHED_GPUS`) disambiguates them before the shared leading array is rewritten.
76const ATTACHED_PARAMS_SIZE: usize = 4 * MAX_ATTACHED_GPUS;
77/// The `params_size` that marks a PROBED response and tells it apart from ATTACHED: this
78/// struct is `gpuIds[32]` followed by `excludedGpuIds[32]` (`4 * MAX_ATTACHED_GPUS * 2`). Only the
79/// shared leading `gpuIds[32]` is rewritten; the trailing `excludedGpuIds[32]` lists GPUs to
80/// exclude, not to use, so it is deliberately left untouched.
81const PROBED_PARAMS_SIZE: usize = 4 * MAX_ATTACHED_GPUS * 2;
82
83/// The param block the RM control ioctl hands back (`NVOS54_PARAMETERS`, 32 bytes, pointed at
84/// by `arg`) — modeled here so the filter can identify and validate an enumeration call before it
85/// touches the GPU-id array it carries.
86///
87/// The rewrite reads `cmd` to identify the enumeration API, `status` and `params` to confirm the
88/// call succeeded and carries a payload, and `params_size` to validate the exact payload layout
89/// before dereferencing `params` (a `u64` user pointer to the command-specific struct).
90#[repr(C)]
91struct NvRmControlParams {
92 h_client: u32,
93 h_object: u32,
94 cmd: u32,
95 flags: u32,
96 params: u64,
97 params_size: u32,
98 status: u32,
99}
100
101/// The `DT_*` `.dynamic` tags the GOT patch needs to locate a library's symbol, string, and
102/// relocation tables — spelled out here because libc exposes `PT_DYNAMIC` but not these individual
103/// values. `DT_NULL` (0) terminates the array.
104const DT_NULL: i64 = 0;
105/// ELF `.dynamic` tag `DT_PLTRELSZ` (2): total byte size of the PLT relocation table.
106const DT_PLTRELSZ: i64 = 2;
107/// ELF `.dynamic` tag `DT_RELA` (7): address of the general relocation table (`.rela.dyn`).
108const DT_RELA: i64 = 7;
109/// ELF `.dynamic` tag `DT_RELASZ` (8): total byte size of the `.rela.dyn` relocation table.
110const DT_RELASZ: i64 = 8;
111/// ELF `.dynamic` tag `DT_STRTAB` (5): address of the dynamic string table (symbol names).
112const DT_STRTAB: i64 = 5;
113/// ELF `.dynamic` tag `DT_SYMTAB` (6): address of the dynamic symbol table.
114const DT_SYMTAB: i64 = 6;
115/// ELF `.dynamic` tag `DT_JMPREL` (23): address of the PLT relocation table (`.rela.plt`).
116const DT_JMPREL: i64 = 23;
117
118// The two GOT-slot relocation types the filter repoints, per target architecture: the
119// eagerly-bound `GLOB_DAT` slot the `-fno-plt` form uses (in `.rela.dyn`, the layout NVIDIA ships)
120// and the classic lazily-bound `JUMP_SLOT` slot (in `.rela.plt`). Their numeric codes differ by
121// architecture, so keying on the x86-64 values alone would silently match nothing on aarch64 and
122// leave the multi-GPU filter a no-op there. `RELOC_ARCH_SUPPORTED` is false on any other
123// architecture, where `install` reports the filter unsupported rather than patching nothing.
124#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
125const _: () = assert!(RELOC_ARCH_SUPPORTED);
126
127cfg_if::cfg_if! {
128 if #[cfg(target_arch = "x86_64")] {
129 const RELOC_GLOB_DAT: u32 = 6;
130 const RELOC_JUMP_SLOT: u32 = 7;
131 const RELOC_ARCH_SUPPORTED: bool = true;
132 } else if #[cfg(target_arch = "aarch64")] {
133 const RELOC_GLOB_DAT: u32 = 1025;
134 const RELOC_JUMP_SLOT: u32 = 1026;
135 const RELOC_ARCH_SUPPORTED: bool = true;
136 } else {
137 const RELOC_GLOB_DAT: u32 = u32::MAX;
138 const RELOC_JUMP_SLOT: u32 = u32::MAX;
139 const RELOC_ARCH_SUPPORTED: bool = false;
140 }
141}
142
143/// Identity of the RM control device, cached so the hook only rewrites responses that truly
144/// came from it: the `rdev` of `/dev/nvidiactl`, resolved once at `install()`. A value of 0 means it
145/// was never resolved, in which case the fd-identity gate is skipped and matching falls back to the
146/// request/size checks alone.
147static NVIDIACTL_RDEV: AtomicU64 = AtomicU64::new(0);
148
149/// One `Elf64_Dyn` entry, modeled so the GOT patch can iterate a library's `.dynamic` array:
150/// a `d_tag` (`DT_*`) paired with `d_un`, the 64-bit `d_val`/`d_ptr` union interpreted per tag.
151#[repr(C)]
152struct Elf64Dyn {
153 d_tag: i64,
154 d_un: u64,
155}
156
157/// One `Elf64_Rela` relocation entry, modeled so the patch can find each GOT slot and the
158/// symbol it binds: `r_offset` (the target GOT slot, base-relative), `r_info` (packed symbol index +
159/// relocation type, split by `elf64_r_sym`/`elf64_r_type`), and the unused `r_addend`.
160#[repr(C)]
161struct Elf64Rela {
162 r_offset: u64,
163 r_info: u64,
164 r_addend: i64,
165}
166
167/// Extract the symbol-table index from an `Elf64_Rela` `r_info` field (its high 32 bits) —
168/// how the patch learns which symbol a relocation binds, so it can check for `ioctl`.
169#[inline]
170fn elf64_r_sym(info: u64) -> u64 {
171 info >> 32
172}
173
174/// Extract the relocation type from an `Elf64_Rela` `r_info` field (its low 32 bits) — how
175/// the patch tells a `JUMP_SLOT`/`GLOB_DAT` GOT entry from relocations it must ignore.
176#[inline]
177fn elf64_r_type(info: u64) -> u32 {
178 (info & 0xffff_ffff) as u32
179}
180
181/// Extract the `_IOC_NR` command byte (bits 0-7) of an ioctl request — the field that names
182/// the command. Used only by documentation and the `ioc_nr_extracts_low_byte` unit test, hence
183/// `#[allow(dead_code)]`.
184#[allow(dead_code)]
185#[inline]
186fn ioc_nr(req: c_ulong) -> c_ulong {
187 req & 0xFF
188}
189
190/// Strip the `_IOC_SIZE` field from a request (leaving DIR|TYPE|NR) so the gate stays bound
191/// to the exact RM control command without coupling to any one driver's param-struct size — a driver
192/// that changes `sizeof(NVOS54_PARAMETERS)` must still match.
193#[inline]
194fn ioc_no_size(req: c_ulong) -> c_ulong {
195 req & !(IOC_SIZEMASK << IOC_SIZESHIFT)
196}
197
198/// The reachability test the whole filter turns on: a GPU id is kept only if its
199/// `/dev/nvidia{minor}` node opens. Some runtimes create a node for every host GPU and enforce the
200/// allocation in the device cgroup, so an unallocated node exists but `open()` fails with `EPERM`;
201/// a presence test would count it as visible and leave the filter uninstalled.
202fn node_reachable(minor: u32) -> bool {
203 let path = format!("/dev/nvidia{}\0", minor);
204 path_opens(CStr::from_bytes_with_nul(path.as_bytes()).unwrap())
205}
206
207/// Whether `path` opens read-write from this process. The descriptor is closed at once.
208fn path_opens(path: &CStr) -> bool {
209 unsafe {
210 let fd = libc::open(
211 path.as_ptr(),
212 libc::O_RDWR | libc::O_CLOEXEC | libc::O_NOCTTY,
213 );
214 if fd < 0 {
215 return false;
216 }
217 libc::close(fd);
218 true
219 }
220}
221
222/// Map an RM `gpuId` to the `/dev/nvidia` minor that `node_reachable` needs: the id only
223/// carries a PCI address, not a device minor, so this bridges the two by scanning
224/// `/proc/driver/nvidia/gpus`, returning -1 when no match is found.
225///
226/// 1. **Extract the PCI address from the id**: the PCI address is encoded in `gpuId >> 8`, so
227/// `want_bus` is the low bus byte `(gpuId >> 8) & 0xFF` and `want_full` is the full
228/// `gpuId >> 8` (domain folded in for larger ids).
229/// 2. **Match a /proc entry**: each subdirectory is named by its PCI address
230/// `domain:bus:slot.func` in hex; split on `:` and `.` (expecting four fields) and parse the
231/// domain and bus. An entry matches when its bus equals `want_bus` **or** its combined
232/// `(domain << 8) | bus` equals `want_full` — the dual test covers ids that fold the domain in.
233/// 3. **Read the minor**: from the matched entry's `information` file, parse the `Device Minor: N`
234/// line and return `N`. The scan processes only the first matching directory (it `break`s
235/// afterward) and returns -1 if no minor line is present.
236fn gpuid_to_minor(gpu_id: u32) -> i32 {
237 let want_bus = (gpu_id >> 8) & 0xFF;
238 let want_full = gpu_id >> 8;
239 let dir = match std::fs::read_dir("/proc/driver/nvidia/gpus") {
240 Ok(d) => d,
241 Err(_) => return -1,
242 };
243 for ent in dir.flatten() {
244 let name = ent.file_name();
245 let name = match name.to_str() {
246 Some(s) => s,
247 None => continue,
248 };
249 let parts: Vec<&str> = name.split([':', '.']).collect();
250 if parts.len() != 4 {
251 continue;
252 }
253 let dom = u32::from_str_radix(parts[0], 16);
254 let bus = u32::from_str_radix(parts[1], 16);
255 let (dom, bus) = match (dom, bus) {
256 (Ok(d), Ok(b)) => (d, b),
257 _ => continue,
258 };
259 if bus != want_bus && ((dom << 8) | bus) != want_full {
260 continue;
261 }
262 let info = format!("/proc/driver/nvidia/gpus/{}/information", name);
263 if let Ok(text) = std::fs::read_to_string(&info) {
264 for line in text.lines() {
265 if let Some(rest) = line.strip_prefix("Device Minor:")
266 && let Ok(m) = rest.trim().parse::<i32>()
267 {
268 return m;
269 }
270 }
271 }
272 break;
273 }
274 -1
275}
276
277/// Drop the hidden GPUs from a `gpuIds[]` array in place — but only ever a strict subset, so
278/// the filter can never accidentally blank out every GPU and strand the container with none. Ids
279/// that pass `keep` are compacted to the front and the rest back-filled with `INVALID_GPU_ID`.
280///
281/// 1. **Scan the populated prefix**: iterate until the first `INVALID_GPU_ID`, counting `total`
282/// live ids and collecting the kept ones into a scratch array (`nkept`).
283/// 2. **Commit only a strict subset** (`0 < nkept < total`): copy the kept prefix back over `ids`
284/// and set every trailing slot to `INVALID_GPU_ID`.
285/// 3. **Fail-safe**: if none survive or all survive, leave the array untouched — never blank out
286/// every GPU, and never do redundant work when nothing was dropped.
287///
288/// The `keep` predicate is injected so this stays free of `/proc` and `/dev` access and is
289/// unit-testable in isolation.
290fn filter_ids(ids: &mut [u32; MAX_ATTACHED_GPUS], keep: impl Fn(u32) -> bool) {
291 let mut kept = [0u32; MAX_ATTACHED_GPUS];
292 let (mut total, mut nkept) = (0usize, 0usize);
293 for &id in ids.iter() {
294 if id == INVALID_GPU_ID {
295 break;
296 }
297 total += 1;
298 if keep(id) {
299 kept[nkept] = id;
300 nkept += 1;
301 }
302 }
303 if nkept > 0 && nkept < total {
304 ids[..nkept].copy_from_slice(&kept[..nkept]);
305 for slot in ids[nkept..].iter_mut() {
306 *slot = INVALID_GPU_ID;
307 }
308 }
309}
310
311/// The seam where a hidden GPU gets scrubbed out: the `ioctl` wrapper installed into the
312/// NVIDIA libraries' GOT slots, which forwards to the real `libc::ioctl` and then post-processes the
313/// response. The three steps below exist only to make interposing on `ioctl` safe:
314///
315/// 1. **Call through**: invoke the genuine `libc::ioctl`. Only this crate's own GOT is left
316/// unpatched, so this inner call resolves to libc rather than back into this wrapper (no
317/// recursion).
318/// 2. **Filter the result** under `catch_unwind`: `rewrite_attached_ids` may drop hidden GPUs from
319/// the response. A panic must never unwind across this `extern "C"` boundary — the compiler
320/// guard would abort the whole process — so any panic is caught and the unmodified real result
321/// is returned instead.
322/// 3. **Preserve `errno`**: the real syscall's `errno` is saved before the `/proc` + `/dev` lookups
323/// inside the filter and restored afterward, so a caller reading `errno` sees the syscall's value.
324unsafe extern "C" fn filtered_ioctl(fd: c_int, req: c_ulong, arg: *mut c_void) -> c_int {
325 let rc = libc::ioctl(fd, req as _, arg);
326 let saved_errno = *libc::__errno_location();
327 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
328 rewrite_attached_ids(fd, rc, req, arg);
329 }));
330 *libc::__errno_location() = saved_errno;
331 rc
332}
333
334/// Scrub the hidden GPUs out of a successful `GET_ATTACHED_IDS` / `GET_PROBED_IDS` response so
335/// the NVIDIA libraries never try to peer-init a GPU the container cannot reach: it drops every id
336/// whose `/dev/nvidia` node is absent. Split from `filtered_ioctl` so the whole thing runs under
337/// `catch_unwind`.
338///
339/// The guards run cheap-to-expensive and each returns early — leaving the response untouched — the
340/// moment one fails, so the overwhelmingly common non-enumeration ioctl pays almost nothing:
341///
342/// 1. **Request gate**: the syscall succeeded (`rc == 0`), `arg` is non-null, and the request is
343/// the RM_CONTROL ioctl matched by DIR|TYPE|NR (size-agnostic, via `ioc_no_size`).
344/// 2. **fd identity gate**: when `NVIDIACTL_RDEV` was resolved at install, `fstat` the fd and
345/// require a character device whose `rdev` equals `/dev/nvidiactl`'s — so only that char
346/// device's responses are rewritten. An `fstat` failure passes through untouched; if the rdev
347/// was never resolved the gate is skipped and matching relies on the request/size checks alone.
348/// 3. **Payload gate**: the RM call itself reports success (`ctrl.status == 0`) and carries a
349/// parameter pointer (`ctrl.params != 0`).
350/// 4. **Command + exact-size dispatch**: `cmd` selects ATTACHED or PROBED, and `params_size` must
351/// equal that command's exact struct size. Requiring the exact size both disambiguates the two
352/// (their layouts share a leading array) and stops a lying size from steering a rewrite under a
353/// different layout. Both APIs are filtered so behavior is uniform across driver versions —
354/// buggy 570-595 where it actually drops GPUs, and correct drivers where the strict-subset rule
355/// makes it a no-op.
356/// 5. **Filter the leading `gpuIds[32]`**: `filter_ids` keeps an id only when `gpuid_to_minor`
357/// resolves it and the corresponding `/dev/nvidia` node is present. For PROBED the trailing
358/// `excludedGpuIds[]` is deliberately left untouched (it lists GPUs to exclude, not to use).
359///
360/// When `PIXELFLUX_GPU_FILTER_DEBUG` is set, the before/after live-id counts are logged.
361unsafe fn rewrite_attached_ids(fd: c_int, rc: c_int, req: c_ulong, arg: *mut c_void) {
362 if rc != 0 || ioc_no_size(req) != ioc_no_size(NV_RM_CONTROL_REQUEST) || arg.is_null() {
363 return;
364 }
365 let cached = NVIDIACTL_RDEV.load(Ordering::Relaxed);
366 if cached != 0 {
367 let mut st: libc::stat = std::mem::zeroed();
368 if libc::fstat(fd, &mut st) != 0 {
369 return;
370 }
371 if (st.st_mode & libc::S_IFMT) != libc::S_IFCHR || st.st_rdev as u64 != cached {
372 return;
373 }
374 }
375 let ctrl = &mut *(arg as *mut NvRmControlParams);
376 if ctrl.status != 0 || ctrl.params == 0 {
377 return;
378 }
379 let which = match ctrl.cmd {
380 GPU_GET_ATTACHED_IDS if ctrl.params_size as usize == ATTACHED_PARAMS_SIZE => "ATTACHED",
381 GPU_GET_PROBED_IDS if ctrl.params_size as usize == PROBED_PARAMS_SIZE => "PROBED",
382 _ => return,
383 };
384 let ids = &mut *(ctrl.params as *mut [u32; MAX_ATTACHED_GPUS]);
385 let debug = std::env::var_os("PIXELFLUX_GPU_FILTER_DEBUG").is_some();
386 let before = ids.iter().take_while(|&&id| id != INVALID_GPU_ID).count();
387 filter_ids(ids, |id| {
388 let minor = gpuid_to_minor(id);
389 minor >= 0 && node_reachable(minor as u32)
390 });
391 if debug {
392 let after = ids.iter().take_while(|&&id| id != INVALID_GPU_ID).count();
393 eprintln!("[pixelflux] GET_{which}_IDS intercepted: {before} host GPU(s) -> {after} kept");
394 }
395}
396
397/// Reports the current `PROT_*` protection of the page holding `addr` (from
398/// `/proc/self/maps`, or -1 when the address is not found or maps is unreadable) for two reasons the
399/// GOT patch depends on: so it can refuse to dereference an address that a wrong relative/absolute
400/// guess landed in an unreadable page, and so it can restore a patched GOT page to its true original
401/// protection rather than a hardcoded read-only.
402///
403/// Each maps line begins `lo-hi perms ...`; the address range and the `rwxp` permission string are
404/// the first two whitespace fields. For the line whose `[lo, hi)` range contains `addr`, the `r`,
405/// `w`, and `x` characters are translated into `PROT_READ`/`PROT_WRITE`/`PROT_EXEC`.
406fn page_prot(addr: usize) -> i32 {
407 let text = match std::fs::read_to_string("/proc/self/maps") {
408 Ok(t) => t,
409 Err(_) => return -1,
410 };
411 for line in text.lines() {
412 let mut it = line.split_whitespace();
413 let range = match it.next() {
414 Some(r) => r,
415 None => continue,
416 };
417 let perms = match it.next() {
418 Some(p) => p,
419 None => continue,
420 };
421 let mut rr = range.split('-');
422 let lo = rr.next().and_then(|s| usize::from_str_radix(s, 16).ok());
423 let hi = rr.next().and_then(|s| usize::from_str_radix(s, 16).ok());
424 if let (Some(lo), Some(hi)) = (lo, hi)
425 && addr >= lo
426 && addr < hi
427 {
428 let b = perms.as_bytes();
429 let mut prot = 0;
430 if b.first() == Some(&b'r') {
431 prot |= libc::PROT_READ;
432 }
433 if b.get(1) == Some(&b'w') {
434 prot |= libc::PROT_WRITE;
435 }
436 if b.get(2) == Some(&b'x') {
437 prot |= libc::PROT_EXEC;
438 }
439 return prot;
440 }
441 }
442 -1
443}
444
445/// Turn a `DT_*` `d_ptr` into an absolute address across libc implementations that disagree
446/// on what it holds: glibc pre-relocates these pointers to absolute, while musl leaves them
447/// file-relative, so the patch cannot assume either. The heuristic treats a value below the load
448/// `base` as a relative offset to add to `base`, and a value at or above `base` as already absolute.
449#[inline]
450fn dyn_addr(base: usize, v: u64) -> usize {
451 let v = v as usize;
452 if v < base { base + v } else { v }
453}
454
455/// Hand every GOT slot through which one loaded library calls `symbol` to `visit`, by walking its
456/// `PT_DYNAMIC` array — the only way to interpose without an LD_PRELOAD object, or to read where the
457/// dynamic linker bound an import — covering both the lazy-PLT and `-fno-plt` relocation forms
458/// because NVIDIA ships the latter.
459///
460/// 1. **Parse `.dynamic`**: iterate the `Elf64Dyn` entries until `DT_NULL`, recording the dynamic
461/// symbol table, string table, PLT relocation table (`DT_JMPREL`/`DT_PLTRELSZ`), and general
462/// relocation table (`DT_RELA`/`DT_RELASZ`). Table addresses are resolved through `dyn_addr` to
463/// handle the glibc-absolute vs musl-relative pointer conventions.
464/// 2. **Sanity-gate the tables**: bail if the symbol or string table is missing, and — via the
465/// `readable` closure over `page_prot` — bail if a table's mapping is known but lacks read
466/// permission (an address whose mapping cannot be determined is presumed readable), so a wrong
467/// relative/absolute guess from `dyn_addr` cannot fault on first dereference.
468/// 3. **Visit both relocation tables**: hand each present, adequately-sized, readable table to
469/// `visit_reloc_table`. The PLT table (`.rela.plt`) carries the classic lazily-bound `JUMP_SLOT`
470/// entries; the general table (`.rela.dyn`) carries the `GLOB_DAT` slot that `-fno-plt` NVIDIA
471/// builds use to bind `ioctl`.
472unsafe fn visit_import_slots(
473 base: usize,
474 dynp: *const Elf64Dyn,
475 symbol: &[u8],
476 visit: &mut dyn FnMut(*mut *mut c_void),
477) {
478 let mut symtab: *const libc::Elf64_Sym = std::ptr::null();
479 let mut strtab: *const c_char = std::ptr::null();
480 let mut jmprel: *const Elf64Rela = std::ptr::null();
481 let mut pltrelsz: usize = 0;
482 let mut rela: *const Elf64Rela = std::ptr::null();
483 let mut relasz: usize = 0;
484
485 let mut d = dynp;
486 while (*d).d_tag != DT_NULL {
487 match (*d).d_tag {
488 DT_SYMTAB => symtab = dyn_addr(base, (*d).d_un) as *const libc::Elf64_Sym,
489 DT_STRTAB => strtab = dyn_addr(base, (*d).d_un) as *const c_char,
490 DT_JMPREL => jmprel = dyn_addr(base, (*d).d_un) as *const Elf64Rela,
491 DT_PLTRELSZ => pltrelsz = (*d).d_un as usize,
492 DT_RELA => rela = dyn_addr(base, (*d).d_un) as *const Elf64Rela,
493 DT_RELASZ => relasz = (*d).d_un as usize,
494 _ => {}
495 }
496 d = d.add(1);
497 }
498 if symtab.is_null() || strtab.is_null() {
499 return;
500 }
501 let readable = |p: usize| {
502 let prot = page_prot(p);
503 prot < 0 || (prot & libc::PROT_READ) != 0
504 };
505 if !readable(symtab as usize) || !readable(strtab as usize) {
506 return;
507 }
508
509 let ent = std::mem::size_of::<Elf64Rela>();
510 if !jmprel.is_null() && pltrelsz >= ent && readable(jmprel as usize) {
511 visit_reloc_table(base, symtab, strtab, jmprel, pltrelsz / ent, symbol, visit);
512 }
513 if !rela.is_null() && relasz >= ent && readable(rela as usize) {
514 visit_reloc_table(base, symtab, strtab, rela, relasz / ent, symbol, visit);
515 }
516}
517
518/// Hand exactly the GOT slots of `symbol` in one relocation table to `visit` and nothing else —
519/// every candidate entry is name-checked so no other symbol the library imports is disturbed. It
520/// handles both the `JUMP_SLOT` and `GLOB_DAT` entry forms: an entry is skipped unless its
521/// relocation type is one of those with a nonzero symbol index whose name, resolved through
522/// `symtab`/`strtab`, is exactly `symbol`; the slot of a match lives at `base + r_offset`.
523unsafe fn visit_reloc_table(
524 base: usize,
525 symtab: *const libc::Elf64_Sym,
526 strtab: *const c_char,
527 rela: *const Elf64Rela,
528 count: usize,
529 symbol: &[u8],
530 visit: &mut dyn FnMut(*mut *mut c_void),
531) {
532 for i in 0..count {
533 let r = rela.add(i);
534 let rtype = elf64_r_type((*r).r_info);
535 if rtype != RELOC_JUMP_SLOT && rtype != RELOC_GLOB_DAT {
536 continue;
537 }
538 let sym_idx = elf64_r_sym((*r).r_info) as usize;
539 if sym_idx == 0 {
540 continue;
541 }
542 let name_off = (*symtab.add(sym_idx)).st_name as usize;
543 let name = CStr::from_ptr(strtab.add(name_off));
544 if name.to_bytes() != symbol {
545 continue;
546 }
547 visit((base + (*r).r_offset as usize) as *mut *mut c_void);
548 }
549}
550
551/// Repoint one `ioctl` GOT slot to `filtered_ioctl`.
552///
553/// 1. **Make the page writable**: read the slot's current protection via `page_prot` (defaulting to
554/// read/write when maps is unreadable) and `mprotect` the page to `PROT_READ | PROT_WRITE`.
555/// 2. **Publish the new pointer atomically**: store `filtered_ioctl` into the slot with an
556/// `AtomicPtr` `Release` store, since another thread may be dispatching through this GOT slot
557/// concurrently and the write must not tear.
558/// 3. **Restore protection**: return the page to its original protection rather than a hardcoded
559/// read-only — under partial RELRO these libraries lazily bind through this page, so leaving it
560/// read-only would fault the next symbol resolve.
561unsafe fn patch_ioctl_slot(slot: *mut *mut c_void) {
562 let page = libc::sysconf(libc::_SC_PAGESIZE) as c_long;
563 if page <= 0 {
564 return;
565 }
566 let page = page as usize;
567 let pg = (slot as usize & !(page - 1)) as *mut c_void;
568 let mut orig = page_prot(slot as usize);
569 if orig < 0 {
570 orig = libc::PROT_READ | libc::PROT_WRITE;
571 }
572 if libc::mprotect(pg, page, libc::PROT_READ | libc::PROT_WRITE) == 0 {
573 let ap = &*(slot as *const AtomicPtr<c_void>);
574 ap.store(filtered_ioctl as *mut c_void, Ordering::Release);
575 libc::mprotect(pg, page, orig);
576 }
577}
578
579/// `dl_iterate_phdr` callback: for each loaded object matching a targeted NVIDIA library,
580/// find its `PT_DYNAMIC` segment and repoint its `ioctl` slots. Returns 0 to keep iterating.
581///
582/// 1. **Panic firewall**: the whole body runs under `catch_unwind` because a panic must not unwind
583/// across this `extern "C"` boundary into the C `dl_iterate_phdr` — that would abort the
584/// process; a caught panic just ends this object's processing and iteration continues.
585/// 2. **Tight library match**: only `libnvcuvid`, `libnvidia-encode`, and `libcuda.so` are patched
586/// — the libraries that issue the enumeration ioctl (`libnvidia-encode` calls through
587/// `libnvcuvid`). Unrelated modules (`libcudart`, `libnvidia-ml`, `libnvidia-glcore`, …) and
588/// objects with no name are skipped so their `ioctl` bindings are left untouched.
589/// 3. **Patch each `PT_DYNAMIC`**: for a matched object, walk its program headers and hand every
590/// `ioctl` slot of each dynamic segment to `patch_ioctl_slot`, using the object's load address as
591/// the base.
592unsafe extern "C" fn patch_phdr_cb(
593 info: *mut libc::dl_phdr_info,
594 _size: libc::size_t,
595 _data: *mut c_void,
596) -> c_int {
597 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
598 let info = &*info;
599 if info.dlpi_name.is_null() || *info.dlpi_name == 0 {
600 return;
601 }
602 let name = CStr::from_ptr(info.dlpi_name).to_string_lossy();
603 if !name.contains("libnvcuvid")
604 && !name.contains("libnvidia-encode")
605 && !name.contains("libcuda.so")
606 {
607 return;
608 }
609 let base = info.dlpi_addr as usize;
610 for i in 0..info.dlpi_phnum as isize {
611 let ph = &*info.dlpi_phdr.offset(i);
612 if ph.p_type == libc::PT_DYNAMIC {
613 visit_import_slots(
614 base,
615 (base + ph.p_vaddr as usize) as *const Elf64Dyn,
616 b"ioctl",
617 &mut |slot| patch_ioctl_slot(slot),
618 );
619 }
620 }
621 }));
622 0
623}
624
625/// Where the first loaded object whose path contains `object` has its import of `symbol` bound:
626/// the address its GOT slot holds, which is the library's own PLT stub while a lazy binding has not
627/// been resolved yet. `None` when no such object is loaded or it does not import `symbol`.
628pub(crate) fn bound_import(object: &str, symbol: &[u8]) -> Option<usize> {
629 struct Query<'a> {
630 object: &'a str,
631 symbol: &'a [u8],
632 found: Option<usize>,
633 done: bool,
634 }
635 unsafe extern "C" fn cb(
636 info: *mut libc::dl_phdr_info,
637 _size: libc::size_t,
638 data: *mut c_void,
639 ) -> c_int {
640 let q = &mut *(data as *mut Query);
641 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
642 let info = &*info;
643 if info.dlpi_name.is_null()
644 || !CStr::from_ptr(info.dlpi_name)
645 .to_string_lossy()
646 .contains(q.object)
647 {
648 return;
649 }
650 q.done = true;
651 let (symbol, found) = (q.symbol, &mut q.found);
652 let base = info.dlpi_addr as usize;
653 for i in 0..info.dlpi_phnum as isize {
654 let ph = &*info.dlpi_phdr.offset(i);
655 if ph.p_type == libc::PT_DYNAMIC {
656 visit_import_slots(
657 base,
658 (base + ph.p_vaddr as usize) as *const Elf64Dyn,
659 symbol,
660 &mut |slot| {
661 if found.is_none() {
662 *found = Some(
663 (*(slot as *const AtomicPtr<c_void>)).load(Ordering::Acquire)
664 as usize,
665 );
666 }
667 },
668 );
669 }
670 }
671 }));
672 q.done as c_int
673 }
674 let mut q = Query {
675 object,
676 symbol,
677 found: None,
678 done: false,
679 };
680 unsafe {
681 libc::dl_iterate_phdr(Some(cb), &mut q as *mut Query as *mut c_void);
682 }
683 q.found
684}
685
686/// True when at least one host GPU is hidden from the container — the only situation that
687/// can trigger the peer-init bug, so it gates whether the filter installs at all.
688///
689/// Compares two counts: `host` is the number of GPUs the kernel driver knows, from the non-dot
690/// entries of `/proc/driver/nvidia/gpus`; `visible` is the number of `/dev/nvidia{0..31}` nodes
691/// the container can open. GPUs are hidden when `host > visible`, and the `visible > 0`
692/// clause ensures there is still a usable GPU (a container with no GPUs at all is not this case).
693fn has_hidden_gpus() -> bool {
694 let host = std::fs::read_dir("/proc/driver/nvidia/gpus")
695 .map(|d| {
696 d.flatten()
697 .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
698 .count()
699 })
700 .unwrap_or(0);
701 let visible = (0..MAX_ATTACHED_GPUS as u32)
702 .filter(|&m| node_reachable(m))
703 .count();
704 host > visible && visible > 0
705}
706
707/// Install the `GET_ATTACHED_IDS`/`GET_PROBED_IDS` GOT filter, at most once and only when a
708/// host GPU is hidden from the container. Idempotent and safe to call before every NVENC session
709/// open (guarded by a `Once`).
710///
711/// 1. **Escape hatch**: if `PIXELFLUX_DISABLE_GPU_FILTER` is set, log and return without patching
712/// (also useful for A/B testing the filter).
713/// 2. **Cache the nvidiactl identity**: `stat` `/dev/nvidiactl` and store its `rdev` in
714/// `NVIDIACTL_RDEV` so the ioctl hook can later verify an fd's identity before rewriting. If the
715/// `stat` fails the value stays 0, which makes the hook skip that identity gate.
716/// 3. **Patch only when needed**: if `has_hidden_gpus()` reports GPUs hidden from the container,
717/// run `dl_iterate_phdr` over `patch_phdr_cb` to repoint the `ioctl` GOT slots in the targeted
718/// NVIDIA libraries. Otherwise nothing is patched — the filter is a strict no-op.
719pub fn install() {
720 static ONCE: Once = Once::new();
721 ONCE.call_once(|| {
722 if std::env::var_os("PIXELFLUX_DISABLE_GPU_FILTER").is_some() {
723 eprintln!("[pixelflux] multi-GPU NVENC filter disabled via PIXELFLUX_DISABLE_GPU_FILTER");
724 return;
725 }
726 if !RELOC_ARCH_SUPPORTED {
727 eprintln!("[pixelflux] multi-GPU NVENC filter unsupported on this architecture; not patching");
728 return;
729 }
730 unsafe {
731 let mut st: libc::stat = std::mem::zeroed();
732 if libc::stat(c"/dev/nvidiactl".as_ptr(), &mut st) == 0 {
733 NVIDIACTL_RDEV.store(st.st_rdev as u64, Ordering::Relaxed);
734 }
735 }
736 if has_hidden_gpus() {
737 unsafe {
738 libc::dl_iterate_phdr(Some(patch_phdr_cb), std::ptr::null_mut());
739 }
740 eprintln!("[pixelflux] multi-GPU NVENC ioctl filter installed (GET_ATTACHED_IDS/GET_PROBED_IDS)");
741 }
742 });
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748
749 /// Dropping a strict subset compacts the survivors and back-fills the rest: of three
750 /// live ids, keeping two (0x100, 0x300) rewrites the array so the survivors move to the front
751 /// and every trailing slot becomes `INVALID_GPU_ID`.
752 #[test]
753 fn filter_keeps_strict_subset_and_invalidates_rest() {
754 let mut ids = [INVALID_GPU_ID; MAX_ATTACHED_GPUS];
755 ids[0] = 0x100;
756 ids[1] = 0x200;
757 ids[2] = 0x300;
758 filter_ids(&mut ids, |id| id == 0x100 || id == 0x300);
759 assert_eq!(ids[0], 0x100);
760 assert_eq!(ids[1], 0x300);
761 assert_eq!(ids[2], INVALID_GPU_ID);
762 assert_eq!(ids[3], INVALID_GPU_ID);
763 }
764
765 /// Fail-safe when every id is kept: `nkept == total`, so the array is left exactly as-is
766 /// rather than needlessly rewritten.
767 #[test]
768 fn filter_noop_when_all_kept() {
769 let mut ids = [INVALID_GPU_ID; MAX_ATTACHED_GPUS];
770 ids[0] = 0xAA;
771 ids[1] = 0xBB;
772 filter_ids(&mut ids, |_| true);
773 assert_eq!(ids[0], 0xAA);
774 assert_eq!(ids[1], 0xBB);
775 assert_eq!(ids[2], INVALID_GPU_ID);
776 }
777
778 /// Fail-safe when no id is kept: `nkept == 0` never blanks the array, so the ids survive
779 /// untouched instead of leaving zero usable GPUs.
780 #[test]
781 fn filter_noop_when_none_kept() {
782 let mut ids = [INVALID_GPU_ID; MAX_ATTACHED_GPUS];
783 ids[0] = 0xAA;
784 ids[1] = 0xBB;
785 filter_ids(&mut ids, |_| false);
786 assert_eq!(ids[0], 0xAA);
787 assert_eq!(ids[1], 0xBB);
788 }
789
790 /// `ioc_nr` extracts the low NR byte: the full request `0xC020462A` yields
791 /// `NV_ESC_RM_CONTROL` (0x2A).
792 #[test]
793 fn ioc_nr_extracts_low_byte() {
794 assert_eq!(ioc_nr(0xC020462A), NV_ESC_RM_CONTROL);
795 }
796
797 /// The GOT-slot relocation codes track the build architecture: keying on the x86-64
798 /// numbers on aarch64 would match nothing and silently disable the filter, so each supported
799 /// arch carries its own `JUMP_SLOT` / `GLOB_DAT` pair. The two arches the filter supports use
800 /// distinct codes, and both must be flagged supported.
801 #[test]
802 #[allow(clippy::assertions_on_constants)]
803 fn reloc_types_track_the_target_arch() {
804 assert_ne!(RELOC_JUMP_SLOT, RELOC_GLOB_DAT);
805 #[cfg(target_arch = "x86_64")]
806 {
807 assert_eq!(RELOC_JUMP_SLOT, 7);
808 assert_eq!(RELOC_GLOB_DAT, 6);
809 }
810 #[cfg(target_arch = "aarch64")]
811 {
812 assert_eq!(RELOC_JUMP_SLOT, 1026);
813 assert_eq!(RELOC_GLOB_DAT, 1025);
814 }
815 }
816
817 /// Proves the property the whole gate rests on — matching stays size-agnostic yet
818 /// NR-precise — so a future driver revision cannot silently defeat it. A request that shares
819 /// DIR|TYPE|NR but encodes a different `_IOC_SIZE` (here 0x30, versus the canonical 0x20 =
820 /// `sizeof(NVOS54_PARAMETERS)`) still matches once the size is masked off, so a driver whose
821 /// `NVOS54_PARAMETERS` differs in size is not rejected by the request gate; a request carrying a
822 /// different NR (0x2B) does not match, confirming NR precision is retained.
823 #[test]
824 fn request_match_ignores_param_size() {
825 let base = ioc_no_size(NV_RM_CONTROL_REQUEST);
826 let other_size = base | (0x30 << IOC_SIZESHIFT);
827 assert_ne!(other_size, NV_RM_CONTROL_REQUEST);
828 assert_eq!(ioc_no_size(other_size), base);
829 let other_nr = (NV_RM_CONTROL_REQUEST & !0xFF) | 0x2B;
830 assert_ne!(ioc_no_size(other_nr), base);
831 }
832
833 /// Guards against silent NVIDIA ABI drift by pinning the on-wire layout the size dispatch
834 /// relies on: ATTACHED params are `gpuIds[32]` (128 bytes) and PROBED params are
835 /// `gpuIds[32] + excludedGpuIds[32]` (256 bytes). Because both lead with the same `gpuIds[32]`,
836 /// these exact sizes are what let the exact-size guards disambiguate the two commands;
837 /// `NvRmControlParams` is 32 bytes.
838 #[test]
839 fn param_struct_sizes_match_nvidia_layout() {
840 assert_eq!(ATTACHED_PARAMS_SIZE, 128);
841 assert_eq!(PROBED_PARAMS_SIZE, 256);
842 assert_eq!(std::mem::size_of::<NvRmControlParams>(), 32);
843 }
844
845 /// A node that exists but refuses `open()` is unreachable, as a device-cgroup-denied
846 /// `/dev/nvidiaN` is; existence alone must not count. Root bypasses mode bits, so the
847 /// unopenable case is only asserted for an unprivileged user.
848 #[test]
849 fn reachability_requires_open_not_existence() {
850 assert!(path_opens(c"/dev/null"));
851 assert!(!path_opens(c"/nonexistent/pixelflux-node"));
852 let path =
853 std::env::temp_dir().join(format!("pixelflux-unopenable-{}", std::process::id()));
854 std::fs::write(&path, b"").unwrap();
855 std::fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(0o000))
856 .unwrap();
857 let cpath = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap();
858 if unsafe { libc::geteuid() } != 0 {
859 assert!(!path_opens(&cpath));
860 }
861 std::fs::remove_file(&path).unwrap();
862 }
863}