From 782f0ae7fa9cfe63befc5df7dfc3a02a50eb62f8 Mon Sep 17 00:00:00 2001 From: Savely Krasovsky Date: Mon, 10 Aug 2026 03:27:50 +0200 Subject: [PATCH 1/2] feat(emu): add deterministic delayed presence and conformance support --- CHANGELOG.md | 8 ++++ scripts/check.sh | 2 + tools/emu/Cargo.toml | 3 ++ tools/emu/README.md | 21 +++++++++ tools/emu/src/device.rs | 6 +-- tools/emu/src/hid.rs | 92 +++++++++++++++++++++++++++++++++---- tools/emu/src/main.rs | 31 +++++++++++-- tools/emu/src/presence.rs | 96 +++++++++++++++++++++++++++------------ 8 files changed, 214 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0405f46..0a4bf20b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,14 @@ tag: the USB `bcdDevice` build counter (bumped on every behavior change), and a non-converging backend returns `CTAP2_ERR_OTHER` instead of wedging the worker. **bcdDevice → 0x0876.** +### Added + +- **Deterministic delayed presence for `rsk-emu`.** `--auto-touch-ms` exposes a + real pending-presence interval to CTAPHID clients, honours channel-scoped + cancellation, and then confirms automatically. The emulator workspace also + forwards the `rsk-fido/fido-conformance` feature for unattended conformance + runs against the socket applet stack. + ## [0.4.9] - 2026-08-09 The emulator release: `tools/emu` runs the applet stack on the host, and with it diff --git a/scripts/check.sh b/scripts/check.sh index 602c2099..4c406157 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -125,6 +125,7 @@ run "test (tui)" cargo test --manifest-path tools/tui/Cargo.toml - # protocol suites without a board, which is exactly when they have no board. run "fmt (emu)" cargo fmt --manifest-path tools/emu/Cargo.toml --check run "clippy (emu)" cargo clippy --manifest-path tools/emu/Cargo.toml --target "$HOST" --all-targets -- -D warnings +run "clippy (emu conformance)" cargo clippy --manifest-path tools/emu/Cargo.toml --target "$HOST" --all-targets --features fido-conformance -- -D warnings # fuzz/ is also its own (nightly) workspace. rustfmt needs no toolchain, so the # stable gate can format-check it here; building/clippy stay in the .#fuzz shell # (deep-checks CI). Format fuzz/ with this same stable rustfmt — not the .#fuzz @@ -234,6 +235,7 @@ run "cargo-audit (tui SCA)" cargo audit --file tools/tui/Cargo.lock # the Linux kernel's and whose framing rule decides how many bytes come off the # socket next; both fail silently on the wire rather than loudly. run "test (emu)" cargo test --manifest-path tools/emu/Cargo.toml --target "$HOST" +run "test (emu conformance)" cargo test --manifest-path tools/emu/Cargo.toml --target "$HOST" --features fido-conformance run "cargo-audit (emu SCA)" cargo audit --file tools/emu/Cargo.lock --ignore RUSTSEC-2023-0071 run "cargo-deny" cargo deny check # Supply-chain provenance-of-review: every dependency must be covered by an diff --git a/tools/emu/Cargo.toml b/tools/emu/Cargo.toml index 65d9aa8d..743a56c7 100644 --- a/tools/emu/Cargo.toml +++ b/tools/emu/Cargo.toml @@ -12,6 +12,9 @@ description = "rs-key software emulator — the applet stack on a socket, no har name = "rsk-emu" path = "src/main.rs" +[features] +fido-conformance = ["rsk-fido/fido-conformance"] + # Only the in-tree crates: the emulator is the same applet code the firmware # runs, wired to a socket instead of USB. No external dependency joins an # authenticator's build for a dev tool — randomness comes from `/dev/urandom` diff --git a/tools/emu/README.md b/tools/emu/README.md index 2a148a41..7f046ed5 100644 --- a/tools/emu/README.md +++ b/tools/emu/README.md @@ -20,6 +20,7 @@ cargo run --manifest-path tools/emu/Cargo.toml --target "$HOST" -- --store ./my. --ccid-port APDU/card port, 0 disables (default 7800) --store the flash image to mount (default: a blank chip, memory only) --touch ask for every user presence on the terminal + --auto-touch-ms report presence pending, then approve after n milliseconds --display open the trusted display in a window (SDL2); presence becomes an on-screen hold, as on a screen board --usbip [addr] serve USB/IP (default 127.0.0.1:3240) so a Linux host can @@ -33,6 +34,26 @@ cargo run --manifest-path tools/emu/Cargo.toml --target "$HOST" -- --store ./my. --power-cut cut the flash's power after n bytes of writes ``` +`--auto-touch-ms` is mutually exclusive with `--touch` and `--display`. During +the delay the CTAPHID endpoint reports `UPNEEDED`; a `CANCEL` on the active +channel ends the operation before it is approved. This mode is intended for +deterministic conformance runs that need to observe keepalive and cancellation. + +Build the emulator with the conformance-specific FIDO feature and run it with +delayed presence like this: + +```bash +HOST="$(rustc -vV | sed -n 's/^host: //p')" +cargo build --manifest-path tools/emu/Cargo.toml --target "$HOST" \ + --features fido-conformance +tools/emu/target/"$HOST"/debug/rsk-emu \ + --store ./conformance.store --fido-port 7799 --ccid-port 7800 \ + --auto-touch-ms 250 --trace +``` + +The `fido-conformance` feature forwards to `rsk-fido/fido-conformance`, which +uses the authenticator profile intended for the upstream FIDO corpus. + ## Running the on-device suites against it ```bash diff --git a/tools/emu/src/device.rs b/tools/emu/src/device.rs index 2b26a2d5..11be874a 100644 --- a/tools/emu/src/device.rs +++ b/tools/emu/src/device.rs @@ -29,7 +29,7 @@ use rsk_device::{AppletHandler, BootState, CcidApplets, Hooks}; use rsk_fs::Fs; use crate::platform::EmuPlatform; -use crate::presence::EmuPresence; +use crate::presence::{EmuPresence, PresenceMode}; use crate::rng::EmuRng; use crate::signals::{self, Signals}; use crate::store::EmuStore; @@ -93,7 +93,7 @@ impl Hooks for EmuHooks { pub struct Config { pub store: Option, - pub touch: bool, + pub presence: PresenceMode, /// Serve the trusted display in a window; presence becomes an on-screen hold. pub display: bool, /// Serve USB/IP on this address, so a Linux host sees a real USB device. @@ -212,7 +212,7 @@ pub fn run( serve(cfg, jobs, signals, fs, rng, &presence), )); } else { - let presence = RefCell::new(EmuPresence::new(cfg.touch, lines, signals.clone())); + let presence = RefCell::new(EmuPresence::new(cfg.presence, lines, signals.clone())); crate::park::block_on(serve(cfg, jobs, signals, fs, rng, &presence)); } } diff --git a/tools/emu/src/hid.rs b/tools/emu/src/hid.rs index b4eb012e..abd8fa20 100644 --- a/tools/emu/src/hid.rs +++ b/tools/emu/src/hid.rs @@ -180,27 +180,101 @@ fn run_job( let _ = rx.recv(); return Ok(None); }; + + let previous_timeout = stream.read_timeout()?; + let result = run_active_job(stream, shared, &rx, cid, is_cbor); + let restore = stream.set_read_timeout(previous_timeout); + match (result, restore) { + (Err(error), _) => Err(error), + (Ok(_), Err(error)) => Err(error), + (Ok(out), Ok(())) => Ok(out), + } +} + +fn run_active_job( + stream: &mut TcpStream, + shared: &Arc, + rx: &mpsc::Receiver>>, + cid: u32, + is_cbor: bool, +) -> io::Result>> { + let poll = Duration::from_millis(50); + let keepalive = Duration::from_millis(KEEPALIVE_MS); + let mut next_keepalive = Instant::now() + keepalive; + let mut watch = [0u8; HID_RPT_SIZE]; + let mut watched = 0; + let mut reading = false; + loop { - match rx.recv_timeout(Duration::from_millis(KEEPALIVE_MS)) { + match rx.try_recv() { Ok(out) => return Ok(out), - Err(RecvTimeoutError::Timeout) => { - if let Some(status) = keepalive_status( - is_cbor, - shared.signals.up_pending_for(crate::signals::SCOPE_FIDO), - ) { - write_msg(stream, cid, CTAPHID_KEEPALIVE, &[status])?; + Err(mpsc::TryRecvError::Disconnected) => { + return Err(io::Error::other("the device thread dropped the job")); + } + Err(mpsc::TryRecvError::Empty) => {} + } + + let now = Instant::now(); + if now >= next_keepalive { + if let Some(status) = keepalive_status( + is_cbor, + shared.signals.up_pending_for(crate::signals::SCOPE_FIDO), + ) { + write_msg(stream, cid, CTAPHID_KEEPALIVE, &[status])?; + } + next_keepalive = now + keepalive; + } + + if !shared.signals.up_pending_for(crate::signals::SCOPE_FIDO) { + let wait = next_keepalive.saturating_duration_since(Instant::now()); + match rx.recv_timeout(wait) { + Ok(out) => return Ok(out), + Err(RecvTimeoutError::Timeout) => continue, + Err(RecvTimeoutError::Disconnected) => { + return Err(io::Error::other("the device thread dropped the job")); } } - Err(RecvTimeoutError::Disconnected) => { - return Err(io::Error::other("the device thread dropped the job")); + } + + if !reading { + stream.set_read_timeout(Some(poll))?; + reading = true; + } + match stream.read(&mut watch[watched..]) { + Ok(0) => return Err(io::Error::from(io::ErrorKind::UnexpectedEof)), + Ok(n) => { + watched += n; + if watched == HID_RPT_SIZE { + if is_cancel_report(&watch, cid) { + shared.signals.request_cancel(cid); + } + watched = 0; + } } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::WouldBlock + | io::ErrorKind::TimedOut + | io::ErrorKind::Interrupted + ) => {} + Err(error) => return Err(error), } } } +fn is_cancel_report(frame: &[u8; HID_RPT_SIZE], cid: u32) -> bool { + frame[4] == CTAPHID_CANCEL + && u32::from_le_bytes([frame[0], frame[1], frame[2], frame[3]]) == cid +} + fn write_msg(stream: &mut TcpStream, cid: u32, cmd: u8, data: &[u8]) -> io::Result<()> { for f in TxFrames::new(cid, cmd, data) { stream.write_all(&f)?; } stream.flush() } + +#[cfg(test)] +#[path = "hid_tests.rs"] +mod tests; diff --git a/tools/emu/src/main.rs b/tools/emu/src/main.rs index 06438650..4221d66c 100644 --- a/tools/emu/src/main.rs +++ b/tools/emu/src/main.rs @@ -36,6 +36,7 @@ use std::sync::{Arc, Mutex}; use std::time::Instant; use device::Config; +use presence::PresenceMode; use signals::Signals; const DEFAULT_FIDO_PORT: u16 = 7799; @@ -61,6 +62,7 @@ usage: rsk-emu [options] --ccid-port APDU/card port, 0 disables (default 7800) --store persist the file system here (default: memory only) --touch ask for every user presence on the terminal + --auto-touch-ms mark presence pending, then approve it after n milliseconds --display open the trusted display in a window; presence is an on-screen hold, exactly as on a screen board --screenshots write the docs' display screens as PNGs and exit @@ -84,7 +86,7 @@ fn main() { let mut ccid_port = DEFAULT_CCID_PORT; let mut cfg = Config { store: None, - touch: false, + presence: PresenceMode::Instant, display: false, usbip: None, seed: None, @@ -95,6 +97,8 @@ fn main() { yubico: false, power_cut: None, }; + let mut touch = false; + let mut auto_touch = None; let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { @@ -111,7 +115,8 @@ fn main() { "--fido-port" => fido_port = parse_port(&value("--fido-port")), "--ccid-port" => ccid_port = parse_port(&value("--ccid-port")), "--store" => cfg.store = Some(value("--store").into()), - "--touch" => cfg.touch = true, + "--touch" => touch = true, + "--auto-touch-ms" => auto_touch = Some(parse_millis(&value("--auto-touch-ms"))), "--display" => cfg.display = true, // Renders and exits: no store, no sockets, nothing to serve. "--screenshots" => shots::run(&value("--screenshots")), @@ -136,6 +141,16 @@ fn main() { if fido_port == 0 && ccid_port == 0 { die("both transports are disabled — nothing to serve"); } + if auto_touch.is_some() && (touch || cfg.display) { + die("--auto-touch-ms is mutually exclusive with --touch and --display"); + } + cfg.presence = if touch { + PresenceMode::Terminal + } else if let Some(delay) = auto_touch { + PresenceMode::Delayed(delay) + } else { + PresenceMode::Instant + }; if cfg.seed.is_some() { eprintln!("emu: DETERMINISTIC SEED — every key this run mints is predictable"); } @@ -146,7 +161,7 @@ fn main() { // The terminal is the only input the prompt has, so it is read once, here, // and handed to the device thread — two readers of stdin would race for the // same line. - let lines = cfg.touch.then(|| { + let lines = (cfg.presence == PresenceMode::Terminal).then(|| { let (tx, rx) = mpsc::channel(); std::thread::spawn(move || { for line in std::io::stdin().lock().lines().map_while(Result::ok) { @@ -233,6 +248,16 @@ fn parse_port(s: &str) -> u16 { .unwrap_or_else(|_| die(&format!("not a port: {s:?}"))) } +fn parse_millis(s: &str) -> std::time::Duration { + let millis: u64 = s + .parse() + .unwrap_or_else(|_| die(&format!("not a positive millisecond delay: {s:?}"))); + if millis == 0 { + die("auto-touch delay must be positive"); + } + std::time::Duration::from_millis(millis) +} + /// Decode hex, optionally demanding an exact byte length. fn parse_hex(s: &str, want: Option) -> Vec { let s = s.strip_prefix("0x").unwrap_or(s); diff --git a/tools/emu/src/presence.rs b/tools/emu/src/presence.rs index 3bd7904e..5ecee1f3 100644 --- a/tools/emu/src/presence.rs +++ b/tools/emu/src/presence.rs @@ -1,9 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 RS-Key contributors -//! User presence for the emulator: either instant (the default, matching a -//! no-touch test image) or a prompt on the terminal that waits for a line on -//! stdin. +//! User presence for the emulator: instant, prompted on the terminal, or +//! deterministically confirmed after a delay. //! //! The prompt prints the [`Confirm`] context — the trusted title plus the //! relying party's untrusted strings — because a terminal is the closest thing @@ -12,7 +11,7 @@ use std::sync::Arc; use std::sync::mpsc::{Receiver, RecvTimeoutError}; -use std::time::Duration; +use std::time::{Duration, Instant}; use rsk_sdk::Confirm; @@ -25,8 +24,15 @@ const TOUCH_TIMEOUT: Duration = Duration::from_secs(30); /// Poll the cancel flag this often while waiting for a line. const POLL: Duration = Duration::from_millis(50); -/// The emulator's own presence verdict, mapped to each crate's `Presence` below. #[derive(Clone, Copy, PartialEq, Eq)] +pub enum PresenceMode { + Instant, + Terminal, + Delayed(Duration), +} + +/// The emulator's own presence verdict, mapped to each crate's `Presence` below. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Verdict { Confirmed, Timeout, @@ -35,29 +41,24 @@ enum Verdict { } pub struct EmuPresence { - prompt: bool, + mode: PresenceMode, lines: Option>, signals: Arc, } impl EmuPresence { - /// `prompt = false` confirms instantly (the `AlwaysConfirm` behaviour host - /// tests and a no-button build already have); `true` waits on `lines`. - pub fn new(prompt: bool, lines: Option>, signals: Arc) -> Self { + pub fn new(mode: PresenceMode, lines: Option>, signals: Arc) -> Self { Self { - prompt, + mode, lines, signals, } } fn ask(&mut self, confirm: Confirm<'_>) -> Verdict { - if !self.prompt { + if self.mode == PresenceMode::Instant { return Verdict::Confirmed; } - let Some(lines) = &self.lines else { - return Verdict::Confirmed; - }; eprintln!("\n┌─ {} ─────────────", confirm.title); if !confirm.primary.is_empty() { eprintln!("│ {}", printable(confirm.primary)); @@ -65,29 +66,62 @@ impl EmuPresence { if !confirm.secondary.is_empty() { eprintln!("│ {}", printable(confirm.secondary)); } - eprintln!("└─ [Enter] approve · [d] deny"); + match self.mode { + PresenceMode::Terminal => eprintln!("└─ [Enter] approve · [d] deny"), + PresenceMode::Delayed(delay) => { + eprintln!("└─ auto-approve in {} ms", delay.as_millis()) + } + PresenceMode::Instant => unreachable!(), + } self.signals.set_up_pending(true); - let deadline = std::time::Instant::now() + TOUCH_TIMEOUT; - let verdict = loop { + let verdict = match self.mode { + PresenceMode::Terminal => self.wait_for_terminal(), + PresenceMode::Delayed(delay) => self.wait_for_delay(delay), + PresenceMode::Instant => unreachable!(), + }; + self.signals.set_up_pending(false); + verdict + } + + fn wait_for_terminal(&self) -> Verdict { + let lines = self + .lines + .as_ref() + .expect("terminal presence requires a stdin receiver"); + let deadline = Instant::now() + TOUCH_TIMEOUT; + loop { if self.signals.cancelled() { eprintln!(" … cancelled by the host"); - break Verdict::Cancelled; + return Verdict::Cancelled; } - if std::time::Instant::now() >= deadline { + if Instant::now() >= deadline { eprintln!(" … timed out"); - break Verdict::Timeout; + return Verdict::Timeout; } match lines.recv_timeout(POLL) { - Ok(l) if l.trim().eq_ignore_ascii_case("d") => break Verdict::Declined, - Ok(_) => break Verdict::Confirmed, + Ok(l) if l.trim().eq_ignore_ascii_case("d") => return Verdict::Declined, + Ok(_) => return Verdict::Confirmed, Err(RecvTimeoutError::Timeout) => continue, // stdin closed: nothing can ever answer, so stop pretending. - Err(RecvTimeoutError::Disconnected) => break Verdict::Timeout, + Err(RecvTimeoutError::Disconnected) => return Verdict::Timeout, } - }; - self.signals.set_up_pending(false); - verdict + } + } + + fn wait_for_delay(&self, delay: Duration) -> Verdict { + let deadline = Instant::now() + delay; + loop { + if self.signals.cancelled() { + eprintln!(" … cancelled by the host"); + return Verdict::Cancelled; + } + let now = Instant::now(); + if now >= deadline { + return Verdict::Confirmed; + } + std::thread::sleep(POLL.min(deadline - now)); + } } } @@ -117,11 +151,9 @@ impl rsk_fido::UserPresence for EmuPresence { } } - /// The terminal prompt does name the operation, so a `--touch` run is the - /// confirm-showing kind of authenticator CTAP 2.1 §6.6 exempts from the - /// reset window; an auto-confirming one is not. + /// Delayed auto-touch emits the same named confirmation as terminal mode. fn shows_confirm(&self) -> bool { - self.prompt + self.mode != PresenceMode::Instant } } @@ -186,3 +218,7 @@ impl rsk_rescue::UserPresence for EmuPresence { } } } + +#[cfg(test)] +#[path = "presence_tests.rs"] +mod tests; From 7743be51638fdfcf58e3d7cadcf2f260d3212b63 Mon Sep 17 00:00:00 2001 From: Savely Krasovsky Date: Mon, 10 Aug 2026 03:55:46 +0200 Subject: [PATCH 2/2] feat(emu): add presence and HID tests for signal handling and cancellations --- tools/emu/src/hid_tests.rs | 78 +++++++++++++++++++++++++++++ tools/emu/src/presence_tests.rs | 88 +++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 tools/emu/src/hid_tests.rs create mode 100644 tools/emu/src/presence_tests.rs diff --git a/tools/emu/src/hid_tests.rs b/tools/emu/src/hid_tests.rs new file mode 100644 index 00000000..f0494f37 --- /dev/null +++ b/tools/emu/src/hid_tests.rs @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 RS-Key contributors + +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::{Duration, Instant}; + +use rsk_usb::ctaphid::{ + CTAPHID_CANCEL, CTAPHID_KEEPALIVE, ChannelLock, CidAllocator, HID_RPT_SIZE, +}; + +use super::{Shared, run_active_job}; +use crate::signals::{SCOPE_FIDO, Signals}; + +#[test] +fn active_job_streams_upneeded_and_reads_fragmented_scoped_cancel() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let signals = Arc::new(Signals::default()); + signals.set_wait_scope(SCOPE_FIDO); + let cid = 0x0102_0304; + signals.begin(cid); + signals.set_up_pending(true); + let (jobs, _requests) = mpsc::channel(); + let shared = Arc::new(Shared { + jobs, + signals: signals.clone(), + cids: Mutex::new(CidAllocator::new()), + lock: Mutex::new(ChannelLock::default()), + boot: Instant::now(), + }); + let (reply, replies) = mpsc::channel(); + let worker_signals = signals.clone(); + std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(2); + while !worker_signals.cancelled() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + reply.send(Some(vec![0x2d])).unwrap(); + }); + + let (server_result, result) = mpsc::channel(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + server_result + .send(run_active_job(&mut stream, &shared, &replies, cid, true)) + .unwrap(); + }); + + let mut client = TcpStream::connect(address).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut keepalive = [0u8; HID_RPT_SIZE]; + client.read_exact(&mut keepalive).unwrap(); + assert_eq!(keepalive[4], CTAPHID_KEEPALIVE); + assert_eq!(keepalive[7], 0x02, "presence must report UPNEEDED"); + + let mut cancel = [0u8; HID_RPT_SIZE]; + cancel[..4].copy_from_slice(&0x0506_0708u32.to_le_bytes()); + cancel[4] = CTAPHID_CANCEL; + client.write_all(&cancel).unwrap(); + client.read_exact(&mut keepalive).unwrap(); + + cancel[..4].copy_from_slice(&cid.to_le_bytes()); + client.write_all(&cancel[..13]).unwrap(); + client.write_all(&cancel[13..]).unwrap(); + + assert_eq!( + result + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .unwrap(), + Some(vec![0x2d]) + ); + assert!(signals.cancelled()); +} diff --git a/tools/emu/src/presence_tests.rs b/tools/emu/src/presence_tests.rs new file mode 100644 index 00000000..edd1ad1b --- /dev/null +++ b/tools/emu/src/presence_tests.rs @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (C) 2026 RS-Key contributors + +use std::sync::Arc; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use rsk_sdk::Confirm; + +use super::{EmuPresence, PresenceMode, Verdict}; +use crate::signals::{SCOPE_FIDO, Signals}; + +#[test] +fn instant_presence_confirms_without_pending_signal() { + let signals = Arc::new(Signals::default()); + signals.set_wait_scope(SCOPE_FIDO); + let mut presence = EmuPresence::new(PresenceMode::Instant, None, signals.clone()); + + assert_eq!(presence.ask(Confirm::titled("Test?")), Verdict::Confirmed); + assert!(!signals.up_pending_for(SCOPE_FIDO)); + assert!(!rsk_fido::UserPresence::shows_confirm(&presence)); +} + +#[test] +fn terminal_presence_accepts_and_declines_lines() { + for (line, want) in [("", Verdict::Confirmed), ("d", Verdict::Declined)] { + let (tx, rx) = mpsc::channel(); + tx.send(line.to_owned()).unwrap(); + let mut presence = EmuPresence::new( + PresenceMode::Terminal, + Some(rx), + Arc::new(Signals::default()), + ); + + assert_eq!(presence.ask(Confirm::titled("Test?")), want); + assert!(rsk_fido::UserPresence::shows_confirm(&presence)); + } +} + +#[test] +fn delayed_presence_marks_pending_and_auto_confirms() { + let signals = Arc::new(Signals::default()); + signals.set_wait_scope(SCOPE_FIDO); + signals.begin(0x0102_0304); + let delay = Duration::from_millis(25); + let mut presence = EmuPresence::new(PresenceMode::Delayed(delay), None, signals.clone()); + + let started = Instant::now(); + assert_eq!(presence.ask(Confirm::titled("Test?")), Verdict::Confirmed); + assert!(started.elapsed() >= delay); + assert!(!signals.up_pending_for(SCOPE_FIDO)); + assert!(rsk_fido::UserPresence::shows_confirm(&presence)); +} + +#[test] +fn delayed_presence_honours_scoped_cancel_before_confirmation() { + let signals = Arc::new(Signals::default()); + signals.set_wait_scope(SCOPE_FIDO); + signals.begin(0x0102_0304); + let mut presence = EmuPresence::new( + PresenceMode::Delayed(Duration::from_millis(500)), + None, + signals.clone(), + ); + let (result_tx, result_rx) = mpsc::channel(); + std::thread::spawn(move || { + result_tx + .send(presence.ask(Confirm::titled("Test?"))) + .unwrap(); + }); + + for _ in 0..100 { + if signals.up_pending_for(SCOPE_FIDO) { + break; + } + std::thread::sleep(Duration::from_millis(1)); + } + assert!(signals.up_pending_for(SCOPE_FIDO)); + + signals.request_cancel(0x0506_0708); + assert!(result_rx.recv_timeout(Duration::from_millis(75)).is_err()); + signals.request_cancel(0x0102_0304); + assert_eq!( + result_rx.recv_timeout(Duration::from_millis(100)).unwrap(), + Verdict::Cancelled + ); + assert!(!signals.up_pending_for(SCOPE_FIDO)); +}