diff --git a/drivers/soc/apple/sbio.rs b/drivers/soc/apple/sbio.rs index 9c69ca184c6912..07b3b95594336e 100644 --- a/drivers/soc/apple/sbio.rs +++ b/drivers/soc/apple/sbio.rs @@ -1653,7 +1653,7 @@ impl SepData { dev_err!(self.dev, "verify: could not start the capture ({:?})\n", e); return bio::VerifyOutcome::Failed(ENROL_STATUS_SENSOR); } - let advertised = match self.await_capture() { + let advertised = match self.await_capture(CapturePurpose::Verification) { CaptureWait::Ready(n) => n, CaptureWait::Timeout => { dev_warn!(self.dev, "verify: the capture ended without a frame\n"); @@ -2712,7 +2712,7 @@ impl SepData { return ImageOutcome::Failed(ENROL_STATUS_SENSOR); } - let available = match self.await_capture() { + let available = match self.await_capture(CapturePurpose::Enrollment) { CaptureWait::Ready(n) => n, CaptureWait::Timeout => { dev_warn!(self.dev, "enrol: capture {} ended without a frame\n", counter); @@ -2846,7 +2846,7 @@ impl SepData { } } - fn await_capture(&self) -> CaptureWait { + fn await_capture(&self, purpose: CapturePurpose) -> CaptureWait { let mut previous: Option<[u8; sensor::STATUS_LEN]> = None; let mut armed_reported = false; @@ -2865,9 +2865,13 @@ impl SepData { // attempt budget would then be spent in milliseconds and time the // capture out before the frame lands. Time-bounding degrades, at worst, // to the same duration as the poll path. - let deadline_ns = crate::shim::monotonic_ns().saturating_add( - u64::from(ENROL_POLL_ATTEMPTS) * u64::from(ENROL_POLL_MS) * 1_000_000, + let timeout_ns = u64::from(ENROL_POLL_ATTEMPTS) * u64::from(ENROL_POLL_MS) * 1_000_000; + let started_ns = crate::shim::monotonic_ns(); + let mut deadline_ns = started_ns.saturating_add(timeout_ns); + let verification_deadline_ns = started_ns.saturating_add( + u64::from(VERIFY_CAPTURE_TIMEOUT_MS) * 1_000_000, ); + let mut waiting_for_finger = matches!(purpose, CapturePurpose::Verification); let mut attempt: u32 = 0; loop { if !bio::capture_is_live(&self.bio_session.lock(), self.capture_epoch.load(Relaxed)) { @@ -2881,6 +2885,12 @@ impl SepData { } }; + // Cancellation or suspend may have happened during the SPI read. + // Do not carry even a complete frame into the old operation. + if !bio::capture_is_live(&self.bio_session.lock(), self.capture_epoch.load(Relaxed)) { + return CaptureWait::Abandon; + } + if attempt == 0 || previous != Some(st.raw) { previous = Some(st.raw); } @@ -2902,9 +2912,24 @@ impl SepData { } } - if st.state == sensor::STATE_ARMED && !armed_reported { - armed_reported = true; + // Keep one idle verification armed longer than enrollment, but + // retain an absolute upper bound even if its caller never cancels. + // A transition out of ARMED still ends the idle extension. + let now_ns = crate::shim::monotonic_ns(); + if matches!(purpose, CapturePurpose::Verification) && now_ns >= verification_deadline_ns { + return CaptureWait::Timeout; + } + // A late ARMED report or frame cannot revive an expired arming, + // enrollment or progress budget, even if SPI consumed the time. + if now_ns >= deadline_ns { + return CaptureWait::Timeout; } + if waiting_for_finger && st.state == sensor::STATE_ARMED { + deadline_ns = now_ns.saturating_add(timeout_ns); + } else if st.state == sensor::STATE_READING || armed_reported { + waiting_for_finger = false; + } + armed_reported |= st.state == sensor::STATE_ARMED; if let sensor::Offset12::Available(count) = st.offset12() { if count == 0 { @@ -2913,10 +2938,6 @@ impl SepData { return CaptureWait::Ready(count); } - if crate::shim::monotonic_ns() >= deadline_ns { - break; - } - if use_irq { // Block on the data-ready line; the interrupt wakes this the // instant a frame is ready, and the backstop bounds a missed @@ -2928,8 +2949,6 @@ impl SepData { } attempt = attempt.saturating_add(1); } - let _ = sensor::status(); - CaptureWait::Timeout } pub(crate) fn on_sbio(&self, msg: Message) { diff --git a/drivers/soc/apple/sep.rs b/drivers/soc/apple/sep.rs index 87b90a7d986ee6..f7a10202b7ff44 100644 --- a/drivers/soc/apple/sep.rs +++ b/drivers/soc/apple/sep.rs @@ -364,6 +364,11 @@ enum CaptureWait { Abandon, } +enum CapturePurpose { + Enrollment, + Verification, +} + const ENROL_STATUS_SENSOR: u32 = 1; const ENROL_STATUS_ENCLAVE: u32 = 2; const ENROL_STATUS_TOO_MANY: u32 = 3; @@ -434,6 +439,9 @@ const ENROL_POLL_MS: u32 = 2; const ENROL_IRQ_WAIT_MS: u32 = 250; const ENROL_CAPTURE_TIMEOUT_MS: u32 = 60_000; +// A forgotten userspace verifier must not retain one capture indefinitely. +// Five minutes preserves a long idle prompt without the old minute-long churn. +const VERIFY_CAPTURE_TIMEOUT_MS: u32 = 300_000; const ENROL_POLL_ATTEMPTS: u32 = ENROL_CAPTURE_TIMEOUT_MS / ENROL_POLL_MS; static_assert!(ENROL_POLL_ATTEMPTS * ENROL_POLL_MS == ENROL_CAPTURE_TIMEOUT_MS); diff --git a/tools/aurora-ci/host-tests.json b/tools/aurora-ci/host-tests.json index 1310ec2bb60146..88531944b16714 100644 --- a/tools/aurora-ci/host-tests.json +++ b/tools/aurora-ci/host-tests.json @@ -2271,9 +2271,27 @@ "{output}" ], "note": "Build only the host Kconfig tool in an isolated output directory; check all built-in/module dependency combinations." + }, + { + "name": "tools/aurora-sep/tests/test_capture_wait.py", + "group": "aurora-sep", + "argv": [ + "python3", + "-m", + "unittest", + "discover", + "-s", + "tools/aurora-sep/tests", + "-p", + "test_capture_wait.py", + "-v" + ], + "rust": "1.85.0", + "timeout": 120 } ], "helpers": { + "tools/aurora-sep/tests/capture-wait.rs": "Synthetic time/SPI/IRQ leaves and behavioral cases compiled with production await_capture by test_capture_wait.py.", "tools/testing/selftests/drivers/gpu/apple-dcp/run.py": "the implementation behind tools/testing/selftests/drivers/gpu/apple-dcp/run.sh, which is listed", "tools/testing/selftests/drivers/gpu/asahi-g15/uat-owner.c": "compiled and executed by the registered asahi-g15/run.py actual reservation-owner fixture", "tools/testing/selftests/drivers/gpu/asahi-g15/pmp-bridge.c": "compiled and executed by the registered asahi-g15/run.py maintained PMP fixture", diff --git a/tools/aurora-sep/CAPTURE-WAIT.md b/tools/aurora-sep/CAPTURE-WAIT.md new file mode 100644 index 00000000000000..829fe74033b2e0 --- /dev/null +++ b/tools/aurora-sep/CAPTURE-WAIT.md @@ -0,0 +1,23 @@ +# Bounded idle verification + +An idle verification no longer tears down a healthy ARMED capture every 60 seconds. It can wait for a finger within a fixed five-minute deadline measured from the start of `await_capture`. The deadline is not refreshed by ARMED reports, transitions, IRQs or a late frame. Cancellation or suspend still invalidates the operation immediately at the next session check. + +Five minutes is a proposed host policy: it leaves margin beyond the earlier combined candidate's attended 185-second idle wait, while bounding a caller that remains alive but never cancels. It is not a measured SEP nonce lifetime or a claim that a sensor remains reliable or power-efficient for five minutes. The earlier combined result does not qualify this changed branch; maintainers can choose a different finite bound after reviewing the caller policy. + +Enrollment retains its absolute 60-second deadline. Verification also retains the existing 60-second bound while initially arming and after capture progress begins or an armed sensor leaves ARMED. Returning to ARMED cannot restart that progress budget. The five-minute limit overrides it if sooner, including when a status read returns a ready frame at or after the limit. Deadline expiry is now checked before any ARMED renewal or ready-frame acceptance. This tightens the old boundary ordering: a first ARMED report at or after the arming deadline cannot revive it, and a late frame cannot bypass an expired enrollment or progress deadline. A zero-length frame, patch loss or status failure retains its existing outcome. Frame processing, nonce checks, persistence and settling delays are unchanged. + +## Caller lifetime + +`pam_fprintd` supplies an independent timeout, 30 seconds by default, and calls D-Bus `VerifyStop` when its verification window expires. The Omarchy sudo and polkit entrypoints use `pam_fprintd.so` without a timeout override, so their ordinary no-touch wait remains governed by PAM. Administrators may change that setting, including disabling its timeout. This kernel patch does not change PAM policy or promise password fallback for every administrator-defined stack. + +The Omarchy lock uses a PAM context for fingerprint verification, aborts that context on unlock or its reachability watchdog, and may schedule a new attempt after a completed one. A longer-lived lock prompt can therefore contain multiple bounded attempts. The kernel limit applies to one capture, not to the lifetime of that UI. + +In fprintd, `VerifyStop`, release and disappearance of the claiming D-Bus client cancel the underlying operation. The Aurora libfprint driver's cancellation callback issues `AURORA_BIO_CANCEL`, and closing its device description also cancels the kernel session. A kernel capture timeout is a terminal `AURORA_BIO_STATUS_TIMEOUT`, not the retry status that fprintd automatically restarts. A custom client that stays connected and never cancels can no longer hold one kernel capture forever; it still owns its D-Bus claim until it releases it or disconnects. The fprintd `--no-timeout` service option concerns idle daemon exit, not the PAM verification deadline. + +The wait checks session validity before and after each SPI status read. IRQ mode has a 250 ms backstop; polling mode sleeps 2 ms between reads. These bound the interval between checks when hardware calls return, not a malfunctioning SPI transaction. The patch does not establish a hard deadline for a blocked transport call. + +## Offline verification + +`python3 -m unittest discover -s tools/aurora-sep/tests -p test_capture_wait.py -v` compiles the production wait method with synthetic clocks and hardware leaves. Thirty-one Rust cases cover delayed touch beyond 185 seconds, both wait modes, enrollment and progress bounds, fixed verification expiry, frames just before and at expiry, a status read crossing expiry, cancellation priority, epoch changes, transport errors and spurious IRQs. Additional exact/just-before-boundary cases cover initial arming, enrollment and progress, including status reads crossing their deadlines. Four compiled negative controls restore the old minute-long idle timeout, remove cancellation checks, remove the fixed verification bound and move progress expiry behind renewal/frame acceptance; each must fail its named behavioral assertion. + +These are host tests. Physical five-minute expiry, idle power, firmware or nonce lifetime, and actual suspend behavior on this changed branch remain unqualified. No sensor is opened by the tests. diff --git a/tools/aurora-sep/tests/capture-wait.rs b/tools/aurora-sep/tests/capture-wait.rs new file mode 100644 index 00000000000000..9f2609b8add5f5 --- /dev/null +++ b/tools/aurora-sep/tests/capture-wait.rs @@ -0,0 +1,376 @@ +// SPDX-License-Identifier: GPL-2.0-only OR MIT +#![allow(dead_code, unused_variables, unused_imports)] +use std::cell::{RefCell, RefMut}; +use std::sync::atomic::{AtomicU64, Ordering::Relaxed}; + +/* PRODUCTION */ + +#[derive(Default)] +struct Mock { + ms: u64, + irq: bool, + spurious: bool, + events: Vec<(u64, Option, u32)>, + cancel_at: Option, + epoch_at: Option, + status_cost: u64, + reads: usize, + arms: usize, + guides: Vec, +} +thread_local! { static MOCK: RefCell = RefCell::new(Mock::default()); } +fn clock() -> u64 { MOCK.with(|m| m.borrow().ms) } +mod shim { + pub fn monotonic_ns() -> u64 { super::clock() * 1_000_000 } +} +mod kernel { + pub mod time { + pub struct Delta(pub i64); + impl Delta { pub fn from_millis(ms: i64) -> Self { Self(ms) } } + pub mod delay { + pub fn fsleep(delta: super::Delta) { + crate::MOCK.with(|m| m.borrow_mut().ms += delta.0 as u64); + } + } + } +} +mod bio { + #[derive(Clone, Copy, Debug, PartialEq)] + pub enum Guidance { Place, HoldStill } + pub struct Session; + pub fn capture_is_live(_: &Session, epoch: u64) -> bool { + super::MOCK.with(|m| { + let m = m.borrow(); + !m.cancel_at.is_some_and(|at| m.ms >= at) + && epoch == if m.epoch_at.is_some_and(|at| m.ms >= at) { 2 } else { 1 } + }) + } + pub fn capture_guide(s: &mut Session, epoch: u64, guide: Guidance) -> bool { + if !capture_is_live(s, epoch) { return false; } + super::MOCK.with(|m| { + let mut m = m.borrow_mut(); + if m.guides.last() == Some(&guide) { false } + else { m.guides.push(guide); true } + }) + } +} +mod sensor { + pub const STATUS_LEN: usize = 16; + pub const STATE_ARMED: u8 = 17; + pub const STATE_READING: u8 = 19; + pub const STATE_NEEDS_PATCH: u8 = 9; + pub const STATE_IDLE: u8 = 0; + pub const STATE_DATA_READY: u8 = 7; + pub enum Offset12 { Available(u32), Other } + pub struct Status { pub state: u8, pub raw: [u8; STATUS_LEN], pub count: u32 } + impl Status { + pub fn offset12(&self) -> Offset12 { + if self.state == STATE_DATA_READY { Offset12::Available(self.count) } + else { Offset12::Other } + } + } + pub fn irq_available() -> bool { super::MOCK.with(|m| m.borrow().irq) } + pub fn irq_arm() { super::MOCK.with(|m| m.borrow_mut().arms += 1); } + pub fn irq_wait(ms: u32) -> bool { + super::MOCK.with(|m| { + let mut m = m.borrow_mut(); + m.ms += if m.spurious { 1 } else { u64::from(ms) }; + }); + true + } + pub fn status() -> Result { + super::MOCK.with(|m| { + let mut m = m.borrow_mut(); + m.reads += 1; + assert!(m.reads < 2_000_000, "wait did not terminate"); + m.ms += m.status_cost; + let (_, state, count) = *m.events.iter().rev().find(|v| v.0 <= m.ms).expect("initial state"); + state.map(|state| Status { state, raw: [state; STATUS_LEN], count }).ok_or(()) + }) + } +} +struct SessionLock(RefCell); +impl SessionLock { fn lock(&self) -> RefMut<'_, bio::Session> { self.0.borrow_mut() } } +struct Driver { bio_session: SessionLock, capture_epoch: AtomicU64 } +impl Driver { + fn new() -> Self { + Self { bio_session: SessionLock(RefCell::new(bio::Session)), capture_epoch: AtomicU64::new(1) } + } + fn bio_wake(&self) {} + fn wait(&self, verify: bool) -> CaptureWait { /* CALL */ } + /* METHOD */ +} +fn begin(irq: bool, events: &[(u64, Option, u32)]) -> Driver { + MOCK.with(|m| *m.borrow_mut() = Mock { irq, events: events.to_vec(), ..Mock::default() }); + Driver::new() +} +fn both(mut test: impl FnMut(bool)) { for irq in [false, true] { test(irq); } } + +#[test] +fn immediate_frame_is_accepted() { + both(|irq| { + let d = begin(irq, &[(0, Some(7), 128)]); + assert!(matches!(d.wait(true), CaptureWait::Ready(128))); + }); +} +#[test] +fn delayed_finger_stays_in_one_capture() { + both(|irq| { + let d = begin(irq, &[(0, Some(17), 0), (185_125, Some(19), 0), (185_375, Some(7), 512)]); + assert!(matches!(d.wait(true), CaptureWait::Ready(512))); + assert!(clock() >= 185_375); + MOCK.with(|m| assert_eq!(m.borrow().guides, vec![bio::Guidance::Place, bio::Guidance::HoldStill])); + }); +} +#[test] +fn enrollment_keeps_absolute_deadline() { + both(|irq| { + let d = begin(irq, &[(0, Some(17), 0), (70_000, Some(7), 512)]); + assert!(matches!(d.wait(false), CaptureWait::Timeout)); + assert_eq!(clock(), 60_000); + }); +} +#[test] +fn enrollment_progress_does_not_reset_deadline() { + both(|irq| { + let d = begin(irq, &[(0, Some(17), 0), (59_000, Some(19), 0), (70_000, Some(7), 512)]); + assert!(matches!(d.wait(false), CaptureWait::Timeout)); + assert_eq!(clock(), 60_000); + }); +} +#[test] +fn cancellation_and_epoch_change_are_bounded() { + both(|irq| { + for epoch in [false, true] { + let d = begin(irq, &[(0, Some(17), 0), (180_000, Some(7), 512)]); + MOCK.with(|m| { + if epoch { m.borrow_mut().epoch_at = Some(120_001); } + else { m.borrow_mut().cancel_at = Some(120_001); } + }); + assert!(matches!(d.wait(true), CaptureWait::Abandon)); + assert!(clock() <= 120_001 + if irq { 250 } else { 2 }); + } + }); +} +#[test] +fn cancellation_during_status_drops_ready_frame() { + let d = begin(false, &[(0, Some(7), 512)]); + MOCK.with(|m| { let mut m=m.borrow_mut(); m.status_cost=10; m.cancel_at=Some(1); }); + assert!(matches!(d.wait(true), CaptureWait::Abandon)); +} +#[test] +fn cancellation_before_first_read_avoids_spi() { + let d = begin(true, &[(0, Some(7), 512)]); + MOCK.with(|m| m.borrow_mut().cancel_at=Some(0)); + assert!(matches!(d.wait(true), CaptureWait::Abandon)); + MOCK.with(|m| assert_eq!(m.borrow().reads, 0)); +} +#[test] +fn status_failure_is_not_retried() { + both(|irq| { + let d = begin(irq, &[(0, Some(17), 0), (120_000, None, 0)]); + assert!(matches!(d.wait(true), CaptureWait::Abandon)); + assert_eq!(clock(), 120_000); + }); +} +#[test] +fn lost_patch_is_a_fault() { + both(|irq| { + let d = begin(irq, &[(0, Some(17), 0), (120_000, Some(9), 0)]); + assert!(matches!(d.wait(true), CaptureWait::Fault(9))); + }); +} +#[test] +fn hardware_zero_frame_is_still_a_timeout() { + both(|irq| { + let d = begin(irq, &[(0, Some(17), 0), (120_000, Some(7), 0)]); + assert!(matches!(d.wait(true), CaptureWait::Timeout)); + assert_eq!(clock(), 120_000); + }); +} +#[test] +fn stuck_reading_is_bounded_after_long_idle() { + both(|irq| { + let d = begin(irq, &[(0, Some(17), 0), (120_000, Some(19), 0)]); + assert!(matches!(d.wait(true), CaptureWait::Timeout)); + assert!(clock() >= 179_750 && clock() <= 180_000); + }); +} +#[test] +fn returning_to_armed_does_not_hide_stalled_progress() { + both(|irq| { + for state in [0, 19, 42] { + let d = begin(irq, &[(0, Some(17), 0), (120_000, Some(state), 0), (120_500, Some(17), 0)]); + assert!(matches!(d.wait(true), CaptureWait::Timeout)); + assert!(clock() <= 180_000); + } + }); +} +#[test] +fn never_armed_is_bounded() { + both(|irq| { + let d = begin(irq, &[(0, Some(0), 0)]); + assert!(matches!(d.wait(true), CaptureWait::Timeout)); + assert_eq!(clock(), 60_000); + }); +} +#[test] +fn spurious_irq_does_not_spend_deadline_early() { + let d = begin(true, &[(0, Some(17), 0), (180_000, Some(7), 512)]); + MOCK.with(|m| m.borrow_mut().spurious = true); + assert!(matches!(d.wait(true), CaptureWait::Ready(512))); + assert_eq!(clock(), 180_000); +} + +#[test] +fn unattended_verification_has_absolute_upper_bound() { + both(|irq| { + let d = begin(irq, &[(0, Some(17), 0)]); + // This later cancellation also bounds the deliberate no-cap mutant. + MOCK.with(|m| m.borrow_mut().cancel_at = Some(360_000)); + assert!(matches!(d.wait(true), CaptureWait::Timeout)); + assert_eq!(clock(), 300_000); + }); +} +#[test] +fn frame_at_verification_deadline_is_not_accepted() { + both(|irq| { + let d = begin(irq, &[(0, Some(17), 0), (300_000, Some(7), 512)]); + assert!(matches!(d.wait(true), CaptureWait::Timeout)); + assert_eq!(clock(), 300_000); + }); +} +#[test] +fn frame_just_before_verification_deadline_is_accepted() { + both(|irq| { + let d = begin(irq, &[(0, Some(17), 0), (299_750, Some(7), 512)]); + assert!(matches!(d.wait(true), CaptureWait::Ready(512))); + assert_eq!(clock(), 299_750); + }); +} +#[test] +fn progress_cannot_extend_the_absolute_verification_deadline() { + both(|irq| { + let d = begin(irq, &[(0, Some(17), 0), (299_000, Some(19), 0), (300_500, Some(7), 512)]); + assert!(matches!(d.wait(true), CaptureWait::Timeout)); + assert_eq!(clock(), 300_000); + }); +} +#[test] +fn cancellation_during_expiring_status_read_takes_priority() { + let d = begin(false, &[(0, Some(7), 512)]); + MOCK.with(|m| { + let mut m = m.borrow_mut(); + m.status_cost = 300_000; + m.cancel_at = Some(300_000); + }); + assert!(matches!(d.wait(true), CaptureWait::Abandon)); +} + +#[test] +fn status_read_crossing_absolute_deadline_discards_ready_frame() { + let d = begin(false, &[(0, Some(7), 512)]); + MOCK.with(|m| m.borrow_mut().status_cost = 300_001); + assert!(matches!(d.wait(true), CaptureWait::Timeout)); +} + +#[test] +fn late_initial_arming_cannot_reopen_the_progress_budget() { + both(|irq| { + let d = begin(irq, &[(0, Some(0), 0), (60_000, Some(17), 0), (60_500, Some(7), 512)]); + assert!(matches!(d.wait(true), CaptureWait::Timeout)); + assert_eq!(clock(), 60_000); + }); +} +#[test] +fn initial_arming_before_deadline_can_extend_idle_wait() { + both(|irq| { + let d = begin(irq, &[(0, Some(0), 0), (59_750, Some(17), 0), (185_500, Some(7), 512)]); + assert!(matches!(d.wait(true), CaptureWait::Ready(512))); + assert_eq!(clock(), 185_500); + }); +} +#[test] +fn status_read_crossing_arming_deadline_cannot_extend_idle_wait() { + both(|irq| { + let d = begin(irq, &[(0, Some(0), 0), (60_000, Some(17), 0), (60_500, Some(7), 512)]); + MOCK.with(|m| m.borrow_mut().status_cost = 60_001); + assert!(matches!(d.wait(true), CaptureWait::Timeout)); + assert_eq!(clock(), 60_001); + MOCK.with(|m| assert_eq!(m.borrow().reads, 1)); + }); +} +#[test] +fn expired_progress_cannot_be_hidden_by_return_to_armed() { + both(|irq| { + let deadline = 60_000 + if irq { 750 } else { 998 }; + let d = begin(irq, &[(0, Some(17), 0), (1_000, Some(19), 0), (deadline, Some(17), 0), (deadline + 500, Some(7), 512)]); + assert!(matches!(d.wait(true), CaptureWait::Timeout)); + assert_eq!(clock(), deadline); + }); +} +#[test] +fn ready_frame_at_progress_deadline_is_refused() { + both(|irq| { + let deadline = 60_000 + if irq { 750 } else { 998 }; + let d = begin(irq, &[(0, Some(17), 0), (1_000, Some(19), 0), (deadline, Some(7), 512)]); + assert!(matches!(d.wait(true), CaptureWait::Timeout)); + assert_eq!(clock(), deadline); + }); +} +#[test] +fn ready_frame_just_before_progress_deadline_is_accepted() { + both(|irq| { + let deadline = 60_000 + if irq { 750 } else { 998 }; + let ready = deadline - if irq { 250 } else { 2 }; + let d = begin(irq, &[(0, Some(17), 0), (1_000, Some(19), 0), (ready, Some(7), 512)]); + assert!(matches!(d.wait(true), CaptureWait::Ready(512))); + assert_eq!(clock(), ready); + }); +} +#[test] +fn enrollment_ready_frame_at_deadline_is_refused() { + both(|irq| { + let d = begin(irq, &[(0, Some(17), 0), (60_000, Some(7), 512)]); + assert!(matches!(d.wait(false), CaptureWait::Timeout)); + assert_eq!(clock(), 60_000); + }); +} +#[test] +fn enrollment_ready_frame_before_deadline_is_accepted() { + both(|irq| { + let d = begin(irq, &[(0, Some(17), 0), (59_750, Some(7), 512)]); + assert!(matches!(d.wait(false), CaptureWait::Ready(512))); + assert_eq!(clock(), 59_750); + }); +} +#[test] +fn status_read_crossing_enrollment_deadline_discards_ready_frame() { + both(|irq| { + let d = begin(irq, &[(0, Some(7), 512)]); + MOCK.with(|m| m.borrow_mut().status_cost = 60_001); + assert!(matches!(d.wait(false), CaptureWait::Timeout)); + assert_eq!(clock(), 60_001); + }); +} +#[test] +fn status_read_crossing_progress_deadline_discards_ready_frame() { + both(|irq| { + let d = begin(irq, &[(0, Some(17), 0), (60_000, Some(19), 0), (90_000, Some(7), 512)]); + MOCK.with(|m| m.borrow_mut().status_cost = 30_000); + assert!(matches!(d.wait(true), CaptureWait::Timeout)); + assert_eq!(clock(), 90_000 + if irq { 500 } else { 4 }); + }); +} +#[test] +fn cancellation_during_arming_deadline_read_takes_priority() { + both(|irq| { + let d = begin(irq, &[(0, Some(17), 0)]); + MOCK.with(|m| { + let mut m = m.borrow_mut(); + m.status_cost = 60_000; + m.cancel_at = Some(60_000); + }); + assert!(matches!(d.wait(true), CaptureWait::Abandon)); + }); +} diff --git a/tools/aurora-sep/tests/test_capture_wait.py b/tools/aurora-sep/tests/test_capture_wait.py new file mode 100644 index 00000000000000..4d0fcb526a7bd1 --- /dev/null +++ b/tools/aurora-sep/tests/test_capture_wait.py @@ -0,0 +1,111 @@ +# SPDX-License-Identifier: GPL-2.0-only OR MIT +"""Execute the production capture wait with synthetic time, SPI and IRQ leaves. + +No sensor is opened. Long idle windows take virtual time only. The timeout +mutation must compile and fail the delayed-finger assertion, not compilation. +""" +from pathlib import Path +import os +import re +import subprocess +import tempfile +import unittest + +APPLE = Path(__file__).resolve().parents[3] / 'drivers/soc/apple' +FIXTURE = Path(__file__).with_name('capture-wait.rs') + + +def item(source, signature): + start = source.index(signature) + end = source.index('{', start) + 1 + depth = 1 + while depth: + depth += (source[end] == '{') - (source[end] == '}') + end += 1 + return source[start:end] + + +def harness(mutation=None): + source = (APPLE / 'sbio.rs').read_text() + method = item(source, 'fn await_capture(') + sep = (APPLE / 'sep.rs').read_text() + constants = '\n'.join(re.findall(r'^const (?:ENROL_(?:POLL_MS|IRQ_WAIT_MS|CAPTURE_TIMEOUT_MS|POLL_ATTEMPTS)|VERIFY_CAPTURE_TIMEOUT_MS):[^;]+;', sep, re.M)) + kinds = item(sep, 'enum CaptureWait {') + # The unmodified parent compiles too, so the before-fix run tests behavior. + if 'enum CapturePurpose {' in sep: + kinds += '\n' + item(sep, 'enum CapturePurpose {') + call = 'self.await_capture(if verify { CapturePurpose::Verification } else { CapturePurpose::Enrollment })' + else: + call = 'self.await_capture()' + if mutation == 'idle-timeout': + old = 'let mut waiting_for_finger = matches!(purpose, CapturePurpose::Verification);' + if method.count(old) != 1: + raise AssertionError('timeout mutation no longer has one target') + method = method.replace(old, 'let mut waiting_for_finger = false;') + elif mutation == 'ignore-cancel': + old = 'if !bio::capture_is_live(' + if method.count(old) != 2: + raise AssertionError('cancellation mutation no longer has two checks') + method = method.replace(old, 'if false && !bio::capture_is_live(') + elif mutation == 'unbounded-verification': + old = 'if matches!(purpose, CapturePurpose::Verification) && now_ns >= verification_deadline_ns {' + if method.count(old) != 1: + raise AssertionError('absolute timeout mutation no longer has one target') + method = method.replace(old, 'if false {') + elif mutation == 'late-progress-expiry': + old = ' if now_ns >= deadline_ns {\n return CaptureWait::Timeout;\n }\n' + if method.count(old) != 1: + raise AssertionError('progress timeout mutation no longer has one target') + method = method.replace(old, '') + marker = ' if use_irq {\n // Block on the data-ready line' + if method.count(marker) != 1: + raise AssertionError('progress timeout mutation lost its late target') + method = method.replace(marker, old + '\n' + marker) + return (FIXTURE.read_text().replace('/* PRODUCTION */', constants + '\n' + kinds) + .replace('/* METHOD */', method) + .replace('/* CALL */', call)) + + +class CaptureWaitTests(unittest.TestCase): + def run_variant(self, mutation=None): + with tempfile.TemporaryDirectory(prefix='sep-capture-wait-') as name: + path = Path(name) + source = path / 'wait.rs' + source.write_text(harness(mutation)) + built = subprocess.run([os.environ.get('RUSTC', 'rustc'), '--edition=2021', + '--test', str(source), '-o', str(path / 'tests')], + text=True, capture_output=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stderr) + result = subprocess.run([str(path / 'tests'), '--test-threads=1'], + text=True, capture_output=True, timeout=30) + if mutation: + self.assertEqual(result.returncode, 101, result.stdout + result.stderr) + expected = { + 'idle-timeout': 'delayed_finger_stays_in_one_capture', + 'ignore-cancel': 'cancellation_and_epoch_change_are_bounded', + 'unbounded-verification': 'unattended_verification_has_absolute_upper_bound', + 'late-progress-expiry': 'late_initial_arming_cannot_reopen_the_progress_budget', + }[mutation] + self.assertIn('test ' + expected + ' ... FAILED', result.stdout) + else: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('31 passed; 0 failed; 0 ignored', result.stdout) + + def test_production(self): + self.run_variant() + + def test_timeout_negative_control(self): + self.run_variant('idle-timeout') + + def test_cancellation_negative_control(self): + self.run_variant('ignore-cancel') + + def test_absolute_timeout_negative_control(self): + self.run_variant('unbounded-verification') + + def test_progress_expiry_order_negative_control(self): + self.run_variant('late-progress-expiry') + + +if __name__ == '__main__': + unittest.main()