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 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::atomic::{AtomicPtr, AtomicU64, Ordering};
35use std::sync::Once;
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.
124cfg_if::cfg_if! {
125 if #[cfg(target_arch = "x86_64")] {
126 const RELOC_GLOB_DAT: u32 = 6;
127 const RELOC_JUMP_SLOT: u32 = 7;
128 const RELOC_ARCH_SUPPORTED: bool = true;
129 } else if #[cfg(target_arch = "aarch64")] {
130 const RELOC_GLOB_DAT: u32 = 1025;
131 const RELOC_JUMP_SLOT: u32 = 1026;
132 const RELOC_ARCH_SUPPORTED: bool = true;
133 } else {
134 const RELOC_GLOB_DAT: u32 = u32::MAX;
135 const RELOC_JUMP_SLOT: u32 = u32::MAX;
136 const RELOC_ARCH_SUPPORTED: bool = false;
137 }
138}
139
140/// Identity of the RM control device, cached so the hook only rewrites responses that truly
141/// came from it: the `rdev` of `/dev/nvidiactl`, resolved once at `install()`. A value of 0 means it
142/// was never resolved, in which case the fd-identity gate is skipped and matching falls back to the
143/// request/size checks alone.
144static NVIDIACTL_RDEV: AtomicU64 = AtomicU64::new(0);
145
146/// One `Elf64_Dyn` entry, modeled so the GOT patch can iterate a library's `.dynamic` array:
147/// a `d_tag` (`DT_*`) paired with `d_un`, the 64-bit `d_val`/`d_ptr` union interpreted per tag.
148#[repr(C)]
149struct Elf64Dyn {
150 d_tag: i64,
151 d_un: u64,
152}
153
154/// One `Elf64_Rela` relocation entry, modeled so the patch can find each GOT slot and the
155/// symbol it binds: `r_offset` (the target GOT slot, base-relative), `r_info` (packed symbol index +
156/// relocation type, split by `elf64_r_sym`/`elf64_r_type`), and the unused `r_addend`.
157#[repr(C)]
158struct Elf64Rela {
159 r_offset: u64,
160 r_info: u64,
161 r_addend: i64,
162}
163
164/// Extract the symbol-table index from an `Elf64_Rela` `r_info` field (its high 32 bits) —
165/// how the patch learns which symbol a relocation binds, so it can check for `ioctl`.
166#[inline]
167fn elf64_r_sym(info: u64) -> u64 {
168 info >> 32
169}
170
171/// Extract the relocation type from an `Elf64_Rela` `r_info` field (its low 32 bits) — how
172/// the patch tells a `JUMP_SLOT`/`GLOB_DAT` GOT entry from relocations it must ignore.
173#[inline]
174fn elf64_r_type(info: u64) -> u32 {
175 (info & 0xffff_ffff) as u32
176}
177
178/// Extract the `_IOC_NR` command byte (bits 0-7) of an ioctl request — the field that names
179/// the command. Used only by documentation and the `ioc_nr_extracts_low_byte` unit test, hence
180/// `#[allow(dead_code)]`.
181#[allow(dead_code)]
182#[inline]
183fn ioc_nr(req: c_ulong) -> c_ulong {
184 req & 0xFF
185}
186
187/// Strip the `_IOC_SIZE` field from a request (leaving DIR|TYPE|NR) so the gate stays bound
188/// to the exact RM control command without coupling to any one driver's param-struct size — a driver
189/// that changes `sizeof(NVOS54_PARAMETERS)` must still match.
190#[inline]
191fn ioc_no_size(req: c_ulong) -> c_ulong {
192 req & !(IOC_SIZEMASK << IOC_SIZESHIFT)
193}
194
195/// The reachability test the whole filter turns on: a GPU id is kept only if its
196/// `/dev/nvidia{minor}` node is actually present in the container, checked here via `access(F_OK)` on
197/// a NUL-terminated path.
198fn node_present(minor: u32) -> bool {
199 let path = format!("/dev/nvidia{}\0", minor);
200 unsafe { libc::access(path.as_ptr() as *const c_char, libc::F_OK) == 0 }
201}
202
203/// Map an RM `gpuId` to the `/dev/nvidia` minor that `node_present` needs: the id only
204/// carries a PCI address, not a device minor, so this bridges the two by scanning
205/// `/proc/driver/nvidia/gpus`, returning -1 when no match is found.
206///
207/// 1. **Extract the PCI address from the id**: the PCI address is encoded in `gpuId >> 8`, so
208/// `want_bus` is the low bus byte `(gpuId >> 8) & 0xFF` and `want_full` is the full
209/// `gpuId >> 8` (domain folded in for larger ids).
210/// 2. **Match a /proc entry**: each subdirectory is named by its PCI address
211/// `domain:bus:slot.func` in hex; split on `:` and `.` (expecting four fields) and parse the
212/// domain and bus. An entry matches when its bus equals `want_bus` **or** its combined
213/// `(domain << 8) | bus` equals `want_full` — the dual test covers ids that fold the domain in.
214/// 3. **Read the minor**: from the matched entry's `information` file, parse the `Device Minor: N`
215/// line and return `N`. The scan processes only the first matching directory (it `break`s
216/// afterward) and returns -1 if no minor line is present.
217fn gpuid_to_minor(gpu_id: u32) -> i32 {
218 let want_bus = (gpu_id >> 8) & 0xFF;
219 let want_full = gpu_id >> 8;
220 let dir = match std::fs::read_dir("/proc/driver/nvidia/gpus") {
221 Ok(d) => d,
222 Err(_) => return -1,
223 };
224 for ent in dir.flatten() {
225 let name = ent.file_name();
226 let name = match name.to_str() {
227 Some(s) => s,
228 None => continue,
229 };
230 let parts: Vec<&str> = name.split([':', '.']).collect();
231 if parts.len() != 4 {
232 continue;
233 }
234 let dom = u32::from_str_radix(parts[0], 16);
235 let bus = u32::from_str_radix(parts[1], 16);
236 let (dom, bus) = match (dom, bus) {
237 (Ok(d), Ok(b)) => (d, b),
238 _ => continue,
239 };
240 if bus != want_bus && ((dom << 8) | bus) != want_full {
241 continue;
242 }
243 let info = format!("/proc/driver/nvidia/gpus/{}/information", name);
244 if let Ok(text) = std::fs::read_to_string(&info) {
245 for line in text.lines() {
246 if let Some(rest) = line.strip_prefix("Device Minor:")
247 && let Ok(m) = rest.trim().parse::<i32>() {
248 return m;
249 }
250 }
251 }
252 break;
253 }
254 -1
255}
256
257/// Drop the hidden GPUs from a `gpuIds[]` array in place — but only ever a strict subset, so
258/// the filter can never accidentally blank out every GPU and strand the container with none. Ids
259/// that pass `keep` are compacted to the front and the rest back-filled with `INVALID_GPU_ID`.
260///
261/// 1. **Scan the populated prefix**: iterate until the first `INVALID_GPU_ID`, counting `total`
262/// live ids and collecting the kept ones into a scratch array (`nkept`).
263/// 2. **Commit only a strict subset** (`0 < nkept < total`): copy the kept prefix back over `ids`
264/// and set every trailing slot to `INVALID_GPU_ID`.
265/// 3. **Fail-safe**: if none survive or all survive, leave the array untouched — never blank out
266/// every GPU, and never do redundant work when nothing was dropped.
267///
268/// The `keep` predicate is injected so this stays free of `/proc` and `/dev` access and is
269/// unit-testable in isolation.
270fn filter_ids(ids: &mut [u32; MAX_ATTACHED_GPUS], keep: impl Fn(u32) -> bool) {
271 let mut kept = [0u32; MAX_ATTACHED_GPUS];
272 let (mut total, mut nkept) = (0usize, 0usize);
273 for &id in ids.iter() {
274 if id == INVALID_GPU_ID {
275 break;
276 }
277 total += 1;
278 if keep(id) {
279 kept[nkept] = id;
280 nkept += 1;
281 }
282 }
283 if nkept > 0 && nkept < total {
284 ids[..nkept].copy_from_slice(&kept[..nkept]);
285 for slot in ids[nkept..].iter_mut() {
286 *slot = INVALID_GPU_ID;
287 }
288 }
289}
290
291/// The seam where a hidden GPU gets scrubbed out: the `ioctl` wrapper installed into the
292/// NVIDIA libraries' GOT slots, which forwards to the real `libc::ioctl` and then post-processes the
293/// response. The three steps below exist only to make interposing on `ioctl` safe:
294///
295/// 1. **Call through**: invoke the genuine `libc::ioctl`. Only this crate's own GOT is left
296/// unpatched, so this inner call resolves to libc rather than back into this wrapper (no
297/// recursion).
298/// 2. **Filter the result** under `catch_unwind`: `rewrite_attached_ids` may drop hidden GPUs from
299/// the response. A panic must never unwind across this `extern "C"` boundary — the compiler
300/// guard would abort the whole process — so any panic is caught and the unmodified real result
301/// is returned instead.
302/// 3. **Preserve `errno`**: the real syscall's `errno` is saved before the `/proc` + `/dev` lookups
303/// inside the filter and restored afterward, so a caller reading `errno` sees the syscall's value.
304unsafe extern "C" fn filtered_ioctl(fd: c_int, req: c_ulong, arg: *mut c_void) -> c_int {
305 let rc = libc::ioctl(fd, req as _, arg);
306 let saved_errno = *libc::__errno_location();
307 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
308 rewrite_attached_ids(fd, rc, req, arg);
309 }));
310 *libc::__errno_location() = saved_errno;
311 rc
312}
313
314/// Scrub the hidden GPUs out of a successful `GET_ATTACHED_IDS` / `GET_PROBED_IDS` response so
315/// the NVIDIA libraries never try to peer-init a GPU the container cannot reach: it drops every id
316/// whose `/dev/nvidia` node is absent. Split from `filtered_ioctl` so the whole thing runs under
317/// `catch_unwind`.
318///
319/// The guards run cheap-to-expensive and each returns early — leaving the response untouched — the
320/// moment one fails, so the overwhelmingly common non-enumeration ioctl pays almost nothing:
321///
322/// 1. **Request gate**: the syscall succeeded (`rc == 0`), `arg` is non-null, and the request is
323/// the RM_CONTROL ioctl matched by DIR|TYPE|NR (size-agnostic, via `ioc_no_size`).
324/// 2. **fd identity gate**: when `NVIDIACTL_RDEV` was resolved at install, `fstat` the fd and
325/// require a character device whose `rdev` equals `/dev/nvidiactl`'s — so only that char
326/// device's responses are rewritten. An `fstat` failure passes through untouched; if the rdev
327/// was never resolved the gate is skipped and matching relies on the request/size checks alone.
328/// 3. **Payload gate**: the RM call itself reports success (`ctrl.status == 0`) and carries a
329/// parameter pointer (`ctrl.params != 0`).
330/// 4. **Command + exact-size dispatch**: `cmd` selects ATTACHED or PROBED, and `params_size` must
331/// equal that command's exact struct size. Requiring the exact size both disambiguates the two
332/// (their layouts share a leading array) and stops a lying size from steering a rewrite under a
333/// different layout. Both APIs are filtered so behavior is uniform across driver versions —
334/// buggy 570-595 where it actually drops GPUs, and correct drivers where the strict-subset rule
335/// makes it a no-op.
336/// 5. **Filter the leading `gpuIds[32]`**: `filter_ids` keeps an id only when `gpuid_to_minor`
337/// resolves it and the corresponding `/dev/nvidia` node is present. For PROBED the trailing
338/// `excludedGpuIds[]` is deliberately left untouched (it lists GPUs to exclude, not to use).
339///
340/// When `PIXELFLUX_GPU_FILTER_DEBUG` is set, the before/after live-id counts are logged.
341unsafe fn rewrite_attached_ids(fd: c_int, rc: c_int, req: c_ulong, arg: *mut c_void) {
342 if rc != 0 || ioc_no_size(req) != ioc_no_size(NV_RM_CONTROL_REQUEST) || arg.is_null() {
343 return;
344 }
345 let cached = NVIDIACTL_RDEV.load(Ordering::Relaxed);
346 if cached != 0 {
347 let mut st: libc::stat = std::mem::zeroed();
348 if libc::fstat(fd, &mut st) != 0 {
349 return;
350 }
351 if (st.st_mode & libc::S_IFMT) != libc::S_IFCHR || st.st_rdev as u64 != cached {
352 return;
353 }
354 }
355 let ctrl = &mut *(arg as *mut NvRmControlParams);
356 if ctrl.status != 0 || ctrl.params == 0 {
357 return;
358 }
359 let which = match ctrl.cmd {
360 GPU_GET_ATTACHED_IDS if ctrl.params_size as usize == ATTACHED_PARAMS_SIZE => "ATTACHED",
361 GPU_GET_PROBED_IDS if ctrl.params_size as usize == PROBED_PARAMS_SIZE => "PROBED",
362 _ => return,
363 };
364 let ids = &mut *(ctrl.params as *mut [u32; MAX_ATTACHED_GPUS]);
365 let debug = std::env::var_os("PIXELFLUX_GPU_FILTER_DEBUG").is_some();
366 let before = ids.iter().take_while(|&&id| id != INVALID_GPU_ID).count();
367 filter_ids(ids, |id| {
368 let minor = gpuid_to_minor(id);
369 minor >= 0 && node_present(minor as u32)
370 });
371 if debug {
372 let after = ids.iter().take_while(|&&id| id != INVALID_GPU_ID).count();
373 eprintln!("[pixelflux] GET_{which}_IDS intercepted: {before} host GPU(s) -> {after} kept");
374 }
375}
376
377/// Reports the current `PROT_*` protection of the page holding `addr` (from
378/// `/proc/self/maps`, or -1 when the address is not found or maps is unreadable) for two reasons the
379/// GOT patch depends on: so it can refuse to dereference an address that a wrong relative/absolute
380/// guess landed in an unreadable page, and so it can restore a patched GOT page to its true original
381/// protection rather than a hardcoded read-only.
382///
383/// Each maps line begins `lo-hi perms ...`; the address range and the `rwxp` permission string are
384/// the first two whitespace fields. For the line whose `[lo, hi)` range contains `addr`, the `r`,
385/// `w`, and `x` characters are translated into `PROT_READ`/`PROT_WRITE`/`PROT_EXEC`.
386fn page_prot(addr: usize) -> i32 {
387 let text = match std::fs::read_to_string("/proc/self/maps") {
388 Ok(t) => t,
389 Err(_) => return -1,
390 };
391 for line in text.lines() {
392 let mut it = line.split_whitespace();
393 let range = match it.next() {
394 Some(r) => r,
395 None => continue,
396 };
397 let perms = match it.next() {
398 Some(p) => p,
399 None => continue,
400 };
401 let mut rr = range.split('-');
402 let lo = rr.next().and_then(|s| usize::from_str_radix(s, 16).ok());
403 let hi = rr.next().and_then(|s| usize::from_str_radix(s, 16).ok());
404 if let (Some(lo), Some(hi)) = (lo, hi)
405 && addr >= lo && addr < hi {
406 let b = perms.as_bytes();
407 let mut prot = 0;
408 if b.first() == Some(&b'r') {
409 prot |= libc::PROT_READ;
410 }
411 if b.get(1) == Some(&b'w') {
412 prot |= libc::PROT_WRITE;
413 }
414 if b.get(2) == Some(&b'x') {
415 prot |= libc::PROT_EXEC;
416 }
417 return prot;
418 }
419 }
420 -1
421}
422
423/// Turn a `DT_*` `d_ptr` into an absolute address across libc implementations that disagree
424/// on what it holds: glibc pre-relocates these pointers to absolute, while musl leaves them
425/// file-relative, so the patch cannot assume either. The heuristic treats a value below the load
426/// `base` as a relative offset to add to `base`, and a value at or above `base` as already absolute.
427#[inline]
428fn dyn_addr(base: usize, v: u64) -> usize {
429 let v = v as usize;
430 if v < base {
431 base + v
432 } else {
433 v
434 }
435}
436
437/// Repoint every `ioctl` GOT slot in one loaded library to `filtered_ioctl` by walking its
438/// `PT_DYNAMIC` array — the only way to interpose without an LD_PRELOAD object — and it must cover
439/// both the lazy-PLT and `-fno-plt` relocation forms because NVIDIA ships the latter.
440///
441/// 1. **Parse `.dynamic`**: iterate the `Elf64Dyn` entries until `DT_NULL`, recording the dynamic
442/// symbol table, string table, PLT relocation table (`DT_JMPREL`/`DT_PLTRELSZ`) and general
443/// relocation table (`DT_RELA`/`DT_RELASZ`). Table addresses are resolved through `dyn_addr` to
444/// handle the glibc-absolute vs musl-relative pointer conventions.
445/// 2. **Sanity-gate the tables**: bail if the symbol or string table is missing, and — via the
446/// `readable` closure over `page_prot` — bail if a table's mapping is known but lacks read
447/// permission (an address whose mapping cannot be determined is presumed readable), so a wrong
448/// relative/absolute guess from `dyn_addr` cannot fault on first dereference. Also bail if the
449/// page size is unavailable.
450/// 3. **Patch both relocation tables**: hand each present, adequately-sized, readable table to
451/// `patch_reloc_table`. The PLT table (`.rela.plt`) carries the classic lazily-bound `JUMP_SLOT`
452/// entries; the general table (`.rela.dyn`) carries the `GLOB_DAT` slot that `-fno-plt` NVIDIA
453/// builds use to bind `ioctl`.
454unsafe fn patch_ioctl_got(base: usize, dynp: *const Elf64Dyn) {
455 let mut symtab: *const libc::Elf64_Sym = std::ptr::null();
456 let mut strtab: *const c_char = std::ptr::null();
457 let mut jmprel: *const Elf64Rela = std::ptr::null();
458 let mut pltrelsz: usize = 0;
459 let mut rela: *const Elf64Rela = std::ptr::null();
460 let mut relasz: usize = 0;
461
462 let mut d = dynp;
463 while (*d).d_tag != DT_NULL {
464 match (*d).d_tag {
465 DT_SYMTAB => symtab = dyn_addr(base, (*d).d_un) as *const libc::Elf64_Sym,
466 DT_STRTAB => strtab = dyn_addr(base, (*d).d_un) as *const c_char,
467 DT_JMPREL => jmprel = dyn_addr(base, (*d).d_un) as *const Elf64Rela,
468 DT_PLTRELSZ => pltrelsz = (*d).d_un as usize,
469 DT_RELA => rela = dyn_addr(base, (*d).d_un) as *const Elf64Rela,
470 DT_RELASZ => relasz = (*d).d_un as usize,
471 _ => {}
472 }
473 d = d.add(1);
474 }
475 if symtab.is_null() || strtab.is_null() {
476 return;
477 }
478 let readable = |p: usize| {
479 let prot = page_prot(p);
480 prot < 0 || (prot & libc::PROT_READ) != 0
481 };
482 if !readable(symtab as usize) || !readable(strtab as usize) {
483 return;
484 }
485
486 let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as c_long;
487 if page <= 0 {
488 return;
489 }
490 let page = page as usize;
491 let ent = std::mem::size_of::<Elf64Rela>();
492 if !jmprel.is_null() && pltrelsz >= ent && readable(jmprel as usize) {
493 patch_reloc_table(base, symtab, strtab, jmprel, pltrelsz / ent, page);
494 }
495 if !rela.is_null() && relasz >= ent && readable(rela as usize) {
496 patch_reloc_table(base, symtab, strtab, rela, relasz / ent, page);
497 }
498}
499
500/// Repoint exactly the `ioctl` GOT slots in one relocation table to `filtered_ioctl` and
501/// nothing else — every candidate entry is name-checked so no other symbol the library imports is
502/// disturbed. It handles both the `JUMP_SLOT` and `GLOB_DAT` entry forms.
503///
504/// For each entry: skip it unless the relocation type is `R_X86_64_JUMP_SLOT` or
505/// `R_X86_64_GLOB_DAT` with a nonzero symbol index, then resolve the symbol name through
506/// `symtab`/`strtab` and skip unless it is exactly `ioctl`. For a match:
507///
508/// 1. **Locate the slot**: the GOT entry lives at `base + r_offset`; `pg` is its page-aligned base.
509/// 2. **Make the page writable**: read the slot's current protection via `page_prot` (defaulting to
510/// read/write when maps is unreadable) and `mprotect` the page to `PROT_READ | PROT_WRITE`.
511/// 3. **Publish the new pointer atomically**: store `filtered_ioctl` into the slot with an
512/// `AtomicPtr` `Release` store, since another thread may be dispatching through this GOT slot
513/// concurrently and the write must not tear.
514/// 4. **Restore protection**: return the page to its original protection rather than a hardcoded
515/// read-only — under partial RELRO these libraries lazily bind through this page, so leaving it
516/// read-only would fault the next symbol resolve.
517unsafe fn patch_reloc_table(
518 base: usize,
519 symtab: *const libc::Elf64_Sym,
520 strtab: *const c_char,
521 rela: *const Elf64Rela,
522 count: usize,
523 page: usize,
524) {
525 for i in 0..count {
526 let r = rela.add(i);
527 let rtype = elf64_r_type((*r).r_info);
528 if rtype != RELOC_JUMP_SLOT && rtype != RELOC_GLOB_DAT {
529 continue;
530 }
531 let sym_idx = elf64_r_sym((*r).r_info) as usize;
532 if sym_idx == 0 {
533 continue;
534 }
535 let name_off = (*symtab.add(sym_idx)).st_name as usize;
536 let name = CStr::from_ptr(strtab.add(name_off));
537 if name.to_bytes() != b"ioctl" {
538 continue;
539 }
540 let slot = (base + (*r).r_offset as usize) as *mut *mut c_void;
541 let pg = (slot as usize & !(page - 1)) as *mut c_void;
542 let mut orig = page_prot(slot as usize);
543 if orig < 0 {
544 orig = libc::PROT_READ | libc::PROT_WRITE;
545 }
546 if libc::mprotect(pg, page, libc::PROT_READ | libc::PROT_WRITE) == 0 {
547 let ap = &*(slot as *const AtomicPtr<c_void>);
548 ap.store(filtered_ioctl as *mut c_void, Ordering::Release);
549 libc::mprotect(pg, page, orig);
550 }
551 }
552}
553
554/// `dl_iterate_phdr` callback: for each loaded object matching a targeted NVIDIA library,
555/// find its `PT_DYNAMIC` segment and hand it to `patch_ioctl_got`. Returns 0 to keep iterating.
556///
557/// 1. **Panic firewall**: the whole body runs under `catch_unwind` because a panic must not unwind
558/// across this `extern "C"` boundary into the C `dl_iterate_phdr` — that would abort the
559/// process; a caught panic just ends this object's processing and iteration continues.
560/// 2. **Tight library match**: only `libnvcuvid`, `libnvidia-encode`, and `libcuda.so` are patched
561/// — the libraries that issue the enumeration ioctl (`libnvidia-encode` calls through
562/// `libnvcuvid`). Unrelated modules (`libcudart`, `libnvidia-ml`, `libnvidia-glcore`, …) and
563/// objects with no name are skipped so their `ioctl` bindings are left untouched.
564/// 3. **Patch each `PT_DYNAMIC`**: for a matched object, walk its program headers and call
565/// `patch_ioctl_got` on every dynamic segment, using the object's load address as the base.
566unsafe extern "C" fn patch_phdr_cb(
567 info: *mut libc::dl_phdr_info,
568 _size: libc::size_t,
569 _data: *mut c_void,
570) -> c_int {
571 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
572 let info = &*info;
573 if info.dlpi_name.is_null() || *info.dlpi_name == 0 {
574 return;
575 }
576 let name = CStr::from_ptr(info.dlpi_name).to_string_lossy();
577 if !name.contains("libnvcuvid")
578 && !name.contains("libnvidia-encode")
579 && !name.contains("libcuda.so")
580 {
581 return;
582 }
583 let base = info.dlpi_addr as usize;
584 for i in 0..info.dlpi_phnum as isize {
585 let ph = &*info.dlpi_phdr.offset(i);
586 if ph.p_type == libc::PT_DYNAMIC {
587 patch_ioctl_got(base, (base + ph.p_vaddr as usize) as *const Elf64Dyn);
588 }
589 }
590 }));
591 0
592}
593
594/// True when at least one host GPU is hidden from the container — the only situation that
595/// can trigger the peer-init bug, so it gates whether the filter installs at all.
596///
597/// Compares two counts: `host` is the number of GPUs the kernel driver knows, from the non-dot
598/// entries of `/proc/driver/nvidia/gpus`; `visible` is the number of `/dev/nvidia{0..31}` nodes
599/// actually present in the container. GPUs are hidden when `host > visible`, and the `visible > 0`
600/// clause ensures there is still a usable GPU (a container with no GPUs at all is not this case).
601fn has_hidden_gpus() -> bool {
602 let host = std::fs::read_dir("/proc/driver/nvidia/gpus")
603 .map(|d| d.flatten().filter(|e| !e.file_name().to_string_lossy().starts_with('.')).count())
604 .unwrap_or(0);
605 let visible = (0..MAX_ATTACHED_GPUS as u32).filter(|&m| node_present(m)).count();
606 host > visible && visible > 0
607}
608
609/// Install the `GET_ATTACHED_IDS`/`GET_PROBED_IDS` GOT filter, at most once and only when a
610/// host GPU is hidden from the container. Idempotent and safe to call before every NVENC session
611/// open (guarded by a `Once`).
612///
613/// 1. **Escape hatch**: if `PIXELFLUX_DISABLE_GPU_FILTER` is set, log and return without patching
614/// (also useful for A/B testing the filter).
615/// 2. **Cache the nvidiactl identity**: `stat` `/dev/nvidiactl` and store its `rdev` in
616/// `NVIDIACTL_RDEV` so the ioctl hook can later verify an fd's identity before rewriting. If the
617/// `stat` fails the value stays 0, which makes the hook skip that identity gate.
618/// 3. **Patch only when needed**: if `has_hidden_gpus()` reports GPUs hidden from the container,
619/// run `dl_iterate_phdr` over `patch_phdr_cb` to repoint the `ioctl` GOT slots in the targeted
620/// NVIDIA libraries. Otherwise nothing is patched — the filter is a strict no-op.
621pub fn install() {
622 static ONCE: Once = Once::new();
623 ONCE.call_once(|| {
624 if std::env::var_os("PIXELFLUX_DISABLE_GPU_FILTER").is_some() {
625 eprintln!("[pixelflux] multi-GPU NVENC filter disabled via PIXELFLUX_DISABLE_GPU_FILTER");
626 return;
627 }
628 if !RELOC_ARCH_SUPPORTED {
629 eprintln!("[pixelflux] multi-GPU NVENC filter unsupported on this architecture; not patching");
630 return;
631 }
632 unsafe {
633 let mut st: libc::stat = std::mem::zeroed();
634 if libc::stat(c"/dev/nvidiactl".as_ptr(), &mut st) == 0 {
635 NVIDIACTL_RDEV.store(st.st_rdev as u64, Ordering::Relaxed);
636 }
637 }
638 if has_hidden_gpus() {
639 unsafe {
640 libc::dl_iterate_phdr(Some(patch_phdr_cb), std::ptr::null_mut());
641 }
642 eprintln!("[pixelflux] multi-GPU NVENC ioctl filter installed (GET_ATTACHED_IDS/GET_PROBED_IDS)");
643 }
644 });
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 /// Dropping a strict subset compacts the survivors and back-fills the rest: of three
652 /// live ids, keeping two (0x100, 0x300) rewrites the array so the survivors move to the front
653 /// and every trailing slot becomes `INVALID_GPU_ID`.
654 #[test]
655 fn filter_keeps_strict_subset_and_invalidates_rest() {
656 let mut ids = [INVALID_GPU_ID; MAX_ATTACHED_GPUS];
657 ids[0] = 0x100;
658 ids[1] = 0x200;
659 ids[2] = 0x300;
660 filter_ids(&mut ids, |id| id == 0x100 || id == 0x300);
661 assert_eq!(ids[0], 0x100);
662 assert_eq!(ids[1], 0x300);
663 assert_eq!(ids[2], INVALID_GPU_ID);
664 assert_eq!(ids[3], INVALID_GPU_ID);
665 }
666
667 /// Fail-safe when every id is kept: `nkept == total`, so the array is left exactly as-is
668 /// rather than needlessly rewritten.
669 #[test]
670 fn filter_noop_when_all_kept() {
671 let mut ids = [INVALID_GPU_ID; MAX_ATTACHED_GPUS];
672 ids[0] = 0xAA;
673 ids[1] = 0xBB;
674 filter_ids(&mut ids, |_| true);
675 assert_eq!(ids[0], 0xAA);
676 assert_eq!(ids[1], 0xBB);
677 assert_eq!(ids[2], INVALID_GPU_ID);
678 }
679
680 /// Fail-safe when no id is kept: `nkept == 0` never blanks the array, so the ids survive
681 /// untouched instead of leaving zero usable GPUs.
682 #[test]
683 fn filter_noop_when_none_kept() {
684 let mut ids = [INVALID_GPU_ID; MAX_ATTACHED_GPUS];
685 ids[0] = 0xAA;
686 ids[1] = 0xBB;
687 filter_ids(&mut ids, |_| false);
688 assert_eq!(ids[0], 0xAA);
689 assert_eq!(ids[1], 0xBB);
690 }
691
692 /// `ioc_nr` extracts the low NR byte: the full request `0xC020462A` yields
693 /// `NV_ESC_RM_CONTROL` (0x2A).
694 #[test]
695 fn ioc_nr_extracts_low_byte() {
696 assert_eq!(ioc_nr(0xC020462A), NV_ESC_RM_CONTROL);
697 }
698
699 /// The GOT-slot relocation codes track the build architecture: keying on the x86-64
700 /// numbers on aarch64 would match nothing and silently disable the filter, so each supported
701 /// arch carries its own `JUMP_SLOT` / `GLOB_DAT` pair. The two arches the filter supports use
702 /// distinct codes, and both must be flagged supported.
703 #[test]
704 fn reloc_types_track_the_target_arch() {
705 assert!(RELOC_ARCH_SUPPORTED, "x86-64 / aarch64 builds are supported");
706 assert_ne!(RELOC_JUMP_SLOT, RELOC_GLOB_DAT);
707 #[cfg(target_arch = "x86_64")]
708 {
709 assert_eq!(RELOC_JUMP_SLOT, 7);
710 assert_eq!(RELOC_GLOB_DAT, 6);
711 }
712 #[cfg(target_arch = "aarch64")]
713 {
714 assert_eq!(RELOC_JUMP_SLOT, 1026);
715 assert_eq!(RELOC_GLOB_DAT, 1025);
716 }
717 }
718
719 /// Proves the property the whole gate rests on — matching stays size-agnostic yet
720 /// NR-precise — so a future driver revision cannot silently defeat it. A request that shares
721 /// DIR|TYPE|NR but encodes a different `_IOC_SIZE` (here 0x30, versus the canonical 0x20 =
722 /// `sizeof(NVOS54_PARAMETERS)`) still matches once the size is masked off, so a driver whose
723 /// `NVOS54_PARAMETERS` differs in size is not rejected by the request gate; a request carrying a
724 /// different NR (0x2B) does not match, confirming NR precision is retained.
725 #[test]
726 fn request_match_ignores_param_size() {
727 let base = ioc_no_size(NV_RM_CONTROL_REQUEST);
728 let other_size = base | (0x30 << IOC_SIZESHIFT);
729 assert_ne!(other_size, NV_RM_CONTROL_REQUEST);
730 assert_eq!(ioc_no_size(other_size), base);
731 let other_nr = (NV_RM_CONTROL_REQUEST & !0xFF) | 0x2B;
732 assert_ne!(ioc_no_size(other_nr), base);
733 }
734
735 /// Guards against silent NVIDIA ABI drift by pinning the on-wire layout the size dispatch
736 /// relies on: ATTACHED params are `gpuIds[32]` (128 bytes) and PROBED params are
737 /// `gpuIds[32] + excludedGpuIds[32]` (256 bytes). Because both lead with the same `gpuIds[32]`,
738 /// these exact sizes are what let the exact-size guards disambiguate the two commands;
739 /// `NvRmControlParams` is 32 bytes.
740 #[test]
741 fn param_struct_sizes_match_nvidia_layout() {
742 assert_eq!(ATTACHED_PARAMS_SIZE, 128);
743 assert_eq!(PROBED_PARAMS_SIZE, 256);
744 assert_eq!(std::mem::size_of::<NvRmControlParams>(), 32);
745 }
746}