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 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}