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}