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>,
secrets: Secrets,
}
#[derive(Default)]
struct Secrets {
key: Option<String>,
claude_setup_token: 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,
secrets: Secrets::default(),
});
}
Err(error) => {
return Err(error).with_context(|| format!("can't read {}", path.display()));
}
};
let source = Source::Credentials(path.clone());
let secrets = parse(&text, &source)?;
warn_if_others_can_read(&path, &source);
Ok(Credentials {
path,
text: Some(text),
secrets,
})
}
pub fn lookup(&self) -> Option<ResendKey> {
from_environment().or_else(|| self.key())
}
fn key(&self) -> Option<ResendKey> {
self.secrets.key.clone().map(|secret| ResendKey {
secret,
source: Source::Credentials(self.path.clone()),
})
}
pub fn has_claude_setup_token(&self) -> bool {
self.secrets.claude_setup_token.is_some()
}
pub fn save(&self, key: Option<&str>, claude_setup_token: Option<&str>) -> Result<()> {
let mut text = self.text.clone().unwrap_or_default();
if let Some(key) = key {
text = with_secret(&text, "resend", "key", key)?;
}
if let Some(token) = claude_setup_token {
text = with_secret(&text, "claude", "setup_token", token)?;
}
let written = match &self.text {
None => create(&self.path, &text),
Some(_) => {
std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600))
.and_then(|()| config::replace(&self.path, &text))
}
};
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_secret(text: &str, section_name: &str, key: &str, secret: &str) -> Result<String> {
let mut document: DocumentMut = text
.parse()
.map_err(|_| anyhow!("can't parse the Credentials: invalid TOML (contents hidden)"))?;
let Some(settings) = document.get_mut(section_name) else {
let mut section = DocumentMut::new();
section[section_name][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 settings = settings
.as_table_like_mut()
.with_context(|| format!("{section_name} must be a section in the Credentials"))?;
match settings.get_mut(key).and_then(Item::as_value_mut) {
Some(old) => {
let decor = old.decor().clone();
*old = secret.into();
*old.decor_mut() = decor;
}
None => {
settings.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<Secrets> {
let table: Table = text
.parse()
.map_err(|_| anyhow!("can't parse {file}: invalid TOML (contents hidden)"))?;
let mut found = Secrets::default();
for (section, value) in &table {
let settings = match (section.as_str(), value) {
("resend" | "claude", Value::Table(settings)) => settings,
("resend" | "claude", _) => {
bail!("{section} must be the section [{section}] in {file}")
}
(_, Value::Table(_)) => bail!("unknown section [{section}] in {file}"),
_ => bail!("unknown key {section} in {file}"),
};
for (key, value) in settings {
match (section.as_str(), key.as_str(), value) {
("resend", "key", Value::String(key)) => found.key = nonempty(key),
("resend", "key", _) => {
bail!("resend.key must be a quoted Resend API key in {file}")
}
("claude", "setup_token", Value::String(token)) => {
found.claude_setup_token = nonempty(token);
}
("claude", "setup_token", _) => {
bail!("claude.setup_token must be a quoted Claude setup token in {file}")
}
_ => bail!("unknown key {section}.{key} in {file}"),
}
}
}
Ok(found)
}
fn nonempty(secret: &str) -> Option<String> {
(!secret.is_empty()).then(|| secret.to_owned())
}
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 Ticket run starts from\n \
(a crontab line, CI secret, or a shell profile the Ticket 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()
.key,
Some("re_file_456".to_string())
);
for text in ["", "[resend]\n", "[resend]\nkey = \"\"\n"] {
assert_eq!(parse(text, &file).unwrap().key, 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_secret(saved, "resend", "key", "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_secret("# Keys go here.\n", "resend", "key", "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().key.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).err().unwrap());
for part in [named, path] {
assert!(error.contains(part), "{text:?}: no {part:?} in {error}");
}
}
}
}