use std::path::{Path, PathBuf};
use crate::auth::SessionKey;
const KEY_VARIABLE: &str = "APP_KEY";
pub fn run(show: bool) -> Result<(), KeyGenerateError> {
let root = std::env::current_dir().map_err(|source| KeyGenerateError::Io {
path: PathBuf::from("."),
source,
})?;
let key = generate(show, &root)?;
if show {
println!("{KEY_VARIABLE}={key}");
} else {
println!("{KEY_VARIABLE} written to .env");
}
Ok(())
}
pub fn generate(show: bool, root: &Path) -> Result<String, KeyGenerateError> {
let key = SessionKey::generate().map_err(|_| KeyGenerateError::Rng)?;
let hex = to_hex(key.as_bytes());
if show {
return Ok(hex);
}
let env_path = root.join(".env");
let existing = match std::fs::read_to_string(&env_path) {
Ok(contents) => contents,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return Err(KeyGenerateError::NoEnvFile {
path: env_path.clone(),
});
}
Err(source) => {
return Err(KeyGenerateError::Io {
path: env_path,
source,
});
}
};
std::fs::write(&env_path, upsert(&existing, &hex)).map_err(|source| KeyGenerateError::Io {
path: env_path,
source,
})?;
Ok(hex)
}
fn upsert(existing: &str, hex: &str) -> String {
let assignment = format!("{KEY_VARIABLE}={hex}");
let prefix = format!("{KEY_VARIABLE}=");
let mut replaced = false;
let mut lines: Vec<String> = existing
.lines()
.map(|line| {
if line.trim_start().starts_with(&prefix) {
replaced = true;
assignment.clone()
} else {
line.to_string()
}
})
.collect();
if !replaced {
if lines.last().is_some_and(|last| !last.trim().is_empty()) {
lines.push(String::new());
}
lines.push(assignment);
}
lines.join("\n").trim_end().to_string() + "\n"
}
fn to_hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes
.iter()
.fold(String::with_capacity(bytes.len() * 2), |mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
})
}
#[derive(Debug)]
pub enum KeyGenerateError {
Rng,
NoEnvFile { path: PathBuf },
Io {
path: PathBuf,
source: std::io::Error,
},
}
impl std::fmt::Display for KeyGenerateError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Rng => formatter.write_str(
"the operating system's random number generator failed; \
no key was generated",
),
Self::NoEnvFile { path } => write!(
formatter,
"{} does not exist; run this from an application root, \
or use `arc key:generate --show` to print a key without \
writing a file",
path.display()
),
Self::Io { path, source } => {
write!(formatter, "could not write {}: {source}", path.display())
}
}
}
}
impl std::error::Error for KeyGenerateError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn project_with_env(contents: &str) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join(".env"), contents).expect("env");
dir
}
#[test]
fn a_generated_key_is_sixty_four_bytes_of_lowercase_hex() {
let dir = project_with_env("APP_NAME=demo\n");
let key = generate(false, dir.path()).expect("generated");
assert_eq!(key.len(), 128);
assert!(
key.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
}
#[test]
fn two_generated_keys_differ() {
let dir = project_with_env("APP_NAME=demo\n");
let first = generate(true, dir.path()).expect("first");
let second = generate(true, dir.path()).expect("second");
assert_ne!(first, second);
}
#[test]
fn the_key_is_appended_when_the_env_file_has_none() {
let dir = project_with_env("APP_NAME=demo\nAPP_ENV=development\n");
let key = generate(false, dir.path()).expect("generated");
let env = std::fs::read_to_string(dir.path().join(".env")).expect("read");
assert!(env.contains("APP_NAME=demo"), "{env}");
assert!(env.contains(&format!("APP_KEY={key}")), "{env}");
}
#[test]
fn regenerating_replaces_the_key_rather_than_adding_a_second_one() {
let dir = project_with_env("APP_KEY=stale\nAPP_ENV=development\n");
let key = generate(false, dir.path()).expect("generated");
let env = std::fs::read_to_string(dir.path().join(".env")).expect("read");
assert_eq!(env.matches("APP_KEY=").count(), 1, "{env}");
assert!(!env.contains("stale"), "{env}");
assert!(env.contains(&key));
assert!(env.contains("APP_ENV=development"), "{env}");
}
#[test]
fn show_prints_a_key_without_touching_the_env_file() {
let dir = project_with_env("APP_NAME=demo\n");
let key = generate(true, dir.path()).expect("generated");
assert_eq!(key.len(), 128);
let env = std::fs::read_to_string(dir.path().join(".env")).expect("read");
assert_eq!(env, "APP_NAME=demo\n");
}
#[test]
fn a_missing_env_file_explains_the_show_flag() {
let dir = tempfile::tempdir().expect("tempdir");
let error = generate(false, dir.path()).expect_err("no .env");
assert!(matches!(error, KeyGenerateError::NoEnvFile { .. }));
assert!(error.to_string().contains("--show"));
}
#[test]
fn hex_encoding_round_trips_a_known_pattern() {
assert_eq!(to_hex(&[0x00, 0x0f, 0xab, 0xff]), "000fabff");
}
}