//! Include-based coverage for aggressive USB gadget recovery helpers. //! //! Scope: exercise forced Lesavka core rebuild and fake UDC recovery branches. //! Targets: `server/src/gadget.rs`. //! Why: recovery is the fragile path that protects UVC enumeration after host //! or gadget bumps, so it needs focused regression coverage. #[allow(warnings)] mod gadget_recovery_contract { include!(env!("LESAVKA_SERVER_GADGET_SRC")); use serial_test::serial; use std::os::unix::fs::{PermissionsExt, symlink}; use temp_env::with_var; use tempfile::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", ); let driver_target = base.join("sys/bus/platform/drivers/dwc2"); let driver_link = base.join(format!("sys/bus/platform/devices/{ctrl}/driver")); if let Some(parent) = driver_link.parent() { std::fs::create_dir_all(parent).expect("create driver link parent"); } symlink(&driver_target, &driver_link).expect("link controller driver"); 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] #[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")); }); } }