use std::fmt;
use std::path::{Path, PathBuf};
use std::str::FromStr;
pub const HEX_LEN: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MonetizePubkey {
Literal([u8; 32]),
Path(PathBuf),
Fed,
}
pub const FED: &str = "fed";
impl MonetizePubkey {
pub fn is_fed(&self) -> bool {
matches!(self, MonetizePubkey::Fed)
}
pub fn literal(&self) -> Option<[u8; 32]> {
match self {
MonetizePubkey::Literal(k) => Some(*k),
MonetizePubkey::Path(_) | MonetizePubkey::Fed => None,
}
}
pub fn path(&self) -> Option<&Path> {
match self {
MonetizePubkey::Literal(_) | MonetizePubkey::Fed => None,
MonetizePubkey::Path(p) => Some(p),
}
}
}
impl fmt::Display for MonetizePubkey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MonetizePubkey::Literal(k) => {
for b in k {
write!(f, "{b:02x}")?;
}
Ok(())
}
MonetizePubkey::Path(p) => write!(f, "{}", p.display()),
MonetizePubkey::Fed => f.write_str(FED),
}
}
}
impl FromStr for MonetizePubkey {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s == FED {
return Ok(MonetizePubkey::Fed);
}
if looks_like_an_inline_key(s) {
return parse_hex32(s).map(MonetizePubkey::Literal).map_err(|why| {
format!(
"`{s}` is key-shaped, so it was read as monetize's public key spelled \
inline rather than as a filename, and {why}. A path is anything with a \
`/` in it (`/data/monetize.pub`) or any name that is not all hexadecimal \
(`monetize.pub`)."
)
});
}
Ok(MonetizePubkey::Path(PathBuf::from(s)))
}
}
fn looks_like_an_inline_key(s: &str) -> bool {
if s.is_empty() || s.contains('/') {
return false;
}
s.chars().all(|c| c.is_ascii_hexdigit()) || s.len() == HEX_LEN
}
pub fn parse_hex32(text: &str) -> Result<[u8; 32], String> {
if text.len() != HEX_LEN {
return Err(format!(
"an Ed25519 public key is 32 bytes, so exactly {HEX_LEN} hexadecimal characters; \
this is {} character(s) long",
text.len()
));
}
if let Some((i, c)) = text.char_indices().find(|(_, c)| !c.is_ascii_hexdigit()) {
return Err(format!(
"character {} of the key is {c:?}, which is not hexadecimal (0-9, a-f)",
i + 1
));
}
let mut out = [0u8; 32];
for (i, slot) in out.iter_mut().enumerate() {
*slot = u8::from_str_radix(&text[2 * i..2 * i + 2], 16)
.map_err(|e| format!("byte {} of the key: {e}", i + 1))?;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
const LIVE: &str = "1c3663248d60900e2996389fba4efa1d7179d6dbd08627d0426f16f94e44c4e6";
const LIVE_BYTES: [u8; 32] = [
0x1c, 0x36, 0x63, 0x24, 0x8d, 0x60, 0x90, 0x0e, 0x29, 0x96, 0x38, 0x9f, 0xba, 0x4e, 0xfa,
0x1d, 0x71, 0x79, 0xd6, 0xdb, 0xd0, 0x86, 0x27, 0xd0, 0x42, 0x6f, 0x16, 0xf9, 0x4e, 0x44,
0xc4, 0xe6,
];
#[test]
fn the_live_key_spelled_inline_is_the_thirty_two_bytes() {
let parsed: MonetizePubkey = LIVE.parse().expect("the live key parses");
assert_eq!(
parsed.literal(),
Some(LIVE_BYTES),
"the inline key must yield monetize's 32 bytes; got {parsed:?}"
);
assert_eq!(parsed.path(), None, "an inline key is not a path");
}
#[test]
fn the_fixture_can_tell_a_correct_key_from_a_mangled_one() {
let mut reversed = LIVE_BYTES;
reversed.reverse();
assert_ne!(
reversed, LIVE_BYTES,
"a palindromic fixture cannot distinguish byte order"
);
let mut typo: Vec<char> = LIVE.chars().collect();
typo[0] = '5';
let typo: String = typo.into_iter().collect();
let other: MonetizePubkey = typo.parse().expect("still key-shaped");
assert_ne!(
other.literal(),
Some(LIVE_BYTES),
"a key differing by one nibble must not parse to the same bytes"
);
}
#[test]
fn fed_is_the_door_feeds_it_and_is_neither_a_key_nor_a_path() {
let parsed: MonetizePubkey = FED.parse().expect("`fed` parses");
assert!(parsed.is_fed(), "{parsed:?}");
assert_eq!((parsed.literal(), parsed.path()), (None, None));
assert_eq!(parsed.to_string(), "fed", "said back as it was spelled");
assert!(
"fed0".parse::<MonetizePubkey>().is_err(),
"a hex near-miss is a wrong KEY, not `fed`"
);
assert!(!LIVE.parse::<MonetizePubkey>().unwrap().is_fed());
}
#[test]
fn a_path_is_still_a_path_even_when_it_does_not_exist() {
let parsed: MonetizePubkey = "/no/such/dir/monetize.pub"
.parse()
.expect("a path always parses");
assert_eq!(
parsed.path(),
Some(Path::new("/no/such/dir/monetize.pub")),
"the literal arm has swallowed the path arm"
);
assert_eq!(parsed.literal(), None);
for p in ["monetize.pub", "keys/monetize"] {
let parsed: MonetizePubkey = p.parse().unwrap();
assert_eq!(
parsed.path(),
Some(Path::new(p)),
"{p} stopped being a path"
);
}
}
#[test]
fn a_hex_key_of_the_wrong_length_is_refused_by_length() {
for (text, n) in [(&LIVE[..63], 63usize), (&*format!("{LIVE}0"), 65)] {
let err = text
.parse::<MonetizePubkey>()
.expect_err("a near-miss key must be refused, not read as a file");
assert!(
err.contains(&n.to_string()) && err.contains("64"),
"the refusal must name the length that was wrong and the one wanted: {err}"
);
}
}
#[test]
fn a_key_shaped_value_that_is_not_hex_names_the_bad_character() {
let mut chars: Vec<char> = LIVE.chars().collect();
chars[7] = 'z';
let text: String = chars.into_iter().collect();
let err = text
.parse::<MonetizePubkey>()
.expect_err("a non-hex key must be refused");
assert!(
err.contains('z') && err.contains('8'),
"the refusal must name the character and where it sat: {err}"
);
}
#[test]
fn display_prints_a_literal_back_as_its_hex() {
let parsed: MonetizePubkey = LIVE.parse().unwrap();
assert_eq!(parsed.to_string(), LIVE);
let parsed: MonetizePubkey = "/etc/monetize.pub".parse().unwrap();
assert_eq!(parsed.to_string(), "/etc/monetize.pub");
}
}