use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use serde_json::Value;
pub const SECRETS_DIR: &str = "secrets";
pub const FILE_NAME: &str = "hitl-approval-token";
pub const PARAM: &str = "approval_token";
const TOKEN_BYTES: usize = 32;
pub fn path(mur_home: &Path) -> PathBuf {
mur_home.join(SECRETS_DIR).join(FILE_NAME)
}
pub fn read(mur_home: &Path) -> io::Result<Option<String>> {
let p = path(mur_home);
match std::fs::read_to_string(&p) {
Ok(s) => parse(&s, &p).map(Some),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
pub fn load_or_create(mur_home: &Path) -> io::Result<String> {
if let Some(t) = read(mur_home)? {
return Ok(t);
}
let dir = mur_home.join(SECRETS_DIR);
create_private_dir(&dir)?;
let token = generate();
let mut tmp = tempfile::NamedTempFile::new_in(&dir)?;
tmp.write_all(token.as_bytes())?;
tmp.as_file().sync_all()?;
match tmp.persist_noclobber(path(mur_home)) {
Ok(_) => Ok(token),
Err(e) if e.error.kind() == io::ErrorKind::AlreadyExists => read(mur_home)?
.ok_or_else(|| io::Error::other("approval token vanished while being created")),
Err(e) => Err(e.error),
}
}
pub fn attach(params: &mut Value, mur_home: &Path) -> io::Result<()> {
if params.get("allow").and_then(Value::as_bool) != Some(true) {
return Ok(());
}
if let Some(t) = read(mur_home)?
&& let Some(obj) = params.as_object_mut()
{
obj.insert(PARAM.to_string(), Value::String(t));
}
Ok(())
}
fn generate() -> String {
use rand_core::RngCore;
let mut bytes = [0u8; TOKEN_BYTES];
rand_core::OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
fn parse(raw: &str, p: &Path) -> io::Result<String> {
let t = raw.trim();
if t.len() == TOKEN_BYTES * 2 && t.bytes().all(|b| b.is_ascii_hexdigit()) {
Ok(t.to_string())
} else {
Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{} is not a valid approval token; delete it and restart the agents to make a new one",
p.display()
),
))
}
}
fn create_private_dir(dir: &Path) -> io::Result<()> {
let mut b = std::fs::DirBuilder::new();
b.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
b.mode(0o700);
}
b.create(dir)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn creates_once_then_returns_the_same_token() {
let home = tempfile::tempdir().unwrap();
assert!(read(home.path()).unwrap().is_none());
let a = load_or_create(home.path()).unwrap();
assert_eq!(a.len(), TOKEN_BYTES * 2);
assert_eq!(load_or_create(home.path()).unwrap(), a);
assert_eq!(read(home.path()).unwrap().as_deref(), Some(a.as_str()));
}
#[test]
fn two_homes_get_different_tokens() {
let (a, b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
assert_ne!(
load_or_create(a.path()).unwrap(),
load_or_create(b.path()).unwrap()
);
}
#[test]
fn racing_creators_agree_on_one_token() {
let home = tempfile::tempdir().unwrap();
let h = home.path().to_path_buf();
let got: Vec<String> = (0..8)
.map(|_| {
let h = h.clone();
std::thread::spawn(move || load_or_create(&h).unwrap())
})
.collect::<Vec<_>>()
.into_iter()
.map(|j| j.join().unwrap())
.collect();
assert!(got.iter().all(|t| t == &got[0]), "{got:?}");
assert_eq!(read(&h).unwrap().as_ref(), Some(&got[0]));
}
#[test]
fn a_malformed_file_is_an_error_not_a_token() {
let home = tempfile::tempdir().unwrap();
std::fs::create_dir_all(home.path().join(SECRETS_DIR)).unwrap();
for bad in ["", " \n", "short", &"z".repeat(TOKEN_BYTES * 2)] {
std::fs::write(path(home.path()), bad).unwrap();
let e = read(home.path()).unwrap_err();
assert_eq!(e.kind(), io::ErrorKind::InvalidData, "{bad:?}");
assert!(load_or_create(home.path()).is_err(), "{bad:?}");
assert_eq!(std::fs::read_to_string(path(home.path())).unwrap(), bad);
}
}
#[cfg(unix)]
#[test]
fn the_token_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let home = tempfile::tempdir().unwrap();
load_or_create(home.path()).unwrap();
let mode = std::fs::metadata(path(home.path()))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "{mode:o}");
}
#[test]
fn only_an_allow_carries_the_token() {
let home = tempfile::tempdir().unwrap();
let token = load_or_create(home.path()).unwrap();
let mut allow = json!({ "hitl_id": "h", "allow": true, "surface": "hub" });
attach(&mut allow, home.path()).unwrap();
assert_eq!(allow[PARAM], token.as_str());
let mut deny = json!({ "hitl_id": "h", "allow": false, "surface": "hub" });
attach(&mut deny, home.path()).unwrap();
assert!(
deny.get(PARAM).is_none(),
"a deny must not carry the secret"
);
}
#[test]
fn no_token_yet_sends_the_allow_without_one() {
let home = tempfile::tempdir().unwrap();
let mut allow = json!({ "hitl_id": "h", "allow": true });
attach(&mut allow, home.path()).unwrap();
assert!(allow.get(PARAM).is_none());
assert!(!path(home.path()).exists());
}
}