From b7b5a222950d413c02fdd839b12030944bb35ac7 Mon Sep 17 00:00:00 2001 From: Ahmed Yaseen Date: Thu, 13 Aug 2026 14:54:12 +0500 Subject: [PATCH 1/6] fix(ebpf): key blocked files on filesystem and inode, not inode alone --- crates/cardwire-daemon/src/core/inode.rs | 70 +++--- crates/cardwire-daemon/src/manager.rs | 2 +- crates/cardwire-ebpf-userspace/src/lib.rs | 273 +++++++++++++++++++--- crates/cardwire-ebpf/src/helpers.rs | 28 ++- crates/cardwire-ebpf/src/main.rs | 114 +++++++-- crates/cardwire-ebpf/src/maps.rs | 31 ++- 6 files changed, 416 insertions(+), 102 deletions(-) diff --git a/crates/cardwire-daemon/src/core/inode.rs b/crates/cardwire-daemon/src/core/inode.rs index 7caa80e7..3bd844ff 100644 --- a/crates/cardwire-daemon/src/core/inode.rs +++ b/crates/cardwire-daemon/src/core/inode.rs @@ -6,6 +6,8 @@ use std::{ use anyhow::Result; use log::{error, warn}; +use cardwire_ebpf_userspace::InodeKey; + use crate::core::pci::PciDevice; pub fn get_inodes( @@ -15,8 +17,8 @@ pub fn get_inodes( parent_pci: &Option, pci_list: &BTreeMap, nvidia_minor: Option, -) -> Result> { - let mut total_inodes: Vec = Vec::new(); +) -> Result> { + let mut total_inodes: Vec = Vec::new(); match card_to_inode(card) { Ok(inode_res) => total_inodes.push(inode_res), @@ -84,26 +86,22 @@ pub fn get_inodes( Ok(total_inodes) } -pub fn render_to_inode(render: u32) -> Result { +pub fn render_to_inode(render: u32) -> Result { let render_path = format!("/dev/dri/renderD{}", render); let metadata = fs::metadata(&render_path).map_err(|e| { warn!("failed to get inode for {}: {}", render_path, e); e })?; - let inode = metadata.ino(); - - Ok(inode) + Ok(InodeKey::new(metadata.dev(), metadata.ino())) } -pub fn card_to_inode(card: u32) -> Result { +pub fn card_to_inode(card: u32) -> Result { let card_path = format!("/dev/dri/card{}", card); let metadata = fs::metadata(&card_path).map_err(|e| { warn!("failed to get inode for {}: {}", card_path, e); e })?; - let inode = metadata.ino(); - - Ok(inode) + Ok(InodeKey::new(metadata.dev(), metadata.ino())) } // Here return a list of inode that contain the pci card, the audio card and their parents @@ -111,21 +109,21 @@ pub fn pci_to_inode( pci: String, parent_pci: &Option, pci_list: &BTreeMap, -) -> Result> { - let mut inodes: Vec = Vec::new(); +) -> Result> { + let mut inodes: Vec = Vec::new(); // quick function that push the inodes into the vec - let push_pci_inode = |pci: &str, inodes: &mut Vec| { + let push_pci_inode = |pci: &str, inodes: &mut Vec| { // First get the link ino let pci_path = format!("/sys/bus/pci/devices/{}", pci); if let Ok(metadata) = fs::metadata(&pci_path) { - inodes.push(metadata.ino()); + inodes.push(InodeKey::new(metadata.dev(), metadata.ino())); } // Now without following link let pci_path = format!("/sys/bus/pci/devices/{}", pci); if let Ok(metadata) = fs::symlink_metadata(&pci_path) { - inodes.push(metadata.ino()); + inodes.push(InodeKey::new(metadata.dev(), metadata.ino())); } }; @@ -149,47 +147,41 @@ pub fn pci_to_inode( } /// Used to verify the block status of a single pci -pub fn single_pci_to_inode(pci: &str) -> Result { +pub fn single_pci_to_inode(pci: &str) -> Result { let pci_path = format!("/sys/bus/pci/devices/{}", pci); let metadata = fs::metadata(&pci_path).map_err(|e| { warn!("failed to get inode for {}: {}", pci_path, e); e })?; - let inode = metadata.ino(); - - Ok(inode) + Ok(InodeKey::new(metadata.dev(), metadata.ino())) } -pub fn nvidia_to_inode(nvidia_minor: u32) -> Result { +pub fn nvidia_to_inode(nvidia_minor: u32) -> Result { let nvidia_path = format!("/dev/nvidia{}", nvidia_minor); let metadata = fs::metadata(&nvidia_path).map_err(|e| { warn!("failed to get inode for {}: {}", nvidia_path, e); e })?; - let inode = metadata.ino(); - - Ok(inode) + Ok(InodeKey::new(metadata.dev(), metadata.ino())) } /// The only gpu vendor that need it's backlight to be blocked is nvidia -pub fn backlight_to_inode(nvidia_minor: u32) -> Result { +pub fn backlight_to_inode(nvidia_minor: u32) -> Result { let nvidia_path = format!("/sys/class/backlight/nvidia_{}", nvidia_minor); let metadata = fs::metadata(&nvidia_path).map_err(|e| { warn!("failed to get inode for {}: {}", nvidia_path, e); e })?; - let inode = metadata.ino(); - - Ok(inode) + Ok(InodeKey::new(metadata.dev(), metadata.ino())) } -pub fn exp_nvidia_inodes() -> Result> { - let mut inodes: Vec = Vec::new(); +pub fn exp_nvidia_inodes() -> Result> { + let mut inodes: Vec = Vec::new(); // Get nvidiactl inode let nvidiactl = "/dev/nvidiactl"; if let Ok(metadata) = fs::metadata(nvidiactl) { - inodes.push(metadata.ino()); + inodes.push(InodeKey::new(metadata.dev(), metadata.ino())); } // Now try to find the vulkan file @@ -218,7 +210,7 @@ pub fn exp_nvidia_inodes() -> Result> { && let Ok(metadata) = fs::metadata(entry.path()) && metadata.is_file() { - inodes.push(metadata.ino()); + inodes.push(InodeKey::new(metadata.dev(), metadata.ino())); } } } @@ -226,8 +218,8 @@ pub fn exp_nvidia_inodes() -> Result> { Ok(inodes) } -pub fn sys_drm_inodes(render: u32, card: u32) -> Result> { - let mut inodes = Vec::new(); +pub fn sys_drm_inodes(render: u32, card: u32) -> Result> { + let mut inodes: Vec = Vec::new(); let sys_path = Path::new("/sys/class/drm"); let card = format!("card{}", card); @@ -240,7 +232,7 @@ pub fn sys_drm_inodes(render: u32, card: u32) -> Result> { // we matched with the blocked device, get the inodes without following the link let inode_res = fs::symlink_metadata(entry.path()); if let Ok(meta) = inode_res { - inodes.push(meta.ino()); + inodes.push(InodeKey::new(meta.dev(), meta.ino())); } } } @@ -248,8 +240,8 @@ pub fn sys_drm_inodes(render: u32, card: u32) -> Result> { Ok(inodes) } -pub fn sys_hwmon(pci: &str) -> Result> { - let mut inodes = Vec::new(); +pub fn sys_hwmon(pci: &str) -> Result> { + let mut inodes: Vec = Vec::new(); let sysfs_pci_path = format!("/sys/bus/pci/devices/{}/hwmon", pci); let sysfs_pci_path = Path::new(&sysfs_pci_path); @@ -257,7 +249,7 @@ pub fn sys_hwmon(pci: &str) -> Result> { let entry = entry?; // First add hwmon from the sysfs pci folder if let Ok(meta) = fs::metadata(entry.path()) { - inodes.push(meta.ino()); + inodes.push(InodeKey::new(meta.dev(), meta.ino())); } // Then add from /sys/class/hwmon if let Ok(hwmon_entry) = entry.file_name().into_string() { @@ -268,10 +260,10 @@ pub fn sys_hwmon(pci: &str) -> Result> { continue; } if let Ok(meta) = fs::metadata(hwmon_path) { - inodes.push(meta.ino()); + inodes.push(InodeKey::new(meta.dev(), meta.ino())); } if let Ok(meta) = fs::symlink_metadata(hwmon_path) { - inodes.push(meta.ino()); + inodes.push(InodeKey::new(meta.dev(), meta.ino())); } } } diff --git a/crates/cardwire-daemon/src/manager.rs b/crates/cardwire-daemon/src/manager.rs index dd33f804..059bb0be 100644 --- a/crates/cardwire-daemon/src/manager.rs +++ b/crates/cardwire-daemon/src/manager.rs @@ -178,7 +178,7 @@ impl DaemonManager { { for inode in inodes { if let Err(err) = blocker.block_exp_inode(inode, *id as u32) { - error!("failed to block nvidia's file {}: {}", inode, err); + error!("failed to block nvidia's file {:?}: {}", inode, err); } } break; diff --git a/crates/cardwire-ebpf-userspace/src/lib.rs b/crates/cardwire-ebpf-userspace/src/lib.rs index 031f2ab6..c6569ec1 100644 --- a/crates/cardwire-ebpf-userspace/src/lib.rs +++ b/crates/cardwire-ebpf-userspace/src/lib.rs @@ -5,7 +5,7 @@ use std::{fs, path::Path, sync::Arc}; pub use crate::errors::{CardwireEbpfError, CardwireEbpfResult}; use aya::{ - Btf, Ebpf, maps::{Array, HashMap, MapError, RingBuf}, programs::{Lsm, TracePoint} + Btf, Ebpf, maps::{Array, HashMap, MapError, RingBuf}, programs::{FEntry, Lsm, TracePoint} }; use aya_log::EbpfLogger; use log::{Log, error, info, warn}; @@ -32,6 +32,45 @@ pub struct InodeState { } unsafe impl aya::Pod for InodeState {} +/// Layout must stay identical to the eBPF side's InodeKey, the kernel hashes +/// the raw key bytes so any drift turns every lookup into a silent miss +#[repr(C, align(8))] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct InodeKey { + pub dev: u64, + pub ino: u64, +} +unsafe impl aya::Pod for InodeKey {} + +impl InodeKey { + /// Build a key from the `st_dev`/`st_ino` of a stat() result + pub fn new(st_dev: u64, ino: u64) -> Self { + Self { + dev: kernel_dev(st_dev), + ino, + } + } +} + +/// Width of the minor field in the kernel's dev_t, MKDEV shifts the major by +/// this much +const MINOR_BITS: u32 = 20; + +/// Repack a glibc `st_dev` into the kernel's dev_t, the eBPF side keys on +/// `(*sb).s_dev` which is already in that form +/// +/// MKDEV gives each number one contiguous field. glibc instead cuts both in +/// half and interleaves them: minor bits 0-7 sit at bits 0-7, major bits 0-11 +/// at 8-19, the rest of minor at 20+, the rest of major at 44+. Each line +/// below rejoins one number's two halves, and the wide mask discards the other +/// number's bits that the shift dragged into range. +fn kernel_dev(st_dev: u64) -> u64 { + let major = ((st_dev >> 8) & 0x0000_0fff) | ((st_dev >> 32) & 0xffff_f000); + let minor = (st_dev & 0x0000_00ff) | ((st_dev >> 12) & 0xffff_ff00); + + (major << MINOR_BITS) | minor +} + impl EbpfBlocker { pub fn new() -> CardwireEbpfResult { // quit if bpf is not enabled @@ -78,6 +117,38 @@ impl EbpfBlocker { .attach("sched", "sched_process_exit") .map_err(CardwireEbpfError::aya)?; + // iterate_dir runs between the two getdents64 tracepoints and supplies + // the device id the dirents lack + // + // Unlike the getdents64 exit hook below, this one writes no userspace + // memory, so lockdown is not what stops it. It can still fail to load on + // kernels without bpf trampoline support, or when the build renamed the + // symbol we attach by name, so degrade instead of refusing to start + let mut did_iterate_dir_success = false; + + let iterate_dir_program: &mut FEntry = ebpf + .program_mut("fentry_iterate_dir") + .ok_or_else(|| CardwireEbpfError::missing_lsm("fentry_iterate_dir"))? + .try_into() + .map_err(CardwireEbpfError::aya)?; + + match iterate_dir_program + .load("iterate_dir", &btf) + .map_err(CardwireEbpfError::aya) + .and_then(|_| iterate_dir_program.attach().map_err(CardwireEbpfError::aya)) + { + Ok(_) => { + did_iterate_dir_success = true; + } + Err(err) => { + warn!( + "Failed to load or attach iterate_dir (fentry unsupported, or symbol not attachable): {}", + err + ); + warn!("no device id for dirents, directory listings will not be filtered"); + } + }; + /* This part can get rejected by the kernel if the lockdown is enabled, we warn but we do not exit carwired, it will just run in a weakened state sys_exit_getdents64 re-write userspace memory to hide an entry (file/folder), it can be rejected @@ -93,27 +164,31 @@ impl EbpfBlocker { .map_err(CardwireEbpfError::aya)?; // Try to load the program into the kernel, if success attach it, else just warn the user - match cardwire_sys_exit_getdents64 - .load() - .map_err(CardwireEbpfError::aya) - { - Ok(_) => { - did_sys_exit_getdents64_success = true; - cardwire_sys_exit_getdents64 - .attach("syscalls", "sys_exit_getdents64") - .map_err(CardwireEbpfError::aya)?; - } - Err(err) => { - // If we cannot load the program, it usually mean the kernel lockdown is enabled - let lockdown = is_lockdown_enabled(); - warn!( - "Failed to load sys_exit_getdents64. Lockdown status: {}", - lockdown - ); - warn!("{}", err); - warn!("falling back to a weakened cardwired..."); - } - }; + // Without the device id from iterate_dir the exit hook cannot build a (dev, ino) key, so it + // would fail open on every entry: skip it entirely + if did_iterate_dir_success { + match cardwire_sys_exit_getdents64 + .load() + .map_err(CardwireEbpfError::aya) + { + Ok(_) => { + did_sys_exit_getdents64_success = true; + cardwire_sys_exit_getdents64 + .attach("syscalls", "sys_exit_getdents64") + .map_err(CardwireEbpfError::aya)?; + } + Err(err) => { + // If we cannot load the program, it usually mean the kernel lockdown is enabled + let lockdown = is_lockdown_enabled(); + warn!( + "Failed to load sys_exit_getdents64. Lockdown status: {}", + lockdown + ); + warn!("{}", err); + warn!("falling back to a weakened cardwired..."); + } + }; + } // Now we try to load sys_enter_getdents64 @@ -181,9 +256,9 @@ impl EbpfBlocker { } } - /// Block an inode, value is the associated GPU id - pub fn block_inode(&mut self, inode: u64, gpu_id: u32) -> CardwireEbpfResult<()> { - let mut inode_map: HashMap<_, u64, InodeState> = HashMap::try_from( + /// Block a file, value is the associated GPU id + pub fn block_inode(&mut self, key: InodeKey, gpu_id: u32) -> CardwireEbpfResult<()> { + let mut inode_map: HashMap<_, InodeKey, InodeState> = HashMap::try_from( self.ebpf .map_mut("CW_BLOCKED_INO") .ok_or_else(|| CardwireEbpfError::missing_map("CW_BLOCKED_INO"))?, @@ -191,7 +266,7 @@ impl EbpfBlocker { .map_err(CardwireEbpfError::aya)?; inode_map .insert( - inode, + key, InodeState { gpu_id, blocked: 1, @@ -203,8 +278,8 @@ impl EbpfBlocker { Ok(()) } - pub fn unblock_inode(&mut self, inode: u64, gpu_id: u32) -> CardwireEbpfResult<()> { - let mut inode_map: HashMap<_, u64, InodeState> = HashMap::try_from( + pub fn unblock_inode(&mut self, key: InodeKey, gpu_id: u32) -> CardwireEbpfResult<()> { + let mut inode_map: HashMap<_, InodeKey, InodeState> = HashMap::try_from( self.ebpf .map_mut("CW_BLOCKED_INO") .ok_or_else(|| CardwireEbpfError::missing_map("CW_BLOCKED_INO"))?, @@ -213,7 +288,7 @@ impl EbpfBlocker { // Keep the inode in the map for tracking, but set blocked to 0 inode_map .insert( - inode, + key, InodeState { gpu_id, blocked: 0, @@ -225,31 +300,31 @@ impl EbpfBlocker { Ok(()) } - pub fn is_inode_blocked(&self, inode: u64, gpu_id: u32) -> CardwireEbpfResult { - let inode_map: HashMap<_, u64, InodeState> = HashMap::try_from( + pub fn is_inode_blocked(&self, key: InodeKey, gpu_id: u32) -> CardwireEbpfResult { + let inode_map: HashMap<_, InodeKey, InodeState> = HashMap::try_from( self.ebpf .map("CW_BLOCKED_INO") .ok_or_else(|| CardwireEbpfError::missing_map("CW_BLOCKED_INO"))?, ) .map_err(CardwireEbpfError::aya)?; - match inode_map.get(&inode, 0) { + match inode_map.get(&key, 0) { Ok(state) => Ok(state.gpu_id == gpu_id && state.blocked == 1), Err(MapError::KeyNotFound) => Ok(false), Err(err) => Err(CardwireEbpfError::aya(err)), } } - pub fn block_exp_inode(&mut self, inode: u64, value: u32) -> CardwireEbpfResult<()> { + pub fn block_exp_inode(&mut self, key: InodeKey, value: u32) -> CardwireEbpfResult<()> { // Also insert hardcoded values for now - let mut inode_map: HashMap<_, u64, u32> = HashMap::try_from( + let mut inode_map: HashMap<_, InodeKey, u32> = HashMap::try_from( self.ebpf .map_mut("CW_EXP_BLK_INO") .ok_or_else(|| CardwireEbpfError::missing_map("CW_EXP_BLK_INO"))?, ) .map_err(CardwireEbpfError::aya)?; inode_map - .insert(inode, value, 0) + .insert(key, value, 0) .map_err(CardwireEbpfError::aya)?; Ok(()) } @@ -463,4 +538,132 @@ mod tests { assert_eq!(&key[..15], b"123456789012345"); assert_eq!(key[15], 0); } + + /// MKDEV, as the kernel builds s_dev + fn mkdev(major: u64, minor: u64) -> u64 { + (major << MINOR_BITS) | minor + } + + #[test] + fn anonymous_devices_are_unchanged_by_the_conversion() { + // tmpfs, sysfs and procfs sit on major 0, where both encodings agree + for minor in [7u64, 25, 28, 50] { + assert_eq!(kernel_dev(minor), mkdev(0, minor)); + } + } + + #[test] + fn real_block_devices_are_re_encoded() { + // an nvme partition: glibc packs 259:4 as 66308, the kernel as MKDEV(259, 4) + assert_eq!(kernel_dev(66308), mkdev(259, 4)); + assert_ne!(kernel_dev(66308), 66308); + + // sd-style major 8 + assert_eq!(kernel_dev(2049), mkdev(8, 1)); + } + + #[test] + fn conversion_round_trips_every_major_minor_split() { + // exercise values that land in the high half of each split field + for (major, minor) in [ + (0u64, 0u64), + (8, 1), + (259, 4), + (4095, 255), + (4096, 256), + (0xffff, 0xfffff), + ] { + let st_dev = ((major & 0xfff) << 8) + | ((major & !0xfff) << 32) + | (minor & 0xff) + | ((minor & !0xff) << 12); + + assert_eq!( + kernel_dev(st_dev), + mkdev(major, minor), + "major {major} minor {minor}" + ); + } + } + + #[test] + fn same_inode_on_different_filesystems_is_not_the_same_key() { + let gpu = InodeKey::new(7, 259); + let unrelated = InodeKey::new(66308, 259); + + assert_ne!(gpu, unrelated); + assert_eq!(gpu.ino, unrelated.ino); + } + + #[test] + fn conversion_matches_the_running_kernel() { + use std::{collections::BTreeMap, fs, os::unix::fs::MetadataExt}; + + let Ok(mountinfo) = fs::read_to_string("/proc/self/mountinfo") else { + return; // not available in every build sandbox + }; + + // stat() on an autofs mount point triggers the automount, so the device id + // we read back is the mounted filesystem's rather than the one mountinfo + // listed. Network filesystems can hang the stat outright, there is no + // timeout to lean on + const SKIPPED_TYPES: &[&str] = &[ + "autofs", + "nfs", + "nfs4", + "cifs", + "smb3", + "fuse", + "fuse.sshfs", + "afs", + "ceph", + ]; + + // Later entries shadow earlier ones when two filesystems share a path + let mut mounts: BTreeMap = BTreeMap::new(); + for line in mountinfo.lines() { + let fields: Vec<&str> = line.split_whitespace().collect(); + let ([major, minor], Some(path)) = ( + match fields.get(2).and_then(|f| f.split_once(':')) { + Some((major, minor)) => match (major.parse(), minor.parse()) { + (Ok(major), Ok(minor)) => [major, minor], + _ => continue, + }, + None => continue, + }, + fields.get(4), + ) else { + continue; + }; + + // The optional fields end at a lone "-", the filesystem type follows it + let fs_type = fields + .iter() + .position(|field| *field == "-") + .and_then(|separator| fields.get(separator + 1)); + match fs_type { + Some(fs_type) if SKIPPED_TYPES.contains(fs_type) => continue, + Some(_) => {} + // A line we cannot classify is not worth stat'ing blindly + None => continue, + } + + mounts.insert((*path).to_owned(), (major, minor)); + } + + let mut checked = 0; + for (path, (major, minor)) in mounts { + let Ok(meta) = fs::metadata(&path) else { + continue; + }; + assert_eq!( + kernel_dev(meta.dev()), + mkdev(major, minor), + "device id mismatch for {path}" + ); + checked += 1; + } + + assert!(checked > 0, "no mount point could be stat'd"); + } } diff --git a/crates/cardwire-ebpf/src/helpers.rs b/crates/cardwire-ebpf/src/helpers.rs index ffcb61ef..623224e2 100644 --- a/crates/cardwire-ebpf/src/helpers.rs +++ b/crates/cardwire-ebpf/src/helpers.rs @@ -4,22 +4,36 @@ use aya_ebpf::helpers::{ use crate::{ CardwiredSetting, DAEMON_INDEX, HYBRID, INTEGRATED, MANUAL, MODE_INDEX, SMART, maps::{ - CW_ALLOWED_COMM, CW_ALLOWED_PID, CW_BLOCKED_INO, CW_DAEMON_PID, CW_EXP_BLK_INO, CW_FORCED_PID, CW_MODE, CW_REPORT_EVENTS, CW_SETTINGS, ReportEvent + CW_ALLOWED_COMM, CW_ALLOWED_PID, CW_BLOCKED_INO, CW_DAEMON_PID, CW_EXP_BLK_INO, CW_FORCED_PID, CW_MODE, CW_REPORT_EVENTS, CW_SETTINGS, InodeKey, ReportEvent } }; -use crate::vmlinux::task_struct; +use crate::vmlinux::{inode, task_struct}; -/// Verify if the inode is inside CW_BLOCKED_INO or not +/// Build the block-map key for an inode #[inline(always)] -pub unsafe fn is_inode_blocked(inode: u64) -> bool { +pub unsafe fn inode_key(inode_ptr: *const inode) -> Option { + let sb = unsafe { (*inode_ptr).i_sb }; + if sb.is_null() { + return None; + } + + Some(InodeKey { + dev: unsafe { (*sb).s_dev } as u64, + ino: unsafe { (*inode_ptr).i_ino }, + }) +} + +/// Verify if the file is inside CW_BLOCKED_INO or not +#[inline(always)] +pub unsafe fn is_inode_blocked(key: InodeKey) -> bool { let mut tracked: bool = false; let mut ino_gpu_id: u32 = 0; let mut blocked: bool = false; 'inode_check: { - // Check if the inode is in the blocked list - if let Some(v) = unsafe { CW_BLOCKED_INO.get(inode) } { + // Check if the file is in the blocked list + if let Some(v) = unsafe { CW_BLOCKED_INO.get(key) } { tracked = true; ino_gpu_id = v.gpu_id; blocked = v.blocked == 1; @@ -27,7 +41,7 @@ pub unsafe fn is_inode_blocked(inode: u64) -> bool { } // We didn't match any inode, try with nvidia inodes if unsafe { is_nvidia_setting_enabled() } - && let Some(v) = unsafe { CW_EXP_BLK_INO.get(inode) } + && let Some(v) = unsafe { CW_EXP_BLK_INO.get(key) } { tracked = true; ino_gpu_id = *v; diff --git a/crates/cardwire-ebpf/src/main.rs b/crates/cardwire-ebpf/src/main.rs index 8a948f87..c19c0779 100644 --- a/crates/cardwire-ebpf/src/main.rs +++ b/crates/cardwire-ebpf/src/main.rs @@ -2,14 +2,16 @@ #![no_main] use aya_ebpf::{ - helpers::{bpf_get_current_pid_tgid, bpf_probe_read_user, bpf_probe_write_user}, macros::{lsm, tracepoint}, programs::{LsmContext, TracePointContext} + helpers::{bpf_get_current_pid_tgid, bpf_probe_read_user, bpf_probe_write_user}, macros::{fentry, lsm, tracepoint}, programs::{FEntryContext, LsmContext, TracePointContext} }; use aya_log_ebpf::{error, warn}; use crate::{ helpers::{ - is_cardwired, is_comm_whitelisted, is_hybrid, is_inode_blocked, is_manual, is_smart - }, maps::{CW_ALLOWED_PID, CW_DIRENT, CW_EXEC_EVENTS, CW_FORCED_PID, ExecEvent}, vmlinux::{dentry, file, inode, linux_dirent64, path} + inode_key, is_cardwired, is_comm_whitelisted, is_hybrid, is_inode_blocked, is_manual, is_smart + }, maps::{ + CW_ALLOWED_PID, CW_DIRENT, CW_DIRENT_DEV, CW_EXEC_EVENTS, CW_FORCED_PID, ExecEvent, InodeKey + }, vmlinux::{dentry, file, inode, linux_dirent64, path} }; #[allow( @@ -133,9 +135,12 @@ unsafe fn try_file_open(ctx: LsmContext) -> Result { if inode_ptr.is_null() { return ReturnCode::SUCCESS; } - let inode: u64 = unsafe { (*inode_ptr).i_ino }; - match unsafe { is_inode_blocked(inode) } { + let Some(key) = (unsafe { inode_key(inode_ptr) }) else { + return ReturnCode::SUCCESS; + }; + + match unsafe { is_inode_blocked(key) } { true => ReturnCode::ENOENT, false => ReturnCode::SUCCESS, } @@ -196,9 +201,12 @@ unsafe fn try_inode_permission(ctx: LsmContext) -> Result { if inode_ptr.is_null() { return ReturnCode::SUCCESS; } - let inode: u64 = unsafe { (*inode_ptr).i_ino }; - match unsafe { is_inode_blocked(inode) } { + let Some(key) = (unsafe { inode_key(inode_ptr) }) else { + return ReturnCode::SUCCESS; + }; + + match unsafe { is_inode_blocked(key) } { true => ReturnCode::ENOENT, false => ReturnCode::SUCCESS, } @@ -270,9 +278,12 @@ unsafe fn try_inode_getattr(ctx: LsmContext) -> Result { if inode_ptr.is_null() { return ReturnCode::SUCCESS; } - let inode: u64 = unsafe { (*inode_ptr).i_ino }; - match unsafe { is_inode_blocked(inode) } { + let Some(key) = (unsafe { inode_key(inode_ptr) }) else { + return ReturnCode::SUCCESS; + }; + + match unsafe { is_inode_blocked(key) } { true => ReturnCode::ENOENT, false => ReturnCode::SUCCESS, } @@ -337,6 +348,69 @@ unsafe fn try_tracepoint_enter_getdents64(ctx: TracePointContext) -> Result u32 { + match unsafe { try_fentry_iterate_dir(ctx) } { + Ok(ret) => ret as u32, + Err(ret) => ret as u32, + } +} + +unsafe fn try_fentry_iterate_dir(ctx: FEntryContext) -> Result { + if is_comm_whitelisted() { + return ReturnCode::SUCCESS; + } + + match is_cardwired() { + Some(res) => { + if res { + return ReturnCode::SUCCESS; + } + } + None => return ReturnCode::SUCCESS, + } + + match unsafe { is_hybrid() } { + Some(res) => { + if res { + return ReturnCode::SUCCESS; + } + } + None => return ReturnCode::SUCCESS, + } + + // Only the getdents64 exit hook drains this map, recording for iterate_dir's + // other callers fills it for good and inserts then start failing open + let tid = bpf_get_current_pid_tgid() as u32; + if unsafe { CW_DIRENT.get(tid) }.is_none() { + return ReturnCode::SUCCESS; + } + + let file_ptr: *const file = ctx.arg(0); + if file_ptr.is_null() { + return ReturnCode::SUCCESS; + } + + let d: *mut dentry = unsafe { (*file_ptr).__bindgen_anon_1.f_path.dentry }; + if d.is_null() { + return ReturnCode::SUCCESS; + } + + let inode_ptr: *mut inode = unsafe { (*d).d_inode }; + if inode_ptr.is_null() { + return ReturnCode::SUCCESS; + } + + let Some(key) = (unsafe { inode_key(inode_ptr) }) else { + return ReturnCode::SUCCESS; + }; + + CW_DIRENT_DEV.insert(tid, key.dev, 0)?; + + ReturnCode::SUCCESS +} + #[tracepoint] pub fn tracepoint_exit_getdents64(ctx: TracePointContext) -> u32 { match unsafe { try_tracepoint_exit_getdents64(ctx) } { @@ -347,13 +421,18 @@ pub fn tracepoint_exit_getdents64(ctx: TracePointContext) -> u32 { unsafe fn try_tracepoint_exit_getdents64(ctx: TracePointContext) -> Result { let tid = bpf_get_current_pid_tgid() as u32; - let dirent_ptr = match unsafe { CW_DIRENT.get(tid) } { - Some(ptr) => *ptr as *const linux_dirent64, - None => return ReturnCode::SUCCESS, - }; - // Remove entry immediately to avoid map leak + // Drain both maps up front to avoid a map leak on an early return + let dirp = unsafe { CW_DIRENT.get(tid) }.copied(); let _ = CW_DIRENT.remove(tid); + let dir_dev = unsafe { CW_DIRENT_DEV.get(tid) }.copied(); + let _ = CW_DIRENT_DEV.remove(tid); + + let (Some(dirp), Some(dir_dev)) = (dirp, dir_dev) else { + return ReturnCode::SUCCESS; + }; + + let dirent_ptr = dirp as *const linux_dirent64; let retval = match unsafe { ctx.read_at::(16) } { Ok(ret) => ret as u64, @@ -389,7 +468,12 @@ unsafe fn try_tracepoint_exit_getdents64(ctx: TracePointContext) -> Result = - HashMap::::with_max_entries(16384, 0); +pub static CW_BLOCKED_INO: HashMap = + HashMap::::with_max_entries(16384, 0); /* Map used to store blocked inodes from exp_nvidia - Key = Inode + Key = (superblock device id, inode) Value = 0, not used because exp files can be shared by multiple devices (nvidiactl) */ #[map] -pub static CW_EXP_BLK_INO: HashMap = HashMap::::with_max_entries(4096, 0); +pub static CW_EXP_BLK_INO: HashMap = + HashMap::::with_max_entries(4096, 0); /* Map used to store a list of allowed pid @@ -80,6 +93,14 @@ pub static CW_ALLOWED_COMM: HashMap<[u8; 16], u8> = #[map] pub static CW_DIRENT: HashMap = HashMap::::with_max_entries(1024, 0); +/* + Device id of the directory being read, recorded by iterate_dir + Key = TID + Value = superblock device id +*/ +#[map] +pub static CW_DIRENT_DEV: HashMap = HashMap::::with_max_entries(1024, 0); + #[repr(C, align(8))] #[allow(dead_code)] pub struct ExecEvent { From 398e094fefee75ce9affc8a97e5aadb4de8f3613 Mon Sep 17 00:00:00 2001 From: Ahmed Yaseen Date: Thu, 13 Aug 2026 14:55:42 +0500 Subject: [PATCH 2/6] fix(daemon): drop stale block entries when gpu device nodes are recreated --- crates/cardwire-daemon/src/interface/debug.rs | 30 ++++++- crates/cardwire-daemon/src/interface/gpu.rs | 78 ++++++++++--------- crates/cardwire-ebpf-userspace/src/lib.rs | 15 ++++ 3 files changed, 82 insertions(+), 41 deletions(-) diff --git a/crates/cardwire-daemon/src/interface/debug.rs b/crates/cardwire-daemon/src/interface/debug.rs index d870b30a..47e2e6e8 100644 --- a/crates/cardwire-daemon/src/interface/debug.rs +++ b/crates/cardwire-daemon/src/interface/debug.rs @@ -3,9 +3,11 @@ use crate::{ env::compute_switcheroo_env, gpu::GpuEnumerator, pci::{self, DbusPciDevice, PciDevice} }, interface::SwitcherooInterface, tasks::watch_power_state }; -use cardwire_ebpf_userspace::EbpfBlocker; +use cardwire_ebpf_userspace::{EbpfBlocker, InodeKey}; use log::{info, warn}; -use std::{collections::BTreeMap, sync::Arc}; +use std::{ + collections::{BTreeMap, HashSet}, sync::Arc +}; use tokio::{sync::RwLock, task}; use zbus::{fdo, interface}; @@ -46,6 +48,24 @@ impl DebugInterface { switcheroo, }) } + + async fn drop_unclaimed_inodes(&self, previous: Vec) { + let claimed: HashSet = { + let gpu_interfaces = self.gpu_list.read().await; + let mut claimed = HashSet::new(); + for gpu in gpu_interfaces.values() { + claimed.extend(gpu.pushed_inodes().await); + } + claimed + }; + + let mut blocker = self.blocker.write().await; + for stale in previous.iter().filter(|key| !claimed.contains(key)) { + if let Err(err) = blocker.remove_inode(*stale) { + warn!("failed to drop stale inode {:?}: {}", stale, err); + } + } + } } #[interface(name = "org.opengamingcollective.cardwire.Debug")] @@ -76,13 +96,15 @@ impl DebugInterface { let mut power_tasks = self.power_tasks.write().await; // get rid of the old gpu api and the old tasks - for id in gpu_interfaces.keys() { + let mut previous_inodes: Vec = Vec::new(); + for (id, gpu) in gpu_interfaces.iter() { let path = format!("/org/opengamingcollective/cardwire/Gpu/{}", id); let _ = object_server.remove::(&path).await; // if task is present, abort if let Some(handle) = power_tasks.remove(id) { handle.abort(); } + previous_inodes.extend(gpu.take_pushed_inodes().await); } // Empty the current gpu_interfaces @@ -165,6 +187,8 @@ impl DebugInterface { warn!("failed to fall back to hybrid mode on hotplug: {fb}"); } } + + self.drop_unclaimed_inodes(previous_inodes).await; self.switcheroo.emit_gpu_list_changed().await; } diff --git a/crates/cardwire-daemon/src/interface/gpu.rs b/crates/cardwire-daemon/src/interface/gpu.rs index a43b217c..288262ee 100644 --- a/crates/cardwire-daemon/src/interface/gpu.rs +++ b/crates/cardwire-daemon/src/interface/gpu.rs @@ -9,7 +9,7 @@ use crate::{ env::is_gpu_launchable, gpu::{DbusGpuDevice, GpuDevice, is_gpu_active, send_drm_uevent}, inode::{card_to_inode, get_inodes, nvidia_to_inode, render_to_inode, single_pci_to_inode}, pci::PciDevice, procfs }, file::{CardwireGpuState, CardwireModeState}, interface::{Modes, SwitcherooInterface} }; -use cardwire_ebpf_userspace::EbpfBlocker; +use cardwire_ebpf_userspace::{EbpfBlocker, InodeKey}; use log::{info, warn}; use tokio::sync::RwLock; use zbus::{fdo, interface, object_server::SignalEmitter}; @@ -36,6 +36,8 @@ pub struct GpuInterface { mode_state: Arc>, pub signal_emitter: Arc>>, switcheroo_int: SwitcherooInterface, + /// What this GPU last pushed into the eBPF map + pushed_inodes: Arc>>, } impl GpuInterface { @@ -60,13 +62,14 @@ impl GpuInterface { mode_state, signal_emitter: Arc::new(OnceLock::new()), switcheroo_int, + pushed_inodes: Arc::new(RwLock::new(Vec::new())), }) } } impl GpuInterface { - /// block the gpu, value = gpu key - pub async fn block_gpu(&self, value: u32) -> fdo::Result<()> { + /// Read the inodes this GPU currently owns + async fn current_inodes(&self) -> fdo::Result> { let (render, card, pci_address, pci_parent, nvidia_minor, pci_list) = { let pci_list_guard = self.pci_list.read().await; @@ -80,7 +83,7 @@ impl GpuInterface { ) }; - let inodes = tokio::task::spawn_blocking(move || { + tokio::task::spawn_blocking(move || { get_inodes( render, card, @@ -92,52 +95,51 @@ impl GpuInterface { }) .await .into_fdo()? - .into_fdo()?; + .into_fdo() + } + + /// Push this GPU's inodes into the map with the given block state, dropping + /// any it pushed earlier that a power cycle or rebind has since renumbered + async fn sync_inodes(&self, gpu_id: u32, blocked: bool) -> fdo::Result<()> { + let inodes = self.current_inodes().await?; let mut blocker = self.blocker.write().await; + let mut pushed = self.pushed_inodes.write().await; + + for stale in pushed.iter().filter(|key| !inodes.contains(key)) { + blocker.remove_inode(*stale).into_fdo()?; + } + + // Record before applying, a failure mid loop must still leave every + // inserted key removable + *pushed = inodes; - for inode in inodes { - blocker.block_inode(inode, value).into_fdo()?; + for inode in pushed.iter() { + match blocked { + true => blocker.block_inode(*inode, gpu_id).into_fdo()?, + false => blocker.unblock_inode(*inode, gpu_id).into_fdo()?, + } } Ok(()) } + /// block the gpu, value = gpu key + pub async fn block_gpu(&self, value: u32) -> fdo::Result<()> { + self.sync_inodes(value, true).await + } + /// unblock the gpu pub async fn unblock_gpu(&self) -> fdo::Result<()> { - let (render, card, pci_address, pci_parent, nvidia_minor, pci_list) = { - let pci_list_guard = self.pci_list.read().await; - - ( - *self.device.render(), - *self.device.card(), - self.device.pci().pci_address().to_owned(), - self.device.pci().parent_pci().to_owned(), - *self.device.nvidia_minor(), - pci_list_guard.clone(), - ) - }; + self.sync_inodes(self.id, false).await + } - // Read the inodes required to unblock the GPU, return if err - let inodes = tokio::task::spawn_blocking(move || { - get_inodes( - render, - card, - &pci_address, - &pci_parent, - &pci_list, - nvidia_minor, - ) - }) - .await - .into_fdo()? - .into_fdo()?; - let mut blocker = self.blocker.write().await; + pub async fn take_pushed_inodes(&self) -> Vec { + std::mem::take(&mut *self.pushed_inodes.write().await) + } - for inode in inodes.iter() { - blocker.unblock_inode(*inode, self.id).into_fdo()?; - } - Ok(()) + pub async fn pushed_inodes(&self) -> Vec { + self.pushed_inodes.read().await.clone() } /// check if the gpu is blocked pub async fn gpu_blocked(&self) -> fdo::Result { diff --git a/crates/cardwire-ebpf-userspace/src/lib.rs b/crates/cardwire-ebpf-userspace/src/lib.rs index c6569ec1..060931c6 100644 --- a/crates/cardwire-ebpf-userspace/src/lib.rs +++ b/crates/cardwire-ebpf-userspace/src/lib.rs @@ -300,6 +300,21 @@ impl EbpfBlocker { Ok(()) } + /// Drop a file from the map entirely, a missing key is not an error + pub fn remove_inode(&mut self, key: InodeKey) -> CardwireEbpfResult<()> { + let mut inode_map: HashMap<_, InodeKey, InodeState> = HashMap::try_from( + self.ebpf + .map_mut("CW_BLOCKED_INO") + .ok_or_else(|| CardwireEbpfError::missing_map("CW_BLOCKED_INO"))?, + ) + .map_err(CardwireEbpfError::aya)?; + + match inode_map.remove(&key) { + Ok(()) | Err(MapError::KeyNotFound) => Ok(()), + Err(err) => Err(CardwireEbpfError::aya(err)), + } + } + pub fn is_inode_blocked(&self, key: InodeKey, gpu_id: u32) -> CardwireEbpfResult { let inode_map: HashMap<_, InodeKey, InodeState> = HashMap::try_from( self.ebpf From 5d5d6764ac91e48840d011738250b9f5f26fb89c Mon Sep 17 00:00:00 2001 From: Ahmed Yaseen Date: Thu, 13 Aug 2026 19:40:25 +0500 Subject: [PATCH 3/6] test(nix): check sandbox files surviving a gpu inode number collision --- nix/ci-2gpu.nix | 48 ++++++++++++++++++++++++++++++++++++++++ nix/vm-configuration.nix | 1 + 2 files changed, 49 insertions(+) diff --git a/nix/ci-2gpu.nix b/nix/ci-2gpu.nix index 87fea072..f2b06e51 100644 --- a/nix/ci-2gpu.nix +++ b/nix/ci-2gpu.nix @@ -4,6 +4,39 @@ self, lib, }: +let + # Fills a sandbox's private tmpfs until a file lands on the inode number + # given as $1, then checks that file and its neighbours survive + sandboxCollision = (pkgs system).writeShellScript "sandbox-collision" '' + set -eu + export PATH=${(pkgs system).coreutils}/bin:$PATH + + # tmpfs hands out inode numbers in percpu blocks of 1024 and can skip + # straight past the target, so overshoot by more than one block + n=$(($1 + 1100)) + i=1 + while [ "$i" -le "$n" ]; do + : > "/tmp/f$i" + i=$((i + 1)) + done + + collider="" + for f in /tmp/f*; do + if [ "$(stat -c %i "$f")" = "$1" ]; then + collider="$f" + break + fi + done + # No collision means the checks below prove nothing + [ -n "$collider" ] + + # inode_getattr and file_open, the dirent path is covered by the ls + stat "$collider" > /dev/null + : < "$collider" + + [ "$(ls -1 /tmp | wc -l)" -eq "$n" ] + ''; +in (pkgs system).testers.runNixOSTest { name = "cardwire-test"; nodes.machine = @@ -102,5 +135,20 @@ t.assertIn("CARDWIRE_FORCE_DGPU=1", env_out, "Default launch didn't target dGPU (GPU 1)") t.assertIn("DRI_PRIME=pci", env_out, "Default launch didn't set DRI_PRIME") + with subtest("Sandboxes keep files that share an inode number with a blocked GPU"): + machine.succeed("bwrap --dev-bind / / --tmpfs /tmp true") + + # Read it while nothing is blocked, the stat would be denied otherwise + machine.succeed("cardwire set hybrid") + dgpu_ino = machine.succeed("stat -c %i /dev/dri/renderD129").strip() + + machine.succeed("cardwire set integrated") + # if this passed the sandbox check below would prove nothing + machine.fail(": < /dev/dri/renderD129") + + machine.succeed( + "bwrap --dev-bind / / --tmpfs /tmp ${sandboxCollision} " + dgpu_ino + ) + ''; } diff --git a/nix/vm-configuration.nix b/nix/vm-configuration.nix index 3d7efab5..99cef09f 100644 --- a/nix/vm-configuration.nix +++ b/nix/vm-configuration.nix @@ -34,6 +34,7 @@ tmux gnugrep coreutils + bubblewrap ]; services.getty.autologinUser = "john"; virtualisation.vmVariant = { From 3edc8240f164d6e93722ed567fdb002c9663249b Mon Sep 17 00:00:00 2001 From: Ahmed Yaseen Date: Thu, 13 Aug 2026 21:58:17 +0500 Subject: [PATCH 4/6] fix(daemon): reconcile the experimental nvidia block map on gpu refresh --- crates/cardwire-daemon/src/interface/debug.rs | 38 +++++++++++++- crates/cardwire-daemon/src/manager.rs | 20 +------- crates/cardwire-ebpf-userspace/src/lib.rs | 51 +++++++++++++++++++ 3 files changed, 90 insertions(+), 19 deletions(-) diff --git a/crates/cardwire-daemon/src/interface/debug.rs b/crates/cardwire-daemon/src/interface/debug.rs index 47e2e6e8..b216c20a 100644 --- a/crates/cardwire-daemon/src/interface/debug.rs +++ b/crates/cardwire-daemon/src/interface/debug.rs @@ -1,6 +1,6 @@ use crate::{ core::{ - env::compute_switcheroo_env, gpu::GpuEnumerator, pci::{self, DbusPciDevice, PciDevice} + env::compute_switcheroo_env, gpu::{GpuEnumerator, GpuVendor}, inode::exp_nvidia_inodes, pci::{self, DbusPciDevice, PciDevice} }, interface::SwitcherooInterface, tasks::watch_power_state }; use cardwire_ebpf_userspace::{EbpfBlocker, InodeKey}; @@ -49,6 +49,41 @@ impl DebugInterface { }) } + pub async fn sync_nvidia_inodes(&self) { + let target = { + let gpu_list = self.gpu_list.read().await; + gpu_list + .iter() + .find(|(_, gpu)| { + gpu.device.gpu_vendor() == GpuVendor::Nvidia && !gpu.device.is_default() + }) + .map(|(id, _)| *id as u32) + }; + + let inodes = match target { + Some(_) => match exp_nvidia_inodes() { + Ok(inodes) => inodes, + Err(err) => { + warn!( + "failed to read nvidia inodes, leaving the map as is: {}", + err + ); + return; + } + }, + None => Vec::new(), + }; + + let mut blocker = self.blocker.write().await; + let result = match target { + Some(gpu_id) => blocker.sync_exp_inodes(inodes, gpu_id), + None => blocker.clear_exp_inodes(), + }; + if let Err(err) = result { + warn!("failed to sync nvidia inodes: {}", err); + } + } + async fn drop_unclaimed_inodes(&self, previous: Vec) { let claimed: HashSet = { let gpu_interfaces = self.gpu_list.read().await; @@ -189,6 +224,7 @@ impl DebugInterface { } self.drop_unclaimed_inodes(previous_inodes).await; + self.sync_nvidia_inodes().await; self.switcheroo.emit_gpu_list_changed().await; } diff --git a/crates/cardwire-daemon/src/manager.rs b/crates/cardwire-daemon/src/manager.rs index 059bb0be..a83cb826 100644 --- a/crates/cardwire-daemon/src/manager.rs +++ b/crates/cardwire-daemon/src/manager.rs @@ -2,7 +2,7 @@ //! startup tasks and background-task futures. use crate::{ analyzer::CardwireAnalyzer, core::{ - env::compute_switcheroo_env, gpu::{GpuEnumerator, GpuVendor}, inode::exp_nvidia_inodes, pci::{self} + env::compute_switcheroo_env, gpu::GpuEnumerator, pci::{self} }, file::{CardwireConfig, CardwireDatabase, CardwireGpuState, CardwireModeState}, interface::{ ConfigInterface, ConfigMemory, DaemonContext, DebugInterface, GpuInterface, LoggerInterface, ModeInterface, Modes, SmartPolicyInterface, SwitcherooInterface }, tasks @@ -167,23 +167,7 @@ impl DaemonManager { .map_err(|err| err.into()) } async fn block_nvidia_inodes(&self) -> Result<()> { - let gpus_list = self.inner.gpu_list.read().await; - let mut blocker = self.inner.blocker.write().await; - // Only block if the device has a Nvidia gpu - for (id, gpu) in gpus_list.iter() { - if gpu.device.gpu_vendor() == GpuVendor::Nvidia - && !gpu.device.is_default() - && let Ok(inodes) = exp_nvidia_inodes() - && !inodes.is_empty() - { - for inode in inodes { - if let Err(err) = blocker.block_exp_inode(inode, *id as u32) { - error!("failed to block nvidia's file {:?}: {}", inode, err); - } - } - break; - } - } + self.debug_interface.sync_nvidia_inodes().await; Ok(()) } async fn whitelist_programs(&self) -> Result<()> { diff --git a/crates/cardwire-ebpf-userspace/src/lib.rs b/crates/cardwire-ebpf-userspace/src/lib.rs index 060931c6..73b4d073 100644 --- a/crates/cardwire-ebpf-userspace/src/lib.rs +++ b/crates/cardwire-ebpf-userspace/src/lib.rs @@ -21,6 +21,7 @@ pub struct EbpfBlocker { ebpf: Ebpf, pub pid_map: Arc>>, pub forced_map: Arc>>, + pushed_exp_inodes: Vec, } #[repr(C)] @@ -230,6 +231,7 @@ impl EbpfBlocker { ebpf, pid_map, forced_map, + pushed_exp_inodes: Vec::new(), }) } @@ -344,6 +346,55 @@ impl EbpfBlocker { Ok(()) } + pub fn remove_exp_inode(&mut self, key: InodeKey) -> CardwireEbpfResult<()> { + let mut inode_map: HashMap<_, InodeKey, u32> = HashMap::try_from( + self.ebpf + .map_mut("CW_EXP_BLK_INO") + .ok_or_else(|| CardwireEbpfError::missing_map("CW_EXP_BLK_INO"))?, + ) + .map_err(CardwireEbpfError::aya)?; + + match inode_map.remove(&key) { + Ok(()) | Err(MapError::KeyNotFound) => Ok(()), + Err(err) => Err(CardwireEbpfError::aya(err)), + } + } + + /// `pushed_exp_inodes` mirrors what we put in `CW_EXP_BLK_INO`, so it is only + /// ever updated once the kernel agrees. Dropping a key from it before the + /// removal succeeds would leave an entry nothing can name afterwards, and it + /// would stay blocked until the daemon restarts + pub fn clear_exp_inodes(&mut self) -> CardwireEbpfResult<()> { + while let Some(key) = self.pushed_exp_inodes.last().copied() { + self.remove_exp_inode(key)?; + self.pushed_exp_inodes.pop(); + } + Ok(()) + } + + pub fn sync_exp_inodes(&mut self, keys: Vec, gpu_id: u32) -> CardwireEbpfResult<()> { + let stale: Vec = self + .pushed_exp_inodes + .iter() + .copied() + .filter(|key| !keys.contains(key)) + .collect(); + + for key in stale { + self.remove_exp_inode(key)?; + self.pushed_exp_inodes.retain(|tracked| *tracked != key); + } + + for key in keys { + self.block_exp_inode(key, gpu_id)?; + if !self.pushed_exp_inodes.contains(&key) { + self.pushed_exp_inodes.push(key); + } + } + + Ok(()) + } + pub fn set_ebpf_setting(&mut self, setting: EbpfSettings, value: u8) -> CardwireEbpfResult<()> { let key: u8 = match setting { EbpfSettings::ExperimentalNvidia => 0, From e3644d5b82d3219254de645e2e8bc88eae76cad9 Mon Sep 17 00:00:00 2001 From: Ahmed Yaseen Date: Thu, 13 Aug 2026 22:14:43 +0500 Subject: [PATCH 5/6] chore(daemon): drop the block_nvidia_inodes wrapper --- crates/cardwire-daemon/src/interface/debug.rs | 26 ++++++++++++------- crates/cardwire-daemon/src/manager.rs | 12 ++++----- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/crates/cardwire-daemon/src/interface/debug.rs b/crates/cardwire-daemon/src/interface/debug.rs index b216c20a..44ea59b6 100644 --- a/crates/cardwire-daemon/src/interface/debug.rs +++ b/crates/cardwire-daemon/src/interface/debug.rs @@ -3,8 +3,9 @@ use crate::{ env::compute_switcheroo_env, gpu::{GpuEnumerator, GpuVendor}, inode::exp_nvidia_inodes, pci::{self, DbusPciDevice, PciDevice} }, interface::SwitcherooInterface, tasks::watch_power_state }; +use anyhow::Context; use cardwire_ebpf_userspace::{EbpfBlocker, InodeKey}; -use log::{info, warn}; +use log::{error, info, warn}; use std::{ collections::{BTreeMap, HashSet}, sync::Arc }; @@ -49,7 +50,11 @@ impl DebugInterface { }) } - pub async fn sync_nvidia_inodes(&self) { + /// Reconcile CW_EXP_BLK_INO with the nvidia files currently on disk + /// + /// Missing inodes only warn, the map keeps what it holds. A failed map + /// write is returned: the block would be advertised but not enforced + pub async fn sync_nvidia_inodes(&self) -> anyhow::Result<()> { let target = { let gpu_list = self.gpu_list.read().await; gpu_list @@ -65,23 +70,21 @@ impl DebugInterface { Ok(inodes) => inodes, Err(err) => { warn!( - "failed to read nvidia inodes, leaving the map as is: {}", + "failed to read nvidia inodes, leaving the map as is: {:#}", err ); - return; + return Ok(()); } }, None => Vec::new(), }; let mut blocker = self.blocker.write().await; - let result = match target { + match target { Some(gpu_id) => blocker.sync_exp_inodes(inodes, gpu_id), None => blocker.clear_exp_inodes(), - }; - if let Err(err) = result { - warn!("failed to sync nvidia inodes: {}", err); } + .context("failed to write the CW_EXP_BLK_INO map") } async fn drop_unclaimed_inodes(&self, previous: Vec) { @@ -224,7 +227,12 @@ impl DebugInterface { } self.drop_unclaimed_inodes(previous_inodes).await; - self.sync_nvidia_inodes().await; + if let Err(err) = self.sync_nvidia_inodes().await { + error!( + "nvidia block is out of date after the gpu refresh: {:#}", + err + ); + } self.switcheroo.emit_gpu_list_changed().await; } diff --git a/crates/cardwire-daemon/src/manager.rs b/crates/cardwire-daemon/src/manager.rs index a83cb826..68b01005 100644 --- a/crates/cardwire-daemon/src/manager.rs +++ b/crates/cardwire-daemon/src/manager.rs @@ -118,8 +118,12 @@ impl DaemonManager { // Set nvidia setting self.set_nvidia_setting().await?; - // Push nvidia inodes, if empty/error just ignore - self.block_nvidia_inodes().await?; + // Fatal: the setting is already on, so an unwritable map advertises a + // block that is never enforced + self.debug_interface + .sync_nvidia_inodes() + .await + .context("failed to prime the experimental nvidia block")?; // Add some programs to the whitelisted comm map self.whitelist_programs().await?; @@ -166,10 +170,6 @@ impl DaemonManager { ) .map_err(|err| err.into()) } - async fn block_nvidia_inodes(&self) -> Result<()> { - self.debug_interface.sync_nvidia_inodes().await; - Ok(()) - } async fn whitelist_programs(&self) -> Result<()> { // List of allowed programs const ALLOWED_PROGRAMS: &[&str] = &[ From b349b27c5f0a77033f75f46633c896f90c9083c0 Mon Sep 17 00:00:00 2001 From: Ahmed Yaseen Date: Fri, 14 Aug 2026 13:51:39 +0500 Subject: [PATCH 6/6] chore(ebpf): log when an inode has no superblock --- crates/cardwire-ebpf/src/helpers.rs | 4 ++++ crates/cardwire-ebpf/src/main.rs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/crates/cardwire-ebpf/src/helpers.rs b/crates/cardwire-ebpf/src/helpers.rs index 623224e2..d880904e 100644 --- a/crates/cardwire-ebpf/src/helpers.rs +++ b/crates/cardwire-ebpf/src/helpers.rs @@ -11,6 +11,10 @@ use crate::{ use crate::vmlinux::{inode, task_struct}; /// Build the block-map key for an inode +/// +/// None means the inode carries no superblock. Every live inode has one, so +/// this cannot happen on a healthy kernel: callers log it to make clear the +/// fault is upstream and not in cardwire #[inline(always)] pub unsafe fn inode_key(inode_ptr: *const inode) -> Option { let sb = unsafe { (*inode_ptr).i_sb }; diff --git a/crates/cardwire-ebpf/src/main.rs b/crates/cardwire-ebpf/src/main.rs index c19c0779..40d35b45 100644 --- a/crates/cardwire-ebpf/src/main.rs +++ b/crates/cardwire-ebpf/src/main.rs @@ -137,6 +137,10 @@ unsafe fn try_file_open(ctx: LsmContext) -> Result { } let Some(key) = (unsafe { inode_key(inode_ptr) }) else { + error!( + &ctx, + "EBPF inode_key() got an inode with no superblock in file_open, this is a kernel bug, skipping" + ); return ReturnCode::SUCCESS; }; @@ -203,6 +207,10 @@ unsafe fn try_inode_permission(ctx: LsmContext) -> Result { } let Some(key) = (unsafe { inode_key(inode_ptr) }) else { + error!( + &ctx, + "EBPF inode_key() got an inode with no superblock in inode_permission, this is a kernel bug, skipping" + ); return ReturnCode::SUCCESS; }; @@ -280,6 +288,10 @@ unsafe fn try_inode_getattr(ctx: LsmContext) -> Result { } let Some(key) = (unsafe { inode_key(inode_ptr) }) else { + error!( + &ctx, + "EBPF inode_key() got an inode with no superblock in inode_getattr, this is a kernel bug, skipping" + ); return ReturnCode::SUCCESS; }; @@ -403,6 +415,10 @@ unsafe fn try_fentry_iterate_dir(ctx: FEntryContext) -> Result { } let Some(key) = (unsafe { inode_key(inode_ptr) }) else { + error!( + &ctx, + "EBPF inode_key() got an inode with no superblock in iterate_dir, this is a kernel bug, skipping" + ); return ReturnCode::SUCCESS; };