//! Per-user filesystem mapping (blueprint §6): the bridge between the path an //! agent sees and the physical host / container path behind it. //! //! An agent sees one namespace: //! //! | Agent path | Backing | //! |-------------------|----------------------------------------------------| //! | `user-memory/…` | SQLite (the user's pool) — routed *before* this | //! | `shared-memory/…` | SQLite (`system.db`) — routed *before* this | //! | `shared/{X}/…` | host `{WD}/shared/{X}`, mount `{home}/shared/{X}` | //! | `projects/{O}/{S}`| host `{WD}/projects/{owner_userid}/{S}`, mount `{home}/projects/{O}/{S}` (O = owner username) | //! | `~/docs/…`, `docs/…` | host `{WD}/docs` (read-only, same for every user), mount `{container_home}/docs` | //! | `~/…`, relative | host `{WD}/homes/{userid}`, mount `{container_home}`| //! //! `UserFs` is a **pure value type** with no filesystem access: it carries the //! resolved paths and does the lexical agent→host / agent→container mapping. //! Containment (canonicalize + prefix-check against the mount root, which follows //! symlinks) lives in `skald-core`, where the fs helpers already are — this crate //! stays dependency-light. The two virtual memory roots are classified by the //! fs-tools *before* reaching here; `UserFs` only ever sees physical paths. use std::path::{Component, Path, PathBuf}; use std::sync::{Arc, RwLock}; /// The subdirectory of a user's home where chat uploads are saved /// (`{home}/uploads/{session_id}/…`, reachable by the agent as `uploads/…`). /// Shared by the upload handler (write path) and the media inliner (containment /// root) so the two anchors can never drift. pub const UPLOADS_SUBDIR: &str = "uploads"; /// One shared folder mounted into a user's container. #[derive(Debug, Clone)] pub struct SharedMount { /// The folder name (`{WD}/shared/{name}`); the first path component under `shared/`. pub name: String, /// Absolute host directory that backs it. pub host: PathBuf, /// Where it is mounted inside the container. pub container: PathBuf, /// Whether this member may write to it. pub can_write: bool, } /// One project folder mounted into a user's container. Unlike a shared folder its /// agent path has **two** segments — `projects/{owner_username}/{slug}` — because a /// project is namespaced by its owner (two members can each own a `budget`). The host /// path keys on the owner's stable **userid**, the agent/container path on the /// (mutable) **username**. #[derive(Debug, Clone)] pub struct ProjectMount { /// The owner's username — the first agent-visible segment under `projects/`. pub owner_username: String, /// The project slug — the second agent-visible segment. pub slug: String, /// Absolute host directory that backs it (`{WD}/projects/{owner_userid}/{slug}`). pub host: PathBuf, /// Where it is mounted inside the container (`{home}/projects/{owner_username}/{slug}`). pub container: PathBuf, /// Whether this member may write to it. pub can_write: bool, } /// The filesystem view of one user: their private home plus the shared folders /// they belong to, and the container those are mounted into. #[derive(Debug, Clone)] pub struct UserFs { pub user_id: String, /// Absolute host path of the user's private home (`{WD}/homes/{userid}`). pub home_host: PathBuf, /// The Docker container name for this user (`skald-{userid}`). pub container_name: String, /// The home mount point inside the container (e.g. `/root`). pub container_home: PathBuf, /// Shared folders this user can reach, in name order. pub shared: Vec, /// Projects this user can reach (owned + shared-with-them), by owner then slug. pub projects: Vec, /// Host directory backing the read-only docs mount (`{WD}/docs`), the same for /// every user. `None` when unset (inert placeholders, unit tests that don't /// touch it) — `docs/…` then resolves like any other unmounted path. pub docs_host: Option, } impl UserFs { pub fn new( user_id: impl Into, home_host: PathBuf, container_name: impl Into, container_home: PathBuf, shared: Vec, projects: Vec, docs_host: Option, ) -> Self { Self { user_id: user_id.into(), home_host, container_name: container_name.into(), container_home, shared, projects, docs_host, } } /// Look up a shared mount by its folder name. pub fn shared_mount(&self, name: &str) -> Option<&SharedMount> { self.shared.iter().find(|m| m.name == name) } /// Look up a project mount by its owner username + slug (the two agent segments). pub fn project_mount(&self, owner_username: &str, slug: &str) -> Option<&ProjectMount> { self.projects .iter() .find(|m| m.owner_username == owner_username && m.slug == slug) } /// Whether the user may **write** at this agent path: their home → always; /// a shared-folder or project mount → the membership's `can_write` flag; /// `docs/…` → never (read-only). A `shared/`/`projects/` mount the user is /// not a member of → false (fail-closed, same as the read side). Purely /// lexical: memory paths never reach here (classified earlier). pub fn can_write_to(&self, agent_path: &str) -> bool { let stripped = strip_home_prefix(agent_path); let mut parts = stripped.splitn(2, ['/', '\\']); match parts.next() { Some("shared") => { let rest = parts.next().unwrap_or(""); let name = rest.splitn(2, ['/', '\\']).next().unwrap_or(""); self.shared_mount(name).map(|m| m.can_write).unwrap_or(false) } Some("projects") => { let rest = parts.next().unwrap_or(""); let mut seg = rest.splitn(3, ['/', '\\']); let owner = seg.next().unwrap_or(""); let slug = seg.next().unwrap_or(""); self.project_mount(owner, slug).map(|m| m.can_write).unwrap_or(false) } Some("docs") => false, _ => true, } } /// The bind mounts for `docker create`: `(host, container, writable)`, home first. pub fn mounts(&self) -> Vec<(PathBuf, PathBuf, bool)> { let mut out = vec![(self.home_host.clone(), self.container_home.clone(), true)]; for m in &self.shared { out.push((m.host.clone(), m.container.clone(), m.can_write)); } for m in &self.projects { out.push((m.host.clone(), m.container.clone(), m.can_write)); } if let Some(docs) = &self.docs_host { out.push((docs.clone(), self.container_home.join("docs"), false)); } out } /// The host base a physical agent path resolves against, and the tail relative /// to it — **without** touching the filesystem. `shared/{X}/…` resolves against /// the shared mount's host dir (only if the user is a member); everything else /// resolves against the private home. Returns `None` when the path names a /// `shared/` folder the user does not belong to. The caller (skald-core) then /// joins + canonicalizes + prefix-checks against the returned base. /// /// Memory paths (`user-memory/…`, `shared-memory/…`) must be classified and /// routed to SQLite *before* calling this — they are not physical paths. pub fn host_base_and_tail<'a>(&self, agent_path: &'a str) -> Option<(PathBuf, String)> { let stripped = strip_home_prefix(agent_path); let mut parts = stripped.splitn(2, ['/', '\\']); match parts.next() { Some("shared") => { let rest = parts.next().unwrap_or(""); let mut seg = rest.splitn(2, ['/', '\\']); let name = seg.next().unwrap_or(""); let tail = seg.next().unwrap_or(""); let mount = self.shared_mount(name)?; Some((mount.host.clone(), tail.to_string())) } Some("projects") => { // Two segments: `projects/{owner_username}/{slug}/{tail…}`. let rest = parts.next().unwrap_or(""); let mut seg = rest.splitn(3, ['/', '\\']); let owner = seg.next().unwrap_or(""); let slug = seg.next().unwrap_or(""); let tail = seg.next().unwrap_or(""); let mount = self.project_mount(owner, slug)?; Some((mount.host.clone(), tail.to_string())) } Some("docs") => { let host = self.docs_host.clone()?; let tail = parts.next().unwrap_or(""); Some((host, tail.to_string())) } _ => Some((self.home_host.clone(), stripped.to_string())), } } /// Map an agent path to its **container** path (pure, lexical): `~`/relative → /// under `container_home`; `shared/{X}` and `projects/{O}/{S}` → under /// `container_home/…` (they mirror the container layout); an already-absolute path /// is taken as a container path as-is. Used to set the working directory of an /// `execute_cmd` inside the container. pub fn to_container(&self, agent_path: &str) -> PathBuf { let p = Path::new(agent_path); if p.is_absolute() { return normalize(p); } let stripped = strip_home_prefix(agent_path); normalize(&self.container_home.join(stripped)) } /// Reverse of [`to_container`](Self::to_container) for an already-absolute path: /// map a **container-absolute** path (`/root/…`, `/root/shared/{X}/…`, /// `/root/projects/{O}/{S}/…`) back to the agent vocabulary. Shared and project /// mounts nest *under* `container_home`, so they are matched **first** — otherwise /// `/root/shared/X` would strip against the home base and mis-route. /// /// Returns `None` when `abs` lies outside every one of this user's container mounts /// (i.e. it points outside their view) — the caller rejects it fail-closed. Purely /// lexical: no membership check, no filesystem access. pub fn container_to_agent(&self, abs: &Path) -> Option { let abs = normalize(abs); for m in &self.shared { if let Ok(tail) = abs.strip_prefix(&m.container) { return Some(agent_join(&format!("shared/{}", m.name), tail)); } } for m in &self.projects { if let Ok(tail) = abs.strip_prefix(&m.container) { return Some(agent_join(&format!("projects/{}/{}", m.owner_username, m.slug), tail)); } } abs.strip_prefix(&self.container_home) .ok() .map(|tail| agent_join("~", tail)) } /// Normalize any path arriving from the show-file / file-viewer surface into a /// **canonical agent path** the UI can display and echo back: a relative or `~/…` /// path is cleaned and rooted (`report.md` → `~/report.md`, `shared/X/y` and /// `projects/O/S/y` keep their root); a container-absolute path is reverse-mapped /// via [`container_to_agent`](Self::container_to_agent). /// /// Returns `None` only for an absolute path outside every container mount — the /// caller rejects it fail-closed. Purely lexical (`.`/`..` collapse, `..` clamps at /// the root); membership + on-disk containment are enforced later, in skald-core. pub fn to_agent_display(&self, input: &str) -> Option { let p = Path::new(input); if p.is_absolute() { return self.container_to_agent(p); } let cleaned = normalize(Path::new(strip_home_prefix(input))); let cleaned = cleaned.to_string_lossy().replace('\\', "/"); let root = cleaned.split('/').next().unwrap_or(""); if root == "shared" || root == "projects" { Some(cleaned) } else if cleaned.is_empty() { Some("~".to_string()) } else { Some(format!("~/{cleaned}")) } } } /// Join an agent-path base (`~`, `shared/X`, `projects/O/S`) with a tail relative to /// the mount, normalizing separators. An empty tail yields the bare base. fn agent_join(base: &str, tail: &Path) -> String { let t = tail.to_string_lossy().replace('\\', "/"); if t.is_empty() { base.to_string() } else { format!("{base}/{t}") } } /// Strips a leading `~/`, bare `~`, or `./` so what remains is relative to the home. fn strip_home_prefix(path: &str) -> &str { if let Some(rest) = path.strip_prefix("~/") { rest } else if path == "~" { "" } else { path.trim_start_matches("./") } } /// A hot-swappable handle to a [`UserFs`] snapshot, shared by every holder that /// must observe a membership change without being rebuilt (blueprint §6 remount). /// /// Cloning shares the *same* cell. `store` replaces the snapshot for all clones at /// once; each `load` returns the current `Arc`. A live chat session's /// handler holds a clone, so a shared-folder change reaches it on its next tool /// call — no handler eviction, and no cross-session race (the swap is a single /// pointer store behind the lock, and each `ToolContext` takes a consistent /// snapshot for the duration of its call). #[derive(Clone)] pub struct SharedFs(Arc>>); impl SharedFs { pub fn new(fs: UserFs) -> Self { Self(Arc::new(RwLock::new(Arc::new(fs)))) } /// The current snapshot. Cheap — clones an `Arc`. pub fn load(&self) -> Arc { Arc::clone(&self.0.read().expect("SharedFs lock poisoned")) } /// Replace the snapshot seen by every holder of this cell. pub fn store(&self, fs: UserFs) { *self.0.write().expect("SharedFs lock poisoned") = Arc::new(fs); } } impl std::fmt::Debug for SharedFs { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_tuple("SharedFs").field(&*self.load()).finish() } } /// Pure lexical normalization (resolve `.`/`..`), no filesystem access. fn normalize(p: &Path) -> PathBuf { let mut out = PathBuf::new(); for comp in p.components() { match comp { Component::ParentDir => { out.pop(); } Component::CurDir => {} other => out.push(other.as_os_str()), } } out }