use hex::encode as hex_encode;
use sha2::{Digest as _, Sha256};
use std::path::Path;
use std::process::Command;
pub fn run_issue(principal_id: &str) {
let database_url = resolve_database_url();
check_psql();
let raw_token = generate_token();
let token_hash = sha256_hex(&raw_token);
run_psql_with_vars_or_die(
&database_url,
"INSERT INTO api_tokens (token_hash, principal_id) VALUES (:'hash', :'principal');",
&[("hash", &token_hash), ("principal", principal_id)],
);
println!("{raw_token}");
eprintln!("\u{2713} Token issued for principal: {principal_id}");
}
pub fn run_revoke(raw_token: &str) {
let database_url = resolve_database_url();
check_psql();
let token_hash = sha256_hex(raw_token);
run_psql_with_vars_or_die(
&database_url,
"UPDATE api_tokens SET revoked_at = NOW() WHERE token_hash = :'hash' AND revoked_at IS NULL;",
&[("hash", &token_hash)],
);
eprintln!("\u{2713} Token revoked.");
}
fn sha256_hex(input: &str) -> String {
hex_encode(Sha256::digest(input.as_bytes()))
}
fn generate_token() -> String {
hex_encode(os_random_bytes())
}
fn os_random_bytes() -> [u8; 32] {
let mut buf = [0u8; 32];
getrandom::fill(&mut buf).expect("OS random source unavailable");
buf
}
fn resolve_database_url() -> String {
let config_table = read_autumn_toml_table();
if let Some(url) =
resolve_primary_database_url_from_sources(|key| std::env::var(key), config_table.as_ref())
{
return url;
}
eprintln!("\u{2717} No database URL found.");
eprintln!(
" Set database.primary_url (or database.url) in autumn.toml, or set AUTUMN_DATABASE__PRIMARY_URL / AUTUMN_DATABASE__URL / DATABASE_URL."
);
std::process::exit(1);
}
fn read_autumn_toml_table() -> Option<toml::Table> {
let config_path = Path::new("autumn.toml");
if !config_path.exists() {
return None;
}
std::fs::read_to_string(config_path)
.ok()
.and_then(|contents| toml::from_str::<toml::Table>(&contents).ok())
}
fn resolve_primary_database_url_from_sources<F>(
env_var: F,
table: Option<&toml::Table>,
) -> Option<String>
where
F: Fn(&str) -> Result<String, std::env::VarError>,
{
for var in [
"AUTUMN_DATABASE__PRIMARY_URL",
"AUTUMN_DATABASE__URL",
"DATABASE_URL",
] {
if let Ok(url) = env_var(var)
&& !url.is_empty()
{
return Some(url);
}
}
let database = table?.get("database").and_then(toml::Value::as_table)?;
for key in ["primary_url", "url"] {
if let Some(url) = database
.get(key)
.and_then(toml::Value::as_str)
.filter(|url| !url.is_empty())
{
return Some(url.to_owned());
}
}
None
}
fn check_psql() {
match Command::new("psql").arg("--version").output() {
Ok(out) if out.status.success() => {
let v = String::from_utf8_lossy(&out.stdout);
eprintln!(" Using {}", v.trim());
}
_ => {
eprintln!("\u{2717} psql not found on PATH.");
eprintln!(" Install PostgreSQL client tools (e.g. `apt install postgresql-client`).");
std::process::exit(1);
}
}
}
fn run_psql_with_vars_or_die(database_url: &str, sql: &str, vars: &[(&str, &str)]) {
let mut cmd = Command::new("psql");
cmd.arg(database_url);
for (name, value) in vars {
cmd.args(["-v", &format!("{name}={value}")]);
}
cmd.args(["-c", sql]);
match cmd.status() {
Ok(s) if s.success() => {}
Ok(_) => {
eprintln!("\u{2717} psql command failed. Check the output above.");
std::process::exit(1);
}
Err(e) => {
eprintln!("\u{2717} Failed to run psql: {e}");
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_hex_is_64_chars() {
let h = sha256_hex("test_token");
assert_eq!(h.len(), 64);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn sha256_hex_is_deterministic() {
assert_eq!(sha256_hex("abc"), sha256_hex("abc"));
}
#[test]
fn sha256_hex_differs_on_different_input() {
assert_ne!(sha256_hex("abc"), sha256_hex("xyz"));
}
#[test]
fn generate_token_produces_64_hex_chars() {
let t = generate_token();
assert_eq!(t.len(), 64);
assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn generate_token_is_not_all_zeros() {
let t = generate_token();
assert_ne!(t, "0".repeat(64));
}
#[test]
fn resolve_prefers_autumn_database_url_over_database_url() {
let url = temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", Some("postgres://autumn-primary")),
("DATABASE_URL", Some("postgres://fallback")),
],
resolve_database_url,
);
assert_eq!(url, "postgres://autumn-primary");
}
#[test]
fn resolve_falls_back_to_database_url_when_autumn_unset() {
let url = temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", Some("postgres://fallback")),
],
resolve_database_url,
);
assert_eq!(url, "postgres://fallback");
}
#[test]
fn resolve_prefers_primary_database_url_env() {
let env = |key: &str| match key {
"AUTUMN_DATABASE__PRIMARY_URL" => Ok("postgres://primary-env".to_owned()),
"AUTUMN_DATABASE__URL" => Ok("postgres://legacy-env".to_owned()),
"DATABASE_URL" => Ok("postgres://fallback-env".to_owned()),
_ => Err(std::env::VarError::NotPresent),
};
let url = resolve_primary_database_url_from_sources(env, None).unwrap();
assert_eq!(url, "postgres://primary-env");
}
#[test]
fn resolve_reads_primary_database_url_from_toml() {
let table = toml::from_str::<toml::Table>(
r#"
[database]
primary_url = "postgres://primary-toml"
url = "postgres://legacy-toml"
"#,
)
.unwrap();
let env =
|_: &str| -> Result<String, std::env::VarError> { Err(std::env::VarError::NotPresent) };
let url = resolve_primary_database_url_from_sources(env, Some(&table)).unwrap();
assert_eq!(url, "postgres://primary-toml");
}
#[test]
fn check_psql_does_not_panic_when_available() {
if std::process::Command::new("psql")
.arg("--version")
.output()
.is_ok_and(|o| o.status.success())
{
check_psql();
}
}
}