doiget_core/credentials.rs
1//! `credentials.toml` — the per-publisher TDM **API keys** (#509).
2//!
3//! `docs/CONFIG.md` §6 specified this file in full — schema, precedence, and
4//! a `0600` permission warning — and nothing read it. A user who followed a
5//! NORMATIVE document wrote their Elsevier key into a file doiget ignored,
6//! and the source then reported itself unavailable for want of a key. That
7//! is the #442 / #454 / #476 shape, landed on credentials.
8//!
9//! ## What this file carries, and what it deliberately does not
10//!
11//! **`api_key`: yes.** A long-lived key is genuinely better off in a file
12//! than in the environment. It survives a shell restart, it is not visible
13//! in `ps`, and it does not leak into the environment of every subprocess.
14//! The `0600` check is then a real control rather than a sentence — which
15//! means it has to be *visible*, so it is an [`Advisory`] that `config
16//! doctor` prints rather than a `tracing::warn!` the default log level
17//! throws away.
18//!
19//! **`agreed`: no.** `docs/LEGAL.md` §6a.2 makes the per-publisher
20//! agreement an *enforced control*, and part of why it is meaningful is
21//! that `DOIGET_AGREE_TDM_<PUBLISHER>=1` is a variable the user sets in the
22//! session that runs the fetch. A boolean written once into a file and
23//! forgotten is a weaker act of consent, and weakening it as a side effect
24//! of adding a convenience is the kind of accident ADR-0048 was written
25//! about. So the key may come from either place; **the agreement is
26//! environment-only**, and an `agreed` key here is reported rather than
27//! silently discarded — a documented field with no reader is the defect
28//! this module exists to close.
29//!
30//! ## Precedence
31//!
32//! `DOIGET_KEY_<PUBLISHER>` wins over `[tdm.<publisher>] api_key`, matching
33//! the `docs/CONFIG.md` §1 chain and the `store_root` / `contact_email`
34//! rungs (#441, #504).
35
36use camino::Utf8PathBuf;
37use serde::Deserialize;
38
39/// Publisher slugs this file may carry, matching the `[tdm.<slug>]` tables
40/// in `docs/CONFIG.md` §6 and the `tdm-<slug>` Cargo features.
41pub const PUBLISHERS: [&str; 4] = ["elsevier", "aps", "springer", "ieee"];
42
43/// Something worth telling the user that does not stop the rest of the
44/// file being used.
45///
46/// These were `tracing::warn!` calls and nothing else, which the CLI's
47/// `EnvFilter::from_default_env()` suppresses at the default level — so the
48/// permission warning `docs/CONFIG.md` §6 promises, and the "you typed a key
49/// that did not load" line, reached nobody. Carrying them as data lets
50/// `config doctor` print them, and lets tests assert on them rather than on
51/// log output that no assertion can see.
52///
53/// File-level failures (unreadable, malformed) are [`CredentialsError`]
54/// instead: those invalidate the whole file rather than one entry.
55#[derive(Debug, Clone, PartialEq, Eq)]
56#[non_exhaustive]
57pub enum Advisory {
58 /// The file is readable beyond its owner. POSIX only.
59 InsecurePermissions {
60 /// The offending file.
61 path: String,
62 /// The permission bits, masked to `0o777`.
63 mode: u32,
64 },
65 /// `[tdm.<publisher>]` names a publisher doiget does not know.
66 UnknownPublisher {
67 /// The unrecognised slug.
68 publisher: String,
69 },
70 /// `agreed` is set. It is parsed only so it can be reported; the
71 /// agreement is environment-only (`docs/LEGAL.md` §6a.2).
72 AgreedIgnored {
73 /// The publisher whose table carried it.
74 publisher: String,
75 },
76 /// `api_key` is present but blank, so it cannot authenticate.
77 ///
78 /// Covers both `api_key = ""` and a whitespace-only value. The first is
79 /// the commoner typo and was, before this, indistinguishable from the
80 /// key being absent — `Option::unwrap_or_default()` collapsed `None` and
81 /// `Some("")` to the same empty string.
82 BlankKey {
83 /// The publisher whose table carried it.
84 publisher: String,
85 },
86}
87
88impl std::fmt::Display for Advisory {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 match self {
91 Self::InsecurePermissions { path, mode } => write!(
92 f,
93 "credentials.toml is readable beyond its owner (mode {mode:04o}); it holds \
94 publisher API keys. Run: chmod 600 {path}"
95 ),
96 Self::UnknownPublisher { publisher } => write!(
97 f,
98 "credentials.toml has [tdm.{publisher}], which is not a publisher doiget \
99 knows; expected one of {PUBLISHERS:?}"
100 ),
101 Self::AgreedIgnored { publisher } => write!(
102 f,
103 "credentials.toml sets [tdm.{publisher}] agreed, which doiget does NOT read. \
104 The per-publisher agreement is environment-only \
105 (DOIGET_AGREE_TDM_{}=1) so that it is an act taken in the session that runs \
106 the fetch — docs/LEGAL.md §6a.2. Only api_key is read from this file.",
107 publisher.to_uppercase()
108 ),
109 Self::BlankKey { publisher } => write!(
110 f,
111 "credentials.toml sets [tdm.{publisher}] api_key to a blank value, which \
112 cannot authenticate and is treated as unset. Remove the line or give it a key."
113 ),
114 }
115 }
116}
117
118/// Parsed `credentials.toml`.
119///
120/// An absent file yields the default (no keys); unreadable and malformed are
121/// [`CredentialsError`], which [`load_or_default`] turns back into the
122/// default for the fetch path so one bad line cannot take a fetch down.
123/// Per-entry problems ride along as [`Advisory`] values. Nothing about this
124/// file is dropped in silence — that is what cost a user their configuration.
125#[derive(Default, Clone)]
126#[non_exhaustive]
127pub struct Credentials {
128 keys: Vec<(String, String)>,
129 advisories: Vec<Advisory>,
130}
131
132/// Hand-written so a stray `{:?}` cannot print a publisher API key.
133///
134/// `TdmGrant` wraps the same value in `secrecy::SecretString` precisely so
135/// `Debug` never renders it; between this file and that wrapper the key is
136/// a plain `String`, and a `#[derive(Debug)]` here would have re-opened the
137/// hole one hop earlier. Publisher names are not secret and are shown.
138impl std::fmt::Debug for Credentials {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 f.debug_struct("Credentials")
141 .field(
142 "keys",
143 &self
144 .keys
145 .iter()
146 .map(|(p, _)| format!("{p}: <redacted>"))
147 .collect::<Vec<_>>(),
148 )
149 .field("advisories", &self.advisories)
150 .finish()
151 }
152}
153
154impl Credentials {
155 /// The `api_key` for `publisher`, if the file supplied one.
156 #[must_use]
157 pub fn api_key(&self, publisher: &str) -> Option<&str> {
158 self.keys
159 .iter()
160 .find(|(p, _)| p == publisher)
161 .map(|(_, k)| k.as_str())
162 }
163
164 /// True when the file carried no usable key at all.
165 #[must_use]
166 pub fn is_empty(&self) -> bool {
167 self.keys.is_empty()
168 }
169
170 /// How many publishers supplied a usable key.
171 ///
172 /// For `config doctor`, which reports that a credentials file was read
173 /// without printing anything from it.
174 #[must_use]
175 pub fn len(&self) -> usize {
176 self.keys.len()
177 }
178
179 /// Everything worth telling the user that did not stop the file being
180 /// used. See [`Advisory`].
181 #[must_use]
182 pub fn advisories(&self) -> &[Advisory] {
183 &self.advisories
184 }
185}
186
187// No `Debug` on either raw type: `RawEntry::api_key` is the untrimmed key
188// straight off disk, one hop before the redacting `Debug` on `Credentials`.
189// A derive here is what a future `tracing::debug!(?raw, ..)` would leak
190// through.
191#[derive(Default, Deserialize)]
192struct RawFile {
193 #[serde(default)]
194 tdm: Option<std::collections::BTreeMap<String, RawEntry>>,
195 #[serde(flatten)]
196 _other: serde::de::IgnoredAny,
197}
198
199#[derive(Default, Deserialize)]
200struct RawEntry {
201 #[serde(default)]
202 api_key: Option<String>,
203 /// Parsed only so its presence can be *reported*. The agreement is
204 /// environment-only (`docs/LEGAL.md` §6a.2); see the module docs.
205 #[serde(default)]
206 agreed: Option<bool>,
207 #[serde(flatten)]
208 _other: serde::de::IgnoredAny,
209}
210
211/// `<config_dir>/doiget/credentials.toml`.
212///
213/// # Errors
214///
215/// As [`crate::user_extension::config_dir`].
216pub fn path() -> Result<Utf8PathBuf, crate::user_extension::ConfigDirError> {
217 Ok(crate::user_extension::config_dir()?
218 .join("doiget")
219 .join("credentials.toml"))
220}
221
222/// Why `credentials.toml` could not be read.
223///
224/// Exists so `doiget config doctor` can *report* the failure. Without it
225/// the only surface was `tracing::warn!`, which the CLI's
226/// `EnvFilter::from_default_env()` suppresses at the default level — so a
227/// malformed or unreadable credentials file produced no warning, no doctor
228/// line, and only the downstream "source unavailable" this module exists to
229/// prevent.
230#[derive(Debug, thiserror::Error)]
231#[non_exhaustive]
232pub enum CredentialsError {
233 /// The file exists but could not be read.
234 #[error("{path}: {source}")]
235 Io {
236 /// The file that could not be read.
237 path: String,
238 /// The underlying filesystem error.
239 source: std::io::Error,
240 },
241 /// The file is not valid TOML.
242 ///
243 /// Deliberately **not** carrying the `toml::de::Error`. Its `Display`
244 /// renders the offending source line verbatim, and the commonest way
245 /// this file is malformed is an unterminated `api_key = "..."` — so the
246 /// error most likely to be printed is the one whose snippet *is* the
247 /// key. Its `Debug` is worse: it holds the whole file. `config doctor`
248 /// prints this variant, so it gets the position and the parser's
249 /// message and nothing off the line itself.
250 #[error("{path}:{line}:{column}: {message}")]
251 Parse {
252 /// The file that failed to parse.
253 path: String,
254 /// 1-based line of the failure.
255 line: usize,
256 /// 1-based column of the failure.
257 column: usize,
258 /// The parser's message, which quotes no file content.
259 message: String,
260 },
261}
262
263/// Build a [`CredentialsError::Parse`] that names the position without
264/// echoing anything from the file. See the variant's own note.
265fn redacted_parse_error(
266 path: &camino::Utf8Path,
267 text: &str,
268 e: &toml::de::Error,
269) -> CredentialsError {
270 let offset = e.span().map_or(0, |s| s.start).min(text.len());
271 let before = &text[..offset];
272 let line = before.matches('\n').count() + 1;
273 let column = before
274 .rsplit_once('\n')
275 .map_or(before, |(_, tail)| tail)
276 .chars()
277 .count()
278 + 1;
279 CredentialsError::Parse {
280 path: path.to_string(),
281 line,
282 column,
283 message: e.message().to_string(),
284 }
285}
286
287/// Load `credentials.toml` from an explicit path, surfacing failures.
288///
289/// A missing file is `Ok(empty)` — TDM is opt-in and most installs have no
290/// such file. Mirrors [`crate::user_extension::load`], which `config
291/// doctor` already reports on the same way.
292///
293/// # Errors
294///
295/// [`CredentialsError::Io`] for a read failure other than not-found;
296/// [`CredentialsError::Parse`] for malformed TOML.
297pub fn load(path: &camino::Utf8Path) -> Result<Credentials, CredentialsError> {
298 let text = match std::fs::read_to_string(path.as_std_path()) {
299 Ok(t) => t,
300 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Credentials::default()),
301 Err(source) => {
302 return Err(CredentialsError::Io {
303 path: path.to_string(),
304 source,
305 })
306 }
307 };
308 let mut creds = parse(&text, path)?;
309 // Prepended: a world-readable key file is the most serious thing this
310 // function can find, and `config doctor` prints advisories in order.
311 if let Some(a) = permission_advisory(path) {
312 creds.advisories.insert(0, a);
313 }
314 Ok(creds)
315}
316
317/// Load the credentials file, or the empty set.
318///
319/// Never fails; every failure mode is warned about. This is the fetch-path
320/// entry point — one bad line must not take a fetch down. Callers that want
321/// to *report* the failure use [`load`] instead. See [`Credentials`].
322#[must_use]
323pub fn load_or_default() -> Credentials {
324 let path = match path() {
325 Ok(p) => p,
326 Err(e) => {
327 tracing::debug!(error = %e, "no config directory; credentials.toml not read");
328 return Credentials::default();
329 }
330 };
331 match load(&path) {
332 Ok(c) => {
333 // The fetch path has no checklist to print to, so advisories
334 // stay logs here. `config doctor` is the surface that shows
335 // them unconditionally.
336 for a in &c.advisories {
337 tracing::warn!(path = %path, "{a}");
338 }
339 c
340 }
341 Err(e) => {
342 tracing::warn!(
343 error = %e,
344 "credentials.toml could not be read; keys from it are unavailable and \
345 only DOIGET_KEY_* will be used"
346 );
347 Credentials::default()
348 }
349 }
350}
351
352/// Parse a `credentials.toml` body. Pure; the path is for messages only.
353fn parse(text: &str, path: &camino::Utf8Path) -> Result<Credentials, CredentialsError> {
354 // A malformed file is a FILE-level failure and is returned, so
355 // `config doctor` can name it. The per-entry problems below (unknown
356 // publisher, an `agreed` key, a blank value) are advisories: they do
357 // not invalidate the rest of the file, so they stay warnings.
358 let raw: RawFile = toml::from_str(text).map_err(|e| redacted_parse_error(path, text, &e))?;
359 let Some(tdm) = raw.tdm else {
360 return Ok(Credentials::default());
361 };
362
363 let mut keys = Vec::new();
364 let mut advisories = Vec::new();
365 for (publisher, entry) in tdm {
366 if !PUBLISHERS.contains(&publisher.as_str()) {
367 advisories.push(Advisory::UnknownPublisher { publisher });
368 continue;
369 }
370 if entry.agreed.is_some() {
371 // Reported, not obeyed. A field parsed and discarded in silence
372 // is exactly what #509 is about.
373 advisories.push(Advisory::AgreedIgnored {
374 publisher: publisher.clone(),
375 });
376 }
377 // Blank means unset, as everywhere else: an empty key cannot
378 // authenticate, and building a grant around one would mask the
379 // misconfiguration `AgreedButNoKey` exists to surface.
380 //
381 // It still gets a line. A present-but-blank value is a thing the
382 // user typed and believes is configured, so dropping it in silence
383 // is the #442 / #476 shape this module was written to close.
384 //
385 // Matched on the `Option` rather than through `unwrap_or_default()`:
386 // that collapsed `None` (no line at all, which is normal) and
387 // `Some("")` (a line the user wrote, which is the typo) into the
388 // same empty string, so the commonest form of the very case this
389 // reports produced no advisory at all.
390 if let Some(raw) = entry.api_key {
391 let key = raw.trim();
392 if key.is_empty() {
393 advisories.push(Advisory::BlankKey { publisher });
394 } else {
395 keys.push((publisher, key.to_string()));
396 }
397 }
398 }
399 Ok(Credentials { keys, advisories })
400}
401
402/// Report when the file is group- or world-accessible on POSIX.
403///
404/// `docs/CONFIG.md` §6 promised this warning from the day it was written.
405/// It did not exist, and neither did the reader it was attached to (#509).
406/// Returned as data rather than logged, so `config doctor` can show it — as
407/// a `tracing::warn!` it was invisible at the CLI's default log level, which
408/// made "the `0600` check is a real control" untrue.
409#[cfg(unix)]
410fn permission_advisory(path: &camino::Utf8Path) -> Option<Advisory> {
411 use std::os::unix::fs::PermissionsExt;
412 let meta = std::fs::metadata(path.as_std_path()).ok()?;
413 let mode = meta.permissions().mode() & 0o777;
414 (mode & 0o077 != 0).then(|| Advisory::InsecurePermissions {
415 path: path.to_string(),
416 mode,
417 })
418}
419
420/// `None` off POSIX: Windows ACLs are not a mode, and a warning phrased in
421/// `chmod` terms would be advice the user cannot follow.
422#[cfg(not(unix))]
423fn permission_advisory(_path: &camino::Utf8Path) -> Option<Advisory> {
424 None
425}
426
427#[cfg(test)]
428#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
429mod tests {
430 use super::*;
431
432 fn p() -> camino::Utf8PathBuf {
433 camino::Utf8PathBuf::from("/tmp/credentials.toml")
434 }
435
436 /// Parse and unwrap, for the cases where the file is well-formed.
437 fn ok(text: &str) -> Credentials {
438 parse(text, &p()).expect("well-formed credentials.toml")
439 }
440
441 #[test]
442 fn an_api_key_is_read_per_publisher() {
443 let c = ok("[tdm.elsevier]\napi_key = \"abc\"\n\n[tdm.aps]\napi_key = \"def\"\n");
444 assert_eq!(c.api_key("elsevier"), Some("abc"));
445 assert_eq!(c.api_key("aps"), Some("def"));
446 assert_eq!(c.api_key("springer"), None);
447 }
448
449 /// The whole point of the split: the file may carry the key, never the
450 /// agreement (`docs/LEGAL.md` §6a.2). There is no accessor for `agreed`
451 /// at all, so no caller can be tempted to consult one.
452 #[test]
453 fn agreed_in_the_file_grants_nothing() {
454 let c = ok("[tdm.elsevier]\napi_key = \"abc\"\nagreed = true\n");
455 assert_eq!(
456 c.api_key("elsevier"),
457 Some("abc"),
458 "the key is still read; only the agreement is refused"
459 );
460 }
461
462 #[test]
463 fn a_blank_key_is_treated_as_unset() {
464 let c = ok("[tdm.aps]\napi_key = \" \"\n");
465 assert!(c.is_empty(), "a blank key cannot authenticate");
466 }
467
468 /// `agreed` is refused, but its presence must be *said*, not swallowed.
469 #[test]
470 fn agreed_in_the_file_is_reported() {
471 let c = ok("[tdm.elsevier]\napi_key = \"abc\"\nagreed = true\n");
472 assert_eq!(
473 c.advisories(),
474 [Advisory::AgreedIgnored {
475 publisher: "elsevier".to_string()
476 }]
477 );
478 }
479
480 #[test]
481 fn an_unknown_publisher_is_reported() {
482 let c = ok("[tdm.wiley]\napi_key = \"abc\"\n");
483 assert_eq!(
484 c.advisories(),
485 [Advisory::UnknownPublisher {
486 publisher: "wiley".to_string()
487 }]
488 );
489 }
490
491 /// A malformed file is a FILE-level failure, returned rather than
492 /// warned about, so `doiget config doctor` can name it. Before #509's
493 /// follow-up the only surface was `tracing::warn!`, which the CLI's
494 /// default `EnvFilter` suppresses — the user saw nothing at all.
495 #[test]
496 fn a_malformed_file_is_a_reportable_error_not_a_silent_empty_set() {
497 match parse("this is not toml = = =", &p()) {
498 Err(CredentialsError::Parse { path, .. }) => {
499 assert!(path.contains("credentials.toml"), "path: {path}");
500 }
501 other => panic!("expected a Parse error naming the file; got {other:?}"),
502 }
503 }
504
505 /// `config doctor` prints this error. `toml::de::Error`'s own `Display`
506 /// quotes the offending source line, and the commonest malformation of
507 /// this file is an unterminated `api_key = "..."` — so rendering it
508 /// would print the key to the terminal, from the one module whose whole
509 /// job is that the key is never printed.
510 #[test]
511 fn a_parse_error_names_the_position_without_quoting_the_line() {
512 let secret = "sk-do-not-print-me";
513 let text = format!("[tdm.elsevier]\napi_key = \"{secret}\n");
514 let e = parse(&text, &p()).expect_err("an unterminated string must not parse");
515 let rendered = format!("{e}");
516 assert!(
517 !rendered.contains(secret),
518 "the key leaked through Display: {rendered}"
519 );
520 assert!(
521 !format!("{e:?}").contains(secret),
522 "the key leaked through Debug: {e:?}"
523 );
524 assert!(
525 rendered.contains(":2:"),
526 "the position is still named: {rendered}"
527 );
528 }
529
530 /// A blank value is a thing the user typed. It is still treated as
531 /// unset, but it must not disappear without a word.
532 ///
533 /// Asserts on the advisory, not just on the resulting key count: the
534 /// previous version of this test used `api_key = ""` and checked only
535 /// `is_empty()`, so it passed while the reporting it is named for did
536 /// not happen at all for that exact input.
537 #[test]
538 fn a_present_but_blank_key_is_reported_not_silently_dropped() {
539 for text in [
540 "[tdm.aps]\napi_key = \"\"\n",
541 "[tdm.aps]\napi_key = \" \"\n",
542 ] {
543 let c = ok(text);
544 assert!(c.is_empty(), "{text:?} must grant no key");
545 assert_eq!(
546 c.advisories(),
547 [Advisory::BlankKey {
548 publisher: "aps".to_string()
549 }],
550 "{text:?} must be reported, not dropped"
551 );
552 }
553 }
554
555 /// The absent case is normal and must stay quiet, or the advisory list
556 /// fills with noise on every well-formed file and stops being read.
557 #[test]
558 fn an_absent_key_is_not_an_advisory() {
559 let c = ok("[tdm.aps]\n");
560 assert!(c.is_empty());
561 assert!(c.advisories().is_empty(), "{:?}", c.advisories());
562 }
563
564 #[test]
565 fn an_unknown_publisher_table_is_skipped() {
566 assert!(
567 ok("[tdm.wiley]\napi_key = \"abc\"\n").is_empty(),
568 "unknown publishers grant nothing"
569 );
570 }
571
572 #[test]
573 fn an_absent_tdm_table_is_not_an_error() {
574 assert!(ok("[something_else]\nx = 1\n").is_empty());
575 assert!(ok("").is_empty());
576 }
577
578 /// A well-formed file must have nothing to say.
579 #[test]
580 fn a_clean_file_produces_no_advisories() {
581 let c = ok("[tdm.elsevier]\napi_key = \"abc\"\n");
582 assert!(c.advisories().is_empty(), "{:?}", c.advisories());
583 }
584}