1use std::path::{Path, PathBuf};
8
9use thiserror::Error;
10
11const SUBDIRS: &[&str] =
12 &["runs", "repos", "findings", "logs", "cache", "bundles", "secrets", "traces"];
13
14#[derive(Debug, Error)]
15pub enum StateError {
16 #[error("could not resolve user data directory (HOME/XDG_DATA_HOME unset?)")]
17 NoDataDir,
18 #[error("failed to create {path}: {source}")]
19 Create {
20 path: PathBuf,
21 #[source]
22 source: std::io::Error,
23 },
24 #[error("failed to set permissions on {path}: {source}")]
25 Permissions {
26 path: PathBuf,
27 #[source]
28 source: std::io::Error,
29 },
30}
31
32#[derive(Debug, Clone)]
33pub struct StateDir {
34 root: PathBuf,
35}
36
37impl StateDir {
38 pub fn default_root() -> Result<PathBuf, StateError> {
39 let base = dirs::data_dir().ok_or(StateError::NoDataDir)?;
40 Ok(base.join("nyx-agent"))
41 }
42
43 pub fn at(root: impl Into<PathBuf>) -> Self {
44 Self { root: root.into() }
45 }
46
47 pub fn discover() -> Result<Self, StateError> {
48 Ok(Self::at(Self::default_root()?))
49 }
50
51 pub fn root(&self) -> &Path {
52 &self.root
53 }
54
55 pub fn runs(&self) -> PathBuf {
56 self.root.join("runs")
57 }
58
59 pub fn repos(&self) -> PathBuf {
60 self.root.join("repos")
61 }
62
63 pub fn project_repos(&self, project_id: &str) -> PathBuf {
68 self.root.join("projects").join(project_id).join("repos")
69 }
70
71 pub fn findings(&self) -> PathBuf {
72 self.root.join("findings")
73 }
74
75 pub fn logs(&self) -> PathBuf {
76 self.root.join("logs")
77 }
78
79 pub fn cache(&self) -> PathBuf {
80 self.root.join("cache")
81 }
82
83 pub fn bundles(&self) -> PathBuf {
87 self.root.join("bundles")
88 }
89
90 pub fn traces(&self) -> PathBuf {
95 self.root.join("traces")
96 }
97
98 pub fn traces_for_run(&self, run_id: &str) -> PathBuf {
102 self.traces().join(run_id)
103 }
104
105 pub fn secrets(&self) -> PathBuf {
111 self.root.join("secrets")
112 }
113
114 pub fn secrets_test_env_path(&self) -> PathBuf {
119 self.secrets().join("test.env")
120 }
121
122 pub fn secrets_test_env_allow_path(&self) -> PathBuf {
126 self.secrets().join("test.env.allow")
127 }
128
129 pub fn auth_token_path(&self) -> PathBuf {
133 self.root.join("auth_token")
134 }
135
136 #[tracing::instrument(skip_all, fields(root = %self.root.display()))]
140 pub fn ensure(&self) -> Result<(), StateError> {
141 create_secure_dir(&self.root)?;
142 for sub in SUBDIRS {
143 create_secure_dir(&self.root.join(sub))?;
144 }
145 Ok(())
146 }
147
148 pub fn load_or_mint_auth_token(&self) -> Result<String, StateError> {
153 let path = self.auth_token_path();
154 if path.exists() {
155 let raw = std::fs::read_to_string(&path)
156 .map_err(|source| StateError::Create { path: path.clone(), source })?;
157 let trimmed = raw.trim().to_string();
158 if !trimmed.is_empty() {
159 return Ok(trimmed);
160 }
161 }
162 let token = mint_token();
163 write_secure_file(&path, token.as_bytes())?;
164 Ok(token)
165 }
166}
167
168pub fn mint_token() -> String {
171 use rand::Rng;
172 let mut buf = [0u8; 32];
173 rand::rng().fill_bytes(&mut buf);
174 hex::encode(buf)
175}
176
177fn write_secure_file(path: &Path, bytes: &[u8]) -> Result<(), StateError> {
178 if let Some(parent) = path.parent() {
179 std::fs::create_dir_all(parent)
180 .map_err(|source| StateError::Create { path: parent.to_path_buf(), source })?;
181 }
182 write_with_mode(path, bytes)
183 .map_err(|source| StateError::Create { path: path.to_path_buf(), source })?;
184 Ok(())
185}
186
187#[cfg(unix)]
188fn write_with_mode(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
189 use std::io::Write;
190 use std::os::unix::fs::OpenOptionsExt;
191 let mut file = std::fs::OpenOptions::new()
192 .write(true)
193 .create(true)
194 .truncate(true)
195 .mode(0o600)
196 .open(path)?;
197 file.write_all(bytes)?;
198 file.flush()
199}
200
201#[cfg(not(unix))]
202fn write_with_mode(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
203 std::fs::write(path, bytes)
204}
205
206fn create_secure_dir(path: &Path) -> Result<(), StateError> {
207 std::fs::create_dir_all(path)
208 .map_err(|source| StateError::Create { path: path.to_path_buf(), source })?;
209 set_secure_perms(path)
210}
211
212#[cfg(unix)]
213fn set_secure_perms(path: &Path) -> Result<(), StateError> {
214 use std::os::unix::fs::PermissionsExt;
215 let perms = std::fs::Permissions::from_mode(0o700);
216 std::fs::set_permissions(path, perms)
217 .map_err(|source| StateError::Permissions { path: path.to_path_buf(), source })
218}
219
220#[cfg(not(unix))]
221fn set_secure_perms(_path: &Path) -> Result<(), StateError> {
222 Ok(())
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 fn tmp_root() -> tempfile::TempDir {
230 tempfile::tempdir().expect("tempdir")
231 }
232
233 #[test]
234 fn ensure_creates_all_subdirs() {
235 let tmp = tmp_root();
236 let sd = StateDir::at(tmp.path().join("nyx-agent"));
237 sd.ensure().expect("ensure once");
238 for sub in SUBDIRS {
239 let p = sd.root().join(sub);
240 assert!(p.is_dir(), "{} should exist", p.display());
241 }
242 }
243
244 #[test]
245 fn ensure_is_idempotent() {
246 let tmp = tmp_root();
247 let sd = StateDir::at(tmp.path().join("nyx-agent"));
248 sd.ensure().expect("first");
249 sd.ensure().expect("second");
250 sd.ensure().expect("third");
251 }
252
253 #[cfg(unix)]
254 #[test]
255 fn ensure_sets_0700_on_unix() {
256 use std::os::unix::fs::PermissionsExt;
257 let tmp = tmp_root();
258 let sd = StateDir::at(tmp.path().join("nyx-agent"));
259 sd.ensure().expect("ensure");
260 for p in std::iter::once(sd.root().to_path_buf())
261 .chain(SUBDIRS.iter().map(|s| sd.root().join(s)))
262 {
263 let mode = std::fs::metadata(&p).expect("meta").permissions().mode() & 0o777;
264 assert_eq!(mode, 0o700, "{} mode {:o}", p.display(), mode);
265 }
266 }
267
268 #[test]
269 fn paths_match_layout() {
270 let sd = StateDir::at("/var/state");
271 assert_eq!(sd.runs(), Path::new("/var/state/runs"));
272 assert_eq!(sd.repos(), Path::new("/var/state/repos"));
273 assert_eq!(sd.project_repos("acme"), Path::new("/var/state/projects/acme/repos"));
274 assert_eq!(sd.findings(), Path::new("/var/state/findings"));
275 assert_eq!(sd.logs(), Path::new("/var/state/logs"));
276 assert_eq!(sd.cache(), Path::new("/var/state/cache"));
277 assert_eq!(sd.bundles(), Path::new("/var/state/bundles"));
278 assert_eq!(sd.traces(), Path::new("/var/state/traces"));
279 assert_eq!(sd.traces_for_run("run-abc"), Path::new("/var/state/traces/run-abc"));
280 assert_eq!(sd.secrets(), Path::new("/var/state/secrets"));
281 assert_eq!(sd.secrets_test_env_path(), Path::new("/var/state/secrets/test.env"));
282 assert_eq!(
283 sd.secrets_test_env_allow_path(),
284 Path::new("/var/state/secrets/test.env.allow"),
285 );
286 assert_eq!(sd.auth_token_path(), Path::new("/var/state/auth_token"));
287 }
288
289 #[cfg(unix)]
290 #[test]
291 fn ensure_creates_secrets_dir_at_0700() {
292 use std::os::unix::fs::PermissionsExt;
293 let tmp = tmp_root();
294 let sd = StateDir::at(tmp.path().join("nyx-agent"));
295 sd.ensure().expect("ensure");
296 let secrets = sd.secrets();
297 assert!(secrets.is_dir(), "secrets dir should exist at {}", secrets.display());
298 let mode = std::fs::metadata(&secrets).unwrap().permissions().mode() & 0o777;
299 assert_eq!(mode, 0o700, "secrets dir mode {mode:o}");
300 }
301
302 #[test]
303 fn load_or_mint_auth_token_persists_idempotently() {
304 let tmp = tmp_root();
305 let sd = StateDir::at(tmp.path().join("nyx-agent"));
306 sd.ensure().expect("ensure");
307 let first = sd.load_or_mint_auth_token().expect("mint first");
308 assert_eq!(first.len(), 64, "32 random bytes -> 64 hex chars");
309 let second = sd.load_or_mint_auth_token().expect("mint second");
310 assert_eq!(first, second, "second call must return the persisted token");
311 let raw = std::fs::read_to_string(sd.auth_token_path()).expect("read file");
312 assert_eq!(raw.trim(), first);
313 }
314
315 #[cfg(unix)]
316 #[test]
317 fn auth_token_file_is_0600() {
318 use std::os::unix::fs::PermissionsExt;
319 let tmp = tmp_root();
320 let sd = StateDir::at(tmp.path().join("nyx-agent"));
321 sd.ensure().expect("ensure");
322 sd.load_or_mint_auth_token().expect("mint");
323 let mode = std::fs::metadata(sd.auth_token_path()).unwrap().permissions().mode() & 0o777;
324 assert_eq!(mode, 0o600, "token file mode {mode:o}");
325 }
326}