Skip to main content

kaizen/core/
paths.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Shared path helpers (used by `workspace` and `machine_registry` to avoid import cycles).
3
4use anyhow::Result;
5use std::path::{Component, Path, PathBuf};
6
7/// `KAIZEN_HOME` or `~/.kaizen` (requires `HOME`), or `None` if undiscoverable.
8pub fn kaizen_dir() -> Option<PathBuf> {
9    let configured = std::env::var("KAIZEN_HOME")
10        .ok()
11        .map(PathBuf::from)
12        .or_else(|| {
13            std::env::var("HOME")
14                .ok()
15                .map(|home| PathBuf::from(home).join(".kaizen"))
16        });
17    configured.map(|path| absolute(&path))
18}
19
20/// `/Users/lucas/Projects/kaizen` → `Users-lucas-Projects-kaizen`
21///
22/// Used for kaizen's own data dir (`~/.kaizen/projects/<slug>/`).
23pub fn workspace_slug(path: &Path) -> String {
24    path.to_string_lossy()
25        .trim_start_matches('/')
26        .replace('/', "-")
27}
28
29/// Cursor project slug: strips leading `/`, then replaces `/` and `.` with `-`.
30///
31/// Cursor stores transcripts at `~/.cursor/projects/<cursor_slug>/agent-transcripts`.
32/// Example: `/Users/lucas.marques/Projects/kaizen` → `Users-lucas-marques-Projects-kaizen`.
33pub fn cursor_slug(path: &Path) -> String {
34    path.to_string_lossy()
35        .trim_start_matches('/')
36        .replace(['/', '.'], "-")
37}
38
39/// Claude Code project slug: leading `/` becomes `-`, then `/` and `.` → `-`.
40///
41/// Claude Code stores sessions at `~/.claude/projects/<claude_slug>/sessions`.
42/// Example: `/Users/lucas.marques/Projects/kaizen` → `-Users-lucas-marques-Projects-kaizen`.
43pub fn claude_code_slug(path: &Path) -> String {
44    let s = path.to_string_lossy();
45    let with_leading = if let Some(rest) = s.strip_prefix('/') {
46        format!("-{rest}")
47    } else {
48        s.into_owned()
49    };
50    with_leading.replace(['/', '.'], "-")
51}
52
53/// `~/.kaizen/projects/<slug>/` (or `$KAIZEN_HOME/projects/<slug>/`) without I/O.
54pub fn project_data_path(workspace: &Path) -> Result<PathBuf> {
55    let home = crate::core::home_paths::root(workspace)?;
56    let canon = std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
57    let slug = workspace_slug(&canon);
58    let data = home.join("projects").join(slug);
59    ensure_project_data_outside_workspace(&data, &canon)?;
60    Ok(data)
61}
62
63fn ensure_project_data_outside_workspace(data: &Path, workspace: &Path) -> Result<()> {
64    ensure_outside_workspace(data, workspace, "Kaizen project data")
65}
66
67fn ensure_outside_workspace(path: &Path, workspace: &Path, label: &str) -> Result<()> {
68    anyhow::ensure!(
69        !path_is_within(path, workspace),
70        "{label} must be outside target repository"
71    );
72    Ok(())
73}
74
75pub(crate) fn path_is_within(path: &Path, root: &Path) -> bool {
76    let root = canonical(root);
77    path.starts_with(&root)
78        || path
79            .ancestors()
80            .find_map(|ancestor| ancestor.canonicalize().ok())
81            .is_some_and(|ancestor| ancestor.starts_with(root))
82}
83
84/// Project data path, created on demand for write-capable callers.
85pub fn project_data_dir(workspace: &Path) -> Result<PathBuf> {
86    let dir = project_data_path(workspace)?;
87    std::fs::create_dir_all(&dir)?;
88    ensure_project_data_outside_workspace(&dir, &canonical(workspace))?;
89    Ok(dir)
90}
91
92/// Existing project-data child path with symlink and traversal rejection.
93pub fn project_data_child(workspace: &Path, relative: &Path) -> Result<PathBuf> {
94    descendant_path(&project_data_path(workspace)?, relative)
95}
96
97/// Project-data directory prepared for a write.
98pub fn project_dir_for_write(workspace: &Path, relative: &Path) -> Result<PathBuf> {
99    descendant_dir_for_write(&project_data_dir(workspace)?, relative)
100}
101
102/// Project-data file path whose parent is prepared for a write.
103pub fn project_file_for_write(workspace: &Path, relative: &Path) -> Result<PathBuf> {
104    descendant_file_for_write(&project_data_dir(workspace)?, relative)
105}
106
107pub fn descendant_path(root: &Path, relative: &Path) -> Result<PathBuf> {
108    ensure_relative(relative)?;
109    let path = root.join(relative);
110    ensure_no_symlinks(root, &path)?;
111    Ok(path)
112}
113
114pub fn descendant_dir_for_write(root: &Path, relative: &Path) -> Result<PathBuf> {
115    let path = descendant_path(root, relative)?;
116    std::fs::create_dir_all(&path)?;
117    ensure_no_symlinks(root, &path)?;
118    Ok(path)
119}
120
121pub fn descendant_file_for_write(root: &Path, relative: &Path) -> Result<PathBuf> {
122    let path = descendant_path(root, relative)?;
123    let parent = path
124        .parent()
125        .ok_or_else(|| anyhow::anyhow!("file path has no parent"))?;
126    let relative_parent = parent.strip_prefix(root)?;
127    if !relative_parent.as_os_str().is_empty() {
128        descendant_dir_for_write(root, relative_parent)?;
129    }
130    ensure_no_symlinks(root, &path)?;
131    Ok(path)
132}
133
134fn ensure_relative(path: &Path) -> Result<()> {
135    let invalid = path
136        .components()
137        .any(|part| !matches!(part, Component::Normal(_)));
138    anyhow::ensure!(
139        !path.as_os_str().is_empty() && !invalid,
140        "project path must be relative without traversal"
141    );
142    Ok(())
143}
144
145fn ensure_no_symlinks(root: &Path, path: &Path) -> Result<()> {
146    let relative = path.strip_prefix(root)?;
147    let mut current = root.to_path_buf();
148    for component in relative.components() {
149        current.push(component);
150        validate_component(&current)?;
151    }
152    Ok(())
153}
154
155fn validate_component(path: &Path) -> Result<()> {
156    match std::fs::symlink_metadata(path) {
157        Ok(metadata) => {
158            anyhow::ensure!(
159                !metadata.file_type().is_symlink(),
160                "project data rejects symlink: {}",
161                path.display()
162            );
163            crate::core::safe_fs::reject_hardlink(path)?;
164        }
165        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
166        Err(error) => return Err(error.into()),
167    }
168    Ok(())
169}
170
171pub fn canonical(path: &Path) -> PathBuf {
172    std::fs::canonicalize(path).unwrap_or_else(|_| absolute(path))
173}
174
175fn absolute(path: &Path) -> PathBuf {
176    if path.is_absolute() {
177        return path.to_path_buf();
178    }
179    std::env::current_dir()
180        .map(|cwd| cwd.join(path))
181        .unwrap_or_else(|_| path.to_path_buf())
182}
183
184#[cfg(test)]
185pub(crate) mod test_lock {
186    use std::sync::{Mutex, OnceLock};
187
188    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
189
190    pub fn global() -> &'static Mutex<()> {
191        LOCK.get_or_init(|| Mutex::new(()))
192    }
193}