diff --git a/.gitignore b/.gitignore index 76de09bd..0f8be73b 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ book # Nix-specific launcher wrapper /cardwired + +# rustc internal compiler error logs +rustc-ice-*.txt diff --git a/Cargo.lock b/Cargo.lock index 2bf0114b..87b00f77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -647,6 +647,7 @@ dependencies = [ "freedesktop-desktop-entry", "khronos-egl", "log", + "rusqlite", "serde", "serde_json", "thiserror 2.0.19", @@ -1211,6 +1212,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -1631,6 +1644,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "heck" version = "0.4.1" @@ -2145,6 +2167,17 @@ dependencies = [ "redox_syscall 0.9.1", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libudev-sys" version = "0.1.4" @@ -3242,6 +3275,31 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.19", +] + +[[package]] +name = "rusqlite" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3606,6 +3664,18 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -4024,6 +4094,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 17d8a2e6..f5afdb61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ env_logger = "0.11" toml = "1.0.3" vulkano = "0.35.2" khronos-egl = { version= "6.0.0", features = ["dynamic", "1_5"] } +rusqlite = { version = "0.40.1", features = ["bundled"] } # EBPF aya = "0.14.0" diff --git a/crates/cardwire-daemon/Cargo.toml b/crates/cardwire-daemon/Cargo.toml index 75d2f8d4..a384ead4 100644 --- a/crates/cardwire-daemon/Cargo.toml +++ b/crates/cardwire-daemon/Cargo.toml @@ -27,6 +27,7 @@ udev.workspace = true aya-log.workspace = true vulkano.workspace = true khronos-egl.workspace = true +rusqlite.workspace = true [[bin]] name = "cardwired" diff --git a/crates/cardwire-daemon/src/analyzer/README.md b/crates/cardwire-daemon/src/analyzer/README.md index efb80551..c83916aa 100644 --- a/crates/cardwire-daemon/src/analyzer/README.md +++ b/crates/cardwire-daemon/src/analyzer/README.md @@ -1,43 +1,39 @@ # Cardwire Analyzer -The goal of the analyzer is to allow or block an app on the fly - -The analyzer will uses a database, and both dynamic and static analysis to determine if an app should be allowed or not. - -Database is for known entities, the result of the static analysis should be stored in this database - -dynamic result will never be stored - -if static return blocked but dynamic return allow, app should be allowed - -## Dynamic - -### Gamemode -If the game uses gamemoderun - -Example with Persona 4 Golden launched with gamemoderun: - -```bash -❯ grep -i gamemode /proc/24710/maps -76631d641000-76631d642000 r--p 00000000 00:25 98239241 /nix/store/mi8lmzjfkmcmiwhsir8z8v4fyihi1mwf-gamemode-1.8.2-lib/lib/libgamemodeauto.so.0.0.0 -76631d642000-76631d644000 r-xp 00001000 00:25 98239241 /nix/store/mi8lmzjfkmcmiwhsir8z8v4fyihi1mwf-gamemode-1.8.2-lib/lib/libgamemodeauto.so.0.0.0 -76631d644000-76631d645000 r--p 00003000 00:25 98239241 /nix/store/mi8lmzjfkmcmiwhsir8z8v4fyihi1mwf-gamemode-1.8.2-lib/lib/libgamemodeauto.so.0.0.0 -76631d645000-76631d646000 r--p 00003000 00:25 98239241 /nix/store/mi8lmzjfkmcmiwhsir8z8v4fyihi1mwf-gamemode-1.8.2-lib/lib/libgamemodeauto.so.0.0.0 -76631d646000-76631d647000 rw-p 00004000 00:25 98239241 /nix/store/mi8lmzjfkmcmiwhsir8z8v4fyihi1mwf-gamemode-1.8.2-lib/lib/libgamemodeauto.so.0.0.0 -``` -Allow - -### Electron -If the daemon detects an app is using electron - -check via /proc/PID/cmdline ? - -Block - -## Static - -### XDG -XDG-dir if category = game or run on dgpu = true - -Allow - +The goal of the analyzer is to allow or block an app on the fly. + +The analyzer combines a database, and both dynamic and static analysis to determine +if an app should be allowed or not. + +The database stores known entities (apps discovered via static analysis) and their +policy. Dynamic results are never stored. + +## Modules + +- `models.rs` — the `CardwireAnalyzer` runtime: eBPF ring buffer consumers, process + evaluation (`evaluate_app`) and app discovery (`discover_app`), blocked-event + reporting. +- `dynamic_analysis.rs` — runtime checks: `CARDWIRE_*` environment parsing, GPU env + detection, Steam app id detection, wayland app id lookup. +- `static_analysis.rs` — FDO desktop entry scanning, builds the `AppMetadata` map. +- `helpers.rs` — generic proc/cmdline helpers shared by the runtime: real process + name parsing (wine/proton, java, flatpak, steam), kernel comm decoding, proc + checks + +## Evaluation order + +When a process exec is reported by eBPF, `evaluate_app` runs: + +1. `CARDWIRE_ALLOW=1` — allow +2. `CARDWIRE_FORCE_DGPU=value` — force dGPU +3. `CARDWIRE_FORCE_GPU=value` — force the given GPU +4. `DRI_PRIME=1` / `__NV_PRIME_RENDER_OFFLOAD=1` — allow +5. Database lookup by app name (or `steam_app_` when `SteamAppId` is set): + - `Blocked` — block + - `Allowed` — allow + - `Forced` — force +6. XDG list lookup — app is new: persist it to the database (blocked by default), + then block +7. Steam fallback — unknown `steam_app_`: persist and block + +If static says blocked but dynamic says allow, the app is allowed. diff --git a/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs b/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs index 4845a277..79305a3b 100644 --- a/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs +++ b/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs @@ -1,8 +1,8 @@ //! Functions for dynamic analysis, contains: -//! - gamemoderun analysis -//! - library analysis +//! - environment analysis +//! - wayland app id lookup use std::{ - collections::HashMap, env, fs, path::{Path, PathBuf}, time::{Duration, Instant} + env, fs, path::{Path, PathBuf}, time::{Duration, Instant} }; use tokio::{ @@ -30,41 +30,18 @@ impl Desktop { } } -/// Read the proc `environ` file to find the `SteamAppId=` string -/// used to identify both native and proton games -pub fn check_steam_environ(environ: &[u8]) -> bool { - environ.windows(11).any(|window| window == b"SteamAppId=") -} - -/// Find the process name inside the flatpak cmdline, and match if it's inside our xdg_list -#[allow(dead_code)] -pub fn check_for_flatpak_run(cmdline: &str, xdg_list: &HashMap) -> bool { - let mut args = cmdline.split('\0').filter(|s| !s.is_empty()); - - if let Some(arg0) = args.next() { - // Ensure the actual executable is flatpak or bwrap, not a wrapper like 'niri msg' - if !arg0.ends_with("flatpak") - && !arg0.ends_with(".flatpak-wrapped") - && !arg0.ends_with("bwrap") +pub fn get_steam_app_id(environ: &[u8]) -> Option { + let prefix = b"SteamAppId="; + for var in environ.split(|&b| b == 0) { + if let Some(value_bytes) = var.strip_prefix(prefix) + && let Ok(id_str) = std::str::from_utf8(value_bytes) + && id_str != "0" + && id_str != "769" { - return false; + return Some(format!("steam_app_{}", id_str)); } - } else { - return false; } - - // Now check if any of the arguments match our allowed app - for arg in args { - if let Some(exec) = arg.strip_prefix("--command=") { - if xdg_list.contains_key(exec) { - return true; - } - } else if xdg_list.contains_key(arg) { - return true; - } - } - - false + None } pub fn check_env(env_var: &str, environ: &[u8]) -> Option { @@ -81,16 +58,6 @@ pub fn check_env(env_var: &str, environ: &[u8]) -> Option { None } -pub fn check_gpu_env(environ: &[u8]) -> bool { - if let Some(val) = check_env("DRI_PRIME", environ) { - return val == 1; - } else if let Some(val) = check_env("__NV_PRIME_RENDER_OFFLOAD", environ) { - return val == 1; - } - // Not present - false -} - /// How long a reported pid keeps getting retried before falling back to the /// process name pub const APP_ID_LOOKUP_TIMEOUT: Duration = Duration::from_millis(2000); @@ -182,90 +149,6 @@ fn find_niri_socket() -> Option { #[cfg(test)] mod tests { use super::*; - use std::collections::HashMap; - - /* - check_steam_environ - */ - #[test] - fn test_check_steam_environ_detects_steam_app_id() { - let environ = b"HOME=/home/user\0SteamAppId=12345\0DISPLAY=:0"; - assert!(check_steam_environ(environ)); - } - - #[test] - fn test_check_steam_environ_returns_false_when_absent() { - let environ = b"HOME=/home/user\0DISPLAY=:0\0TERM=xterm"; - assert!(!check_steam_environ(environ)); - } - - #[test] - fn test_check_steam_environ_returns_false_for_empty_input() { - assert!(!check_steam_environ(b"")); - } - - #[test] - fn test_check_steam_environ_detects_at_start_of_environ() { - let environ = b"SteamAppId=999"; - assert!(check_steam_environ(environ)); - } - - /* - check_for_flatpak_run - */ - #[test] - fn test_check_for_flatpak_run_detects_flatpak_binary() { - let mut xdg_list = HashMap::new(); - xdg_list.insert("com.valvesoftware.Steam".to_string(), true); - let cmdline = "/usr/bin/flatpak\0run\0com.valvesoftware.Steam"; - assert!(check_for_flatpak_run(cmdline, &xdg_list)); - } - - #[test] - fn test_check_for_flatpak_run_detects_bwrap_binary() { - let mut xdg_list = HashMap::new(); - xdg_list.insert("com.valvesoftware.Steam".to_string(), true); - let cmdline = "/usr/bin/bwrap\0--arg\0com.valvesoftware.Steam"; - assert!(check_for_flatpak_run(cmdline, &xdg_list)); - } - - #[test] - fn test_check_for_flatpak_run_detects_command_flag() { - let mut xdg_list = HashMap::new(); - xdg_list.insert("steam".to_string(), true); - let cmdline = "/usr/bin/flatpak\0run\0--command=steam\0com.example.App"; - assert!(check_for_flatpak_run(cmdline, &xdg_list)); - } - - #[test] - fn test_check_for_flatpak_run_rejects_non_flatpak_binary() { - let mut xdg_list = HashMap::new(); - xdg_list.insert("steam".to_string(), true); - let cmdline = "/usr/bin/niri\0msg\0steam"; - assert!(!check_for_flatpak_run(cmdline, &xdg_list)); - } - - #[test] - fn test_check_for_flatpak_run_rejects_empty_cmdline() { - let xdg_list = HashMap::new(); - assert!(!check_for_flatpak_run("", &xdg_list)); - } - - #[test] - fn test_check_for_flatpak_run_returns_false_for_unknown_app() { - let xdg_list = HashMap::new(); - let cmdline = "/usr/bin/flatpak\0run\0com.unknown.App"; - assert!(!check_for_flatpak_run(cmdline, &xdg_list)); - } - - #[test] - fn test_check_for_flatpak_run_detects_flatpak_wrapped() { - let mut xdg_list = HashMap::new(); - xdg_list.insert("com.valvesoftware.Steam".to_string(), true); - let cmdline = "/app/bin/.flatpak-wrapped\0com.valvesoftware.Steam"; - assert!(check_for_flatpak_run(cmdline, &xdg_list)); - } - /* check_env */ @@ -306,45 +189,6 @@ mod tests { assert_eq!(check_env("CARDWIRE_ALLOW", environ), None); } - /* - check_gpu_env - */ - - #[test] - fn test_check_gpu_env_detects_dri_prime_1() { - let environ = b"HOME=/home\0DRI_PRIME=1\0DISPLAY=:0"; - assert!(check_gpu_env(environ)); - } - - #[test] - fn test_check_gpu_env_rejects_dri_prime_0() { - let environ = b"HOME=/home\0DRI_PRIME=0\0DISPLAY=:0"; - assert!(!check_gpu_env(environ)); - } - - #[test] - fn test_check_gpu_env_detects_nv_prime_render_offload_1() { - let environ = b"__NV_PRIME_RENDER_OFFLOAD=1"; - assert!(check_gpu_env(environ)); - } - - #[test] - fn test_check_gpu_env_rejects_nv_prime_render_offload_0() { - let environ = b"__NV_PRIME_RENDER_OFFLOAD=0"; - assert!(!check_gpu_env(environ)); - } - - #[test] - fn test_check_gpu_env_returns_false_when_neither_present() { - let environ = b"HOME=/home\0DISPLAY=:0\0TERM=xterm"; - assert!(!check_gpu_env(environ)); - } - - #[test] - fn test_check_gpu_env_returns_false_for_empty_input() { - assert!(!check_gpu_env(b"")); - } - #[test] fn test_desktop_from_str_all_known_variants() { assert!(matches!(Desktop::from_str("niri"), Some(Desktop::Niri))); diff --git a/crates/cardwire-daemon/src/analyzer/helpers.rs b/crates/cardwire-daemon/src/analyzer/helpers.rs new file mode 100644 index 00000000..ceab6053 --- /dev/null +++ b/crates/cardwire-daemon/src/analyzer/helpers.rs @@ -0,0 +1,170 @@ +//! Generic proc/cmdline helpers used by the analyzer + +use std::{fs, path::Path}; + +/// Read the real process name from `/proc/{pid}/cmdline`, taking into account +/// wrappers like Wine/Proton, Java, Flatpak and Steam +pub fn get_real_process_name(pid: u32) -> Option { + let cmdline_path = format!("/proc/{}/cmdline", pid); + let cmdline_bytes = match fs::read(&cmdline_path) { + Ok(b) => b, + Err(_) => return None, // process died + }; + parse_cmdline_name(&cmdline_bytes) +} + +/// Parse a NUL-separated cmdline and find the real process name +pub fn parse_cmdline_name(cmdline_bytes: &[u8]) -> Option { + if cmdline_bytes.is_empty() { + return None; + } + let args: Vec<&str> = cmdline_bytes + .split(|&b| b == 0) + .filter_map(|b| std::str::from_utf8(b).ok()) + .filter(|s| !s.is_empty()) + .collect(); + if args.is_empty() { + return None; + } + let binary = args[0]; + + // Check Wine/Proton + if binary.contains("wine") || binary.contains("proton") { + for arg in args.iter().skip(1) { + if arg.to_lowercase().ends_with(".exe") { + let file_name = arg.split(&['/', '\\'][..]).next_back().unwrap_or(arg); + return Some(file_name.to_string()); + } + } + } + + // Minecraft/Java games, return java instead of the real name to allow Close event bypass + if binary.ends_with(".java") { + for arg in args.iter().skip(1) { + if arg.ends_with(".jar") { + let file_name = arg.split('/').next_back().unwrap_or(arg); + return Some(file_name.to_string()); + } + } + } + + // Fallback, just use the binary name + let base_name = binary.split('/').next_back().unwrap_or(binary); + + // Flatpak/Brwap + if base_name == "flatpak" || base_name == ".flatpak-wrapped" || base_name == "bwrap" { + for arg in args.iter().skip(1) { + if let Some(exec) = arg.strip_prefix("--command=") { + return Some(exec.to_string()); + } + // Extract the flatpak ID + if !arg.starts_with('-') && *arg != "run" && arg.contains('.') { + return Some(arg.to_string()); + } + } + } + + if base_name == "steam" { + for arg in args.iter().skip(1) { + if arg.starts_with("steam://rungameid/") { + return Some(arg.to_string()); + } + } + } + + // Fix for discord or other apps: + if base_name.contains("--") { + return base_name.split_whitespace().next().map(|s| s.to_string()); + } + + Some(base_name.to_string()) +} + +pub fn is_proc_still_alive(pid: u32) -> bool { + Path::new(&format!("/proc/{}", pid)).exists() +} + +/// Decode the 16-byte kernel comm into a String, trimming trailing NULs +pub fn comm_to_string(comm: [u8; 16]) -> String { + match String::from_utf8(comm.to_vec()) { + Ok(str) => str.trim_end_matches('\0').to_string(), + Err(_) => "no_comm_err".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_cmdline_name_returns_exe_for_wine_proton_cmdline() { + let cmdline_bytes = b"proton\0Z:\\home\\user\\GAME\\game.exe\0--fullscreen"; + assert_eq!( + parse_cmdline_name(cmdline_bytes), + Some("game.exe".to_string()) + ); + } + + #[test] + fn test_parse_cmdline_name_returns_java_binary_for_game_launcher() { + // Java launchers fall back to the binary name so that Close events can + // be attributed to the java process + let cmdline_bytes = b"/usr/lib/jvm/default/bin/java\0-p\0minecraft.jar"; + assert_eq!(parse_cmdline_name(cmdline_bytes), Some("java".to_string())); + } + + #[test] + fn test_parse_cmdline_name_returns_basename_for_regular_binary() { + // Simulate a regular binary like "/usr/bin/steam" + let cmdline_bytes = b"/usr/bin/steam\0--no-browser\0"; + assert_eq!(parse_cmdline_name(cmdline_bytes), Some("steam".to_string())); + } + + #[test] + fn test_parse_cmdline_name_returns_none_for_empty_cmdline() { + assert_eq!(parse_cmdline_name(b""), None); + } + + #[test] + fn test_parse_cmdline_name_extracts_wine_exe_from_multiarg_cmdline() { + // Simulates: wine64-preloader\0C:\game\app.exe\0--fullscreen + let cmdline_bytes = b"wine64-preloader\0C:\\game\\app.exe\0--fullscreen"; + assert_eq!( + parse_cmdline_name(cmdline_bytes), + Some("app.exe".to_string()) + ); + } + + #[test] + fn test_parse_cmdline_name_extracts_flatpak_id() { + let cmdline_bytes = b"/usr/bin/flatpak\0run\0com.valvesoftware.Steam"; + assert_eq!( + parse_cmdline_name(cmdline_bytes), + Some("com.valvesoftware.Steam".to_string()) + ); + } + + #[test] + fn test_comm_to_string_trims_trailing_nuls() { + let comm = *b"bash\0\0\0\0\0\0\0\0\0\0\0\0"; + assert_eq!(comm_to_string(comm), "bash"); + } + + #[test] + fn test_comm_to_string_full_length() { + let comm = *b"a-very-long-comm"; + assert_eq!(comm_to_string(comm), "a-very-long-comm"); + } + + #[test] + fn test_comm_to_string_invalid_utf8() { + let comm = [0xFFu8; 16]; + assert_eq!(comm_to_string(comm), "no_comm_err"); + } + + #[test] + fn test_is_proc_still_alive() { + assert!(is_proc_still_alive(std::process::id())); + assert!(!is_proc_still_alive(0)); + } +} diff --git a/crates/cardwire-daemon/src/analyzer/mod.rs b/crates/cardwire-daemon/src/analyzer/mod.rs index 78badd95..76d39ca2 100644 --- a/crates/cardwire-daemon/src/analyzer/mod.rs +++ b/crates/cardwire-daemon/src/analyzer/mod.rs @@ -1,6 +1,8 @@ //! Cardwire analyzer, only in analysis mode rn mod dynamic_analysis; +mod helpers; mod models; mod static_analysis; pub use models::CardwireAnalyzer; +pub use static_analysis::AppMetadata; diff --git a/crates/cardwire-daemon/src/analyzer/models.rs b/crates/cardwire-daemon/src/analyzer/models.rs index a56b64b1..410f0a46 100644 --- a/crates/cardwire-daemon/src/analyzer/models.rs +++ b/crates/cardwire-daemon/src/analyzer/models.rs @@ -3,19 +3,17 @@ use aya_log::EbpfLogger; use cardwire_ebpf_userspace::EbpfBlocker; use log::{Log, debug, error, info, warn}; use std::{ - collections::{HashMap, HashSet, VecDeque}, fs, path::{Path, PathBuf}, ptr, sync::Arc, time::SystemTime + collections::{HashMap, HashSet, VecDeque}, fs, ptr, sync::Arc, time::SystemTime }; use tokio::{ - io::{Interest, unix::AsyncFd}, sync::{Mutex, RwLock, Semaphore}, task, time::Instant + io::{Interest, unix::AsyncFd}, sync::{Mutex, RwLock, Semaphore, mpsc, oneshot}, task, time::Instant }; use zbus::object_server::SignalEmitter; use crate::{ analyzer::{ - dynamic_analysis::{ - check_env, check_gpu_env, check_steam_environ, get_app_id_wayland_with_retry - }, static_analysis - }, interface::{LogEntry, LoggerInterfaceSignals} + dynamic_analysis::{check_env, get_app_id_wayland_with_retry, get_steam_app_id}, helpers::{comm_to_string, get_real_process_name, is_proc_still_alive}, static_analysis::{self, AppMetadata} + }, file::GpuPolicy, interface::{LogEntry, LoggerInterfaceSignals} }; #[repr(C)] #[derive(Debug, Copy, Clone)] @@ -44,13 +42,14 @@ pub struct CardwireAnalyzer { pid_map: Arc>>, forced_map: Arc>>, ebpf_logger: Arc>>>, - xdg_list: Arc>>, + xdg_list: Arc>>, + db_cache: Arc>>, + pending_discoveries: Arc>>, + db_tx: mpsc::Sender<(String, AppMetadata, oneshot::Sender)>, report_vec: Arc>>, reported_pids: Arc>>, report_semaphore: Arc, signal: Option>, - #[allow(dead_code)] - xdg_folders: Vec, } // Bound the number of concurrent report tasks @@ -63,6 +62,8 @@ impl CardwireAnalyzer { blocker: Arc>, report_vec: Arc>>, signal: Option>, + db_cache: Arc>>, + db_tx: mpsc::Sender<(String, AppMetadata, oneshot::Sender)>, ) -> anyhow::Result { let mut blocker = blocker.write().await; let exec_ring = blocker.get_exec_ring()?; @@ -80,9 +81,8 @@ impl CardwireAnalyzer { let ebpf_logger: Arc>>> = Arc::new(Mutex::new(ebpf_logger)); - let xdg_result = static_analysis::get_fdo_apps().await?; - let xdg_list = Arc::new(RwLock::new(xdg_result.0)); - let xdg_folders: Vec = xdg_result.1; + let xdg_list = Arc::new(RwLock::new(static_analysis::get_fdo_apps().await?)); + Ok(CardwireAnalyzer { exec_ring, report_ring, @@ -90,11 +90,13 @@ impl CardwireAnalyzer { forced_map, ebpf_logger, xdg_list, + db_cache, + pending_discoveries: Arc::new(Mutex::new(HashSet::new())), + db_tx, report_vec, reported_pids: Arc::new(RwLock::new(HashSet::new())), report_semaphore: Arc::new(Semaphore::new(REPORT_SEMAPHORE_PERMITS)), signal, - xdg_folders, }) } pub async fn run(self) -> anyhow::Result<()> { @@ -245,8 +247,8 @@ impl CardwireAnalyzer { String::new(), ) .await; - // we check if the proc is still here to not log noise caused by fish } else if is_proc_still_alive(event.pid) { + // we check if the proc is still here to not log noise caused by fish report_blocked( report_vec, signal, @@ -266,7 +268,7 @@ impl CardwireAnalyzer { /// Default app are blocked, try to find if it's a game or a gpu intensive app, the u8 is the /// gpu id - async fn evaluate_app(&self, pid: u32, _comm: &str) -> Option<(bool, PidType, u32)> { + async fn evaluate_app(&self, pid: u32, comm: &str) -> Option<(bool, PidType, u32)> { let path = format!("/proc/{}/environ", pid); let environ = match fs::read(path) { Ok(content) => content, @@ -283,17 +285,90 @@ impl CardwireAnalyzer { return Some((true, PidType::Forced, value)); } - let result = check_steam_environ(&environ) || check_gpu_env(&environ); - Some((result, PidType::Allowed, 0)) - } + // Check the database now, we can take our time since if we reached it, the app would've + // been blocked + let mut lookup_name = comm.to_lowercase(); + if let Some(steam_app) = get_steam_app_id(&environ) { + lookup_name = steam_app; + } + { + let db = self.db_cache.read().await; + if let Some(policy) = db.get(&lookup_name) { + // For now this only work for the smart mode, will need to find a way to get the GPU + // id to make it compatible with manual mode + match policy { + GpuPolicy::Blocked => return None, + GpuPolicy::Allowed => return Some((true, PidType::Allowed, 0)), + } + } + } + + { + let xdg_list = self.xdg_list.read().await; + if let Some(meta) = xdg_list.get(&lookup_name) { + let meta = meta.clone(); + drop(xdg_list); + self.discover_app(&lookup_name, meta).await; + } + } + // Fallback for steam games + if let Some(app_id) = lookup_name.strip_prefix("steam_app_") { + let meta = AppMetadata { + display_name: format!("Steam Game {}", app_id), + desktop_file_id: None, + icon_name: Some(format!("steam_icon_{}", app_id)), + }; - #[allow(dead_code)] - pub fn xdg_list(&self) -> Arc>> { - Arc::clone(&self.xdg_list) + self.discover_app(&lookup_name, meta).await; + info!("Discovered Steam Game {}, blocked by default.", app_id); + return Some((false, PidType::Allowed, 0)); + } + None } - #[allow(dead_code)] - pub fn xdg_folders(&self) -> &Vec { - &self.xdg_folders + + /// Persist a newly discovered app in the database and mirror it in the cache + async fn discover_app(&self, lookup_name: &str, meta: AppMetadata) { + // Allow only one persistence request per unknown app at a time, skip + // duplicate discoveries while a request is still pending + { + let mut pending = self.pending_discoveries.lock().await; + if !pending.insert(lookup_name.to_string()) { + return; + } + } + + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + let res = self + .db_tx + .send((lookup_name.to_string(), meta, reply_tx)) + .await; + match res { + Ok(_) => match reply_rx.await { + Ok(true) => { + // Mirror in the cache + self.db_cache + .write() + .await + .insert(lookup_name.to_string(), GpuPolicy::Blocked); + info!( + "Discovered a new app: {}, adding to the database", + lookup_name + ); + } + Ok(false) => { + error!("Couldn't write {} to the database", lookup_name) + } + Err(err) => { + error!("DB worker dropped the reply for {}: {}", lookup_name, err) + } + }, + Err(err) => { + error!("Couldn't send new app to DB rw: {}", err) + } + } + + // Remove the pending entry on every exit path + self.pending_discoveries.lock().await.remove(lookup_name); } } @@ -338,66 +413,12 @@ async fn report_blocked( } } -fn get_real_process_name(pid: u32) -> Option { - let cmdline_path = format!("/proc/{}/cmdline", pid); - let cmdline_bytes = match fs::read(&cmdline_path) { - Ok(b) => b, - Err(_) => return None, // process died - }; - if cmdline_bytes.is_empty() { - return None; - } - let args: Vec<&str> = cmdline_bytes - .split(|&b| b == 0) - .filter_map(|b| std::str::from_utf8(b).ok()) - .filter(|s| !s.is_empty()) - .collect(); - if args.is_empty() { - return None; - } - let binary = args[0]; - - // Check Wine/Proton - if binary.contains("wine") || binary.contains("proton") { - for arg in args.iter().skip(1) { - if arg.to_lowercase().ends_with(".exe") { - let file_name = arg.split(&['/', '\\'][..]).next_back().unwrap_or(arg); - return Some(file_name.to_string()); - } - } - } - - // Minecraft/Java games, return java instead of the real name to allow Close event bypass - if binary.ends_with(".java") { - for arg in args.iter().skip(1) { - if arg.ends_with(".jar") { - let file_name = arg.split('/').next_back().unwrap_or(arg); - return Some(file_name.to_string()); - } - } - } - // Fallback, just use the binary name - let base_name = binary.split('/').next_back().unwrap_or(binary); - Some(base_name.to_string()) -} - -fn is_proc_still_alive(pid: u32) -> bool { - Path::new(&format!("/proc/{}", pid)).exists() -} - -/// Decode the 16-byte kernel comm into a String, trimming trailing NULs -fn comm_to_string(comm: [u8; 16]) -> String { - match String::from_utf8(comm.to_vec()) { - Ok(str) => str.trim_end_matches('\0').to_string(), - Err(_) => "no_comm_err".to_string(), - } -} - // TESTS #[cfg(test)] mod tests { use super::*; + use crate::analyzer::helpers::comm_to_string; use std::ptr; #[test] @@ -424,106 +445,6 @@ mod tests { assert_eq!(event.pid, u32::MAX); } - #[test] - fn test_get_real_process_name_returns_exe_for_wine_proton_cmdline() { - let cmdline_bytes = - r"S:\common\NieR·Replicant·ver.1.22474487139\NieR·Replicant·ver.1.22474487139.exe" - .as_bytes(); - - assert!(!cmdline_bytes.is_empty()); - let args: Vec<&str> = cmdline_bytes - .split(|&b| b == 0) - .filter_map(|b| std::str::from_utf8(b).ok()) - .filter(|s| !s.is_empty()) - .collect(); - assert!(!args.is_empty()); - { - let binary = args[0]; - - // Check Wine/Proton - if binary.contains("wine") || binary.contains("proton") { - for arg in args.iter().skip(1) { - if arg.to_lowercase().ends_with(".exe") { - let file_name = arg.split(&['/', '\\'][..]).next_back().unwrap_or(arg); - assert_eq!(file_name, "NieR·Replicant·ver.1.22474487139.exe"); - } - } - } - } - } - - #[test] - fn test_get_real_process_name_returns_jar_for_java_cmdline() { - let cmdline_bytes = "minecraft.jar".as_bytes(); - - assert!(!cmdline_bytes.is_empty()); - let args: Vec<&str> = cmdline_bytes - .split(|&b| b == 0) - .filter_map(|b| std::str::from_utf8(b).ok()) - .filter(|s| !s.is_empty()) - .collect(); - assert!(!args.is_empty()); - { - let binary = args[0]; - - // Check Wine/Proton - if binary.ends_with(".java") { - for arg in args.iter().skip(1) { - if arg.ends_with(".jar") { - let file_name = arg.split('/').next_back().unwrap_or(arg); - assert_eq!(file_name, "minecraft"); - } - } - } - } - } - - #[test] - fn test_get_real_process_name_returns_basename_for_regular_binary() { - // Simulate a regular binary like "/usr/bin/steam" - let cmdline_bytes = b"/usr/bin/steam\0--no-browser\0"; - let args: Vec<&str> = cmdline_bytes - .split(|&b| b == 0) - .filter_map(|b| std::str::from_utf8(b).ok()) - .filter(|s| !s.is_empty()) - .collect(); - assert!(!args.is_empty()); - let binary = args[0]; - let base_name = binary.split('/').next_back().unwrap_or(binary); - assert_eq!(base_name, "steam"); - } - - #[test] - fn test_get_real_process_name_returns_none_for_empty_cmdline() { - let cmdline_bytes = b""; - assert!(cmdline_bytes.is_empty()); - } - - #[test] - fn test_get_real_process_name_extracts_wine_exe_from_multiarg_cmdline() { - // Simulates: wine64-preloader\0C:\game\app.exe\0--fullscreen - let cmdline_bytes = b"wine64-preloader\0C:\\game\\app.exe\0--fullscreen"; - let args: Vec<&str> = cmdline_bytes - .split(|&b| b == 0) - .filter_map(|b| std::str::from_utf8(b).ok()) - .filter(|s| !s.is_empty()) - .collect(); - let binary = args[0]; - assert!(binary.contains("wine")); - // Find the .exe argument - let exe_arg = args - .iter() - .skip(1) - .find(|a| a.to_lowercase().ends_with(".exe")); - assert!(exe_arg.is_some()); - let file_name = exe_arg - .unwrap() - .split(&['/', '\\'][..]) - .next_back() - .unwrap(); - assert_eq!(file_name, "app.exe"); - } - // ── ReportEvent ────────────────────────────────────────────────── #[test] @@ -571,32 +492,4 @@ mod tests { assert_eq!(event.gpu_id, 2); assert_eq!(&event.comm, &[0u8; 16]); } - - // ── comm_to_string ─────────────────────────────────────────────── - - #[test] - fn test_comm_to_string_trims_trailing_nuls() { - let comm = *b"bash\0\0\0\0\0\0\0\0\0\0\0\0"; - assert_eq!(comm_to_string(comm), "bash"); - } - - #[test] - fn test_comm_to_string_full_length() { - let comm = *b"a-very-long-comm"; - assert_eq!(comm_to_string(comm), "a-very-long-comm"); - } - - #[test] - fn test_comm_to_string_invalid_utf8() { - let comm = [0xFFu8; 16]; - assert_eq!(comm_to_string(comm), "no_comm_err"); - } - - // ── is_proc_still_alive ────────────────────────────────────────── - - #[test] - fn test_is_proc_still_alive() { - assert!(is_proc_still_alive(std::process::id())); - assert!(!is_proc_still_alive(0)); - } } diff --git a/crates/cardwire-daemon/src/analyzer/static_analysis.rs b/crates/cardwire-daemon/src/analyzer/static_analysis.rs index 66b3d654..a29246ef 100644 --- a/crates/cardwire-daemon/src/analyzer/static_analysis.rs +++ b/crates/cardwire-daemon/src/analyzer/static_analysis.rs @@ -6,8 +6,15 @@ use std::{ }; use xdg::BaseDirectories; +#[derive(Clone, Debug)] +pub struct AppMetadata { + pub display_name: String, + pub desktop_file_id: Option, + pub icon_name: Option, +} + /// Return a list of fdo apps present in the system -pub async fn get_fdo_apps() -> anyhow::Result<(HashMap, Vec)> { +pub async fn get_fdo_apps() -> anyhow::Result> { let mut app_directories: Vec = Vec::new(); // get from ENV let xdg_dir = BaseDirectories::new(); @@ -55,7 +62,7 @@ pub async fn get_fdo_apps() -> anyhow::Result<(HashMap, Vec = HashMap::new(); + let mut app_list: HashMap = HashMap::new(); let locales = get_languages_from_env(); for app_directory in &app_directories { @@ -68,7 +75,7 @@ pub async fn get_fdo_apps() -> anyhow::Result<(HashMap, Vec anyhow::Result<(HashMap, Vec = exec_str.split_whitespace().collect(); + + // Scan all parts for a steam URI before applying the wrapper-binary + // stop condition, so `Exec=steam steam://rungameid/` still maps + // to the steam app metadata + if let Some(uri_part) = exec_parts + .iter() + .find(|part| part.starts_with("steam://rungameid/")) + { + let app_id = uri_part + .trim_start_matches("steam://rungameid/") + .trim_matches('/'); + app_list.insert(format!("steam_app_{}", app_id), meta.clone()); + } else { + for part in exec_parts { + if part == "env" || part.contains('=') { + continue; + } + let binary = part.split('/').next_back().unwrap_or(part); + if ["flatpak", "steam", "sh", "bash", "bwrap"].contains(&binary) { + break; + } + if !binary.is_empty() { + app_list.insert(binary.to_lowercase(), meta.clone()); + } + break; + } + } } } } } } - Ok((app_list, app_directories)) + Ok(app_list) } diff --git a/crates/cardwire-daemon/src/file/mod.rs b/crates/cardwire-daemon/src/file/mod.rs index 1472dca5..58d784fd 100644 --- a/crates/cardwire-daemon/src/file/mod.rs +++ b/crates/cardwire-daemon/src/file/mod.rs @@ -1,6 +1,8 @@ mod common; mod config; +mod sql; mod state; pub use config::CardwireConfig; +pub use sql::{CardwireDatabase, DbusAppMetadata, GpuPolicy}; pub use state::{CardwireGpuState, CardwireGpuUnit, CardwireModeState}; diff --git a/crates/cardwire-daemon/src/file/sql.rs b/crates/cardwire-daemon/src/file/sql.rs new file mode 100644 index 00000000..f1bae7be --- /dev/null +++ b/crates/cardwire-daemon/src/file/sql.rs @@ -0,0 +1,163 @@ +use std::{collections::HashMap, sync::Arc}; + +use crate::{analyzer::AppMetadata, file::state::STATE_PATH}; +use log::error; +use rusqlite::{Connection, Result}; +use tokio::sync::{RwLock, mpsc, oneshot}; +use zbus::zvariant; + +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum GpuPolicy { + Blocked = 0, + Allowed = 1, +} +impl GpuPolicy { + pub fn from_i32(val: i32) -> Self { + match val { + 0 => GpuPolicy::Blocked, + 1 => GpuPolicy::Allowed, + _ => GpuPolicy::Blocked, + } + } + + pub fn try_from_i32(val: i32) -> Option { + match val { + 0 => Some(GpuPolicy::Blocked), + 1 => Some(GpuPolicy::Allowed), + _ => None, + } + } +} + +fn open_db() -> Result { + let db_path = format!("{}/cardwire.db", STATE_PATH); + let conn = Connection::open(db_path)?; + conn.busy_timeout(std::time::Duration::from_secs(5))?; + Ok(conn) +} + +#[derive(Debug, Clone, zvariant::Type, serde::Serialize)] +pub struct DbusAppMetadata { + pub display_name: String, + pub desktop_file_id: Option, + pub icon_name: Option, + pub gpu_policy: u32, +} + +#[derive(Debug, Clone)] +pub struct CardwireDatabase { + pub cache: Arc>>, + pub tx: mpsc::Sender<(String, AppMetadata, oneshot::Sender)>, +} +impl CardwireDatabase { + pub fn build() -> Result { + let conn = open_db()?; + conn.execute( + "CREATE TABLE IF NOT EXISTS app_policies ( + binary_name TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + desktop_file_id TEXT, + icon_name TEXT, + policy INTEGER NOT NULL DEFAULT 0 + )", + [], + )?; + + let mut cache_map = HashMap::new(); + { + let mut stmt = conn.prepare("SELECT binary_name, policy FROM app_policies")?; + let rows = stmt.query_map([], |row| { + let name: String = row.get(0)?; + let policy: i32 = row.get(1)?; + Ok((name, GpuPolicy::from_i32(policy))) + })?; + for row in rows.flatten() { + cache_map.insert(row.0, row.1); + } + } + + let cache = Arc::new(RwLock::new(cache_map)); + + let (tx, mut rx) = mpsc::channel::<(String, AppMetadata, oneshot::Sender)>(100); + + tokio::task::spawn_blocking(move || { + let conn = conn; + while let Some((binary_name, meta, reply)) = rx.blocking_recv() { + let res = conn.execute( + "INSERT INTO app_policies (binary_name, display_name, desktop_file_id, icon_name, policy) + VALUES (?1, ?2, ?3, ?4, 0) + ON CONFLICT(binary_name) DO NOTHING", + rusqlite::params![ + binary_name, + meta.display_name, + meta.desktop_file_id, + meta.icon_name + ], + ); + match res { + Ok(_) => { + let _ = reply.send(true); + } + Err(err) => { + error!("failed to write {} to cardwire.db: {}", binary_name, err); + let _ = reply.send(false); + } + } + } + }); + + Ok(Self { cache, tx }) + } + + pub fn read_db(&self) -> Result> { + let conn = open_db()?; + + let mut apps: HashMap = HashMap::new(); + + { + let mut stmt = conn.prepare("SELECT binary_name, display_name, desktop_file_id, icon_name, policy FROM app_policies")?; + let rows = stmt.query_map([], |row| { + let name: String = row.get(0)?; + let meta = DbusAppMetadata { + display_name: row.get(1)?, + desktop_file_id: row.get(2)?, + icon_name: row.get(3)?, + gpu_policy: row.get(4)?, + }; + Ok((name, meta)) + })?; + for row in rows.flatten() { + apps.insert(row.0, row.1); + } + } + + Ok(apps) + } + pub fn update_policy(&self, binary_name: &str, gpu_policy: i32) -> Result<()> { + let conn = open_db()?; + + let affected = conn.execute( + "UPDATE app_policies SET policy = ?1 WHERE binary_name = ?2", + rusqlite::params![gpu_policy, binary_name], + )?; + if affected == 0 { + return Err(rusqlite::Error::QueryReturnedNoRows); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_gpu_policy_from_i32_defaults_to_blocked() { + assert_eq!(GpuPolicy::from_i32(-1), GpuPolicy::Blocked); + assert_eq!(GpuPolicy::from_i32(42), GpuPolicy::Blocked); + assert_eq!(GpuPolicy::try_from_i32(-1), None); + assert_eq!(GpuPolicy::try_from_i32(42), None); + assert_eq!(GpuPolicy::try_from_i32(1), Some(GpuPolicy::Allowed)); + } +} diff --git a/crates/cardwire-daemon/src/file/state.rs b/crates/cardwire-daemon/src/file/state.rs index 14be3636..e83f7e36 100644 --- a/crates/cardwire-daemon/src/file/state.rs +++ b/crates/cardwire-daemon/src/file/state.rs @@ -7,7 +7,7 @@ use anyhow::{Context, Ok}; use log::{info, warn}; use serde::{Deserialize, Serialize}; use std::{collections::BTreeMap, fs}; -const STATE_PATH: &str = "/var/lib/cardwire"; +pub const STATE_PATH: &str = "/var/lib/cardwire"; #[derive(Serialize, Deserialize)] #[serde(default)] diff --git a/crates/cardwire-daemon/src/interface/smart.rs b/crates/cardwire-daemon/src/interface/smart.rs index 73f4acb5..236dbdb8 100644 --- a/crates/cardwire-daemon/src/interface/smart.rs +++ b/crates/cardwire-daemon/src/interface/smart.rs @@ -1,26 +1,32 @@ use aya::maps::{HashMap as AyaHashMap, MapError as AyaMapError}; use cardwire_ebpf_userspace::EbpfBlocker; -use std::{path::Path, sync::Arc}; +use std::{collections::HashMap, path::Path, sync::Arc}; -use tokio::sync::RwLock; +use tokio::sync::{Mutex, RwLock}; use zbus::{ fdo::{self, Error::Failed}, interface }; +use crate::file::{CardwireDatabase, DbusAppMetadata, GpuPolicy}; + #[derive(Clone, Debug)] pub struct SmartPolicyInterface { pid_map: Arc>>, forced_map: Arc>>, + pub database: CardwireDatabase, + policy_lock: Arc>, } impl SmartPolicyInterface { - pub fn build(blocker: &mut EbpfBlocker) -> Self { + pub fn build(blocker: &mut EbpfBlocker, db: CardwireDatabase) -> Self { let pid_map = Arc::clone(&blocker.pid_map); let forced_map = Arc::clone(&blocker.forced_map); Self { pid_map, forced_map, + database: db, + policy_lock: Arc::new(Mutex::new(())), } } } @@ -133,4 +139,41 @@ impl SmartPolicyInterface { Ok((status, gpu_id)) } + + pub async fn get_app_policies(&self) -> fdo::Result> { + let db_clone = self.database.clone(); + + tokio::task::spawn_blocking(move || { + db_clone + .read_db() + .map_err(|err| fdo::Error::Failed(err.to_string())) + }) + .await + .map_err(|err| fdo::Error::Failed(err.to_string()))? + } + + pub async fn set_app_policies(&self, app_id: String, policy: i32) -> Result<(), fdo::Error> { + let gpu_policy = GpuPolicy::try_from_i32(policy) + .ok_or_else(|| fdo::Error::InvalidArgs(format!("invalid policy: {}", policy)))?; + + if !self.database.cache.read().await.contains_key(&app_id) { + return Err(fdo::Error::UnknownObject(format!( + "app not found: {}", + app_id + ))); + } + + let db_clone = self.database.clone(); + let app_id_clone = app_id.clone(); + + let _policy_guard = self.policy_lock.lock().await; + tokio::task::spawn_blocking(move || db_clone.update_policy(&app_id_clone, policy)) + .await + .map_err(|e| fdo::Error::Failed(e.to_string()))? + .map_err(|e| fdo::Error::Failed(e.to_string()))?; + + self.database.cache.write().await.insert(app_id, gpu_policy); + + Ok(()) + } } diff --git a/crates/cardwire-daemon/src/models.rs b/crates/cardwire-daemon/src/models.rs index f6095110..86a1991d 100644 --- a/crates/cardwire-daemon/src/models.rs +++ b/crates/cardwire-daemon/src/models.rs @@ -2,7 +2,7 @@ use crate::{ analyzer::CardwireAnalyzer, core::{ gpu::{GpuEnumerator, GpuVendor}, inode::exp_nvidia_inodes, pci::{self} - }, file::{CardwireConfig, CardwireGpuState, CardwireModeState}, interface::{ + }, file::{CardwireConfig, CardwireDatabase, CardwireGpuState, CardwireModeState}, interface::{ ConfigInterface, ConfigMemory, DebugInterface, GpuInterface, LoggerInterface, ModeInterface, Modes, SmartPolicyInterface, SwitcherooInterface }, tasks }; @@ -62,7 +62,9 @@ impl DaemonManager { let mut blocker = EbpfBlocker::new()?; - let smart_policy_interface = SmartPolicyInterface::build(&mut blocker); + let database = CardwireDatabase::build()?; + + let smart_policy_interface = SmartPolicyInterface::build(&mut blocker, database); let blocker = Arc::new(RwLock::new(blocker)); @@ -301,8 +303,11 @@ impl DaemonManager { let blocker = Arc::clone(&self.inner.blocker); let logger = Arc::clone(&self.logger_interface.report_logs); let signal = self.logger_signal.clone(); + let db_cache = self.smart_policy_interface.database.cache.clone(); + let tx = self.smart_policy_interface.database.tx.clone(); + async move { - let cardwire_analyzer = CardwireAnalyzer::build(blocker, logger, signal) + let cardwire_analyzer = CardwireAnalyzer::build(blocker, logger, signal, db_cache, tx) .await .map_err(|err| { error!("Failed to build CardwireAnalyzer: {}", err);