clawft-types 0.8.3

Core types for the clawft AI assistant framework
//! Paths of a per-project child kernel (ADR-103 A6, Phase 2 package A).
//!
//! A child kernel splits its files across two owners:
//!
//! - **user-daemon-owned, ephemeral**, under `~/.weftos/run/<id>/`:
//!   `kernel.sock kernel.pid kernel.lock kernel.log spawn.json
//!   parent-policy.json state.json` (plus the rest of the run dir);
//! - **project-owned, durable** (moves with the project), under
//!   `<project_root>/.weftos/`: `project.key` (0600), `project.cert.json`,
//!   `chain/{chain.json,chain.rvf,chain.tree.json,anchors.jsonl}`,
//!   `state/{workloads.json,apps.json}` and the committed `overlay.toml`.
//!
//! The node key and the chain signing key are the same file, `project.key`
//! (one project key). The project walk-up in [`resolve_root`] never yields a
//! child; the only way to get one is [`RuntimePaths::child_at`] /
//! [`RuntimePaths::child_with`].
//!
//! [`resolve_root`]: super::resolve_root

use std::path::{Path, PathBuf};
use std::sync::RwLock;

use super::{RootSource, RuntimePaths, absolutize, user_runtime_root};

/// Process-wide child profile: the paths of the spawned child this process
/// is. Set once by the child's boot (`weave::project_boot::pre_boot`) after
/// it has read and validated `spawn.json`; [`RuntimePaths::resolve`] then
/// returns it for every caller, so the socket, lock, chain, key and
/// governance overlay all agree. The walk-up never sets it.
static CHILD_PROFILE: RwLock<Option<RuntimePaths>> = RwLock::new(None);

/// Make [`RuntimePaths::resolve`] return `paths` for this process. `paths`
/// must be a child root ([`RuntimePaths::child_at`]); anything else is
/// refused (`false`) and leaves the state unchanged. `None` clears it.
pub fn set_child_profile(paths: Option<RuntimePaths>) -> bool {
    if paths.as_ref().is_some_and(|p| p.child_id().is_none()) {
        return false;
    }
    *CHILD_PROFILE.write().unwrap_or_else(|e| e.into_inner()) = paths;
    true
}

/// The child paths set by [`set_child_profile`], if any.
pub fn child_profile() -> Option<RuntimePaths> {
    CHILD_PROFILE.read().unwrap_or_else(|e| e.into_inner()).clone()
}

/// Spawn handshake written by the user daemon (0600, 60 s expiry).
pub const SPAWN_JSON_FILE: &str = "spawn.json";
/// Signed parent policy written by the user daemon.
pub const PARENT_POLICY_FILE: &str = "parent-policy.json";
/// Supervisor state machine snapshot.
pub const STATE_JSON_FILE: &str = "state.json";
/// The one project key (node key, chain key, anchor and PoP key).
pub const PROJECT_KEY_FILE: &str = "project.key";
/// The project certificate mirror inside the project.
pub const PROJECT_CERT_FILE: &str = "project.cert.json";
/// The committed overlay (only the owner writes it).
pub const OVERLAY_FILE: &str = "overlay.toml";
/// Terminal revocation marker in a child's run dir. Only the user daemon's
/// `project.revoke` writes it and nothing clears it; while it exists the
/// supervisor never starts the project and the child refuses to boot, reload
/// or take policy.
pub const REVOKED_FILE: &str = "revoked";

/// `<run_root>/<id>`: the run dir of child `id` under a user daemon's run
/// root (`RuntimePaths::user_with(..).root()`, which honours
/// `$WEFTOS_RUNTIME_DIR`). `None` when `id` is not a safe path component.
pub fn child_run_dir(run_root: &Path, id: &str) -> Option<PathBuf> {
    safe_component(id).then(|| run_root.join(id))
}

/// `<run_root>/<id>/revoked`. The one derivation of the revoked marker: the
/// user daemon's writer and the supervisor call this with the daemon's run
/// root, and the child reaches the same file through
/// [`RuntimePaths::revoked_marker`] because its run dir is
/// [`child_run_dir`]. `None` when `id` is not a safe path component.
pub fn revoked_marker(run_root: &Path, id: &str) -> Option<PathBuf> {
    child_run_dir(run_root, id).map(|d| d.join(REVOKED_FILE))
}

/// True when `id` is usable as a single path component: non-empty, ASCII
/// alphanumeric only (a ULID qualifies). Rejects separators and `.`.
fn safe_component(id: &str) -> bool {
    !id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric())
}

impl RuntimePaths {
    /// A child kernel's paths with an explicit run dir (what the child sees
    /// as `$WEFTOS_RUNTIME_DIR`). `None` when `id` is not a safe path
    /// component.
    pub fn child_at(
        run_dir: impl Into<PathBuf>,
        id: &str,
        project_root: impl Into<PathBuf>,
    ) -> Option<Self> {
        let run_dir = absolutize(&run_dir.into());
        safe_component(id).then(|| Self {
            root: run_dir.clone(),
            source: RootSource::Child {
                id: id.to_owned(),
                project_root: absolutize(&project_root.into()),
                trust_root: run_dir,
            },
        })
    }

    /// A container child has a writable runtime and a separate read-only
    /// parent-owned trust directory.
    pub fn child_container_at(
        run_dir: impl Into<PathBuf>,
        trust_dir: impl Into<PathBuf>,
        id: &str,
        project_root: impl Into<PathBuf>,
    ) -> Option<Self> {
        let mut paths = Self::child_at(run_dir, id, project_root)?;
        if let RootSource::Child { trust_root, .. } = &mut paths.source {
            *trust_root = absolutize(&trust_dir.into());
        }
        Some(paths)
    }

    /// Directory containing files only the parent controls.
    pub fn trust_root(&self) -> &Path {
        match &self.source {
            RootSource::Child { trust_root, .. } => trust_root,
            _ => &self.root,
        }
    }

    /// A child kernel's paths under `<home>/.weftos/run/<id>/`. `None` when
    /// `id` is not a safe path component.
    pub fn child_with(home: &Path, id: &str, project_root: impl Into<PathBuf>) -> Option<Self> {
        Self::child_at(child_run_dir(&user_runtime_root(home), id)?, id, project_root)
    }

    /// `<project_root>/.weftos` when this is a child; `None` otherwise.
    pub(super) fn child_weftos_dir(&self) -> Option<PathBuf> {
        match &self.source {
            RootSource::Child { project_root, .. } => Some(project_root.join(".weftos")),
            _ => None,
        }
    }

    /// The child's project id, when this is a child.
    pub fn child_id(&self) -> Option<&str> {
        match &self.source {
            RootSource::Child { id, .. } => Some(id),
            _ => None,
        }
    }

    /// `<run>/spawn.json`.
    pub fn spawn_json(&self) -> PathBuf {
        self.file(SPAWN_JSON_FILE)
    }
    /// `<run>/parent-policy.json`.
    pub fn parent_policy(&self) -> PathBuf {
        self.trust_root().join(PARENT_POLICY_FILE)
    }
    /// `<run>/state.json`.
    pub fn state_json(&self) -> PathBuf {
        self.file(STATE_JSON_FILE)
    }
    /// `<run>/revoked`: for a child, the file [`revoked_marker`] names.
    pub fn revoked_marker(&self) -> PathBuf {
        self.trust_root().join(REVOKED_FILE)
    }
    /// `<root>/.weftos/project.key` (children only).
    pub fn project_key(&self) -> Option<PathBuf> {
        self.child_weftos_dir().map(|d| d.join(PROJECT_KEY_FILE))
    }
    /// `<root>/.weftos/project.cert.json` (children only).
    pub fn project_cert(&self) -> Option<PathBuf> {
        self.child_weftos_dir().map(|d| d.join(PROJECT_CERT_FILE))
    }
    /// `<root>/.weftos/overlay.toml` (children only).
    pub fn overlay(&self) -> Option<PathBuf> {
        self.child_weftos_dir().map(|d| d.join(OVERLAY_FILE))
    }
    /// `<root>/.weftos/state/` (children only).
    pub fn state_dir(&self) -> Option<PathBuf> {
        self.child_weftos_dir().map(|d| d.join("state"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const ID: &str = "01JB8Z3Q0V6X9KQ4M2N7T5R1WD";

    fn child() -> RuntimePaths {
        RuntimePaths::child_with(Path::new("/h"), ID, "/work/p").unwrap()
    }

    #[test]
    fn layout_table() {
        let p = child();
        let run = PathBuf::from(format!("/h/.weftos/run/{ID}"));
        assert_eq!(p.root(), run);
        assert_eq!(p.child_id(), Some(ID));
        // Ephemeral, user-daemon-owned: the run dir.
        assert_eq!(p.socket(), run.join("kernel.sock"));
        assert_eq!(p.pid(), run.join("kernel.pid"));
        assert_eq!(p.lock(), run.join("kernel.lock"));
        assert_eq!(p.log(), run.join("kernel.log"));
        assert_eq!(p.spawn_json(), run.join("spawn.json"));
        assert_eq!(p.parent_policy(), run.join("parent-policy.json"));
        assert_eq!(p.state_json(), run.join("state.json"));
        // Durable, project-owned.
        let w = PathBuf::from("/work/p/.weftos");
        assert_eq!(p.project_key(), Some(w.join("project.key")));
        assert_eq!(p.project_cert(), Some(w.join("project.cert.json")));
        assert_eq!(p.overlay(), Some(w.join("overlay.toml")));
        assert_eq!(p.state_dir(), Some(w.join("state")));
        assert_eq!(p.chain_checkpoint(), w.join("chain/chain.json"));
        assert_eq!(p.chain_rvf(), w.join("chain/chain.rvf"));
        assert_eq!(p.chain_tree(), w.join("chain/chain.tree.json"));
        assert_eq!(p.chain_dir(), w.join("chain"));
        assert_eq!(p.anchors_ledger(), w.join("chain/anchors.jsonl"));
        assert_eq!(p.workloads(), w.join("state/workloads.json"));
        assert_eq!(p.apps(), w.join("state/apps.json"));
    }

    #[test]
    fn one_project_key_is_node_and_chain_key() {
        let p = child();
        assert_eq!(p.node_key(), p.chain_key());
        assert_eq!(Some(p.node_key()), p.project_key());
    }

    #[test]
    fn non_child_roots_keep_their_layout_and_have_no_project_files() {
        let p = RuntimePaths::at("/run/probe");
        assert_eq!(p.node_key(), PathBuf::from("/run/probe/node.key"));
        assert_eq!(p.chain_key(), PathBuf::from("/run/probe/chain.key"));
        assert_eq!(p.workloads(), PathBuf::from("/run/probe/workloads.json"));
        assert_eq!(p.child_id(), None);
        assert_eq!(p.project_key(), None);
        assert_eq!(p.overlay(), None);
    }

    #[test]
    fn walk_up_never_selects_a_child() {
        let t = tempfile::tempdir().unwrap();
        let home = t.path().join("home");
        let root = t.path().join("proj");
        std::fs::create_dir_all(root.join(".weftos")).unwrap();
        std::fs::write(root.join(".weftos/project.toml"), "").unwrap();
        let run = home.join(".weftos/run").join(ID);
        std::fs::create_dir_all(&run).unwrap();
        for cwd in [&root, &run, &home] {
            let p = RuntimePaths::resolve_with(None, Some(cwd), Some(&home));
            assert!(!matches!(p.source(), RootSource::Child { .. }), "{cwd:?}");
        }
        let p = RuntimePaths::resolve_with(None, Some(&root), Some(&home));
        assert!(matches!(p.source(), RootSource::Project(_)));
    }

    #[test]
    fn unsafe_ids_are_refused() {
        for bad in ["", "..", "a/b", "a\\b", "x.y", "../etc"] {
            assert!(
                RuntimePaths::child_with(Path::new("/h"), bad, "/p").is_none(),
                "{bad:?}"
            );
        }
    }

    #[test]
    fn relative_inputs_are_made_absolute() {
        let p = RuntimePaths::child_at("rel/run", ID, "rel/proj").unwrap();
        let cwd = std::env::current_dir().unwrap();
        assert_eq!(p.root(), cwd.join("rel/run"));
        assert_eq!(p.chain_dir(), cwd.join("rel/proj/.weftos/chain"));
        assert!(p.project_key().unwrap().is_absolute());
    }

    #[test]
    fn revoked_marker_is_one_path_for_parent_and_child() {
        let run_root = Path::new("/iso/run");
        let marker = revoked_marker(run_root, ID).unwrap();
        assert_eq!(marker, PathBuf::from(format!("/iso/run/{ID}/revoked")));
        let dir = child_run_dir(run_root, ID).unwrap();
        let at = RuntimePaths::child_at(&dir, ID, "/p").unwrap();
        assert_eq!(at.revoked_marker(), marker);
        // The default layout agrees too.
        assert_eq!(
            child().revoked_marker(),
            revoked_marker(&user_runtime_root(Path::new("/h")), ID).unwrap()
        );
        for bad in ["", "..", "a/b"] {
            assert!(revoked_marker(run_root, bad).is_none() && child_run_dir(run_root, bad).is_none());
        }
    }

    #[test]
    fn child_at_honours_explicit_run_dir() {
        let p = RuntimePaths::child_at("/iso/run", ID, "/p").unwrap();
        assert_eq!(p.socket(), PathBuf::from("/iso/run/kernel.sock"));
        assert_eq!(p.chain_dir(), PathBuf::from("/p/.weftos/chain"));
    }
}