Skip to main content

astrid_core/
dirs.rs

1//! Directory scaffolding for Astrid home and workspace directories.
2//!
3//! Two key directory structures:
4//!
5//! - [`AstridHome`]: Global state at `~/.astrid/` (or `$ASTRID_HOME`).
6//!   Linux FHS-aligned layout with `etc/`, `var/`, `run/`, `log/`, `keys/`,
7//!   `bin/`, `lib/`, and `home/` for multi-principal isolation.
8//!
9//! - [`WorkspaceDir`]: Per-project directory at `<project>/.astrid/`.
10//!   Holds only committable project-level config (like `.astrid/ASTRID.md`).
11//!   Contains a `workspace-id` UUID that links the project to its global state.
12//!
13//! - [`PrincipalHome`]: Per-principal home directory under `~/.astrid/home/{id}/`.
14//!   Each principal gets isolated capsules, KV data, audit chain, tokens, and
15//!   config — portable across deployments.
16//!
17//! # Layout
18//!
19//! ```text
20//! ~/.astrid/                           (AstridHome)
21//! ├── etc/
22//! │   ├── config.toml                    deployment config
23//! │   ├── servers.toml                   MCP server config
24//! │   ├── gateway.toml                   daemon config
25//! │   ├── hooks/                         system hooks
26//! │   └── layout-version                 layout version sentinel
27//! ├── var/
28//! │   └── state.db/                      system KV (SurrealKV, persistent)
29//! ├── run/                               ephemeral runtime state
30//! │   ├── system.sock
31//! │   ├── system.token
32//! │   ├── system.ready
33//! │   └── deferred.db/                   deferred queue (ephemeral)
34//! ├── log/                               system logs
35//! ├── keys/                              runtime signing key
36//! ├── bin/                               content-addressed compiled WASM binaries
37//! ├── lib/                               shared WASM component libraries (WIT, future)
38//! └── home/
39//!     └── {principal}/                   per-principal home
40//!         ├── .local/
41//!         │   ├── capsules/              user-installed capsules
42//!         │   ├── kv/                    capsule KV data
43//!         │   ├── log/                   capsule logs
44//!         │   ├── audit/                 user's audit chain
45//!         │   ├── tokens/                capability tokens
46//!         │   └── tmp/                   VFS mounts as /tmp
47//!         └── .config/
48//!             └── env/                   capsule config overrides
49//!
50//! <project>/.astrid/                   (WorkspaceDir)
51//! ├── workspace-id                       UUID linking project to global state
52//! └── ASTRID.md                        project-level instructions
53//! ```
54
55use std::io;
56use std::path::{Component, Path, PathBuf};
57
58use uuid::Uuid;
59
60use crate::principal::PrincipalId;
61
62/// Current layout version. Written to `etc/layout-version` on first boot.
63pub const LAYOUT_VERSION: &str = "1";
64
65/// Reject paths containing `..` (parent directory) components.
66fn reject_parent_traversal(path: &Path, var_name: &str) -> io::Result<()> {
67    if path.components().any(|c| matches!(c, Component::ParentDir)) {
68        return Err(io::Error::new(
69            io::ErrorKind::InvalidInput,
70            format!("{var_name} must not contain '..' path components"),
71        ));
72    }
73    Ok(())
74}
75
76// ── AstridHome (system-level) ────────────────────────────────────────────
77
78/// Global Astrid home directory (`~/.astrid/` or `$ASTRID_HOME`).
79///
80/// FHS-aligned system layout. Contains config (`etc/`), persistent state
81/// (`var/`), ephemeral runtime (`run/`), logs (`log/`), keys (`keys/`),
82/// shared WASM modules (`lib/`), system capsules (`capsules/`), and
83/// per-principal home directories (`home/`).
84#[derive(Debug, Clone)]
85pub struct AstridHome {
86    root: PathBuf,
87}
88
89impl AstridHome {
90    /// Resolve the home directory.
91    ///
92    /// Checks `$ASTRID_HOME` first, then falls back to `$HOME/.astrid/`.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if neither `$ASTRID_HOME` nor `$HOME` is set.
97    pub fn resolve() -> io::Result<Self> {
98        let astrid_home = std::env::var("ASTRID_HOME").ok();
99        let home = if astrid_home.is_none() {
100            std::env::var("HOME").ok()
101        } else {
102            None
103        };
104        Self::resolve_with_env(astrid_home, home)
105    }
106
107    /// Internal resolver used to mock environment variables in tests securely.
108    fn resolve_with_env(astrid_home: Option<String>, home: Option<String>) -> io::Result<Self> {
109        let root = if let Some(custom) = astrid_home {
110            let p = PathBuf::from(&custom);
111            if !p.is_absolute() {
112                return Err(io::Error::new(
113                    io::ErrorKind::InvalidInput,
114                    "ASTRID_HOME must be an absolute path",
115                ));
116            }
117            reject_parent_traversal(&p, "ASTRID_HOME")?;
118            p
119        } else {
120            let home = home.ok_or_else(|| {
121                io::Error::new(
122                    io::ErrorKind::NotFound,
123                    "neither ASTRID_HOME nor HOME environment variable is set",
124                )
125            })?;
126            let home_path = PathBuf::from(&home);
127            if !home_path.is_absolute() {
128                return Err(io::Error::new(
129                    io::ErrorKind::InvalidInput,
130                    "HOME must be an absolute path",
131                ));
132            }
133            reject_parent_traversal(&home_path, "HOME")?;
134            home_path.join(".astrid")
135        };
136
137        Ok(Self { root })
138    }
139
140    /// Create from an explicit path (useful for testing).
141    #[must_use]
142    pub fn from_path(root: impl Into<PathBuf>) -> Self {
143        Self { root: root.into() }
144    }
145
146    /// Ensure the system directory structure exists with secure permissions.
147    ///
148    /// Creates `etc/`, `var/`, `run/`, `log/`, `keys/`, `lib/`, and `home/`.
149    /// Writes `etc/layout-version` with the current version.
150    /// Sets all directories to `0o700` on Unix.
151    ///
152    /// Note: `capsules/` (system/distro capsules) is NOT created eagerly.
153    /// Nothing writes there yet — user installs go to principal home.
154    /// It will be created when an operator install mechanism lands.
155    ///
156    /// # Errors
157    ///
158    /// Returns an error if directory creation or permission setting fails.
159    pub fn ensure(&self) -> io::Result<()> {
160        let dirs = [
161            self.etc_dir(),
162            self.hooks_dir(),
163            self.var_dir(),
164            self.run_dir(),
165            self.log_dir(),
166            self.keys_dir(),
167            self.bin_dir(),
168            self.wit_dir(),
169            self.home_dir(),
170        ];
171        for dir in &dirs {
172            std::fs::create_dir_all(dir)?;
173        }
174
175        // Write layout version sentinel (idempotent).
176        let version_path = self.etc_dir().join("layout-version");
177        if !version_path.exists() {
178            std::fs::write(&version_path, LAYOUT_VERSION)?;
179        }
180
181        #[cfg(unix)]
182        {
183            use std::os::unix::fs::PermissionsExt;
184            let perms = std::fs::Permissions::from_mode(0o700);
185            std::fs::set_permissions(self.root(), perms.clone())?;
186            for dir in &dirs {
187                std::fs::set_permissions(dir, perms.clone())?;
188            }
189        }
190        Ok(())
191    }
192
193    // ── Path accessors ───────────────────────────────────────────────
194
195    /// Root directory path (`~/.astrid/`).
196    #[must_use]
197    pub fn root(&self) -> &Path {
198        &self.root
199    }
200
201    /// Configuration directory (`etc/`).
202    #[must_use]
203    pub fn etc_dir(&self) -> PathBuf {
204        self.root.join("etc")
205    }
206
207    /// Path to the global runtime configuration file (`etc/config.toml`).
208    #[must_use]
209    pub fn config_path(&self) -> PathBuf {
210        self.etc_dir().join("config.toml")
211    }
212
213    /// Path to the MCP servers configuration file (`etc/servers.toml`).
214    #[must_use]
215    pub fn servers_config_path(&self) -> PathBuf {
216        self.etc_dir().join("servers.toml")
217    }
218
219    /// Path to the gateway daemon configuration file (`etc/gateway.toml`).
220    #[must_use]
221    pub fn gateway_config_path(&self) -> PathBuf {
222        self.etc_dir().join("gateway.toml")
223    }
224
225    /// System hooks directory (`etc/hooks/`).
226    #[must_use]
227    pub fn hooks_dir(&self) -> PathBuf {
228        self.etc_dir().join("hooks")
229    }
230
231    /// Per-principal profile directory (`etc/profiles/`).
232    ///
233    /// Per-principal `profile.toml` files live here, NOT inside the
234    /// principal's own home directory. Profile contents (enabled,
235    /// groups, grants, revokes, quotas, auth public keys, egress
236    /// policy, process allowlist) are system-managed policy: a capsule
237    /// running as a principal with `fs_read = ["home://"]` must not be
238    /// able to read its own policy, and `fs_write` must not let it
239    /// self-elevate. Keeping profiles under `etc/` puts them outside
240    /// the `home://` VFS scheme entirely.
241    #[must_use]
242    pub fn profiles_dir(&self) -> PathBuf {
243        self.etc_dir().join("profiles")
244    }
245
246    /// Per-principal profile path (`etc/profiles/{principal}.toml`).
247    /// See [`Self::profiles_dir`] for why this lives outside the
248    /// principal's home directory.
249    #[must_use]
250    pub fn profile_path(&self, id: &PrincipalId) -> PathBuf {
251        self.profiles_dir().join(format!("{id}.toml"))
252    }
253
254    /// Persistent state directory (`var/`).
255    #[must_use]
256    pub fn var_dir(&self) -> PathBuf {
257        self.root.join("var")
258    }
259
260    /// Path to the system KV database (`var/state.db/`).
261    #[must_use]
262    pub fn state_db_path(&self) -> PathBuf {
263        self.var_dir().join("state.db")
264    }
265
266    /// Ephemeral runtime directory (`run/`).
267    #[must_use]
268    pub fn run_dir(&self) -> PathBuf {
269        self.root.join("run")
270    }
271
272    /// Path to the kernel's Unix domain socket (`run/system.sock`).
273    #[must_use]
274    pub fn socket_path(&self) -> PathBuf {
275        self.run_dir().join("system.sock")
276    }
277
278    /// Path to the session authentication token (`run/system.token`).
279    #[must_use]
280    pub fn token_path(&self) -> PathBuf {
281        self.run_dir().join("system.token")
282    }
283
284    /// Path to the daemon readiness sentinel (`run/system.ready`).
285    ///
286    /// Written by the daemon after all capsules are loaded and accepting
287    /// connections. The CLI polls for this file instead of the socket file
288    /// to avoid connecting before the daemon is fully initialized.
289    #[must_use]
290    pub fn ready_path(&self) -> PathBuf {
291        self.run_dir().join("system.ready")
292    }
293
294    /// Path to the deferred queue database (`run/deferred.db/`).
295    #[must_use]
296    pub fn deferred_db_path(&self) -> PathBuf {
297        self.run_dir().join("deferred.db")
298    }
299
300    /// System log directory (`log/`).
301    #[must_use]
302    pub fn log_dir(&self) -> PathBuf {
303        self.root.join("log")
304    }
305
306    /// Secrets directory (`secrets/`).
307    ///
308    /// File-per-secret store keyed by
309    /// `secrets/<scope>/<capsule>/<key>`. `<scope>` is either an
310    /// agent principal name (per-agent override) or `__host__` (the
311    /// shared / operator-wide value the kernel's secret-resolve path
312    /// falls back to). Files are written `0600`, parent dirs `0700`.
313    #[must_use]
314    pub fn secrets_dir(&self) -> PathBuf {
315        self.root.join("secrets")
316    }
317
318    /// Keys directory (`keys/`).
319    #[must_use]
320    pub fn keys_dir(&self) -> PathBuf {
321        self.root.join("keys")
322    }
323
324    /// Path to the runtime signing key (`keys/runtime.key`).
325    #[must_use]
326    pub fn runtime_key_path(&self) -> PathBuf {
327        self.keys_dir().join("runtime.key")
328    }
329
330    /// Content-addressed compiled WASM binaries (`bin/`).
331    #[must_use]
332    pub fn bin_dir(&self) -> PathBuf {
333        self.root.join("bin")
334    }
335
336    /// Content-addressed WIT interface definitions (`wit/`).
337    ///
338    /// Stores BLAKE3-hashed `.wit` files from third-party capsules.
339    /// Standard interfaces ship with the SDK; custom interfaces are
340    /// stored here on capsule install.
341    #[must_use]
342    pub fn wit_dir(&self) -> PathBuf {
343        self.root.join("wit")
344    }
345
346    /// Shared WASM component libraries (`lib/`).
347    ///
348    /// Reserved for future WIT interface components that capsules can import.
349    /// Not created eagerly — will be populated when component linking lands.
350    #[must_use]
351    pub fn lib_dir(&self) -> PathBuf {
352        self.root.join("lib")
353    }
354
355    /// Principal home directories root (`home/`).
356    #[must_use]
357    pub fn home_dir(&self) -> PathBuf {
358        self.root.join("home")
359    }
360
361    /// Get the home directory for a specific principal.
362    #[must_use]
363    pub fn principal_home(&self, id: &PrincipalId) -> PrincipalHome {
364        PrincipalHome {
365            root: self.home_dir().join(id.as_str()),
366        }
367    }
368}
369
370// ── PrincipalHome (per-user) ─────────────────────────────────────────────
371
372/// Per-principal home directory (`~/.astrid/home/{principal}/`).
373///
374/// Each principal gets isolated storage following the XDG-like convention:
375/// `.local/` for data and `.config/` for configuration.
376#[derive(Debug, Clone)]
377pub struct PrincipalHome {
378    root: PathBuf,
379}
380
381impl PrincipalHome {
382    /// Create from an explicit path (useful for testing).
383    #[must_use]
384    pub fn from_path(root: impl Into<PathBuf>) -> Self {
385        Self { root: root.into() }
386    }
387
388    /// Ensure the full principal directory tree exists with secure permissions.
389    ///
390    /// # Errors
391    ///
392    /// Returns an error if directory creation or permission setting fails.
393    pub fn ensure(&self) -> io::Result<()> {
394        let dirs = [
395            self.capsules_dir(),
396            self.kv_dir(),
397            self.log_dir(),
398            self.audit_dir(),
399            self.tokens_dir(),
400            self.tmp_dir(),
401            self.env_dir(),
402        ];
403        for dir in &dirs {
404            std::fs::create_dir_all(dir)?;
405        }
406        #[cfg(unix)]
407        {
408            use std::os::unix::fs::PermissionsExt;
409            let perms = std::fs::Permissions::from_mode(0o700);
410            std::fs::set_permissions(&self.root, perms.clone())?;
411            // Secure the two top-level dot-dirs.
412            std::fs::set_permissions(self.root.join(".local"), perms.clone())?;
413            std::fs::set_permissions(self.root.join(".config"), perms)?;
414        }
415        Ok(())
416    }
417
418    // ── Path accessors ───────────────────────────────────────────────
419
420    /// Principal home root (`home/{principal}/`).
421    #[must_use]
422    pub fn root(&self) -> &Path {
423        &self.root
424    }
425
426    /// User-installed capsules (`.local/capsules/`).
427    #[must_use]
428    pub fn capsules_dir(&self) -> PathBuf {
429        self.root.join(".local").join("capsules")
430    }
431
432    /// Capsule KV data (`.local/kv/`).
433    #[must_use]
434    pub fn kv_dir(&self) -> PathBuf {
435        self.root.join(".local").join("kv")
436    }
437
438    /// Capsule logs (`.local/log/`).
439    #[must_use]
440    pub fn log_dir(&self) -> PathBuf {
441        self.root.join(".local").join("log")
442    }
443
444    /// Audit chain (`.local/audit/`).
445    #[must_use]
446    pub fn audit_dir(&self) -> PathBuf {
447        self.root.join(".local").join("audit")
448    }
449
450    /// Capability tokens (`.local/tokens/`).
451    #[must_use]
452    pub fn tokens_dir(&self) -> PathBuf {
453        self.root.join(".local").join("tokens")
454    }
455
456    /// Temporary files, VFS-mounted as `/tmp` (`.local/tmp/`).
457    #[must_use]
458    pub fn tmp_dir(&self) -> PathBuf {
459        self.root.join(".local").join("tmp")
460    }
461
462    /// Configuration directory (`.config/`).
463    #[must_use]
464    pub fn config_dir(&self) -> PathBuf {
465        self.root.join(".config")
466    }
467
468    /// Capsule environment config overrides (`.config/env/`).
469    #[must_use]
470    pub fn env_dir(&self) -> PathBuf {
471        self.root.join(".config").join("env")
472    }
473}
474
475// ── WorkspaceDir (per-project) ───────────────────────────────────────────
476
477/// Per-project workspace directory (`<project>/.astrid/`).
478///
479/// Contains only committable project-level config. A `workspace-id` UUID
480/// links the project to its global state in `~/.astrid/`.
481#[derive(Debug, Clone)]
482pub struct WorkspaceDir {
483    /// The project root (parent of `.astrid/`).
484    project_root: PathBuf,
485}
486
487impl WorkspaceDir {
488    /// Detect the workspace directory by walking up from `start_dir`.
489    ///
490    /// Detection order:
491    /// 1. Directory containing `.astrid/`
492    /// 2. Directory containing `.git`
493    /// 3. Directory containing `ASTRID.md`
494    /// 4. Fallback to `start_dir` itself
495    #[must_use]
496    pub fn detect(start_dir: &Path) -> Self {
497        let start = if start_dir.is_absolute() {
498            start_dir.to_path_buf()
499        } else {
500            std::env::current_dir().unwrap_or_default().join(start_dir)
501        };
502
503        let mut current = start.as_path();
504
505        loop {
506            if current.join(".astrid").is_dir() {
507                return Self {
508                    project_root: current.to_path_buf(),
509                };
510            }
511            if current.join(".git").exists() {
512                return Self {
513                    project_root: current.to_path_buf(),
514                };
515            }
516            if current.join("ASTRID.md").exists() {
517                return Self {
518                    project_root: current.to_path_buf(),
519                };
520            }
521            match current.parent() {
522                Some(parent) if parent != current => current = parent,
523                _ => break,
524            }
525        }
526
527        Self {
528            project_root: start,
529        }
530    }
531
532    /// Create from an explicit project root (useful for testing).
533    #[must_use]
534    pub fn from_path(project_root: impl Into<PathBuf>) -> Self {
535        Self {
536            project_root: project_root.into(),
537        }
538    }
539
540    /// Ensure the `.astrid/` directory exists and generate a workspace ID
541    /// if one does not already exist.
542    ///
543    /// # Errors
544    ///
545    /// Returns an error if directory creation or workspace ID generation fails.
546    pub fn ensure(&self) -> io::Result<()> {
547        std::fs::create_dir_all(self.dot_astrid())?;
548        let _ = self.workspace_id()?;
549        Ok(())
550    }
551
552    /// Project root directory (parent of `.astrid/`).
553    #[must_use]
554    pub fn root(&self) -> &Path {
555        &self.project_root
556    }
557
558    /// The `.astrid/` directory itself.
559    #[must_use]
560    pub fn dot_astrid(&self) -> PathBuf {
561        self.project_root.join(".astrid")
562    }
563
564    /// Workspace capsules directory (`.astrid/capsules/`).
565    #[must_use]
566    pub fn capsules_dir(&self) -> PathBuf {
567        self.dot_astrid().join("capsules")
568    }
569
570    /// Path to the workspace-id file (`.astrid/workspace-id`).
571    #[must_use]
572    pub fn workspace_id_path(&self) -> PathBuf {
573        self.dot_astrid().join("workspace-id")
574    }
575
576    /// Read or generate the workspace ID.
577    ///
578    /// If the file exists (e.g. cloned from a repo), its UUID is adopted.
579    /// Otherwise a new UUID is generated and written.
580    ///
581    /// # Errors
582    ///
583    /// Returns an error if the file cannot be read or written.
584    pub fn workspace_id(&self) -> io::Result<Uuid> {
585        let path = self.workspace_id_path();
586        if let Ok(content) = std::fs::read_to_string(&path) {
587            let trimmed = content.trim();
588            if let Ok(id) = Uuid::parse_str(trimmed) {
589                return Ok(id);
590            }
591        }
592        std::fs::create_dir_all(self.dot_astrid())?;
593        let id = Uuid::new_v4();
594        std::fs::write(&path, id.to_string())?;
595        Ok(id)
596    }
597
598    /// Path to the project-level instructions file (`.astrid/ASTRID.md`).
599    #[must_use]
600    pub fn instructions_path(&self) -> PathBuf {
601        self.dot_astrid().join("ASTRID.md")
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    // ── AstridHome resolution ────────────────────────────────────────
610
611    #[test]
612    fn test_astrid_home_resolve_with_env() {
613        let dir = tempfile::tempdir().unwrap();
614        let path = dir.path().to_path_buf();
615        let path_str = path.to_string_lossy().to_string();
616
617        let home = AstridHome::resolve_with_env(Some(path_str), None).unwrap();
618        assert_eq!(home.root(), path);
619    }
620
621    #[test]
622    fn test_astrid_home_resolve_default() {
623        let home_val = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
624        let home = AstridHome::resolve_with_env(None, Some(home_val.clone())).unwrap();
625        let expected = PathBuf::from(home_val).join(".astrid");
626        assert_eq!(home.root(), expected);
627    }
628
629    #[test]
630    fn test_astrid_home_rejects_traversal_in_astrid_home() {
631        let result = AstridHome::resolve_with_env(Some("/tmp/../etc".to_string()), None);
632        assert!(result.is_err());
633        let err = result.unwrap_err();
634        assert!(
635            err.to_string().contains("'..'"),
636            "expected path traversal error, got: {err}"
637        );
638    }
639
640    #[test]
641    fn test_astrid_home_rejects_traversal_in_home() {
642        let result = AstridHome::resolve_with_env(None, Some("/tmp/../etc".to_string()));
643        assert!(result.is_err());
644        let err = result.unwrap_err();
645        assert!(
646            err.to_string().contains("'..'"),
647            "expected path traversal error, got: {err}"
648        );
649    }
650
651    #[test]
652    fn test_astrid_home_rejects_relative_env() {
653        let result = AstridHome::resolve_with_env(Some("relative/path".to_string()), None);
654        assert!(result.is_err());
655        assert!(result.unwrap_err().to_string().contains("absolute"));
656    }
657
658    #[test]
659    fn test_astrid_home_rejects_empty_env() {
660        let result = AstridHome::resolve_with_env(Some(String::new()), None);
661        assert!(result.is_err());
662    }
663
664    #[test]
665    fn test_astrid_home_rejects_relative_home() {
666        let result = AstridHome::resolve_with_env(None, Some("relative/path".to_string()));
667        assert!(result.is_err());
668        assert!(result.unwrap_err().to_string().contains("absolute"));
669    }
670
671    // ── AstridHome ensure ────────────────────────────────────────────
672
673    #[test]
674    fn test_astrid_home_ensure_creates_dirs() {
675        let dir = tempfile::tempdir().unwrap();
676        let home = AstridHome::from_path(dir.path());
677        home.ensure().unwrap();
678
679        assert!(home.etc_dir().exists());
680        assert!(home.hooks_dir().exists());
681        assert!(home.var_dir().exists());
682        assert!(home.run_dir().exists());
683        assert!(home.log_dir().exists());
684        assert!(home.keys_dir().exists());
685        assert!(home.bin_dir().exists());
686        assert!(home.home_dir().exists());
687    }
688
689    #[test]
690    fn test_astrid_home_ensure_writes_layout_version() {
691        let dir = tempfile::tempdir().unwrap();
692        let home = AstridHome::from_path(dir.path());
693        home.ensure().unwrap();
694
695        let version_path = home.etc_dir().join("layout-version");
696        assert!(version_path.exists());
697        let content = std::fs::read_to_string(&version_path).unwrap();
698        assert_eq!(content, LAYOUT_VERSION);
699    }
700
701    #[test]
702    fn test_astrid_home_ensure_idempotent() {
703        let dir = tempfile::tempdir().unwrap();
704        let home = AstridHome::from_path(dir.path());
705        home.ensure().unwrap();
706        home.ensure().unwrap(); // second call should not fail
707    }
708
709    #[cfg(unix)]
710    #[test]
711    fn test_astrid_home_ensure_sets_permissions() {
712        use std::os::unix::fs::PermissionsExt;
713
714        let dir = tempfile::tempdir().unwrap();
715        let home = AstridHome::from_path(dir.path());
716        home.ensure().unwrap();
717
718        let root_perms = std::fs::metadata(home.root()).unwrap().permissions();
719        assert_eq!(root_perms.mode() & 0o777, 0o700);
720
721        let keys_perms = std::fs::metadata(home.keys_dir()).unwrap().permissions();
722        assert_eq!(keys_perms.mode() & 0o777, 0o700);
723    }
724
725    // ── AstridHome path accessors ────────────────────────────────────
726
727    #[test]
728    fn test_astrid_home_fhs_paths() {
729        let home = AstridHome::from_path("/tmp/test-astrid");
730        let r = "/tmp/test-astrid";
731
732        assert_eq!(home.root(), Path::new(r));
733        assert_eq!(home.etc_dir(), PathBuf::from(format!("{r}/etc")));
734        assert_eq!(
735            home.config_path(),
736            PathBuf::from(format!("{r}/etc/config.toml"))
737        );
738        assert_eq!(
739            home.servers_config_path(),
740            PathBuf::from(format!("{r}/etc/servers.toml"))
741        );
742        assert_eq!(
743            home.gateway_config_path(),
744            PathBuf::from(format!("{r}/etc/gateway.toml"))
745        );
746        assert_eq!(home.hooks_dir(), PathBuf::from(format!("{r}/etc/hooks")));
747        assert_eq!(home.var_dir(), PathBuf::from(format!("{r}/var")));
748        assert_eq!(
749            home.state_db_path(),
750            PathBuf::from(format!("{r}/var/state.db"))
751        );
752        assert_eq!(home.run_dir(), PathBuf::from(format!("{r}/run")));
753        assert_eq!(
754            home.socket_path(),
755            PathBuf::from(format!("{r}/run/system.sock"))
756        );
757        assert_eq!(
758            home.token_path(),
759            PathBuf::from(format!("{r}/run/system.token"))
760        );
761        assert_eq!(
762            home.ready_path(),
763            PathBuf::from(format!("{r}/run/system.ready"))
764        );
765        assert_eq!(
766            home.deferred_db_path(),
767            PathBuf::from(format!("{r}/run/deferred.db"))
768        );
769        assert_eq!(home.log_dir(), PathBuf::from(format!("{r}/log")));
770        assert_eq!(home.keys_dir(), PathBuf::from(format!("{r}/keys")));
771        assert_eq!(
772            home.runtime_key_path(),
773            PathBuf::from(format!("{r}/keys/runtime.key"))
774        );
775        assert_eq!(home.bin_dir(), PathBuf::from(format!("{r}/bin")));
776        assert_eq!(home.home_dir(), PathBuf::from(format!("{r}/home")));
777    }
778
779    // ── PrincipalHome ────────────────────────────────────────────────
780
781    #[test]
782    fn test_principal_home_from_astrid_home() {
783        let home = AstridHome::from_path("/tmp/test-astrid");
784        let principal = PrincipalId::default();
785        let ph = home.principal_home(&principal);
786        assert_eq!(ph.root(), Path::new("/tmp/test-astrid/home/default"));
787    }
788
789    #[test]
790    fn test_principal_home_paths() {
791        let ph = PrincipalHome::from_path("/tmp/test-astrid/home/alice");
792        let r = "/tmp/test-astrid/home/alice";
793
794        assert_eq!(ph.root(), Path::new(r));
795        assert_eq!(
796            ph.capsules_dir(),
797            PathBuf::from(format!("{r}/.local/capsules"))
798        );
799        assert_eq!(ph.kv_dir(), PathBuf::from(format!("{r}/.local/kv")));
800        assert_eq!(ph.log_dir(), PathBuf::from(format!("{r}/.local/log")));
801        assert_eq!(ph.audit_dir(), PathBuf::from(format!("{r}/.local/audit")));
802        assert_eq!(ph.tokens_dir(), PathBuf::from(format!("{r}/.local/tokens")));
803        assert_eq!(ph.tmp_dir(), PathBuf::from(format!("{r}/.local/tmp")));
804        assert_eq!(ph.config_dir(), PathBuf::from(format!("{r}/.config")));
805        assert_eq!(ph.env_dir(), PathBuf::from(format!("{r}/.config/env")));
806    }
807
808    #[test]
809    fn test_principal_home_ensure_creates_dirs() {
810        let dir = tempfile::tempdir().unwrap();
811        let ph = PrincipalHome::from_path(dir.path().join("alice"));
812        ph.ensure().unwrap();
813
814        assert!(ph.capsules_dir().exists());
815        assert!(ph.kv_dir().exists());
816        assert!(ph.log_dir().exists());
817        assert!(ph.audit_dir().exists());
818        assert!(ph.tokens_dir().exists());
819        assert!(ph.tmp_dir().exists());
820        assert!(ph.env_dir().exists());
821    }
822
823    #[cfg(unix)]
824    #[test]
825    fn test_principal_home_ensure_sets_permissions() {
826        use std::os::unix::fs::PermissionsExt;
827
828        let dir = tempfile::tempdir().unwrap();
829        let ph = PrincipalHome::from_path(dir.path().join("bob"));
830        ph.ensure().unwrap();
831
832        let root_perms = std::fs::metadata(ph.root()).unwrap().permissions();
833        assert_eq!(root_perms.mode() & 0o777, 0o700);
834
835        let local_perms = std::fs::metadata(ph.root().join(".local"))
836            .unwrap()
837            .permissions();
838        assert_eq!(local_perms.mode() & 0o777, 0o700);
839
840        let config_perms = std::fs::metadata(ph.root().join(".config"))
841            .unwrap()
842            .permissions();
843        assert_eq!(config_perms.mode() & 0o777, 0o700);
844    }
845
846    #[test]
847    fn test_principal_home_ensure_idempotent() {
848        let dir = tempfile::tempdir().unwrap();
849        let ph = PrincipalHome::from_path(dir.path().join("charlie"));
850        ph.ensure().unwrap();
851        ph.ensure().unwrap(); // second call should not fail
852    }
853
854    // ── WorkspaceDir ─────────────────────────────────────────────────
855
856    #[test]
857    fn test_workspace_detect_with_dot_astrid() {
858        let dir = tempfile::tempdir().unwrap();
859        let astrid_dir = dir.path().join(".astrid");
860        std::fs::create_dir(&astrid_dir).unwrap();
861
862        let sub = dir.path().join("src").join("deep");
863        std::fs::create_dir_all(&sub).unwrap();
864
865        let ws = WorkspaceDir::detect(&sub);
866        assert_eq!(ws.root(), dir.path());
867    }
868
869    #[test]
870    fn test_workspace_detect_with_git() {
871        let dir = tempfile::tempdir().unwrap();
872        let git_dir = dir.path().join(".git");
873        std::fs::create_dir(&git_dir).unwrap();
874
875        let sub = dir.path().join("src");
876        std::fs::create_dir_all(&sub).unwrap();
877
878        let ws = WorkspaceDir::detect(&sub);
879        assert_eq!(ws.root(), dir.path());
880    }
881
882    #[test]
883    fn test_workspace_detect_with_astrid_md() {
884        let dir = tempfile::tempdir().unwrap();
885        std::fs::write(dir.path().join("ASTRID.md"), "# Project").unwrap();
886
887        let sub = dir.path().join("src");
888        std::fs::create_dir_all(&sub).unwrap();
889
890        let ws = WorkspaceDir::detect(&sub);
891        assert_eq!(ws.root(), dir.path());
892    }
893
894    #[test]
895    fn test_workspace_detect_fallback() {
896        let dir = tempfile::tempdir().unwrap();
897        let isolated = dir.path().join("isolated");
898        std::fs::create_dir_all(&isolated).unwrap();
899
900        let ws = WorkspaceDir::from_path(&isolated);
901        assert_eq!(ws.root(), isolated);
902    }
903
904    #[test]
905    fn test_workspace_detect_prefers_dot_astrid_over_git() {
906        let dir = tempfile::tempdir().unwrap();
907        std::fs::create_dir(dir.path().join(".astrid")).unwrap();
908        std::fs::create_dir(dir.path().join(".git")).unwrap();
909
910        let sub = dir.path().join("src");
911        std::fs::create_dir_all(&sub).unwrap();
912
913        let ws = WorkspaceDir::detect(&sub);
914        assert_eq!(ws.root(), dir.path());
915    }
916
917    #[test]
918    fn test_workspace_ensure_creates_dirs_and_id() {
919        let dir = tempfile::tempdir().unwrap();
920        let ws = WorkspaceDir::from_path(dir.path());
921        ws.ensure().unwrap();
922
923        assert!(ws.dot_astrid().exists());
924        assert!(ws.workspace_id_path().exists());
925
926        let content = std::fs::read_to_string(ws.workspace_id_path()).unwrap();
927        uuid::Uuid::parse_str(content.trim()).expect("workspace-id should be a valid UUID");
928    }
929
930    #[test]
931    fn test_workspace_id_adopts_existing() {
932        let dir = tempfile::tempdir().unwrap();
933        let ws = WorkspaceDir::from_path(dir.path());
934
935        std::fs::create_dir_all(ws.dot_astrid()).unwrap();
936        let pre_id = uuid::Uuid::new_v4();
937        std::fs::write(ws.workspace_id_path(), pre_id.to_string()).unwrap();
938
939        let id = ws.workspace_id().unwrap();
940        assert_eq!(id, pre_id);
941    }
942
943    #[test]
944    fn test_workspace_id_stable_across_calls() {
945        let dir = tempfile::tempdir().unwrap();
946        let ws = WorkspaceDir::from_path(dir.path());
947        let id1 = ws.workspace_id().unwrap();
948        let id2 = ws.workspace_id().unwrap();
949        assert_eq!(id1, id2);
950    }
951
952    #[test]
953    fn test_workspace_path_accessors() {
954        let ws = WorkspaceDir::from_path("/home/user/project");
955        assert_eq!(ws.root(), Path::new("/home/user/project"));
956        assert_eq!(ws.dot_astrid(), PathBuf::from("/home/user/project/.astrid"));
957        assert_eq!(
958            ws.capsules_dir(),
959            PathBuf::from("/home/user/project/.astrid/capsules")
960        );
961        assert_eq!(
962            ws.workspace_id_path(),
963            PathBuf::from("/home/user/project/.astrid/workspace-id")
964        );
965        assert_eq!(
966            ws.instructions_path(),
967            PathBuf::from("/home/user/project/.astrid/ASTRID.md")
968        );
969    }
970}