Skip to main content

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}