Skip to main content

qcli/provider/
key.rs

1//! A provider's key, read from the person's key file and never shown.
2//!
3//! The key is a value of its own rather than a `String` so that nothing can print it by
4//! accident: it has no `Display`, its `Debug` writes only the marker and the last four
5//! characters, and the one method that hands the secret out ([`expose`](Key::expose)) is visible
6//! only inside this crate, where the single caller is the transport that puts it in a request
7//! header. A key never travels in an address, so nothing that records or reports an address can
8//! record it either.
9
10use std::fmt;
11use std::path::{Path, PathBuf};
12
13/// How many characters of a key are ever shown.
14const SHOWN: usize = 4;
15
16/// The shortest key whose last four characters can be shown without showing most of it.
17const SHOWABLE: usize = 12;
18
19/// A provider's key.
20#[derive(Clone, PartialEq, Eq)]
21pub struct Key(String);
22
23/// Why a key file gave no key. It names the file and never its contents.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum KeyError {
26    /// The file could not be read.
27    Unreadable {
28        /// The key file.
29        path: PathBuf,
30        /// What the system said.
31        reason: String,
32    },
33    /// The file holds nothing that can be a key: it is empty, or more than one line.
34    NotAKey {
35        /// The key file.
36        path: PathBuf,
37    },
38}
39
40impl fmt::Display for KeyError {
41    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            Self::Unreadable { path, reason } => write!(formatter, "{}: {reason}", path.display()),
44            Self::NotAKey { path } => write!(formatter, "{}", path.display()),
45        }
46    }
47}
48
49impl Key {
50    /// The key in `text` with the spaces around it dropped, or `None` when there is none. A key
51    /// file often ends with a newline, and a key with a newline in it makes a header no server
52    /// accepts.
53    #[must_use]
54    pub fn new(text: &str) -> Option<Self> {
55        let trimmed = text.trim();
56        // A header holds no control characters, and a file that is a whole paragraph is not a key.
57        if trimmed.is_empty() || trimmed.chars().any(char::is_control) {
58            return None;
59        }
60        Some(Self(trimmed.to_owned()))
61    }
62
63    /// Reads the key from `path`.
64    ///
65    /// # Errors
66    ///
67    /// When the file cannot be read or holds no key. The error names the file only.
68    pub fn from_file(path: &Path) -> Result<Self, KeyError> {
69        let text = std::fs::read_to_string(path)
70            .map_err(|error| KeyError::Unreadable { path: path.to_owned(), reason: error.kind().to_string() })?;
71        Self::new(&text).ok_or_else(|| KeyError::NotAKey { path: path.to_owned() })
72    }
73
74    /// The last four characters, or `None` for a key so short that four would give most of it
75    /// away.
76    #[must_use]
77    pub fn last_four(&self) -> Option<String> {
78        let characters: Vec<char> = self.0.chars().collect();
79        (characters.len() >= SHOWABLE).then(|| characters[characters.len() - SHOWN..].iter().collect())
80    }
81
82    /// The secret itself, for the one place that has to send it.
83    pub(crate) fn expose(&self) -> &str {
84        &self.0
85    }
86}
87
88impl fmt::Debug for Key {
89    /// The marker and the last four characters, so a key inside a structure that is printed
90    /// while something is being looked into does not end up in a log.
91    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
92        match self.last_four() {
93            Some(tail) => write!(formatter, "Key(…{tail})"),
94            None => formatter.write_str("Key(…)"),
95        }
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    /// Not a key of anyone's: the characters spell what it is.
104    const MADE_UP: &str = "not-a-real-key-0000-wxyz";
105
106    /// A folder of its own for one test, named for that test: the process id is the same for
107    /// every test in a run, so it cannot keep two of them apart on its own.
108    fn folder(name: &str) -> PathBuf {
109        let folder = std::env::temp_dir().join(format!("qcli-key-{name}-{}", std::process::id()));
110        let _ = std::fs::remove_dir_all(&folder);
111        std::fs::create_dir_all(&folder).expect("folder");
112        folder
113    }
114
115    #[test]
116    fn only_the_last_four_characters_are_ever_shown() {
117        let key = Key::new(MADE_UP).expect("a key");
118        assert_eq!(format!("{key:?}"), "Key(…wxyz)");
119        assert!(!format!("{key:?}").contains("not-a-real"));
120        let short = Key::new("abcdefgh").expect("a key");
121        assert_eq!(format!("{short:?}"), "Key(…)", "four of eight characters is most of the key");
122    }
123
124    #[test]
125    fn a_key_file_gives_its_one_line_and_its_errors_name_only_the_file() {
126        let folder = folder("one-line");
127        let file = folder.join("key");
128        std::fs::write(&file, format!("{MADE_UP}\n")).expect("write");
129        assert_eq!(Key::from_file(&file).expect("a key").expose(), MADE_UP);
130
131        std::fs::write(&file, format!("{MADE_UP}\nsecond line")).expect("write");
132        let error = Key::from_file(&file).expect_err("two lines are not a key");
133        assert_eq!(error, KeyError::NotAKey { path: file.clone() });
134        assert!(!error.to_string().contains("not-a-real"), "the error does not quote the file");
135
136        let missing = Key::from_file(&folder.join("missing")).expect_err("no file");
137        assert!(matches!(missing, KeyError::Unreadable { .. }));
138        std::fs::remove_dir_all(&folder).expect("clean up");
139    }
140
141    #[test]
142    fn a_key_file_of_ten_thousand_lines_or_with_a_nul_byte_in_it_holds_no_key() {
143        let folder = folder("not-a-key");
144        let file = folder.join("key");
145
146        std::fs::write(&file, format!("{MADE_UP}\n").repeat(10_000)).expect("write");
147        assert_eq!(Key::from_file(&file).expect_err("ten thousand lines"), KeyError::NotAKey { path: file.clone() });
148
149        std::fs::write(&file, b"abc\0def").expect("write");
150        assert_eq!(Key::from_file(&file).expect_err("a NUL byte"), KeyError::NotAKey { path: file.clone() });
151
152        std::fs::write(&file, "     ").expect("write");
153        assert_eq!(Key::from_file(&file).expect_err("only spaces"), KeyError::NotAKey { path: file.clone() });
154
155        std::fs::write(&file, "").expect("write");
156        assert_eq!(Key::from_file(&file).expect_err("empty"), KeyError::NotAKey { path: file.clone() });
157
158        // Bytes that are not text are not a key file at all, and the file is named.
159        std::fs::write(&file, [0xff, 0xfe, 0x00]).expect("write");
160        let error = Key::from_file(&file).expect_err("not text");
161        assert!(matches!(&error, KeyError::Unreadable { path, .. } if *path == file), "{error:?}");
162        assert!(!error.to_string().contains("0xff"), "the error quotes the file, not the bytes");
163        std::fs::remove_dir_all(&folder).expect("clean up");
164    }
165
166    #[test]
167    fn a_key_of_a_megabyte_in_one_line_is_the_key_the_file_holds() {
168        let folder = folder("one-megabyte");
169        let file = folder.join("key");
170        std::fs::write(&file, "k".repeat(1024 * 1024)).expect("write");
171        let key = Key::from_file(&file).expect("a key");
172        assert_eq!(key.expose().len(), 1024 * 1024, "every character of the line is the key");
173        assert_eq!(key.last_four().as_deref(), Some("kkkk"));
174        std::fs::remove_dir_all(&folder).expect("clean up");
175    }
176
177    #[test]
178    fn a_key_file_that_cannot_be_read_is_named_and_not_quoted() {
179        use std::os::unix::fs::PermissionsExt as _;
180
181        let folder = folder("unreadable");
182        let file = folder.join("key");
183        std::fs::write(&file, format!("{MADE_UP}\n")).expect("write");
184        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).expect("no rights");
185
186        match Key::from_file(&file) {
187            Err(KeyError::Unreadable { path, reason }) => {
188                assert_eq!(path, file, "the file is named");
189                assert_eq!(reason, "permission denied", "what the system said, in its own words");
190                assert!(!reason.contains(MADE_UP), "what the system said carries no key");
191            }
192            // A process that may read a file nobody else can (root) is told so and stops here.
193            Ok(_) => eprintln!("skipped: this process can read a file with no rights, so there is no error to see"),
194            Err(other) => panic!("a file with no rights cannot be anything else, got {other:?}"),
195        }
196        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).expect("give the rights back");
197        std::fs::remove_dir_all(&folder).expect("clean up");
198    }
199}