use super::VettingError;
use crate::protocols::vetting::request::v0_1 as request;
use crate::protocols::vetting::shape;
pub const TICKET_URI_SCHEME: &str = "vetting-ticket";
pub const TICKET_URI_VERSION: &str = "1";
const WHAT: &str = "ticket URI";
#[derive(Debug, Clone)]
pub struct TicketUri {
pub community: String,
pub vetter: String,
pub presentation: request::Ticket,
}
pub fn encode(uri: &TicketUri) -> Result<String, VettingError> {
let mut out = format!(
"{TICKET_URI_SCHEME}:?v={TICKET_URI_VERSION}&community={}&vetter={}",
pct_encode(&uri.community),
pct_encode(&uri.vetter)
);
match &uri.presentation {
request::Ticket::QrTicket(ticket) => {
out.push_str("&ticket=");
out.push_str(&pct_encode(&ticket.ticket_id));
out.push_str("&secret=");
out.push_str(&pct_encode(&ticket.secret));
}
request::Ticket::ShortCodeTicket(ticket) => {
out.push_str("&code=");
out.push_str(&pct_encode(&ticket.code));
}
_ => return Err(malformed("a ticket form the URI has no members for")),
}
Ok(out)
}
pub fn decode(input: &str) -> Result<TicketUri, VettingError> {
let input = input.trim_matches(|c: char| c.is_ascii_whitespace());
let (scheme, rest) = input
.split_once(':')
.ok_or_else(|| malformed("no scheme"))?;
if !scheme.eq_ignore_ascii_case(TICKET_URI_SCHEME) {
return Err(malformed("not a vetting-ticket URI"));
}
let query = rest
.strip_prefix('?')
.ok_or_else(|| malformed("no query"))?;
if query.contains('#') {
return Err(malformed("a ticket URI carries no fragment"));
}
let mut members = Members::default();
for pair in query.split('&') {
if pair.is_empty() {
continue;
}
let (name, value) = pair
.split_once('=')
.ok_or_else(|| malformed("a member has no value"))?;
let slot = match name {
"v" => &mut members.v,
"community" => &mut members.community,
"vetter" => &mut members.vetter,
"ticket" => &mut members.ticket,
"secret" => &mut members.secret,
"code" => &mut members.code,
_ => continue,
};
if slot.is_some() {
return Err(malformed("a member is repeated"));
}
*slot = Some(pct_decode(value)?);
}
match members.v.as_deref() {
None => return Err(malformed("no version")),
Some(TICKET_URI_VERSION) => {}
Some(other) => {
return Err(VettingError::UnsupportedVersion {
what: WHAT,
version: truncate(other),
});
}
}
let community = members.community.ok_or_else(|| malformed("no community"))?;
let vetter = members.vetter.ok_or_else(|| malformed("no vetter"))?;
request::PayloadCommunity::try_from(community.as_str())
.map_err(|e| malformed(&format!("community: {e}")))?;
shape::did("vetter", &vetter).map_err(|e| malformed(&e.to_string()))?;
let presentation = match (members.ticket, members.secret, members.code) {
(Some(ticket_id), Some(secret), None) => request::Ticket::QrTicket(
request::QrTicket::try_from(
request::QrTicket::builder()
.ticket_id(ticket_id)
.secret(secret),
)
.map_err(|e| malformed(&e.to_string()))?,
),
(None, None, Some(code)) => request::Ticket::ShortCodeTicket(
request::ShortCodeTicket::try_from(request::ShortCodeTicket::builder().code(code))
.map_err(|e| malformed(&e.to_string()))?,
),
(None, None, None) => return Err(malformed("no ticket")),
(Some(_), None, None) | (None, Some(_), None) => {
return Err(malformed("a scanned ticket needs both ticket and secret"));
}
_ => return Err(malformed("a ticket is scanned or spoken, not both")),
};
Ok(TicketUri {
community,
vetter,
presentation,
})
}
#[derive(Default)]
struct Members {
v: Option<String>,
community: Option<String>,
vetter: Option<String>,
ticket: Option<String>,
secret: Option<String>,
code: Option<String>,
}
fn malformed(detail: &str) -> VettingError {
VettingError::Malformed {
what: WHAT,
detail: detail.to_string(),
}
}
fn truncate(s: &str) -> String {
s.chars().take(16).collect()
}
fn is_unreserved(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~')
}
fn pct_encode(value: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut out = String::with_capacity(value.len());
for &b in value.as_bytes() {
if is_unreserved(b) {
out.push(char::from(b));
} else {
out.push('%');
out.push(char::from(HEX[usize::from(b >> 4)]));
out.push(char::from(HEX[usize::from(b & 0x0F)]));
}
}
out
}
fn pct_decode(value: &str) -> Result<String, VettingError> {
let bytes = value.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' => {
let hi = bytes.get(i + 1).and_then(|b| hex_value(*b));
let lo = bytes.get(i + 2).and_then(|b| hex_value(*b));
match (hi, lo) {
(Some(hi), Some(lo)) => out.push((hi << 4) | lo),
_ => return Err(malformed("bad percent-encoding")),
}
i += 3;
}
b if is_unreserved(b) => {
out.push(b);
i += 1;
}
_ => return Err(malformed("a character that must be percent-encoded")),
}
}
String::from_utf8(out).map_err(|_| malformed("a member is not UTF-8"))
}
fn hex_value(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
const COMMUNITY: &str = "did:webvh:QmCommunity:vtc.example.com";
const VETTER: &str = "did:key:z6MkVetter";
const SECRET: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
fn scanned() -> TicketUri {
TicketUri {
community: COMMUNITY.into(),
vetter: VETTER.into(),
presentation: serde_json::from_value(
serde_json::json!({ "ticketId": "t-01:ab.c_d", "secret": SECRET }),
)
.unwrap(),
}
}
fn spoken() -> TicketUri {
TicketUri {
community: COMMUNITY.into(),
vetter: VETTER.into(),
presentation: serde_json::from_value(serde_json::json!({ "code": "K7QF-2M9X" }))
.unwrap(),
}
}
fn same(a: &TicketUri, b: &TicketUri) -> bool {
a.community == b.community
&& a.vetter == b.vetter
&& serde_json::to_value(&a.presentation).unwrap()
== serde_json::to_value(&b.presentation).unwrap()
}
#[test]
fn a_scanned_ticket_round_trips() {
let uri = encode(&scanned()).unwrap();
assert_eq!(
uri,
"vetting-ticket:?v=1&community=did%3Awebvh%3AQmCommunity%3Avtc.example.com\
&vetter=did%3Akey%3Az6MkVetter&ticket=t-01%3Aab.c_d&secret=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
);
assert!(same(&decode(&uri).unwrap(), &scanned()));
}
#[test]
fn a_spoken_ticket_round_trips() {
let uri = encode(&spoken()).unwrap();
assert!(uri.ends_with("&code=K7QF-2M9X"), "{uri}");
assert!(same(&decode(&uri).unwrap(), &spoken()));
}
#[test]
fn member_order_the_scheme_case_and_surrounding_whitespace_do_not_matter() {
let uri = " VETTING-TICKET:?code=K7QF-2M9X&vetter=did%3Akey%3Az6MkVetter\
&community=did%3Awebvh%3AQmCommunity%3Avtc.example.com&v=1\n";
assert!(same(&decode(uri).unwrap(), &spoken()));
}
#[test]
fn an_unknown_member_is_ignored() {
let uri = format!("{}&future=yes", encode(&spoken()).unwrap());
assert!(same(&decode(&uri).unwrap(), &spoken()));
}
#[test]
fn an_unknown_or_missing_version_is_refused() {
let good = encode(&spoken()).unwrap();
let v2 = good.replace("v=1", "v=2");
assert!(matches!(
decode(&v2),
Err(VettingError::UnsupportedVersion { version, .. }) if version == "2"
));
let none = good.replace("v=1&", "");
assert!(matches!(decode(&none), Err(VettingError::Malformed { .. })));
let long = good.replace("v=1", &format!("v={}", "9".repeat(4096)));
match decode(&long) {
Err(VettingError::UnsupportedVersion { version, .. }) => assert_eq!(version.len(), 16),
other => panic!("expected an unsupported version, got {other:?}"),
}
}
#[test]
fn a_ticket_is_scanned_or_spoken_but_not_both_or_half() {
let both = format!("{}&code=K7QF-2M9X", encode(&scanned()).unwrap());
assert!(decode(&both).is_err());
let half = encode(&scanned())
.unwrap()
.replace(&format!("&secret={SECRET}"), "");
assert!(decode(&half).is_err());
let neither = format!(
"vetting-ticket:?v=1&community={}&vetter={}",
pct_encode(COMMUNITY),
pct_encode(VETTER)
);
assert!(decode(&neither).is_err());
}
#[test]
fn hostile_and_malformed_input_is_refused_without_panicking() {
let good = encode(&spoken()).unwrap();
let scanned_uri = encode(&scanned()).unwrap();
for bad in [
String::new(),
"vetting-ticket".into(),
"vetting-ticket:".into(),
"vetting-ticket:v=1".into(),
"https://example.com/?v=1".into(),
good.replace("v=1", "v=1&v=1"),
good.replace("community=did", "community=%zzdid"),
good.replace("community=did", "community=did%3"),
good.replace("community=did", "community=did%"),
good.replace("did%3Akey", "did:key"),
good.replace("did%3Akey", "did%20key"),
good.replace("community=did%3Awebvh", "community=%FF%FEdid"),
good.replace(
"community=did%3Awebvh%3AQmCommunity%3Avtc.example.com",
"community=",
),
good.replace("community=did%3Awebvh", "community=notadid"),
good.replace("K7QF-2M9X", "K7QF-2M9U"),
good.replace("K7QF-2M9X", "k7qf-2m9x"),
good.replace("K7QF-2M9X", "K7QF2M9X"),
format!("{good}#frag"),
format!("{good}&vetter"),
scanned_uri.replace(SECRET, "short"),
scanned_uri.replace("t-01%3Aab.c_d", &"a".repeat(129)),
"vetting-ticket:?".to_string() + &"&".repeat(10_000),
] {
assert!(decode(&bad).is_err(), "accepted {bad:?}");
}
}
#[test]
fn every_byte_value_survives_percent_encoding() {
let all: String = (0u32..=0x2FF).filter_map(char::from_u32).collect();
assert_eq!(pct_decode(&pct_encode(&all)).unwrap(), all);
}
}