diff --git a/Cargo.lock b/Cargo.lock index 62e6cce..ea4f7c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1642,7 +1642,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.14.43" +version = "0.14.44" dependencies = [ "anyhow", "async-stream", @@ -1676,7 +1676,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.14.43" +version = "0.14.44" dependencies = [ "anyhow", "base64", @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.14.43" +version = "0.14.44" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index f922fc3..0f5bd93 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.14.43" +version = "0.14.44" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index d9bc8c4..d7c7f22 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.14.43" +version = "0.14.44" edition = "2024" build = "build.rs" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 7fdcd39..1af66a5 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -36,6 +36,7 @@ LESAVKA_UVC_WIDTH=${LESAVKA_UVC_WIDTH:-640} LESAVKA_UVC_HEIGHT=${LESAVKA_UVC_HEIGHT:-480} LESAVKA_UVC_CODEC=${INSTALL_UVC_CODEC} LESAVKA_UVC_BLOCKING=${LESAVKA_UVC_BLOCKING:-1} +LESAVKA_UVC_CONTROL_READ_ONLY=${LESAVKA_UVC_CONTROL_READ_ONLY:-1} LESAVKA_UVC_MAXBURST=${LESAVKA_UVC_MAXBURST:-0} EOF } diff --git a/server/Cargo.toml b/server/Cargo.toml index cb5c5be..aac309a 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.14.43" +version = "0.14.44" edition = "2024" autobins = false diff --git a/server/src/bin/lesavka-uvc.real.inc b/server/src/bin/lesavka-uvc.real.inc index 1ab1dbc..4ea8046 100644 --- a/server/src/bin/lesavka-uvc.real.inc +++ b/server/src/bin/lesavka-uvc.real.inc @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use std::env; -use std::fs::OpenOptions; +use std::fs::{File, OpenOptions}; use std::os::unix::fs::OpenOptionsExt; use std::os::unix::io::AsRawFd; use std::thread; @@ -135,6 +135,7 @@ struct ConfigfsSnapshot { fn main() -> Result<()> { let (dev, cfg) = parse_args()?; + let _singleton = acquire_singleton_lock()?; let interfaces = load_interfaces(); eprintln!("[lesavka-uvc] starting (dev={dev})"); eprintln!( @@ -425,15 +426,20 @@ fn read_interface(path: &str) -> Option { fn open_with_retry(path: &str) -> Result { let nonblock = env::var("LESAVKA_UVC_BLOCKING").is_err(); + let read_only = uvc_control_read_only(); for attempt in 1..=200 { let mut opts = OpenOptions::new(); - opts.read(true).write(true); + opts.read(true); + if !read_only { + opts.write(true); + } if nonblock { opts.custom_flags(libc::O_NONBLOCK); } match opts.open(path) { Ok(f) => { - eprintln!("[lesavka-uvc] opened {path} (attempt {attempt})"); + let mode = if read_only { "ro" } else { "rw" }; + eprintln!("[lesavka-uvc] opened {path} mode={mode} (attempt {attempt})"); return Ok(f); } Err(err) if err.raw_os_error() == Some(libc::ENOENT) => { @@ -445,6 +451,42 @@ fn open_with_retry(path: &str) -> Result { Err(anyhow::anyhow!("timeout opening {path}")) } +fn uvc_control_read_only() -> bool { + env::var("LESAVKA_UVC_CONTROL_READ_ONLY") + .ok() + .map(|value| { + let trimmed = value.trim(); + !(trimmed.eq_ignore_ascii_case("0") + || trimmed.eq_ignore_ascii_case("false") + || trimmed.eq_ignore_ascii_case("no") + || trimmed.eq_ignore_ascii_case("off")) + }) + .unwrap_or(true) +} + +fn acquire_singleton_lock() -> Result { + let path = + env::var("LESAVKA_UVC_LOCK_PATH").unwrap_or_else(|_| "/run/lesavka-uvc.lock".to_string()); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&path) + .with_context(|| format!("open singleton lock {path}"))?; + let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if rc < 0 { + let err = std::io::Error::last_os_error(); + match err.raw_os_error() { + Some(code) if code == libc::EWOULDBLOCK || code == libc::EAGAIN => { + eprintln!("[lesavka-uvc] another helper already owns {path}; exiting"); + std::process::exit(0); + } + _ => return Err(err).with_context(|| format!("lock singleton {path}")), + } + } + Ok(file) +} + fn subscribe_event(fd: i32, req: libc::c_ulong, event: u32) -> Result<()> { let mut sub = V4l2EventSubscription { type_: event, @@ -951,14 +993,15 @@ fn compute_payload_cap(bulk: bool) -> Option { let mut non_periodic = read_fifo_min("/sys/module/dwc2/parameters/g_np_tx_fifo_size").map(|v| (v, "dwc2.params")); if (periodic.is_none() || non_periodic.is_none()) - && let Some((p, np)) = read_debugfs_fifos() { - if periodic.is_none() { - periodic = p.map(|v| (v, "debugfs.params")); - } - if non_periodic.is_none() { - non_periodic = np.map(|v| (v, "debugfs.params")); - } + && let Some((p, np)) = read_debugfs_fifos() + { + if periodic.is_none() { + periodic = p.map(|v| (v, "debugfs.params")); } + if non_periodic.is_none() { + non_periodic = np.map(|v| (v, "debugfs.params")); + } + } let periodic_dw = periodic.map(|(v, _)| v); let non_periodic_dw = non_periodic.map(|(v, _)| v); diff --git a/testing/tests/server_install_script_contract.rs b/testing/tests/server_install_script_contract.rs index 8819671..73e49a0 100644 --- a/testing/tests/server_install_script_contract.rs +++ b/testing/tests/server_install_script_contract.rs @@ -30,6 +30,7 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { "LESAVKA_UVC_WIDTH=", "LESAVKA_UVC_HEIGHT=", "LESAVKA_UVC_CODEC=", + "LESAVKA_UVC_CONTROL_READ_ONLY=", ] { assert!( SERVER_INSTALL.contains(expected), @@ -52,6 +53,7 @@ fn server_install_pins_hdmi_camera_and_display_defaults() { assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_INTERVAL:-500000}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_WIDTH:-640}")); assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_HEIGHT:-480}")); + assert!(SERVER_INSTALL.contains("${LESAVKA_UVC_CONTROL_READ_ONLY:-1}")); assert!( !SERVER_INSTALL.contains("LESAVKA_UVC_CODEC=${LESAVKA_UVC_CODEC:-mjpeg}"), "install script should not let ambient LESAVKA_UVC_CODEC leak into persisted defaults" diff --git a/testing/tests/server_uvc_binary_contract.rs b/testing/tests/server_uvc_binary_contract.rs index 905e1e7..8d811a3 100644 --- a/testing/tests/server_uvc_binary_contract.rs +++ b/testing/tests/server_uvc_binary_contract.rs @@ -311,6 +311,22 @@ mod uvc_binary { }); } + #[test] + #[serial] + fn uvc_control_open_mode_defaults_read_only_with_escape_hatch() { + with_var("LESAVKA_UVC_CONTROL_READ_ONLY", None::<&str>, || { + assert!(uvc_control_read_only()); + }); + for disabled in ["0", "false", "no", "off"] { + with_var("LESAVKA_UVC_CONTROL_READ_ONLY", Some(disabled), || { + assert!(!uvc_control_read_only()); + }); + } + with_var("LESAVKA_UVC_CONTROL_READ_ONLY", Some("1"), || { + assert!(uvc_control_read_only()); + }); + } + #[test] fn interface_helpers_and_configfs_snapshot_are_stable_without_sysfs() { let tmp = NamedTempFile::new().expect("tmp"); diff --git a/testing/tests/server_uvc_process_contract.rs b/testing/tests/server_uvc_process_contract.rs index b54e096..581ec6a 100644 --- a/testing/tests/server_uvc_process_contract.rs +++ b/testing/tests/server_uvc_process_contract.rs @@ -73,9 +73,11 @@ fn uvc_binary_applies_env_config_and_fails_fast_on_non_v4l2_node() { }; let fake_device = NamedTempFile::new().expect("temp device"); + let lock = NamedTempFile::new().expect("temp lock"); let child = Command::new(Path::new(&bin)) .arg("--device") .arg(fake_device.path()) + .env("LESAVKA_UVC_LOCK_PATH", lock.path()) .env("LESAVKA_UVC_MAXPAYLOAD_LIMIT", "256") .env("LESAVKA_UVC_MAXPACKET", "4096") .env("LESAVKA_UVC_BULK", "1") @@ -98,8 +100,10 @@ fn uvc_binary_accepts_positional_device_argument() { }; let fake_device = NamedTempFile::new().expect("temp device"); + let lock = NamedTempFile::new().expect("temp lock"); let child = Command::new(Path::new(&bin)) .arg(fake_device.path()) + .env("LESAVKA_UVC_LOCK_PATH", lock.path()) .env_remove("LESAVKA_UVC_BULK") .env("LESAVKA_UVC_MAXPAYLOAD_LIMIT", "2048") .env("LESAVKA_UVC_MAXPACKET", "1024")