Skip to main content

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}