use std::fmt;
use std::str::FromStr;
use reqwest::Url;
use crate::protocol::ProtocolError;
pub const ALPHABET: &str = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
pub const ENROLLMENT_LENGTH: usize = 8;
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct EnrollmentCode(String);
impl EnrollmentCode {
pub fn parse(text: &str) -> Result<Self, ProtocolError> {
let code: String = text
.chars()
.filter(|c| !c.is_whitespace() && *c != '-')
.map(|c| match c.to_ascii_uppercase() {
'O' => '0',
'I' | 'L' => '1',
other => other,
})
.collect();
if code.chars().count() != ENROLLMENT_LENGTH || !code.chars().all(|c| ALPHABET.contains(c)) {
return Err(ProtocolError::new("an enrollment code is 8 letters and digits, like K7QF-2M9X"));
}
Ok(Self(code))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn grouped(&self) -> String {
format!("{}-{}", &self.0[..4], &self.0[4..])
}
pub fn digest(&self) -> String {
let digest = ring::digest::digest(&ring::digest::SHA256, self.0.as_bytes());
digest.as_ref().iter().map(|byte| format!("{byte:02x}")).collect()
}
}
impl fmt::Debug for EnrollmentCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("EnrollmentCode(…)")
}
}
impl FromStr for EnrollmentCode {
type Err = ProtocolError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
Self::parse(text)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PairingUri {
core: String,
code: EnrollmentCode,
}
impl PairingUri {
pub fn parse(uri: &str) -> Result<Self, ProtocolError> {
let wrong = || ProtocolError::new("a pairing URI looks like nestor+http://host:port/?code=K7QF2M9X");
let uri = uri.trim();
let (scheme, rest) = uri.split_once("://").ok_or_else(wrong)?;
let scheme = match scheme.to_ascii_lowercase().as_str() {
"nestor+http" => "http",
"nestor+https" => "https",
_ => return Err(wrong()),
};
let url = Url::parse(&format!("{scheme}://{rest}")).map_err(|_| wrong())?;
let codes: Vec<String> = url.query_pairs().filter(|(key, _)| key == "code").map(|(_, v)| v.into()).collect();
let [code] = codes.as_slice() else {
return Err(wrong());
};
Ok(Self { core: base_url(url.as_str()).map_err(|_| wrong())?, code: EnrollmentCode::parse(code)? })
}
pub fn core(&self) -> &str {
&self.core
}
pub fn code(&self) -> &EnrollmentCode {
&self.code
}
}
impl FromStr for PairingUri {
type Err = ProtocolError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
Self::parse(text)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Enrollment {
code: EnrollmentCode,
core: Option<String>,
}
impl Enrollment {
pub fn parse(text: &str) -> Result<Self, ProtocolError> {
if text.trim().to_ascii_lowercase().starts_with("nestor+") {
Ok(PairingUri::parse(text)?.into())
} else {
Ok(EnrollmentCode::parse(text)?.into())
}
}
pub fn code(&self) -> &EnrollmentCode {
&self.code
}
pub fn core(&self) -> Option<&str> {
self.core.as_deref()
}
}
impl From<PairingUri> for Enrollment {
fn from(uri: PairingUri) -> Self {
Self { code: uri.code, core: Some(uri.core) }
}
}
impl From<EnrollmentCode> for Enrollment {
fn from(code: EnrollmentCode) -> Self {
Self { code, core: None }
}
}
impl FromStr for Enrollment {
type Err = ProtocolError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
Self::parse(text)
}
}
pub(crate) fn base_url(text: &str) -> Result<String, ProtocolError> {
let wrong = || ProtocolError::new(format!("the core's address {text:?} must be an http(s) URL"));
let url = Url::parse(text.trim()).map_err(|_| wrong())?;
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return Err(wrong());
}
Ok(format!("{}{}", url.origin().ascii_serialization(), url.path().trim_end_matches('/')))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_enrollment_code_forgives_case_dashes_spaces_and_look_alike_letters() {
for typed in ["K7QF2M9X", "k7qf-2m9x", " K7QF 2M9X ", "k7qf-2m9x\n"] {
assert_eq!(EnrollmentCode::parse(typed).unwrap().as_str(), "K7QF2M9X", "{typed:?}");
}
assert_eq!(EnrollmentCode::parse("oil0-ILOO").unwrap().as_str(), "01101100");
assert_eq!(EnrollmentCode::parse("K7QF2M9X").unwrap().grouped(), "K7QF-2M9X");
}
#[test]
fn a_code_s_digest_is_the_sha256_of_its_canonical_form() {
let digest = EnrollmentCode::parse("k7qf-2m9x").unwrap().digest();
assert_eq!(digest, "af9f1438361c898452f3da3b89fa0541991ac63a6b120e08cb9c41c2c06f7e86");
assert_eq!(EnrollmentCode::parse("K7QF2M9X").unwrap().digest(), digest);
}
#[test]
fn what_is_not_eight_crockford_symbols_is_no_enrollment_code() {
for wrong in ["", "K7QF2M9", "K7QF2M9XY", "K7QF2M9U", "K7QF_2M9X", "K7QF2M9É", "042917"] {
assert!(EnrollmentCode::parse(wrong).is_err(), "{wrong:?}");
}
}
#[test]
fn an_enrollment_code_is_never_logged() {
let code = EnrollmentCode::parse("K7QF2M9X").unwrap();
assert!(!format!("{code:?} {:?}", Enrollment::from(code.clone())).contains("K7QF"));
}
#[test]
fn a_pairing_uri_gives_the_core_s_base_url_and_the_code() {
let uri = PairingUri::parse("nestor+http://nestor.local:8471/?code=K7QF2M9X").unwrap();
assert_eq!((uri.core(), uri.code().as_str()), ("http://nestor.local:8471", "K7QF2M9X"));
let uri = PairingUri::parse("NESTOR+HTTPS://nestor.example.org/satellites-proxy/?code=k7qf-2m9x").unwrap();
assert_eq!((uri.core(), uri.code().as_str()), ("https://nestor.example.org/satellites-proxy", "K7QF2M9X"));
let uri = PairingUri::parse("nestor+http://127.0.0.1:9/?code=K7QF%202M9X").unwrap();
assert_eq!((uri.core(), uri.code().as_str()), ("http://127.0.0.1:9", "K7QF2M9X"));
}
#[test]
fn a_uri_without_the_nestor_scheme_or_a_single_valid_code_is_refused() {
for wrong in [
"http://nestor.local:8471/?code=K7QF2M9X",
"nestor+ftp://nestor.local/?code=K7QF2M9X",
"nestor+http://nestor.local:8471/",
"nestor+http://nestor.local:8471/?code=K7QF2M9X&code=K7QF2M9X",
"nestor+http://nestor.local:8471/?code=nope",
"nestor+http:///?code=K7QF2M9X",
] {
assert!(PairingUri::parse(wrong).is_err(), "{wrong}");
}
}
#[test]
fn an_enrollment_is_a_pairing_uri_or_a_code_alone() {
let from_uri = Enrollment::parse("nestor+http://nestor.local:8471/?code=K7QF2M9X").unwrap();
assert_eq!(from_uri.core(), Some("http://nestor.local:8471"));
let alone = Enrollment::parse("K7QF-2M9X").unwrap();
assert_eq!((alone.core(), alone.code().as_str()), (None, "K7QF2M9X"));
}
#[test]
fn a_core_s_address_is_compared_without_trailing_slash() {
assert_eq!(base_url("http://Nestor.local:8471/").unwrap(), "http://nestor.local:8471");
assert_eq!(base_url("https://h/proxy/").unwrap(), "https://h/proxy");
assert!(base_url("nestor.local:8471").is_err());
assert!(base_url("ftp://h").is_err());
}
}