use std::path::{Path, PathBuf};
pub const INLINE_KEY_REMOVAL_VERSION: &str = "2.0.0";
const FILE_PREFIX: &str = "file:";
const ENV_PREFIX: &str = "env:";
pub const KEY_ARG_HELP: &str = "Signing key, named indirectly so it stays out of `ps`: \
`file:<PATH>` (key is the file's contents) or `env:<VAR>` (key is the variable's value). \
A bare literal is still accepted but is DEPRECATED — it is visible to every user on this \
host — and is removed in forjar 2.0.0";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeySource {
File(PathBuf),
Env(String),
Inline,
}
pub fn classify(spec: &str) -> KeySource {
if let Some(rest) = spec.strip_prefix(FILE_PREFIX) {
KeySource::File(PathBuf::from(rest))
} else if let Some(rest) = spec.strip_prefix(ENV_PREFIX) {
KeySource::Env(rest.to_string())
} else {
KeySource::Inline
}
}
pub fn resolve(spec: &str, flag: &str) -> Result<String, String> {
match classify(spec) {
KeySource::File(path) => read_key_file(&path, flag),
KeySource::Env(var) => read_key_env(&var, flag),
KeySource::Inline => {
warn_inline(flag);
Ok(spec.to_string())
}
}
}
pub fn resolve_opt(spec: Option<&str>, flag: &str) -> Result<Option<String>, String> {
match spec {
Some(s) => resolve(s, flag).map(Some),
None => Ok(None),
}
}
fn read_key_file(path: &Path, flag: &str) -> Result<String, String> {
if path.as_os_str().is_empty() {
return Err(format!("{flag} file:<PATH> is missing the path"));
}
let raw = std::fs::read_to_string(path)
.map_err(|e| format!("{flag}: cannot read key file {}: {e}", path.display()))?;
warn_if_shared(path, flag);
let key = raw.trim().to_string();
if key.is_empty() {
return Err(format!("{flag}: key file {} is empty", path.display()));
}
Ok(key)
}
#[cfg(unix)]
fn warn_if_shared(path: &Path, flag: &str) {
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = std::fs::metadata(path) else {
return;
};
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
eprintln!(
"warning: {flag}: key file {} is mode {mode:04o} — readable by other users on this host. \
chmod 600 it.",
path.display()
);
}
}
#[cfg(not(unix))]
fn warn_if_shared(_path: &Path, _flag: &str) {}
fn read_key_env(var: &str, flag: &str) -> Result<String, String> {
if var.is_empty() {
return Err(format!("{flag} env:<VAR> is missing the variable name"));
}
let raw =
std::env::var(var).map_err(|_| format!("{flag}: environment variable {var} is not set"))?;
let key = raw.trim().to_string();
if key.is_empty() {
return Err(format!("{flag}: environment variable {var} is empty"));
}
Ok(key)
}
fn warn_inline(flag: &str) {
eprintln!(
"warning: {flag} was given key material directly on the command line.\n\
warning: Every user on this host can read it out of `ps`.\n\
warning: Use {flag} file:<PATH> or {flag} env:<VAR> instead.\n\
warning: Inline key material is deprecated and is REMOVED in forjar {v}.",
flag = flag,
v = INLINE_KEY_REMOVAL_VERSION
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_recognises_the_indirect_forms() {
assert_eq!(
classify("file:/etc/forjar.key"),
KeySource::File(PathBuf::from("/etc/forjar.key"))
);
assert_eq!(
classify("env:FORJAR_KEY"),
KeySource::Env("FORJAR_KEY".into())
);
assert_eq!(classify("hunter2"), KeySource::Inline);
assert_eq!(classify("/etc/forjar.key"), KeySource::Inline);
}
#[test]
fn file_form_yields_the_contents_not_the_spec() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("k");
std::fs::write(&p, " top-secret\n").unwrap();
let got = resolve(&format!("file:{}", p.display()), "--key").unwrap();
assert_eq!(got, "top-secret");
}
#[test]
fn missing_key_file_is_an_error_naming_the_flag() {
let d = tempfile::tempdir().unwrap();
let spec = format!("file:{}", d.path().join("absent").display());
let err = resolve(&spec, "--old-key").unwrap_err();
assert!(err.contains("--old-key"), "{err}");
assert!(err.contains("cannot read key file"), "{err}");
}
#[test]
fn empty_key_file_is_an_error() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("k");
std::fs::write(&p, "\n \n").unwrap();
let err = resolve(&format!("file:{}", p.display()), "--key").unwrap_err();
assert!(err.contains("is empty"), "{err}");
}
#[test]
fn file_form_without_a_path_is_an_error() {
let err = resolve("file:", "--key").unwrap_err();
assert!(err.contains("missing the path"), "{err}");
}
#[test]
fn env_form_without_a_name_is_an_error() {
let err = resolve("env:", "--key").unwrap_err();
assert!(err.contains("missing the variable name"), "{err}");
}
#[test]
fn unset_env_var_is_an_error_that_does_not_fall_back() {
let err = resolve("env:FORJAR_KEY_SOURCE_UNSET_XYZ", "--key").unwrap_err();
assert!(err.contains("is not set"), "{err}");
}
#[test]
fn inline_form_is_returned_verbatim() {
assert_eq!(resolve("hunter2", "--key").unwrap(), "hunter2");
}
#[test]
fn resolve_opt_passes_none_through() {
assert_eq!(resolve_opt(None, "--key").unwrap(), None);
assert_eq!(
resolve_opt(Some("hunter2"), "--key").unwrap(),
Some("hunter2".to_string())
);
}
#[test]
fn help_documents_both_indirect_forms_and_the_removal() {
assert!(KEY_ARG_HELP.contains("file:<PATH>"));
assert!(KEY_ARG_HELP.contains("env:<VAR>"));
assert!(KEY_ARG_HELP.contains(INLINE_KEY_REMOVAL_VERSION));
assert!(!KEY_ARG_HELP.contains("path to key file or inline"));
}
}