use std::fmt;
use std::path::{Path, PathBuf};
const SHOWN: usize = 4;
const SHOWABLE: usize = 12;
#[derive(Clone, PartialEq, Eq)]
pub struct Key(String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyError {
Unreadable {
path: PathBuf,
reason: String,
},
NotAKey {
path: PathBuf,
},
}
impl fmt::Display for KeyError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unreadable { path, reason } => write!(formatter, "{}: {reason}", path.display()),
Self::NotAKey { path } => write!(formatter, "{}", path.display()),
}
}
}
impl Key {
#[must_use]
pub fn new(text: &str) -> Option<Self> {
let trimmed = text.trim();
if trimmed.is_empty() || trimmed.chars().any(char::is_control) {
return None;
}
Some(Self(trimmed.to_owned()))
}
pub fn from_file(path: &Path) -> Result<Self, KeyError> {
let text = std::fs::read_to_string(path)
.map_err(|error| KeyError::Unreadable { path: path.to_owned(), reason: error.kind().to_string() })?;
Self::new(&text).ok_or_else(|| KeyError::NotAKey { path: path.to_owned() })
}
#[must_use]
pub fn last_four(&self) -> Option<String> {
let characters: Vec<char> = self.0.chars().collect();
(characters.len() >= SHOWABLE).then(|| characters[characters.len() - SHOWN..].iter().collect())
}
pub(crate) fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for Key {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.last_four() {
Some(tail) => write!(formatter, "Key(…{tail})"),
None => formatter.write_str("Key(…)"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const MADE_UP: &str = "not-a-real-key-0000-wxyz";
fn folder(name: &str) -> PathBuf {
let folder = std::env::temp_dir().join(format!("qcli-key-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&folder);
std::fs::create_dir_all(&folder).expect("folder");
folder
}
#[test]
fn only_the_last_four_characters_are_ever_shown() {
let key = Key::new(MADE_UP).expect("a key");
assert_eq!(format!("{key:?}"), "Key(…wxyz)");
assert!(!format!("{key:?}").contains("not-a-real"));
let short = Key::new("abcdefgh").expect("a key");
assert_eq!(format!("{short:?}"), "Key(…)", "four of eight characters is most of the key");
}
#[test]
fn a_key_file_gives_its_one_line_and_its_errors_name_only_the_file() {
let folder = folder("one-line");
let file = folder.join("key");
std::fs::write(&file, format!("{MADE_UP}\n")).expect("write");
assert_eq!(Key::from_file(&file).expect("a key").expose(), MADE_UP);
std::fs::write(&file, format!("{MADE_UP}\nsecond line")).expect("write");
let error = Key::from_file(&file).expect_err("two lines are not a key");
assert_eq!(error, KeyError::NotAKey { path: file.clone() });
assert!(!error.to_string().contains("not-a-real"), "the error does not quote the file");
let missing = Key::from_file(&folder.join("missing")).expect_err("no file");
assert!(matches!(missing, KeyError::Unreadable { .. }));
std::fs::remove_dir_all(&folder).expect("clean up");
}
#[test]
fn a_key_file_of_ten_thousand_lines_or_with_a_nul_byte_in_it_holds_no_key() {
let folder = folder("not-a-key");
let file = folder.join("key");
std::fs::write(&file, format!("{MADE_UP}\n").repeat(10_000)).expect("write");
assert_eq!(Key::from_file(&file).expect_err("ten thousand lines"), KeyError::NotAKey { path: file.clone() });
std::fs::write(&file, b"abc\0def").expect("write");
assert_eq!(Key::from_file(&file).expect_err("a NUL byte"), KeyError::NotAKey { path: file.clone() });
std::fs::write(&file, " ").expect("write");
assert_eq!(Key::from_file(&file).expect_err("only spaces"), KeyError::NotAKey { path: file.clone() });
std::fs::write(&file, "").expect("write");
assert_eq!(Key::from_file(&file).expect_err("empty"), KeyError::NotAKey { path: file.clone() });
std::fs::write(&file, [0xff, 0xfe, 0x00]).expect("write");
let error = Key::from_file(&file).expect_err("not text");
assert!(matches!(&error, KeyError::Unreadable { path, .. } if *path == file), "{error:?}");
assert!(!error.to_string().contains("0xff"), "the error quotes the file, not the bytes");
std::fs::remove_dir_all(&folder).expect("clean up");
}
#[test]
fn a_key_of_a_megabyte_in_one_line_is_the_key_the_file_holds() {
let folder = folder("one-megabyte");
let file = folder.join("key");
std::fs::write(&file, "k".repeat(1024 * 1024)).expect("write");
let key = Key::from_file(&file).expect("a key");
assert_eq!(key.expose().len(), 1024 * 1024, "every character of the line is the key");
assert_eq!(key.last_four().as_deref(), Some("kkkk"));
std::fs::remove_dir_all(&folder).expect("clean up");
}
#[test]
fn a_key_file_that_cannot_be_read_is_named_and_not_quoted() {
use std::os::unix::fs::PermissionsExt as _;
let folder = folder("unreadable");
let file = folder.join("key");
std::fs::write(&file, format!("{MADE_UP}\n")).expect("write");
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).expect("no rights");
match Key::from_file(&file) {
Err(KeyError::Unreadable { path, reason }) => {
assert_eq!(path, file, "the file is named");
assert_eq!(reason, "permission denied", "what the system said, in its own words");
assert!(!reason.contains(MADE_UP), "what the system said carries no key");
}
Ok(_) => eprintln!("skipped: this process can read a file with no rights, so there is no error to see"),
Err(other) => panic!("a file with no rights cannot be anything else, got {other:?}"),
}
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).expect("give the rights back");
std::fs::remove_dir_all(&folder).expect("clean up");
}
}