Skip to main content

onetaskgraph_core/
secrets.rs

1//! The credentials file, and the resolver a plugin reads its credential through.
2//!
3//! A configuration document never carries a credential value — it names the
4//! environment variable holding one (`api_key_env: LINEAR_API_KEY`). This is the
5//! layer that answers those names: the process environment first, and a file under
6//! the configuration home for whatever the process environment does not define.
7//!
8//! Parsing here is pure; the read is [`discovery`](crate::config::read_optional)'s,
9//! like every other read this crate makes.
10
11use std::borrow::Borrow;
12use std::collections::BTreeMap;
13use std::fmt;
14use std::path::{Path, PathBuf};
15
16use onetaskgraph_plugin_api::SecretResolver;
17use schemars::JsonSchema;
18use secrecy::SecretString;
19use serde::{Deserialize, Serialize};
20
21use crate::Environment;
22use crate::config::{ConfigError, read_optional, secrets_path};
23
24/// Where a plugin's named credential is looked up.
25///
26/// # Debug redacts
27///
28/// A credential must never reach standard output, an error message, a log line, or a
29/// `Debug` rendering. `SecretString` already refuses to print itself; the
30/// implementation below goes further and prints only the *names* this resolver can
31/// answer, so even a `{:#?}` of the whole resolver carries nothing to leak.
32#[derive(Clone)]
33pub struct Secrets {
34    environment: Environment,
35    file: BTreeMap<CredentialName, SecretString>,
36    path: Option<PathBuf>,
37}
38
39impl Secrets {
40    /// Read the credentials file this environment points at.
41    ///
42    /// The path is `$ONETASKGRAPH_SECRETS_FILE`, or
43    /// `$XDG_CONFIG_HOME/onetaskgraph/secrets.env`, or
44    /// `$HOME/.config/onetaskgraph/secrets.env`. A file that is not there is not an
45    /// error: a host with both credentials exported and no file is a configured host.
46    ///
47    /// # Errors
48    ///
49    /// Returns [`ConfigError::Read`] when the file exists and cannot be read, and
50    /// [`ConfigError::Setting`] when a line of it is not `KEY=VALUE`.
51    pub fn load(environment: Environment) -> Result<Self, ConfigError> {
52        let path = secrets_path(&environment);
53        let file = match &path {
54            Some(path) => read_optional(path)?
55                .map(|text| parse(&text, path))
56                .transpose()?
57                .unwrap_or_default(),
58            None => BTreeMap::new(),
59        };
60        Ok(Self {
61            environment,
62            file,
63            path,
64        })
65    }
66
67    /// What the credentials file supplied and which layer each name resolves from.
68    ///
69    /// Names and layers only — never a value. This is what lets a user check that
70    /// their key was picked up, and which of the two layers is answering, without the
71    /// key itself ever reaching a terminal or a log.
72    #[must_use]
73    pub fn report(&self) -> SecretsReport {
74        SecretsReport {
75            path: self.path.clone(),
76            variables: self
77                .file
78                .keys()
79                .map(|variable| ResolvedCredential {
80                    variable: variable.clone(),
81                    resolved_from: if self.environment.non_empty(variable.as_str()).is_some() {
82                        CredentialLayer::Environment
83                    } else {
84                        CredentialLayer::SecretsFile
85                    },
86                })
87                .collect(),
88        }
89    }
90}
91
92impl SecretResolver for Secrets {
93    /// The process environment wins.
94    ///
95    /// A variable someone exported deliberately for this one command has to beat a
96    /// file that was written once and forgotten, or the file becomes impossible to
97    /// override without editing it.
98    fn get(&self, var: &str) -> Option<SecretString> {
99        if let Some(exported) = self.environment.non_empty(var) {
100            return Some(SecretString::from(exported.to_owned()));
101        }
102        self.file.get(var).cloned()
103    }
104}
105
106impl fmt::Debug for Secrets {
107    /// Names only. See the type's own note: every value here is a credential.
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        f.debug_struct("Secrets")
110            .field("path", &self.path)
111            .field("file_variables", &self.file.keys().collect::<Vec<_>>())
112            .field("values", &"<redacted>")
113            .finish()
114    }
115}
116
117/// Which layer answered for one credential name.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
119#[serde(rename_all = "kebab-case")]
120pub enum CredentialLayer {
121    /// The process environment already defined it, so the file's value is unused.
122    Environment,
123    /// The credentials file supplied it.
124    SecretsFile,
125}
126
127impl fmt::Display for CredentialLayer {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match self {
130            Self::Environment => f.write_str("environment"),
131            Self::SecretsFile => f.write_str("secrets file"),
132        }
133    }
134}
135
136/// The name of an environment variable a credential arrives under.
137///
138/// A newtype rather than a `String` because the domain is real and narrow — what a
139/// shell could have exported — and a report naming something outside it would be
140/// reporting a credential that no layer could ever resolve. [`CredentialName::new`] is
141/// the only way to make one, so that report cannot be built.
142#[derive(
143    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
144)]
145#[serde(into = "String", try_from = "String")]
146// As for `SettingPath`: schemars does not read `serde(into)`, and this is a string on
147// the wire.
148#[schemars(with = "String")]
149pub struct CredentialName(String);
150
151impl TryFrom<String> for CredentialName {
152    type Error = String;
153
154    /// Read a name a shell could have exported, and refuse anything else where it is
155    /// written.
156    ///
157    /// This is what lets a configuration field hold the type rather than a `String` it
158    /// checks later: a name no layer could ever resolve stops being representable at all,
159    /// instead of surfacing as a credential mysteriously reported absent.
160    fn try_from(value: String) -> Result<Self, Self::Error> {
161        Self::new(&value).ok_or_else(|| {
162            format!(
163                "{value:?} is not an environment variable name; a name is letters, digits \
164                 and underscores, not starting with a digit"
165            )
166        })
167    }
168}
169
170impl CredentialName {
171    /// `name` when a shell could have exported it, and nothing otherwise.
172    #[must_use]
173    pub fn new(name: &str) -> Option<Self> {
174        is_variable_name(name).then(|| Self(name.to_owned()))
175    }
176
177    /// The name itself.
178    #[must_use]
179    pub fn as_str(&self) -> &str {
180        &self.0
181    }
182}
183
184impl Borrow<str> for CredentialName {
185    fn borrow(&self) -> &str {
186        &self.0
187    }
188}
189
190impl From<CredentialName> for String {
191    fn from(value: CredentialName) -> Self {
192        value.0
193    }
194}
195
196impl fmt::Display for CredentialName {
197    /// Through `pad`, so a `{:width$}` of one lines a column of these up. `write_str`
198    /// would silently ignore the width, which is how the report's column stops aligning.
199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200        f.pad(&self.0)
201    }
202}
203
204/// One credential name the file supplied, and where its value comes from.
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
206pub struct ResolvedCredential {
207    /// The variable's name. Never its value.
208    pub variable: CredentialName,
209    /// The layer whose value a plugin would receive.
210    pub resolved_from: CredentialLayer,
211}
212
213/// What the credentials file supplied, without supplying it.
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
215pub struct SecretsReport {
216    /// The file that was looked for, whether or not it was there.
217    pub path: Option<PathBuf>,
218    /// Every name the file defines, in order.
219    pub variables: Vec<ResolvedCredential>,
220}
221
222/// Read a `KEY=VALUE` credentials file.
223///
224/// Blank lines are skipped and a line whose first non-blank character is `#` is a
225/// comment. A value may be wrapped in matching single or double quotes, which are
226/// stripped; otherwise it is the rest of the line with surrounding blanks removed,
227/// `#` included — a credential may contain one, and guessing where a comment starts
228/// inside a value would silently truncate a key.
229///
230/// # Errors
231///
232/// Returns [`ConfigError::Setting`] naming the file and the line number when a line
233/// is not `KEY=VALUE` or its key is not a usable variable name. Neither message
234/// carries any part of the line's value.
235fn parse(text: &str, path: &Path) -> Result<BTreeMap<CredentialName, SecretString>, ConfigError> {
236    let mut values = BTreeMap::new();
237    for (index, line) in text.lines().enumerate() {
238        let number = index + 1;
239        let line = line.trim();
240        if line.is_empty() || line.starts_with('#') {
241            continue;
242        }
243        let line = line.strip_prefix("export ").unwrap_or(line);
244
245        let Some((name, value)) = line.split_once('=') else {
246            return Err(ConfigError::setting(
247                format!("{}:{number}", path.display()),
248                "this line is not `KEY=VALUE`",
249                "write it as `NAME=value`, comment it out with `#`, or delete it.",
250            ));
251        };
252        let name = name.trim();
253        let Some(name) = CredentialName::new(name) else {
254            return Err(ConfigError::setting(
255                format!("{}:{number}", path.display()),
256                format!("{name:?} is not a usable environment variable name"),
257                "use letters, digits and underscores, starting with a letter or an \
258                 underscore — the names this product reads are LINEAR_API_KEY and \
259                 GH_PROJECTS_TOKEN.",
260            ));
261        };
262        values.insert(name, SecretString::from(unquote(value.trim())));
263    }
264    Ok(values)
265}
266
267/// Whether `name` is something a shell could have exported.
268fn is_variable_name(name: &str) -> bool {
269    let mut characters = name.chars();
270    characters
271        .next()
272        .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
273        && characters.all(|character| character.is_ascii_alphanumeric() || character == '_')
274}
275
276/// Strip one matching pair of surrounding quotes, if there is one.
277fn unquote(value: &str) -> String {
278    for quote in ['"', '\''] {
279        if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) {
280            return value[1..value.len() - 1].to_owned();
281        }
282    }
283    value.to_owned()
284}