use std::fmt;
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use toml::{Table, Value};
use toml_edit::{DocumentMut, Item};
use crate::{config, progress};
pub struct ResendKey {
pub secret: String,
pub source: Source,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
Environment,
Credentials(PathBuf),
}
impl fmt::Display for Source {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Source::Environment => write!(f, "RESEND_API_KEY"),
Source::Credentials(path) => write!(f, "the Credentials {}", path.display()),
}
}
}
impl ResendKey {
pub fn find() -> Result<Option<Self>> {
match from_environment() {
Some(key) => Ok(Some(key)),
None => Ok(Credentials::read()?.key()),
}
}
pub fn require() -> Result<Self> {
match ResendKey::find()? {
Some(key) => Ok(key),
None => bail!("{}", missing(&path()?)),
}
}
}
fn from_environment() -> Option<ResendKey> {
environment_key(std::env::var("RESEND_API_KEY").ok())
}
fn environment_key(value: Option<String>) -> Option<ResendKey> {
value.filter(|key| !key.is_empty()).map(|secret| ResendKey {
secret,
source: Source::Environment,
})
}
pub struct Credentials {
path: PathBuf,
text: Option<String>,
key: Option<String>,
}
impl Credentials {
pub fn read() -> Result<Self> {
let path = path()?;
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Credentials {
path,
text: None,
key: None,
});
}
Err(error) => {
return Err(error).with_context(|| format!("can't read {}", path.display()));
}
};
let source = Source::Credentials(path.clone());
let key = parse(&text, &source)?;
warn_if_others_can_read(&path, &source);
Ok(Credentials {
path,
text: Some(text),
key,
})
}
pub fn lookup(&self) -> Option<ResendKey> {
from_environment().or_else(|| self.key())
}
fn key(&self) -> Option<ResendKey> {
self.key.clone().map(|secret| ResendKey {
secret,
source: Source::Credentials(self.path.clone()),
})
}
pub fn save(&self, secret: &str) -> Result<()> {
let written = match &self.text {
None => create(&self.path, &with_key("", secret)?),
Some(text) => config::replace(&self.path, &with_key(text, secret)?),
};
written.with_context(|| format!("can't write {}", self.path.display()))
}
}
impl fmt::Display for Credentials {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "the Credentials {}", self.path.display())
}
}
pub fn is_key(text: &str) -> bool {
text.starts_with("re_")
}
fn with_key(text: &str, secret: &str) -> Result<String> {
let mut document: DocumentMut = text.parse().context("can't parse the Credentials")?;
let Some(resend) = document.get_mut("resend") else {
let mut section = DocumentMut::new();
section["resend"]["key"] = toml_edit::value(secret);
let mut text = text.to_string();
if !text.trim().is_empty() {
if !text.ends_with('\n') {
text.push('\n');
}
text.push('\n');
}
text.push_str(§ion.to_string());
return Ok(text);
};
let resend = resend
.as_table_like_mut()
.context("resend must be a section in the Credentials")?;
match resend.get_mut("key").and_then(Item::as_value_mut) {
Some(old) => {
let decor = old.decor().clone();
*old = secret.into();
*old.decor_mut() = decor;
}
None => {
resend.insert("key", toml_edit::value(secret));
}
}
Ok(document.to_string())
}
fn create(path: &Path, text: &str) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?
.write_all(text.as_bytes())
}
fn path() -> Result<PathBuf> {
Ok(config::home()?.join(".thirdshift/credentials.toml"))
}
fn parse(text: &str, file: &Source) -> Result<Option<String>> {
let table: Table = text
.parse()
.map_err(|error| anyhow!("{error}"))
.with_context(|| format!("can't parse {file}"))?;
let mut found = None;
for (section, value) in &table {
let settings = match (section.as_str(), value) {
("resend", Value::Table(settings)) => settings,
("resend", _) => bail!("resend must be the section [resend] in {file}"),
(_, Value::Table(_)) => bail!("unknown section [{section}] in {file}"),
_ => bail!("unknown key {section} in {file}"),
};
for (key, value) in settings {
match (key.as_str(), value) {
("key", Value::String(key)) => found = Some(key.clone()),
("key", _) => bail!("resend.key must be a quoted Resend API key in {file}"),
_ => bail!("unknown key resend.{key} in {file}"),
}
}
}
Ok(found.filter(|key| !key.is_empty()))
}
fn warn_if_others_can_read(path: &Path, file: &Source) {
let mode = std::fs::metadata(path).map(|metadata| metadata.permissions().mode());
if mode.is_ok_and(|mode| mode & 0o077 != 0) {
progress::step(format_args!(
"warning: others can read {file}; chmod 600 {}",
path.display()
));
}
}
fn missing(path: &Path) -> String {
format!(
"no Resend API key. Either:\n \
- run `thirdshift setup`, or\n \
- add it to {} (mode 600):\n \
[resend]\n \
key = \"re_...\"\n \
- or set RESEND_API_KEY in the environment the Run starts from\n \
(a crontab line, CI secret, or a shell profile the Run's shell reads)",
path.display()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resend_api_key_unset_or_empty_holds_no_key() {
for value in [None, Some(String::new())] {
assert!(environment_key(value.clone()).is_none(), "{value:?}");
}
let key = environment_key(Some("re_test_123".into())).unwrap();
assert_eq!(key.secret, "re_test_123");
assert_eq!(key.source, Source::Environment);
}
#[test]
fn the_credentials_key_is_read_and_an_empty_one_is_none() {
let file = Source::Credentials(PathBuf::from("/home/me/.thirdshift/credentials.toml"));
assert_eq!(
parse("[resend]\nkey = \"re_file_456\"\n", &file).unwrap(),
Some("re_file_456".to_string())
);
for text in ["", "[resend]\n", "[resend]\nkey = \"\"\n"] {
assert_eq!(parse(text, &file).unwrap(), None, "{text:?}");
}
}
#[test]
fn a_saved_key_is_replaced_in_place_keeping_the_rest_of_the_credentials() {
let saved = "# My secrets.\n[resend]\n# Rotated monthly.\nkey = \"re_saved_456\" # from the dashboard\n";
assert_eq!(
with_key(saved, "re_secret_123").unwrap(),
"# My secrets.\n[resend]\n# Rotated monthly.\nkey = \"re_secret_123\" # from the dashboard\n"
);
}
#[test]
fn a_key_is_added_after_credentials_that_hold_none() {
let text = with_key("# Keys go here.\n", "re_secret_123").unwrap();
assert!(text.starts_with("# Keys go here.\n"), "{text}");
let file = Source::Credentials(PathBuf::from("/home/me/.thirdshift/credentials.toml"));
assert_eq!(
parse(&text, &file).unwrap().as_deref(),
Some("re_secret_123")
);
}
#[test]
fn broken_credentials_are_an_error_naming_the_file_and_what_is_wrong() {
let path = "/home/me/.thirdshift/credentials.toml";
let file = Source::Credentials(PathBuf::from(path));
for (text, named) in [
("[resend\n", "can't parse"),
("[resend]\nkye = \"re_file_456\"\n", "resend.kye"),
("[resend]\nkey = true\n", "resend.key"),
("resend = 1\n", "resend must be the section [resend]"),
("[email]\n", "unknown section [email]"),
("key = \"re_file_456\"\n", "unknown key key"),
] {
let error = format!("{:#}", parse(text, &file).unwrap_err());
for part in [named, path] {
assert!(error.contains(part), "{text:?}: no {part:?} in {error}");
}
}
}
}