pub const NONCE_LEN: usize = 6;
const NONCE_ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
pub fn nonce_from_bytes(bytes: &[u8; NONCE_LEN]) -> String {
bytes
.iter()
.map(|b| NONCE_ALPHABET[*b as usize % NONCE_ALPHABET.len()] as char)
.collect()
}
pub fn fingerprint(tool: &str, args: &serde_json::Value) -> String {
let mut stripped = args.clone();
if let Some(map) = stripped.as_object_mut() {
map.remove("confirmation");
}
format!("{tool}:{stripped}")
}
#[derive(Debug, PartialEq, Eq)]
pub enum ConfirmOutcome {
Challenge {
nonce: String,
},
Approved,
NotTypedByUser,
}
struct Pending {
fingerprint: String,
nonce: String,
}
#[derive(Default)]
pub struct ConfirmGate {
pending: Option<Pending>,
}
impl ConfirmGate {
pub fn new() -> Self {
Self::default()
}
pub fn check(
&mut self,
fingerprint: &str,
confirmation: &str,
last_user_msg: &str,
fresh_nonce: String,
) -> ConfirmOutcome {
let supplied = confirmation.trim();
let matches_pending = self
.pending
.as_ref()
.is_some_and(|p| {
p.fingerprint == fingerprint
&& !supplied.is_empty()
&& supplied.eq_ignore_ascii_case(&p.nonce)
});
if !matches_pending {
self.pending = Some(Pending {
fingerprint: fingerprint.to_string(),
nonce: fresh_nonce.clone(),
});
return ConfirmOutcome::Challenge { nonce: fresh_nonce };
}
let nonce_upper = supplied.to_ascii_uppercase();
if last_user_msg.to_ascii_uppercase().contains(&nonce_upper) {
self.pending = None; ConfirmOutcome::Approved
} else {
ConfirmOutcome::NotTypedByUser
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fp(name: &str) -> String {
fingerprint(
"release_subdomain",
&serde_json::json!({ "name": name, "confirmation": "anything" }),
)
}
#[test]
fn fingerprint_ignores_confirmation_arg() {
let a = fingerprint(
"release_subdomain",
&serde_json::json!({ "name": "fsmoke", "confirmation": "" }),
);
let b = fingerprint(
"release_subdomain",
&serde_json::json!({ "name": "fsmoke", "confirmation": "ABC234" }),
);
assert_eq!(a, b);
let c = fingerprint(
"release_subdomain",
&serde_json::json!({ "name": "other", "confirmation": "ABC234" }),
);
assert_ne!(a, c);
}
#[test]
fn first_call_issues_challenge() {
let mut gate = ConfirmGate::new();
let out = gate.check(&fp("fsmoke"), "", "please release fsmoke", "AAAAAA".into());
assert_eq!(out, ConfirmOutcome::Challenge { nonce: "AAAAAA".into() });
}
#[test]
fn auto_filled_confirmation_cannot_skip_the_challenge() {
let mut gate = ConfirmGate::new();
let out = gate.check(&fp("fsmoke"), "fsmoke", "please release fsmoke", "AAAAAA".into());
assert_eq!(out, ConfirmOutcome::Challenge { nonce: "AAAAAA".into() });
}
#[test]
fn wrong_nonce_rejected_and_reissues() {
let mut gate = ConfirmGate::new();
gate.check(&fp("fsmoke"), "", "msg", "AAAAAA".into());
let out = gate.check(&fp("fsmoke"), "ZZZZZZ", "ZZZZZZ", "BBBBBB".into());
assert_eq!(out, ConfirmOutcome::Challenge { nonce: "BBBBBB".into() });
let out = gate.check(&fp("fsmoke"), "AAAAAA", "AAAAAA", "CCCCCC".into());
assert_eq!(out, ConfirmOutcome::Challenge { nonce: "CCCCCC".into() });
}
#[test]
fn args_mismatch_rejected() {
let mut gate = ConfirmGate::new();
gate.check(&fp("fsmoke"), "", "msg", "AAAAAA".into());
let out = gate.check(&fp("other"), "AAAAAA", "AAAAAA", "BBBBBB".into());
assert_eq!(out, ConfirmOutcome::Challenge { nonce: "BBBBBB".into() });
}
#[test]
fn model_echo_without_user_typing_is_rejected_then_user_typed_succeeds() {
let mut gate = ConfirmGate::new();
gate.check(&fp("fsmoke"), "", "please release fsmoke", "AAAAAA".into());
let out = gate.check(&fp("fsmoke"), "AAAAAA", "please release fsmoke", "BBBBBB".into());
assert_eq!(out, ConfirmOutcome::NotTypedByUser);
let out = gate.check(&fp("fsmoke"), "AAAAAA", "ok: aaaaaa", "CCCCCC".into());
assert_eq!(out, ConfirmOutcome::Approved);
}
#[test]
fn happy_path_then_single_use() {
let mut gate = ConfirmGate::new();
gate.check(&fp("fsmoke"), "", "please release fsmoke", "AAAAAA".into());
let out = gate.check(&fp("fsmoke"), "AAAAAA", "AAAAAA", "BBBBBB".into());
assert_eq!(out, ConfirmOutcome::Approved);
let out = gate.check(&fp("fsmoke"), "AAAAAA", "AAAAAA", "CCCCCC".into());
assert_eq!(out, ConfirmOutcome::Challenge { nonce: "CCCCCC".into() });
}
#[test]
fn nonce_from_bytes_maps_into_alphabet() {
let nonce = nonce_from_bytes(&[0, 30, 31, 61, 200, 255]);
assert_eq!(nonce.len(), NONCE_LEN);
for c in nonce.bytes() {
assert!(NONCE_ALPHABET.contains(&c), "char {c} outside alphabet");
}
for banned in [b'I', b'L', b'O', b'0', b'1'] {
assert!(!NONCE_ALPHABET.contains(&banned));
}
}
}