use std::borrow::Borrow;
use std::collections::BTreeMap;
use std::fmt;
use std::path::{Path, PathBuf};
use onetaskgraph_plugin_api::SecretResolver;
use schemars::JsonSchema;
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use crate::Environment;
use crate::config::{ConfigError, read_optional, secrets_path};
#[derive(Clone)]
pub struct Secrets {
environment: Environment,
file: BTreeMap<CredentialName, SecretString>,
path: Option<PathBuf>,
}
impl Secrets {
pub fn load(environment: Environment) -> Result<Self, ConfigError> {
let path = secrets_path(&environment);
let file = match &path {
Some(path) => read_optional(path)?
.map(|text| parse(&text, path))
.transpose()?
.unwrap_or_default(),
None => BTreeMap::new(),
};
Ok(Self {
environment,
file,
path,
})
}
#[must_use]
pub fn report(&self) -> SecretsReport {
SecretsReport {
path: self.path.clone(),
variables: self
.file
.keys()
.map(|variable| ResolvedCredential {
variable: variable.clone(),
resolved_from: if self.environment.non_empty(variable.as_str()).is_some() {
CredentialLayer::Environment
} else {
CredentialLayer::SecretsFile
},
})
.collect(),
}
}
}
impl SecretResolver for Secrets {
fn get(&self, var: &str) -> Option<SecretString> {
if let Some(exported) = self.environment.non_empty(var) {
return Some(SecretString::from(exported.to_owned()));
}
self.file.get(var).cloned()
}
}
impl fmt::Debug for Secrets {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Secrets")
.field("path", &self.path)
.field("file_variables", &self.file.keys().collect::<Vec<_>>())
.field("values", &"<redacted>")
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum CredentialLayer {
Environment,
SecretsFile,
}
impl fmt::Display for CredentialLayer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Environment => f.write_str("environment"),
Self::SecretsFile => f.write_str("secrets file"),
}
}
}
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
)]
#[serde(into = "String", try_from = "String")]
#[schemars(with = "String")]
pub struct CredentialName(String);
impl TryFrom<String> for CredentialName {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(&value).ok_or_else(|| {
format!(
"{value:?} is not an environment variable name; a name is letters, digits \
and underscores, not starting with a digit"
)
})
}
}
impl CredentialName {
#[must_use]
pub fn new(name: &str) -> Option<Self> {
is_variable_name(name).then(|| Self(name.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Borrow<str> for CredentialName {
fn borrow(&self) -> &str {
&self.0
}
}
impl From<CredentialName> for String {
fn from(value: CredentialName) -> Self {
value.0
}
}
impl fmt::Display for CredentialName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
pub struct ResolvedCredential {
pub variable: CredentialName,
pub resolved_from: CredentialLayer,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
pub struct SecretsReport {
pub path: Option<PathBuf>,
pub variables: Vec<ResolvedCredential>,
}
fn parse(text: &str, path: &Path) -> Result<BTreeMap<CredentialName, SecretString>, ConfigError> {
let mut values = BTreeMap::new();
for (index, line) in text.lines().enumerate() {
let number = index + 1;
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let Some((name, value)) = line.split_once('=') else {
return Err(ConfigError::setting(
format!("{}:{number}", path.display()),
"this line is not `KEY=VALUE`",
"write it as `NAME=value`, comment it out with `#`, or delete it.",
));
};
let name = name.trim();
let Some(name) = CredentialName::new(name) else {
return Err(ConfigError::setting(
format!("{}:{number}", path.display()),
format!("{name:?} is not a usable environment variable name"),
"use letters, digits and underscores, starting with a letter or an \
underscore — the names this product reads are LINEAR_API_KEY and \
GH_PROJECTS_TOKEN.",
));
};
values.insert(name, SecretString::from(unquote(value.trim())));
}
Ok(values)
}
fn is_variable_name(name: &str) -> bool {
let mut characters = name.chars();
characters
.next()
.is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
&& characters.all(|character| character.is_ascii_alphanumeric() || character == '_')
}
fn unquote(value: &str) -> String {
for quote in ['"', '\''] {
if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) {
return value[1..value.len() - 1].to_owned();
}
}
value.to_owned()
}