//! Include-based coverage for USB gadget orchestration helpers. //! //! Scope: include `server/src/gadget.rs` and exercise deterministic helper //! logic and error branches without touching live gadget sysfs state. //! Targets: `server/src/gadget.rs`. //! Why: most gadget logic is file-system and errno handling that should remain //! stable regardless of host environment. #[allow(warnings)] mod gadget_include_contract { include!(env!("LESAVKA_SERVER_GADGET_SRC")); use serial_test::serial; use std::os::unix::fs::PermissionsExt; use temp_env::with_var; use tempfile::{NamedTempFile, tempdir}; fn write_file(path: &Path, content: &str) { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).expect("create parent"); } std::fs::write(path, content).expect("write file"); } fn with_fake_roots(sys_root: &Path, cfg_root: &Path, f: impl FnOnce()) { let sys_root = sys_root.to_string_lossy().to_string(); let cfg_root = cfg_root.to_string_lossy().to_string(); with_var("LESAVKA_GADGET_SYSFS_ROOT", Some(sys_root), || { with_var("LESAVKA_GADGET_CONFIGFS_ROOT", Some(cfg_root), f); }); } fn build_fake_tree(base: &Path, ctrl: &str, gadget_name: &str, state: &str) { write_file( &base.join(format!("sys/class/udc/{ctrl}/state")), &format!("{state}\n"), ); write_file( &base.join(format!("sys/class/udc/{ctrl}/soft_connect")), "1\n", ); write_file( &base.join("sys/bus/platform/drivers/dwc2/unbind"), "placeholder\n", ); write_file( &base.join("sys/bus/platform/drivers/dwc2/bind"), "placeholder\n", ); write_file( &base.join(format!("cfg/{gadget_name}/UDC")), &format!("{ctrl}\n"), ); } fn write_helper(path: &Path, body: &str) { write_file(path, body); let mut perms = std::fs::metadata(path) .expect("helper metadata") .permissions(); perms.set_mode(0o755); std::fs::set_permissions(path, perms).expect("chmod helper"); } fn with_fast_recovery_env(helper: &Path, f: impl FnOnce()) { let helper = helper.to_string_lossy().to_string(); with_var("LESAVKA_CORE_HELPER", Some(helper), || { with_var("LESAVKA_USB_RECOVERY_CYCLE_WAIT_MS", Some("0"), || { with_var("LESAVKA_USB_RECOVERY_REBUILD_WAIT_MS", Some("0"), || { with_var("LESAVKA_USB_RECOVERY_FINAL_WAIT_MS", Some("0"), f); }) }) }); } #[test] fn new_builds_expected_udc_path() { let gadget = UsbGadget::new("lesavka-test"); assert!(gadget.udc_file.ends_with("/lesavka-test/UDC")); } #[test] fn state_errors_for_missing_controller() { let result = UsbGadget::state("definitely-missing-udc"); assert!(result.is_err()); } #[test] fn host_attached_state_matches_udc_states_that_can_accept_hid() { for state in ["configured", "addressed", "default", "suspended", "unknown"] { assert!(UsbGadget::host_attached_state(state), "{state}"); } assert!(!UsbGadget::host_attached_state("not attached")); assert!(!UsbGadget::host_attached_state("broken")); } #[test] fn host_enumerated_state_excludes_unknown_and_not_attached() { for state in ["configured", "addressed", "default", "suspended"] { assert!(UsbGadget::host_enumerated_state(state), "{state}"); } assert!(!UsbGadget::host_enumerated_state("unknown")); assert!(!UsbGadget::host_enumerated_state("not attached")); } #[test] #[serial] fn current_controller_state_reads_fake_udc_tree() { let dir = tempdir().expect("tempdir"); let ctrl = "fake-ctrl.usb"; build_fake_tree(dir.path(), ctrl, "lesavka-test", "configured"); with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { let (found_ctrl, state) = UsbGadget::current_controller_state().expect("current state"); assert_eq!(found_ctrl, ctrl); assert_eq!(state, "configured"); }); } #[test] fn wait_state_any_times_out_for_missing_controller() { let result = UsbGadget::wait_state_any("definitely-missing-udc", 0); assert!(result.is_err()); } #[test] #[serial] fn wait_state_any_accepts_configured_and_not_attached_fake_states() { let dir = tempdir().expect("tempdir"); let ctrl = "fake-ctrl.usb"; build_fake_tree(dir.path(), ctrl, "lesavka-test", "configured"); with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { assert_eq!( UsbGadget::wait_state_any(ctrl, 0).expect("configured state"), "configured" ); let state_path = dir.path().join(format!("sys/class/udc/{ctrl}/state")); std::fs::write(state_path, "not attached\n").expect("flip state"); assert_eq!( UsbGadget::wait_state_any(ctrl, 0).expect("not attached state"), "not attached" ); }); } #[test] fn wait_state_times_out_for_missing_controller() { let result = UsbGadget::wait_state("definitely-missing-udc", "configured", 0); assert!(result.is_err()); } #[test] #[serial] fn wait_state_accepts_matching_fake_state() { let dir = tempdir().expect("tempdir"); let ctrl = "fake-ctrl.usb"; build_fake_tree(dir.path(), ctrl, "lesavka-test", "configured"); with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { UsbGadget::wait_state(ctrl, "configured", 0).expect("configured state"); }); } #[test] fn write_attr_writes_value_with_trailing_newline() { let file = NamedTempFile::new().expect("temp file"); UsbGadget::write_attr(file.path(), "configured").expect("write attr"); let content = std::fs::read_to_string(file.path()).expect("read back"); assert_eq!(content, "configured\n"); } #[test] fn wait_udc_present_times_out_for_missing_path() { let result = UsbGadget::wait_udc_present("definitely-missing-udc", 0); assert!(result.is_err()); } #[test] fn probe_platform_udc_is_non_panicking() { let _ = UsbGadget::probe_platform_udc(); } #[test] fn find_controller_returns_name_or_error_without_panicking() { let result = UsbGadget::find_controller(); if let Ok(name) = result { assert!(!name.is_empty()); } } #[test] fn is_still_detaching_matches_expected_errno_set() { let busy = anyhow::Error::from(std::io::Error::from_raw_os_error(libc::EBUSY)); let missing = anyhow::Error::from(std::io::Error::from_raw_os_error(libc::ENOENT)); let no_dev = anyhow::Error::from(std::io::Error::from_raw_os_error(libc::ENODEV)); let other = anyhow::Error::from(std::io::Error::from_raw_os_error(libc::EACCES)); let non_io = anyhow::anyhow!("plain error"); assert!(UsbGadget::is_still_detaching(&busy)); assert!(UsbGadget::is_still_detaching(&missing)); assert!(UsbGadget::is_still_detaching(&no_dev)); assert!(!UsbGadget::is_still_detaching(&other)); assert!(!UsbGadget::is_still_detaching(&non_io)); } #[test] fn rebind_driver_errors_when_driver_nodes_are_absent() { let dir = tempdir().expect("tempdir"); with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { let result = UsbGadget::rebind_driver("definitely-missing-udc"); assert!(result.is_err()); }); } #[test] #[serial] fn cycle_handles_missing_gadget_sysfs_gracefully() { let gadget = UsbGadget::new("lesavka-test"); with_var("LESAVKA_GADGET_FORCE_CYCLE", None::<&str>, || { let result = gadget.cycle(); assert!(result.is_err() || result.is_ok()); }); } #[test] #[serial] fn cycle_force_mode_still_returns_without_panicking() { let dir = tempdir().expect("tempdir"); let fake_udc = dir.path().join("UDC"); std::fs::write(&fake_udc, "").expect("create fake udc file"); let gadget = UsbGadget { udc_file: Box::leak(fake_udc.to_string_lossy().to_string().into_boxed_str()), }; with_var("LESAVKA_GADGET_FORCE_CYCLE", Some("1"), || { let result = gadget.cycle(); assert!(result.is_err() || result.is_ok()); }); } #[test] #[serial] fn cycle_short_circuits_when_host_is_attached_without_force() { let dir = tempdir().expect("tempdir"); let ctrl = "fake-ctrl.usb"; build_fake_tree(dir.path(), ctrl, "lesavka-test", "configured"); with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { let gadget = UsbGadget::new("lesavka-test"); let result = gadget.cycle(); assert!( result.is_ok(), "configured host state should short-circuit safely" ); }); } #[test] #[serial] fn cycle_short_circuits_when_state_file_disappears_without_force() { let dir = tempdir().expect("tempdir"); let ctrl = "fake-ctrl.usb"; std::fs::create_dir_all(dir.path().join(format!("sys/class/udc/{ctrl}"))) .expect("create fake controller without state"); write_file( &dir.path().join("cfg/lesavka-test/UDC"), &format!("{ctrl}\n"), ); with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { let gadget = UsbGadget::new("lesavka-test"); gadget .cycle() .expect("missing state should allow standby short-circuit"); }); } #[test] #[serial] fn cycle_force_mode_completes_on_fake_tree_when_state_stays_not_attached() { let dir = tempdir().expect("tempdir"); let ctrl = "fake-ctrl.usb"; build_fake_tree(dir.path(), ctrl, "lesavka-test", "not attached"); with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { with_var("LESAVKA_GADGET_FORCE_CYCLE", Some("1"), || { let gadget = UsbGadget::new("lesavka-test"); let result = gadget.cycle(); assert!( result.is_ok(), "force cycle should complete on fake sysfs tree" ); let udc_path = dir.path().join("cfg/lesavka-test/UDC"); let value = std::fs::read_to_string(udc_path).expect("read udc file"); assert_eq!(value.trim(), ctrl); }); }); } #[test] #[serial] fn cycle_force_mode_accepts_late_configured_transition() { let dir = tempdir().expect("tempdir"); let ctrl = "fake-ctrl.usb"; build_fake_tree(dir.path(), ctrl, "lesavka-test", "not attached"); let state_path = dir.path().join(format!("sys/class/udc/{ctrl}/state")); let state_path_bg = state_path.clone(); let writer = std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(150)); std::fs::write(state_path_bg, "configured\n").expect("flip state"); }); with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { with_var("LESAVKA_GADGET_FORCE_CYCLE", Some("1"), || { let gadget = UsbGadget::new("lesavka-test"); let result = gadget.cycle(); assert!( result.is_ok(), "configured transition should satisfy final wait_state" ); }); }); writer.join().expect("join state writer"); } #[test] #[serial] fn recover_enumeration_runs_forced_core_rebuild_after_stuck_soft_cycle() { let dir = tempdir().expect("tempdir"); let ctrl = "fake-ctrl.usb"; build_fake_tree(dir.path(), ctrl, "lesavka-test", "not attached"); let helper = dir.path().join("fake-core.sh"); write_helper( &helper, r#"#!/usr/bin/env bash set -euo pipefail echo forced core helper >&2 printf 'configured\n' > "$LESAVKA_GADGET_SYSFS_ROOT/class/udc/fake-ctrl.usb/state" "#, ); with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { with_fast_recovery_env(&helper, || { let gadget = UsbGadget::new("lesavka-test"); gadget .recover_enumeration() .expect("forced rebuild should recover fake UDC"); }); }); let state = std::fs::read_to_string(dir.path().join(format!("sys/class/udc/{ctrl}/state"))) .expect("read state"); assert_eq!(state.trim(), "configured"); } #[test] #[serial] fn recover_enumeration_passes_aggressive_rebuild_environment_to_core_helper() { let dir = tempdir().expect("tempdir"); let ctrl = "fake-ctrl.usb"; build_fake_tree(dir.path(), ctrl, "lesavka-test", "not attached"); let helper = dir.path().join("fake-core-env.sh"); let env_dump = dir.path().join("helper-env.txt"); write_helper( &helper, r#"#!/usr/bin/env bash set -euo pipefail cat > "$LESAVKA_HELPER_ENV_DUMP" < "$LESAVKA_GADGET_SYSFS_ROOT/class/udc/fake-ctrl.usb/state" "#, ); with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { with_fast_recovery_env(&helper, || { with_var( "LESAVKA_HELPER_ENV_DUMP", Some(env_dump.to_string_lossy().to_string()), || { let gadget = UsbGadget::new("lesavka-test"); gadget .recover_enumeration() .expect("forced rebuild should recover fake UDC"); }, ); }); }); let dumped = std::fs::read_to_string(env_dump).expect("read helper env dump"); for line in [ "LESAVKA_ALLOW_GADGET_RESET=1", "LESAVKA_ATTACH_WRITE_UDC=1", "LESAVKA_DETACH_CLEAR_UDC=1", "LESAVKA_RELOAD_UVCVIDEO=1", "LESAVKA_UVC_FALLBACK=0", "LESAVKA_UVC_CODEC=mjpeg", ] { assert!(dumped.contains(line), "{line} missing from {dumped}"); } } #[test] #[serial] fn recover_enumeration_honors_explicit_uvc_fallback_override() { let dir = tempdir().expect("tempdir"); let ctrl = "fake-ctrl.usb"; build_fake_tree(dir.path(), ctrl, "lesavka-test", "not attached"); let helper = dir.path().join("fake-core-env-override.sh"); let env_dump = dir.path().join("helper-env-override.txt"); write_helper( &helper, r#"#!/usr/bin/env bash set -euo pipefail cat > "$LESAVKA_HELPER_ENV_DUMP" < "$LESAVKA_GADGET_SYSFS_ROOT/class/udc/fake-ctrl.usb/state" "#, ); with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { with_fast_recovery_env(&helper, || { with_var("LESAVKA_UVC_FALLBACK", Some("1"), || { with_var( "LESAVKA_HELPER_ENV_DUMP", Some(env_dump.to_string_lossy().to_string()), || { let gadget = UsbGadget::new("lesavka-test"); gadget .recover_enumeration() .expect("forced rebuild should recover fake UDC"); }, ); }); }); }); let dumped = std::fs::read_to_string(env_dump).expect("read helper env dump"); assert!( dumped.contains("LESAVKA_UVC_FALLBACK=1"), "explicit fallback override missing from {dumped}" ); } #[test] #[serial] fn recover_enumeration_reports_clear_failure_when_helper_leaves_udc_unattached() { let dir = tempdir().expect("tempdir"); let ctrl = "fake-ctrl.usb"; build_fake_tree(dir.path(), ctrl, "lesavka-test", "not attached"); let helper = dir.path().join("fake-core-noop.sh"); write_helper( &helper, r#"#!/usr/bin/env bash set -euo pipefail echo noop core helper >&2 "#, ); with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { with_fast_recovery_env(&helper, || { let gadget = UsbGadget::new("lesavka-test"); let err = gadget .recover_enumeration() .expect_err("still-unattached UDC should fail recovery"); let message = format!("{err:#}"); assert!(message.contains("still not attached"), "{message}"); assert!( message.contains("forced gadget rebuild helper"), "{message}" ); }); }); } #[test] #[serial] fn run_forced_core_rebuild_reports_helper_failure_and_truncates_tail() { let dir = tempdir().expect("tempdir"); let helper = dir.path().join("fake-core-fail.sh"); write_helper( &helper, r#"#!/usr/bin/env bash set -euo pipefail printf '%*s\n' 1400 '' | tr ' ' x exit 42 "#, ); with_fast_recovery_env(&helper, || { let gadget = UsbGadget::new("lesavka-test"); let err = gadget .run_forced_core_rebuild() .expect_err("failing helper should report stdout/stderr"); let message = format!("{err:#}"); assert!(message.contains("exited with"), "{message}"); assert!(message.contains("..."), "{message}"); }); } #[test] #[serial] fn probe_platform_udc_reads_fake_platform_tree() { let dir = tempdir().expect("tempdir"); let dev_root = dir.path().join("sys/bus/platform/devices"); std::fs::create_dir_all(&dev_root).expect("create platform devices"); std::fs::create_dir_all(dev_root.join("foo.usb")).expect("create usb entry"); with_fake_roots(&dir.path().join("sys"), &dir.path().join("cfg"), || { let found = UsbGadget::probe_platform_udc().expect("probe"); assert_eq!(found.as_deref(), Some("foo.usb")); }); } }