use std::path::{Path, PathBuf};
use thiserror::Error;
const SUBDIRS: &[&str] =
&["runs", "repos", "findings", "logs", "cache", "bundles", "secrets", "traces"];
#[derive(Debug, Error)]
pub enum StateError {
#[error("could not resolve user data directory (HOME/XDG_DATA_HOME unset?)")]
NoDataDir,
#[error("failed to create {path}: {source}")]
Create {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to set permissions on {path}: {source}")]
Permissions {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
#[derive(Debug, Clone)]
pub struct StateDir {
root: PathBuf,
}
impl StateDir {
pub fn default_root() -> Result<PathBuf, StateError> {
let base = dirs::data_dir().ok_or(StateError::NoDataDir)?;
Ok(base.join("nyx-agent"))
}
pub fn at(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn discover() -> Result<Self, StateError> {
Ok(Self::at(Self::default_root()?))
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn runs(&self) -> PathBuf {
self.root.join("runs")
}
pub fn repos(&self) -> PathBuf {
self.root.join("repos")
}
pub fn project_repos(&self, project_id: &str) -> PathBuf {
self.root.join("projects").join(project_id).join("repos")
}
pub fn findings(&self) -> PathBuf {
self.root.join("findings")
}
pub fn logs(&self) -> PathBuf {
self.root.join("logs")
}
pub fn cache(&self) -> PathBuf {
self.root.join("cache")
}
pub fn bundles(&self) -> PathBuf {
self.root.join("bundles")
}
pub fn traces(&self) -> PathBuf {
self.root.join("traces")
}
pub fn traces_for_run(&self, run_id: &str) -> PathBuf {
self.traces().join(run_id)
}
pub fn secrets(&self) -> PathBuf {
self.root.join("secrets")
}
pub fn secrets_test_env_path(&self) -> PathBuf {
self.secrets().join("test.env")
}
pub fn secrets_test_env_allow_path(&self) -> PathBuf {
self.secrets().join("test.env.allow")
}
pub fn auth_token_path(&self) -> PathBuf {
self.root.join("auth_token")
}
#[tracing::instrument(skip_all, fields(root = %self.root.display()))]
pub fn ensure(&self) -> Result<(), StateError> {
create_secure_dir(&self.root)?;
for sub in SUBDIRS {
create_secure_dir(&self.root.join(sub))?;
}
Ok(())
}
pub fn load_or_mint_auth_token(&self) -> Result<String, StateError> {
let path = self.auth_token_path();
if path.exists() {
let raw = std::fs::read_to_string(&path)
.map_err(|source| StateError::Create { path: path.clone(), source })?;
let trimmed = raw.trim().to_string();
if !trimmed.is_empty() {
return Ok(trimmed);
}
}
let token = mint_token();
write_secure_file(&path, token.as_bytes())?;
Ok(token)
}
}
pub fn mint_token() -> String {
use rand::Rng;
let mut buf = [0u8; 32];
rand::rng().fill_bytes(&mut buf);
hex::encode(buf)
}
fn write_secure_file(path: &Path, bytes: &[u8]) -> Result<(), StateError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|source| StateError::Create { path: parent.to_path_buf(), source })?;
}
write_with_mode(path, bytes)
.map_err(|source| StateError::Create { path: path.to_path_buf(), source })?;
Ok(())
}
#[cfg(unix)]
fn write_with_mode(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(bytes)?;
file.flush()
}
#[cfg(not(unix))]
fn write_with_mode(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
std::fs::write(path, bytes)
}
fn create_secure_dir(path: &Path) -> Result<(), StateError> {
std::fs::create_dir_all(path)
.map_err(|source| StateError::Create { path: path.to_path_buf(), source })?;
set_secure_perms(path)
}
#[cfg(unix)]
fn set_secure_perms(path: &Path) -> Result<(), StateError> {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o700);
std::fs::set_permissions(path, perms)
.map_err(|source| StateError::Permissions { path: path.to_path_buf(), source })
}
#[cfg(not(unix))]
fn set_secure_perms(_path: &Path) -> Result<(), StateError> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_root() -> tempfile::TempDir {
tempfile::tempdir().expect("tempdir")
}
#[test]
fn ensure_creates_all_subdirs() {
let tmp = tmp_root();
let sd = StateDir::at(tmp.path().join("nyx-agent"));
sd.ensure().expect("ensure once");
for sub in SUBDIRS {
let p = sd.root().join(sub);
assert!(p.is_dir(), "{} should exist", p.display());
}
}
#[test]
fn ensure_is_idempotent() {
let tmp = tmp_root();
let sd = StateDir::at(tmp.path().join("nyx-agent"));
sd.ensure().expect("first");
sd.ensure().expect("second");
sd.ensure().expect("third");
}
#[cfg(unix)]
#[test]
fn ensure_sets_0700_on_unix() {
use std::os::unix::fs::PermissionsExt;
let tmp = tmp_root();
let sd = StateDir::at(tmp.path().join("nyx-agent"));
sd.ensure().expect("ensure");
for p in std::iter::once(sd.root().to_path_buf())
.chain(SUBDIRS.iter().map(|s| sd.root().join(s)))
{
let mode = std::fs::metadata(&p).expect("meta").permissions().mode() & 0o777;
assert_eq!(mode, 0o700, "{} mode {:o}", p.display(), mode);
}
}
#[test]
fn paths_match_layout() {
let sd = StateDir::at("/var/state");
assert_eq!(sd.runs(), Path::new("/var/state/runs"));
assert_eq!(sd.repos(), Path::new("/var/state/repos"));
assert_eq!(sd.project_repos("acme"), Path::new("/var/state/projects/acme/repos"));
assert_eq!(sd.findings(), Path::new("/var/state/findings"));
assert_eq!(sd.logs(), Path::new("/var/state/logs"));
assert_eq!(sd.cache(), Path::new("/var/state/cache"));
assert_eq!(sd.bundles(), Path::new("/var/state/bundles"));
assert_eq!(sd.traces(), Path::new("/var/state/traces"));
assert_eq!(sd.traces_for_run("run-abc"), Path::new("/var/state/traces/run-abc"));
assert_eq!(sd.secrets(), Path::new("/var/state/secrets"));
assert_eq!(sd.secrets_test_env_path(), Path::new("/var/state/secrets/test.env"));
assert_eq!(
sd.secrets_test_env_allow_path(),
Path::new("/var/state/secrets/test.env.allow"),
);
assert_eq!(sd.auth_token_path(), Path::new("/var/state/auth_token"));
}
#[cfg(unix)]
#[test]
fn ensure_creates_secrets_dir_at_0700() {
use std::os::unix::fs::PermissionsExt;
let tmp = tmp_root();
let sd = StateDir::at(tmp.path().join("nyx-agent"));
sd.ensure().expect("ensure");
let secrets = sd.secrets();
assert!(secrets.is_dir(), "secrets dir should exist at {}", secrets.display());
let mode = std::fs::metadata(&secrets).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700, "secrets dir mode {mode:o}");
}
#[test]
fn load_or_mint_auth_token_persists_idempotently() {
let tmp = tmp_root();
let sd = StateDir::at(tmp.path().join("nyx-agent"));
sd.ensure().expect("ensure");
let first = sd.load_or_mint_auth_token().expect("mint first");
assert_eq!(first.len(), 64, "32 random bytes -> 64 hex chars");
let second = sd.load_or_mint_auth_token().expect("mint second");
assert_eq!(first, second, "second call must return the persisted token");
let raw = std::fs::read_to_string(sd.auth_token_path()).expect("read file");
assert_eq!(raw.trim(), first);
}
#[cfg(unix)]
#[test]
fn auth_token_file_is_0600() {
use std::os::unix::fs::PermissionsExt;
let tmp = tmp_root();
let sd = StateDir::at(tmp.path().join("nyx-agent"));
sd.ensure().expect("ensure");
sd.load_or_mint_auth_token().expect("mint");
let mode = std::fs::metadata(sd.auth_token_path()).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "token file mode {mode:o}");
}
}