use crate::otp::{Algorithm, Digits, OtpSecret};
use crate::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UriType {
Totp,
Hotp,
}
impl UriType {
fn parse(value: &str) -> Result<Self> {
if value.eq_ignore_ascii_case("totp") {
Ok(Self::Totp)
} else if value.eq_ignore_ascii_case("hotp") {
Ok(Self::Hotp)
} else {
Err(Error::UriUnsupportedType {
name: truncate(value, 16),
})
}
}
}
pub struct OtpAuth {
pub uri_type: UriType,
pub secret: OtpSecret,
pub issuer: Option<String>,
pub account: String,
pub algorithm: Algorithm,
pub digits: Digits,
pub period: u64,
pub counter: Option<u64>,
}
const DEBUG_MAX_FIELD: usize = 64;
impl core::fmt::Debug for OtpAuth {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("OtpAuth")
.field("uri_type", &self.uri_type)
.field("secret", &"<redacted>")
.field(
"issuer",
&self.issuer.as_deref().map(|s| truncate(s, DEBUG_MAX_FIELD)),
)
.field("account", &truncate(&self.account, DEBUG_MAX_FIELD))
.field("algorithm", &self.algorithm)
.field("digits", &self.digits)
.field("period", &self.period)
.field("counter", &self.counter)
.finish()
}
}
fn truncate(value: &str, max: usize) -> String {
if value.len() <= max {
return value.to_owned();
}
let mut end = max;
while end > 0 && !value.is_char_boundary(end) {
end -= 1;
}
let mut out = String::from(&value[..end]);
out.push('…');
out
}
pub fn parse(input: &str) -> Result<OtpAuth> {
let after_scheme =
strip_prefix_ignore_ascii_case(input, "otpauth://").ok_or(Error::UriUnsupportedScheme)?;
let (path_and_authority, query) = match after_scheme.split_once('?') {
Some((left, right)) => (left, Some(right)),
None => (after_scheme, None),
};
let (type_part, label_part) = path_and_authority
.split_once('/')
.ok_or(Error::UriMissingType)?;
let uri_type = UriType::parse(type_part)?;
let label = percent_decode_path(label_part)?;
let params = parse_params(query)?;
let secret_raw = params.secret.ok_or(Error::UriMissingSecret)?;
let secret = OtpSecret::from_base32(&secret_raw)?;
let algorithm = match ¶ms.algorithm {
Some(name) => Algorithm::parse(name)?,
None => Algorithm::default(),
};
let digits = match ¶ms.digits {
Some(value) => Digits::parse(value)?,
None => Digits::default(),
};
let period = match ¶ms.period {
Some(value) => parse_period(value)?,
None => DEFAULT_PERIOD,
};
let counter = match uri_type {
UriType::Hotp => Some(parse_counter(
params.counter.as_deref().ok_or(Error::UriMissingCounter)?,
)?),
UriType::Totp => {
if params.counter.is_some() {
return Err(Error::UriUnexpectedCounter);
}
None
}
};
let period = if uri_type == UriType::Hotp {
DEFAULT_PERIOD
} else {
period
};
let (label_issuer, account) = split_label(&label)?;
let issuer = resolve_issuer(label_issuer, params.issuer)?;
Ok(OtpAuth {
uri_type,
secret,
issuer,
account,
algorithm,
digits,
period,
counter,
})
}
pub const DEFAULT_PERIOD: u64 = 30;
const MAX_PERIOD: u64 = 366 * 24 * 60 * 60;
fn parse_period(value: &str) -> Result<u64> {
let Ok(period) = value.parse::<u64>() else {
return Err(Error::UriInvalidParameter {
name: "period".to_owned(),
reason: format!("不是合法整数:{}", truncate(value, 16)),
});
};
if period == 0 {
return Err(Error::InvalidPeriod);
}
if period > MAX_PERIOD {
return Err(Error::UriInvalidParameter {
name: "period".to_owned(),
reason: format!("超出上限 {MAX_PERIOD} 秒"),
});
}
Ok(period)
}
fn parse_counter(value: &str) -> Result<u64> {
value
.parse::<u64>()
.map_err(|_| Error::UriInvalidParameter {
name: "counter".to_owned(),
reason: format!("不是合法无符号整数:{}", truncate(value, 16)),
})
}
fn split_label(label: &str) -> Result<(Option<&str>, String)> {
let (prefix, account) = match label.split_once(':') {
Some((prefix, rest)) if !prefix.is_empty() => (Some(prefix), rest),
Some((_, rest)) => (None, rest),
None => (None, label),
};
if account.contains(':') {
return Err(Error::UriAccountContainsColon {
account: truncate(account, 32),
});
}
Ok((prefix, account.to_owned()))
}
fn resolve_issuer(
label_issuer: Option<&str>,
param_issuer: Option<String>,
) -> Result<Option<String>> {
match (label_issuer, param_issuer) {
(Some(from_label), Some(from_param)) => {
if from_label == from_param {
Ok(Some(from_param))
} else {
Err(Error::UriIssuerMismatch {
in_label: truncate(from_label, 32),
in_parameter: truncate(&from_param, 32),
})
}
}
(Some(from_label), None) => Ok(Some(from_label.to_owned())),
(None, from_param) => Ok(from_param),
}
}
fn strip_prefix_ignore_ascii_case<'a>(input: &'a str, prefix: &str) -> Option<&'a str> {
let head = input.get(..prefix.len())?;
head.eq_ignore_ascii_case(prefix)
.then(|| input.get(prefix.len()..).unwrap_or_default())
}
fn percent_decode_path(input: &str) -> Result<String> {
let bytes = input.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
if i + 2 >= bytes.len() {
return Err(Error::UriInvalidPercentEncoding);
}
let hi = hex_value(bytes[i + 1]).ok_or(Error::UriInvalidPercentEncoding)?;
let lo = hex_value(bytes[i + 2]).ok_or(Error::UriInvalidPercentEncoding)?;
decoded.push(hi << 4 | lo);
i += 3;
} else {
let start = i;
while i < bytes.len() && bytes[i] != b'%' {
i += 1;
}
decoded.extend_from_slice(&bytes[start..i]);
}
}
String::from_utf8(decoded).map_err(|_| Error::UriInvalidPercentEncoding)
}
const fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
#[derive(Default)]
struct Params {
secret: Option<String>,
issuer: Option<String>,
algorithm: Option<String>,
digits: Option<String>,
period: Option<String>,
counter: Option<String>,
}
fn parse_params(query: Option<&str>) -> Result<Params> {
let mut params = Params::default();
let Some(query) = query else {
return Ok(params);
};
if query.is_empty() {
return Ok(params);
}
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
let slot = match key.as_ref() {
"secret" => &mut params.secret,
"issuer" => &mut params.issuer,
"algorithm" => &mut params.algorithm,
"digits" => &mut params.digits,
"period" => &mut params.period,
"counter" => &mut params.counter,
_ => continue,
};
if slot.is_some() {
return Err(Error::UriDuplicateParameter {
name: truncate(&key, 16),
});
}
*slot = Some(value.into_owned());
}
Ok(params)
}
pub fn serialize(auth: &OtpAuth) -> Result<String> {
if auth.account.contains(':') {
return Err(Error::UriAccountContainsColon {
account: truncate(&auth.account, 32),
});
}
let mut out = String::from("otpauth://");
out.push_str(match auth.uri_type {
UriType::Totp => "totp",
UriType::Hotp => "hotp",
});
out.push('/');
if let Some(issuer) = &auth.issuer {
out.push_str(&percent_encode_path(issuer));
out.push(':');
}
out.push_str(&percent_encode_path(&auth.account));
out.push('?');
out.push_str("secret=");
out.push_str(&auth.secret.to_base32());
if let Some(issuer) = &auth.issuer {
out.push_str("&issuer=");
out.push_str(&percent_encode_query(issuer));
}
if auth.algorithm != Algorithm::default() {
out.push_str("&algorithm=");
out.push_str(match auth.algorithm {
Algorithm::Sha1 => "SHA1",
Algorithm::Sha256 => "SHA256",
Algorithm::Sha512 => "SHA512",
});
}
if auth.digits != Digits::default() {
out.push_str("&digits=");
out.push_str(&auth.digits.count().to_string());
}
if auth.uri_type == UriType::Totp && auth.period != DEFAULT_PERIOD {
out.push_str("&period=");
out.push_str(&auth.period.to_string());
}
if let Some(counter) = auth.counter {
out.push_str("&counter=");
out.push_str(&counter.to_string());
}
Ok(out)
}
fn percent_encode_path(input: &str) -> String {
const SAFE: &[u8] = b"-._~!$&'()*+,;=@";
let mut out = String::with_capacity(input.len());
for &byte in input.as_bytes() {
if byte.is_ascii_alphanumeric() || SAFE.contains(&byte) {
out.push(char::from(byte));
} else {
out.push('%');
out.push_str(&format!("{byte:02X}"));
}
}
out
}
fn percent_encode_query(input: &str) -> String {
const SAFE: &[u8] = b"-._~";
let mut out = String::with_capacity(input.len());
for &byte in input.as_bytes() {
if byte.is_ascii_alphanumeric() || SAFE.contains(&byte) {
out.push(char::from(byte));
} else {
out.push('%');
out.push_str(&format!("{byte:02X}"));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_value_accepts_both_cases() {
assert_eq!(hex_value(b'0'), Some(0));
assert_eq!(hex_value(b'9'), Some(9));
assert_eq!(hex_value(b'a'), Some(10));
assert_eq!(hex_value(b'F'), Some(15));
for &bad in b"gG/: " {
assert_eq!(hex_value(bad), None, "{bad:?} 不应被接受");
}
}
#[test]
fn path_decoding_does_not_treat_plus_as_space() {
assert_eq!(percent_decode_path("a+b").expect("应可解码"), "a+b");
assert_eq!(percent_decode_path("a%2Bb").expect("应可解码"), "a+b");
assert_eq!(percent_decode_path("a%20b").expect("应可解码"), "a b");
}
#[test]
fn path_decoding_handles_multibyte_utf8() {
assert_eq!(percent_decode_path("%E4%B8%AD").expect("应可解码"), "中");
assert_eq!(percent_decode_path("中").expect("应可解码"), "中");
assert_eq!(
percent_decode_path("a%E4%B8%ADb").expect("应可解码"),
"a中b"
);
}
#[test]
fn path_decoding_rejects_malformed_escapes() {
for bad in ["%", "%A", "%GG", "abc%", "abc%A"] {
assert!(
matches!(
percent_decode_path(bad),
Err(Error::UriInvalidPercentEncoding)
),
"{bad:?} 应因编码格式被拒绝"
);
}
}
#[test]
fn path_decoding_rejects_invalid_utf8() {
for bad in ["%FF", "%80", "%E4%B8", "%C0%80", "ok%FE"] {
assert!(
matches!(
percent_decode_path(bad),
Err(Error::UriInvalidPercentEncoding)
),
"{bad:?} 应因非法 UTF-8 被拒绝"
);
}
}
#[test]
fn strip_prefix_is_case_insensitive() {
assert_eq!(
strip_prefix_ignore_ascii_case("OTPAUTH://x", "otpauth://"),
Some("x")
);
assert_eq!(
strip_prefix_ignore_ascii_case("otpauth://x", "otpauth://"),
Some("x")
);
assert_eq!(
strip_prefix_ignore_ascii_case("http://x", "otpauth://"),
None
);
assert_eq!(strip_prefix_ignore_ascii_case("otp", "otpauth://"), None);
}
#[test]
fn label_splits_on_first_colon_only() {
assert_eq!(
split_label("ACME:john@example.com").expect("合法"),
(Some("ACME"), "john@example.com".to_owned())
);
assert_eq!(
split_label("justaccount").expect("合法"),
(None, "justaccount".to_owned())
);
assert_eq!(
split_label(":acct").expect("合法"),
(None, "acct".to_owned())
);
}
#[test]
fn label_rejects_colon_in_account() {
for bad in ["ACME:john:8080", ":a::a", "a::b", "x:y:z"] {
assert!(
matches!(split_label(bad), Err(Error::UriAccountContainsColon { .. })),
"{bad:?} 应被拒绝"
);
}
assert_eq!(
split_label("a:").expect("空账号名应被接受"),
(Some("a"), String::new())
);
assert!(
matches!(
split_label("::"),
Err(Error::UriAccountContainsColon { .. })
),
"\"::\" 的账号名是 \":\",应被拒绝"
);
}
#[test]
fn serialize_rejects_colon_in_account() {
let auth = OtpAuth {
uri_type: UriType::Totp,
secret: OtpSecret::from_bytes(vec![1, 2, 3, 4]).expect("非空"),
issuer: Some("ACME".to_owned()),
account: "john:8080".to_owned(),
algorithm: Algorithm::Sha1,
digits: Digits::Six,
period: DEFAULT_PERIOD,
counter: None,
};
assert!(matches!(
serialize(&auth),
Err(Error::UriAccountContainsColon { .. })
));
}
#[test]
fn issuer_mismatch_is_rejected() {
let result = resolve_issuer(Some("GitHub"), Some("Gitlab".to_owned()));
assert!(matches!(result, Err(Error::UriIssuerMismatch { .. })));
}
#[test]
fn issuer_may_come_from_either_source() {
assert_eq!(
resolve_issuer(Some("GitHub"), None).expect("应成功"),
Some("GitHub".to_owned())
);
assert_eq!(
resolve_issuer(None, Some("GitHub".to_owned())).expect("应成功"),
Some("GitHub".to_owned())
);
assert_eq!(
resolve_issuer(Some("GitHub"), Some("GitHub".to_owned())).expect("一致时应成功"),
Some("GitHub".to_owned())
);
}
#[test]
fn period_bounds_are_enforced() {
assert_eq!(parse_period("30").expect("30 合法"), 30);
assert!(matches!(parse_period("0"), Err(Error::InvalidPeriod)));
assert!(matches!(
parse_period("99999999999999999999"),
Err(Error::UriInvalidParameter { .. })
));
assert!(matches!(
parse_period(&(MAX_PERIOD + 1).to_string()),
Err(Error::UriInvalidParameter { .. })
));
assert!(parse_period(&MAX_PERIOD.to_string()).is_ok());
}
#[test]
fn counter_parsing_is_strict() {
assert_eq!(parse_counter("0").expect("0 合法"), 0);
assert_eq!(
parse_counter("18446744073709551615").expect("u64 上限合法"),
u64::MAX
);
for bad in ["-1", "1.5", "", "abc", "18446744073709551616"] {
assert!(
matches!(parse_counter(bad), Err(Error::UriInvalidParameter { .. })),
"{bad:?} 应被拒绝"
);
}
}
#[test]
fn percent_encoding_roundtrips_through_decoding() {
for original in [
"simple",
"with space",
"with+plus",
"with:colon",
"with/slash",
"中文",
"a%b",
"a&b=c",
"100%",
] {
let encoded = percent_encode_path(original);
let decoded = percent_decode_path(&encoded)
.unwrap_or_else(|e| panic!("{original:?} 编码为 {encoded:?} 后无法解码: {e}"));
assert_eq!(decoded, original, "路径编码往返失败:{encoded:?}");
}
}
#[test]
fn query_encoding_roundtrips_through_form_decoding() {
for original in ["simple", "with space", "with+plus", "中文", "a&b=c", "a%b"] {
let encoded = percent_encode_query(original);
let decoded = form_urlencoded::parse(format!("v={encoded}").as_bytes())
.next()
.expect("应有一个参数")
.1
.into_owned();
assert_eq!(decoded, original, "query 编码往返失败:{encoded:?}");
}
}
#[test]
fn truncate_never_splits_a_character() {
let long = "中".repeat(20);
let text = truncate(&long, 16);
assert!(text.ends_with('…'));
assert!(long.starts_with(text.trim_end_matches('…')));
}
#[test]
fn debug_output_hides_the_secret() {
let auth = parse("otpauth://totp/ACME:alice?secret=JBSWY3DPEHPK3PXP&issuer=ACME")
.expect("合法 URI");
let text = format!("{auth:?}");
assert!(text.contains("redacted"), "应标注脱敏:{text}");
assert!(
!text.contains("JBSWY3DPEHPK3PXP"),
"Debug 泄露了 Base32 种子:{text}"
);
assert!(text.contains("ACME"), "应显示 issuer:{text}");
assert!(text.contains("alice"), "应显示 account:{text}");
}
#[test]
fn debug_truncates_oversized_issuer_and_account() {
let long_issuer = "I".repeat(500);
let long_account = "A".repeat(500);
let input = format!("otpauth://totp/{long_issuer}:{long_account}?secret=AA");
let auth = parse(&input).expect("应可解析");
let text = format!("{auth:?}");
assert!(!text.contains(&long_issuer), "超长 issuer 未被截断:{text}");
assert!(
!text.contains(&long_account),
"超长 account 未被截断:{text}"
);
assert!(text.contains('…'), "截断应带省略号:{text}");
assert_eq!(auth.account, long_account, "截断不应修改真实数据");
assert_eq!(auth.issuer.as_deref(), Some(long_issuer.as_str()));
assert!(
text.len() < 512,
"Debug 输出应被限长,实际 {} 字节:{text}",
text.len()
);
}
}