use std::time::Duration;
use vta_sdk::error::VtaError;
const POLL_INTERVAL: Duration = Duration::from_secs(3);
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
#[derive(Debug)]
pub enum ConsentOutcome {
Resolved,
TimedOut,
}
fn announce(payload_digest: &str, approver_set: &str, min_approvals: u32, exclude_requester: bool) {
eprintln!();
eprintln!(" Approval required before this can run.");
eprintln!();
eprintln!(" code: {payload_digest}");
eprintln!();
if exclude_requester {
eprintln!(
" {min_approvals} approval(s) needed from `{approver_set}`, and this device is not \
eligible to give them — your policy requires a different device."
);
eprintln!(" Check the code above matches the one on your approving device, then approve");
eprintln!(" it there. Do not approve a code that differs: a mismatch means the change");
eprintln!(" being shown is not the change that would be made.");
} else {
eprintln!(
" {min_approvals} approval(s) needed from `{approver_set}`. This device may give \
one if its DID is a member of that set."
);
eprintln!(" Approve on whichever enrolled device is showing this code.");
}
eprintln!();
eprintln!(" Waiting… (Ctrl-C to stop; the request stays pending and re-running resumes it)");
}
pub async fn with_consent<F, Fut, T>(submit: F) -> Result<Result<T, ConsentOutcome>, VtaError>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<T, VtaError>>,
{
with_consent_timeout(submit, DEFAULT_TIMEOUT).await
}
pub async fn with_consent_timeout<F, Fut, T>(
submit: F,
timeout: Duration,
) -> Result<Result<T, ConsentOutcome>, VtaError>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<T, VtaError>>,
{
let waiting_on = match submit().await {
Ok(value) => return Ok(Ok(value)),
Err(VtaError::ConsentRequired {
payload_digest,
challenge,
approver_set,
min_approvals,
exclude_requester,
}) => {
announce(
&payload_digest,
&approver_set,
min_approvals,
exclude_requester,
);
challenge
}
Err(other) => return Err(other),
};
let deadline = tokio::time::Instant::now() + timeout;
loop {
if tokio::time::Instant::now() >= deadline {
return Ok(Err(ConsentOutcome::TimedOut));
}
tokio::time::sleep(POLL_INTERVAL).await;
match submit().await {
Ok(value) => return Ok(Ok(value)),
Err(VtaError::ConsentRequired { challenge, .. }) if challenge == waiting_on => {
}
Err(VtaError::ConsentRequired { .. }) => {
return Ok(Err(ConsentOutcome::Resolved));
}
Err(other) => return Err(other),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn consent_required(challenge: &str) -> VtaError {
VtaError::ConsentRequired {
payload_digest: "ABC123".into(),
challenge: challenge.into(),
approver_set: "webvh-approvers".into(),
min_approvals: 1,
exclude_requester: true,
}
}
#[tokio::test(start_paused = true)]
async fn an_approval_lets_the_task_through() {
let calls = AtomicUsize::new(0);
let out = with_consent(|| async {
match calls.fetch_add(1, Ordering::SeqCst) {
0 | 1 => Err(consent_required("chal-1")),
_ => Ok("published"),
}
})
.await
.expect("no transport error");
assert!(matches!(out, Ok("published")));
assert_eq!(calls.load(Ordering::SeqCst), 3, "polled until approved");
}
#[tokio::test(start_paused = true)]
async fn an_ungated_task_runs_immediately() {
let calls = AtomicUsize::new(0);
let out = with_consent(|| async {
calls.fetch_add(1, Ordering::SeqCst);
Ok::<_, VtaError>("published")
})
.await
.expect("no transport error");
assert!(matches!(out, Ok("published")));
assert_eq!(calls.load(Ordering::SeqCst), 1, "submitted exactly once");
}
#[tokio::test(start_paused = true)]
async fn a_changed_challenge_stops_the_loop() {
let calls = AtomicUsize::new(0);
let out = with_consent(|| async {
match calls.fetch_add(1, Ordering::SeqCst) {
0 => Err::<(), _>(consent_required("chal-1")),
_ => Err(consent_required("chal-2")),
}
})
.await
.expect("no transport error");
assert!(matches!(out, Err(ConsentOutcome::Resolved)));
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"stopped on the first changed challenge — no further prompts"
);
}
#[tokio::test(start_paused = true)]
async fn waiting_is_bounded() {
let calls = AtomicUsize::new(0);
let out = with_consent_timeout(
|| async {
calls.fetch_add(1, Ordering::SeqCst);
Err::<(), _>(consent_required("chal-1"))
},
Duration::from_secs(10),
)
.await
.expect("no transport error");
assert!(matches!(out, Err(ConsentOutcome::TimedOut)));
}
#[tokio::test(start_paused = true)]
async fn a_non_consent_error_surfaces_immediately() {
let calls = AtomicUsize::new(0);
let err = with_consent(|| async {
match calls.fetch_add(1, Ordering::SeqCst) {
0 => Err::<(), _>(consent_required("chal-1")),
_ => Err(VtaError::Protocol("the DID vanished".into())),
}
})
.await
.expect_err("a transport error must propagate");
assert!(matches!(err, VtaError::Protocol(_)));
}
}