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, 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> {
std::env::var("RESEND_API_KEY")
.ok()
.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()
)
}