use camino::Utf8PathBuf;
use serde::Deserialize;
pub const PUBLISHERS: [&str; 4] = ["elsevier", "aps", "springer", "ieee"];
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Advisory {
InsecurePermissions {
path: String,
mode: u32,
},
UnknownPublisher {
publisher: String,
},
AgreedIgnored {
publisher: String,
},
BlankKey {
publisher: String,
},
}
impl std::fmt::Display for Advisory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InsecurePermissions { path, mode } => write!(
f,
"credentials.toml is readable beyond its owner (mode {mode:04o}); it holds \
publisher API keys. Run: chmod 600 {path}"
),
Self::UnknownPublisher { publisher } => write!(
f,
"credentials.toml has [tdm.{publisher}], which is not a publisher doiget \
knows; expected one of {PUBLISHERS:?}"
),
Self::AgreedIgnored { publisher } => write!(
f,
"credentials.toml sets [tdm.{publisher}] agreed, which doiget does NOT read. \
The per-publisher agreement is environment-only \
(DOIGET_AGREE_TDM_{}=1) so that it is an act taken in the session that runs \
the fetch — docs/LEGAL.md §6a.2. Only api_key is read from this file.",
publisher.to_uppercase()
),
Self::BlankKey { publisher } => write!(
f,
"credentials.toml sets [tdm.{publisher}] api_key to a blank value, which \
cannot authenticate and is treated as unset. Remove the line or give it a key."
),
}
}
}
#[derive(Default, Clone)]
#[non_exhaustive]
pub struct Credentials {
keys: Vec<(String, String)>,
advisories: Vec<Advisory>,
}
impl std::fmt::Debug for Credentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Credentials")
.field(
"keys",
&self
.keys
.iter()
.map(|(p, _)| format!("{p}: <redacted>"))
.collect::<Vec<_>>(),
)
.field("advisories", &self.advisories)
.finish()
}
}
impl Credentials {
#[must_use]
pub fn api_key(&self, publisher: &str) -> Option<&str> {
self.keys
.iter()
.find(|(p, _)| p == publisher)
.map(|(_, k)| k.as_str())
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.keys.len()
}
#[must_use]
pub fn advisories(&self) -> &[Advisory] {
&self.advisories
}
}
#[derive(Default, Deserialize)]
struct RawFile {
#[serde(default)]
tdm: Option<std::collections::BTreeMap<String, RawEntry>>,
#[serde(flatten)]
_other: serde::de::IgnoredAny,
}
#[derive(Default, Deserialize)]
struct RawEntry {
#[serde(default)]
api_key: Option<String>,
#[serde(default)]
agreed: Option<bool>,
#[serde(flatten)]
_other: serde::de::IgnoredAny,
}
pub fn path() -> Result<Utf8PathBuf, crate::user_extension::ConfigDirError> {
Ok(crate::user_extension::config_dir()?
.join("doiget")
.join("credentials.toml"))
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CredentialsError {
#[error("{path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
#[error("{path}:{line}:{column}: {message}")]
Parse {
path: String,
line: usize,
column: usize,
message: String,
},
}
fn redacted_parse_error(
path: &camino::Utf8Path,
text: &str,
e: &toml::de::Error,
) -> CredentialsError {
let offset = e.span().map_or(0, |s| s.start).min(text.len());
let before = &text[..offset];
let line = before.matches('\n').count() + 1;
let column = before
.rsplit_once('\n')
.map_or(before, |(_, tail)| tail)
.chars()
.count()
+ 1;
CredentialsError::Parse {
path: path.to_string(),
line,
column,
message: e.message().to_string(),
}
}
pub fn load(path: &camino::Utf8Path) -> Result<Credentials, CredentialsError> {
let text = match std::fs::read_to_string(path.as_std_path()) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Credentials::default()),
Err(source) => {
return Err(CredentialsError::Io {
path: path.to_string(),
source,
})
}
};
let mut creds = parse(&text, path)?;
if let Some(a) = permission_advisory(path) {
creds.advisories.insert(0, a);
}
Ok(creds)
}
#[must_use]
pub fn load_or_default() -> Credentials {
let path = match path() {
Ok(p) => p,
Err(e) => {
tracing::debug!(error = %e, "no config directory; credentials.toml not read");
return Credentials::default();
}
};
match load(&path) {
Ok(c) => {
for a in &c.advisories {
tracing::warn!(path = %path, "{a}");
}
c
}
Err(e) => {
tracing::warn!(
error = %e,
"credentials.toml could not be read; keys from it are unavailable and \
only DOIGET_KEY_* will be used"
);
Credentials::default()
}
}
}
fn parse(text: &str, path: &camino::Utf8Path) -> Result<Credentials, CredentialsError> {
let raw: RawFile = toml::from_str(text).map_err(|e| redacted_parse_error(path, text, &e))?;
let Some(tdm) = raw.tdm else {
return Ok(Credentials::default());
};
let mut keys = Vec::new();
let mut advisories = Vec::new();
for (publisher, entry) in tdm {
if !PUBLISHERS.contains(&publisher.as_str()) {
advisories.push(Advisory::UnknownPublisher { publisher });
continue;
}
if entry.agreed.is_some() {
advisories.push(Advisory::AgreedIgnored {
publisher: publisher.clone(),
});
}
if let Some(raw) = entry.api_key {
let key = raw.trim();
if key.is_empty() {
advisories.push(Advisory::BlankKey { publisher });
} else {
keys.push((publisher, key.to_string()));
}
}
}
Ok(Credentials { keys, advisories })
}
#[cfg(unix)]
fn permission_advisory(path: &camino::Utf8Path) -> Option<Advisory> {
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(path.as_std_path()).ok()?;
let mode = meta.permissions().mode() & 0o777;
(mode & 0o077 != 0).then(|| Advisory::InsecurePermissions {
path: path.to_string(),
mode,
})
}
#[cfg(not(unix))]
fn permission_advisory(_path: &camino::Utf8Path) -> Option<Advisory> {
None
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
fn p() -> camino::Utf8PathBuf {
camino::Utf8PathBuf::from("/tmp/credentials.toml")
}
fn ok(text: &str) -> Credentials {
parse(text, &p()).expect("well-formed credentials.toml")
}
#[test]
fn an_api_key_is_read_per_publisher() {
let c = ok("[tdm.elsevier]\napi_key = \"abc\"\n\n[tdm.aps]\napi_key = \"def\"\n");
assert_eq!(c.api_key("elsevier"), Some("abc"));
assert_eq!(c.api_key("aps"), Some("def"));
assert_eq!(c.api_key("springer"), None);
}
#[test]
fn agreed_in_the_file_grants_nothing() {
let c = ok("[tdm.elsevier]\napi_key = \"abc\"\nagreed = true\n");
assert_eq!(
c.api_key("elsevier"),
Some("abc"),
"the key is still read; only the agreement is refused"
);
}
#[test]
fn a_blank_key_is_treated_as_unset() {
let c = ok("[tdm.aps]\napi_key = \" \"\n");
assert!(c.is_empty(), "a blank key cannot authenticate");
}
#[test]
fn agreed_in_the_file_is_reported() {
let c = ok("[tdm.elsevier]\napi_key = \"abc\"\nagreed = true\n");
assert_eq!(
c.advisories(),
[Advisory::AgreedIgnored {
publisher: "elsevier".to_string()
}]
);
}
#[test]
fn an_unknown_publisher_is_reported() {
let c = ok("[tdm.wiley]\napi_key = \"abc\"\n");
assert_eq!(
c.advisories(),
[Advisory::UnknownPublisher {
publisher: "wiley".to_string()
}]
);
}
#[test]
fn a_malformed_file_is_a_reportable_error_not_a_silent_empty_set() {
match parse("this is not toml = = =", &p()) {
Err(CredentialsError::Parse { path, .. }) => {
assert!(path.contains("credentials.toml"), "path: {path}");
}
other => panic!("expected a Parse error naming the file; got {other:?}"),
}
}
#[test]
fn a_parse_error_names_the_position_without_quoting_the_line() {
let secret = "sk-do-not-print-me";
let text = format!("[tdm.elsevier]\napi_key = \"{secret}\n");
let e = parse(&text, &p()).expect_err("an unterminated string must not parse");
let rendered = format!("{e}");
assert!(
!rendered.contains(secret),
"the key leaked through Display: {rendered}"
);
assert!(
!format!("{e:?}").contains(secret),
"the key leaked through Debug: {e:?}"
);
assert!(
rendered.contains(":2:"),
"the position is still named: {rendered}"
);
}
#[test]
fn a_present_but_blank_key_is_reported_not_silently_dropped() {
for text in [
"[tdm.aps]\napi_key = \"\"\n",
"[tdm.aps]\napi_key = \" \"\n",
] {
let c = ok(text);
assert!(c.is_empty(), "{text:?} must grant no key");
assert_eq!(
c.advisories(),
[Advisory::BlankKey {
publisher: "aps".to_string()
}],
"{text:?} must be reported, not dropped"
);
}
}
#[test]
fn an_absent_key_is_not_an_advisory() {
let c = ok("[tdm.aps]\n");
assert!(c.is_empty());
assert!(c.advisories().is_empty(), "{:?}", c.advisories());
}
#[test]
fn an_unknown_publisher_table_is_skipped() {
assert!(
ok("[tdm.wiley]\napi_key = \"abc\"\n").is_empty(),
"unknown publishers grant nothing"
);
}
#[test]
fn an_absent_tdm_table_is_not_an_error() {
assert!(ok("[something_else]\nx = 1\n").is_empty());
assert!(ok("").is_empty());
}
#[test]
fn a_clean_file_produces_no_advisories() {
let c = ok("[tdm.elsevier]\napi_key = \"abc\"\n");
assert!(c.advisories().is_empty(), "{:?}", c.advisories());
}
}