Skip to main content

cpm_planner/
project.rs

1//! Project-root discovery and confined plan-file paths.
2//!
3//! Plan files live at `<root>/.cpm-planner/plans/<name>/<variant>.json`.
4//! Caller-supplied paths are relative to the root and validated component by
5//! component: no `..`, no absolute paths, no backslashes, no extra
6//! components, slug-checked name/variant.
7//!
8//! # Guarantees
9//!
10//! All filesystem access walks from a handle on the canonical root through
11//! `.cpm-planner` -> `plans` -> `<name>` using `cap-std` directories opened
12//! with no-follow semantics (`O_NOFOLLOW` on unix; reparse points are not
13//! followed on Windows). A symlink at any step is rejected with
14//! `INVALID_PATH`, and every later operation (create temp file, write, fsync,
15//! rename, read) is performed relative to the opened directory handle rather
16//! than by re-resolving the original path string, so swapping a parent for a
17//! symlink after the check cannot redirect I/O outside the root. Writes are
18//! atomic: a create-new temp file in the final directory is fsynced and
19//! renamed over `<variant>.json`; the directory is fsynced afterwards.
20//! Metadata errors other than NotFound fail closed. On unix, reads open the
21//! file with `O_NONBLOCK` so a FIFO cannot block the open, then verify via
22//! `fstat` that the handle is a regular file before reading it. Plan files
23//! larger than [`MAX_PLAN_FILE_BYTES`] (8 MiB) are refused.
24//!
25//! [`ProjectRoot::write_new_graph`] links the fsynced temp file into place
26//! with `link(2)`, which fails instead of replacing. On filesystems without
27//! hard links (`EPERM`, `EOPNOTSUPP`/`ENOTSUP`, `EXDEV`, or the Windows
28//! equivalents) it falls back to an exclusive create of the target
29//! (`O_CREAT|O_EXCL`, no-follow), write, fsync, then a directory fsync. The
30//! fallback still never replaces an existing file, but it is not atomic: a
31//! crash or a concurrent reader mid-write can observe a partial file.
32//!
33//! # Residual limitations
34//!
35//! - Directory fsync is best-effort on Windows (opening a directory for sync
36//!   is not supported there), so crash-durability of the rename is not
37//!   guaranteed on Windows. Atomicity of the rename is unaffected.
38//! - The canonical root itself is resolved once (symlinks in the root path
39//!   above the project are legitimate and followed at that point).
40//!
41//! # Residual limitations (Windows)
42//!
43//! On Windows, `create_dir`, `remove_file`/`remove_dir`, and `rename` are
44//! path-based: `cap-std` rebuilds the target path from the directory handle
45//! via `GetFinalPathNameByHandle` before issuing the Win32 call. A parent that
46//! is swapped for a reparse point inside that window can therefore make those
47//! calls act on the wrong location. The worst observable effect is an empty
48//! directory created outside the root (or a rename that fails); no plan bytes
49//! can be written outside the root, because file creation, writes, and fsync
50//! all occur on handles opened relative to the confined directory.
51
52use std::io::{Read as _, Write as _};
53use std::path::{Path, PathBuf};
54
55#[cfg(unix)]
56use cap_fs_ext::OpenOptionsSyncExt as _;
57use cap_fs_ext::{DirExt as _, FollowSymlinks, OpenOptionsFollowExt as _};
58use cap_std::ambient_authority;
59use cap_std::fs::{Dir, OpenOptions};
60
61use crate::plan::{PlanGraph, PlannerError};
62
63/// Environment variable overriding repo-root discovery.
64pub const PROJECT_ROOT_ENV: &str = "CPM_PROJECT_ROOT";
65
66/// Largest plan file read (`INVALID_PATH: plan file exceeds 8 MiB`).
67pub const MAX_PLAN_FILE_BYTES: u64 = 8 * 1024 * 1024;
68
69const PLANNER_DIR: &str = ".cpm-planner";
70const PLANS_SUBDIR: &str = "plans";
71
72/// A validated plan file location (not necessarily existing yet).
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct PlanFileRef {
75    pub name: String,
76    pub variant: String,
77    /// `.cpm-planner/plans/<name>/<variant>.json`, relative to the root.
78    pub rel_path: String,
79}
80
81/// A canonical project root.
82#[derive(Debug, Clone)]
83pub struct ProjectRoot {
84    root: PathBuf,
85}
86
87fn invalid(reason: impl Into<String>) -> PlannerError {
88    PlannerError::InvalidPath {
89        reason: reason.into(),
90    }
91}
92
93fn backend(err: impl Into<anyhow::Error>) -> PlannerError {
94    PlannerError::BackendError(err.into())
95}
96
97const WINDOWS_RESERVED: &[&str] = &[
98    "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
99    "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
100];
101
102/// Validate a `name` / `variant`: `^[a-z0-9][a-z0-9._-]{0,62}[a-z0-9]$` (or a
103/// single `[a-z0-9]`), at most 64 chars, and not a Windows reserved device
104/// name (`con`, `nul`, `com1`...; also with an extension, e.g. `con.x`),
105/// enforced on every platform so plans are portable.
106pub fn validate_slug(kind: &str, s: &str) -> Result<(), PlannerError> {
107    let alnum = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit();
108    let bytes = s.as_bytes();
109    let shape_ok = !bytes.is_empty()
110        && bytes.len() <= 64
111        && alnum(bytes[0])
112        && alnum(bytes[bytes.len() - 1])
113        && bytes
114            .iter()
115            .all(|&b| alnum(b) || matches!(b, b'.' | b'_' | b'-'));
116    if !shape_ok {
117        return Err(invalid(format!(
118            "{kind} {s:?} must match ^[a-z0-9][a-z0-9._-]{{0,63}}$ and end in [a-z0-9]"
119        )));
120    }
121    let stem = s.split('.').next().unwrap_or(s);
122    if WINDOWS_RESERVED.contains(&stem) {
123        return Err(invalid(format!("{kind} {s:?} is a reserved device name")));
124    }
125    Ok(())
126}
127
128/// Open `name` under `parent` without following symlinks. `Ok(None)` only on
129/// NotFound; a symlink is `INVALID_PATH`; anything else fails closed.
130fn open_subdir(parent: &Dir, name: &str) -> Result<Option<Dir>, PlannerError> {
131    match parent.symlink_metadata(name) {
132        Ok(m) if m.file_type().is_symlink() => {
133            return Err(invalid(format!(
134                "{name} is a symlink; symlinks are not allowed"
135            )));
136        }
137        Ok(m) if !m.is_dir() => {
138            return Err(invalid(format!("{name} is not a directory")));
139        }
140        Ok(_) => {}
141        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
142        Err(e) => return Err(backend(e)),
143    }
144    match parent.open_dir_nofollow(name) {
145        Ok(d) => Ok(Some(d)),
146        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
147        // Raced into a symlink / non-directory between lstat and open.
148        Err(e) => Err(invalid(format!(
149            "cannot open {name} without following links: {e}"
150        ))),
151    }
152}
153
154/// Like [`open_subdir`] but creates the directory when absent (mkdirat,
155/// EEXIST ignored) and fsyncs the parent when it was created.
156fn ensure_subdir(parent: &Dir, name: &str) -> Result<Dir, PlannerError> {
157    match parent.create_dir(name) {
158        Ok(()) => sync_dir(parent)?,
159        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
160        Err(e) => return Err(backend(e)),
161    }
162    open_subdir(parent, name)?.ok_or_else(|| invalid(format!("{name} vanished while being opened")))
163}
164
165/// fsync a directory handle. Best-effort (ignored) on non-unix platforms.
166fn sync_dir(dir: &Dir) -> Result<(), PlannerError> {
167    #[cfg(unix)]
168    {
169        dir.open(".").and_then(|f| f.sync_all()).map_err(backend)?;
170    }
171    #[cfg(not(unix))]
172    {
173        let _ = dir;
174    }
175    Ok(())
176}
177
178impl ProjectRoot {
179    /// `CPM_PROJECT_ROOT` if set, else the nearest ancestor of `cwd` (itself
180    /// included) containing `.cpm-planner/` or `.git`.
181    pub fn discover(cwd: &Path) -> Option<ProjectRoot> {
182        if let Some(v) = std::env::var_os(PROJECT_ROOT_ENV).filter(|v| !v.is_empty()) {
183            return Self::from_path(Path::new(&v)).ok();
184        }
185        let start = cwd.canonicalize().ok()?;
186        start
187            .ancestors()
188            .find(|d| d.join(PLANNER_DIR).is_dir() || d.join(".git").exists())
189            .map(|d| ProjectRoot {
190                root: d.to_path_buf(),
191            })
192    }
193
194    /// Build a root from an existing directory (canonicalized).
195    pub fn from_path(root: &Path) -> Result<ProjectRoot, PlannerError> {
196        let root = root
197            .canonicalize()
198            .map_err(|e| invalid(format!("project root {}: {e}", root.display())))?;
199        if !root.is_dir() {
200            return Err(invalid(format!(
201                "project root {} is not a directory",
202                root.display()
203            )));
204        }
205        Ok(ProjectRoot { root })
206    }
207
208    pub fn root(&self) -> &Path {
209        &self.root
210    }
211
212    /// Canonical root as a UTF-8 string (lossy for non-UTF-8 paths).
213    pub fn project_key(&self) -> String {
214        self.root.to_string_lossy().into_owned()
215    }
216
217    pub fn plans_dir(&self) -> PathBuf {
218        self.root.join(PLANNER_DIR).join(PLANS_SUBDIR)
219    }
220
221    /// Validate a root-relative path of the exact form
222    /// `.cpm-planner/plans/<name>/<variant>.json`.
223    pub fn resolve_plan_file(&self, rel: &str) -> Result<PlanFileRef, PlannerError> {
224        if rel.contains('\0') || rel.contains('\\') {
225            return Err(invalid("path contains NUL or backslash"));
226        }
227        if rel.starts_with('/') || Path::new(rel).is_absolute() {
228            return Err(invalid("absolute paths are not allowed"));
229        }
230        let parts: Vec<&str> = rel.split('/').collect();
231        if parts.contains(&"..") {
232            return Err(invalid("`..` is not allowed"));
233        }
234        let [planner, plans, name, file] = parts.as_slice() else {
235            return Err(invalid(format!(
236                "path must be {PLANNER_DIR}/{PLANS_SUBDIR}/<name>/<variant>.json, got {rel:?}"
237            )));
238        };
239        if *planner != PLANNER_DIR || *plans != PLANS_SUBDIR {
240            return Err(invalid(format!(
241                "path must be inside {PLANNER_DIR}/{PLANS_SUBDIR}/, got {rel:?}"
242            )));
243        }
244        let Some(variant) = file.strip_suffix(".json") else {
245            return Err(invalid(format!("plan file {file:?} must end in .json")));
246        };
247        self.plan_file(name, variant)
248    }
249
250    /// Build and confinement-check the ref for `<name>/<variant>`.
251    pub fn plan_file(&self, name: &str, variant: &str) -> Result<PlanFileRef, PlannerError> {
252        validate_slug("name", name)?;
253        validate_slug("variant", variant)?;
254        let f = PlanFileRef {
255            name: name.to_string(),
256            variant: variant.to_string(),
257            rel_path: format!("{PLANNER_DIR}/{PLANS_SUBDIR}/{name}/{variant}.json"),
258        };
259        self.check_confined(&f)?;
260        Ok(f)
261    }
262
263    /// Walk root -> `.cpm-planner` -> `plans` -> (`name`) with no-follow
264    /// opens. `create` makes missing directories. `Ok(None)` when a directory
265    /// is missing (only possible without `create`).
266    fn open_chain(&self, name: Option<&str>, create: bool) -> Result<Option<Dir>, PlannerError> {
267        let root = Dir::open_ambient_dir(&self.root, ambient_authority()).map_err(backend)?;
268        let mut cur = root;
269        let mut parts = vec![PLANNER_DIR, PLANS_SUBDIR];
270        parts.extend(name);
271        for part in parts {
272            cur = if create {
273                ensure_subdir(&cur, part)?
274            } else {
275                match open_subdir(&cur, part)? {
276                    Some(d) => d,
277                    None => return Ok(None),
278                }
279            };
280        }
281        Ok(Some(cur))
282    }
283
284    /// Confinement check: no symlink anywhere on the path (including the
285    /// file itself, if it exists).
286    fn check_confined(&self, f: &PlanFileRef) -> Result<(), PlannerError> {
287        if let Some(dir) = self.open_chain(Some(&f.name), false)? {
288            match dir.symlink_metadata(format!("{}.json", f.variant)) {
289                Ok(m) if m.file_type().is_symlink() => {
290                    return Err(invalid(format!(
291                        "{} is a symlink; symlinks are not allowed",
292                        f.rel_path
293                    )));
294                }
295                Ok(_) => {}
296                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
297                Err(e) => return Err(backend(e)),
298            }
299        }
300        Ok(())
301    }
302
303    /// All plan files, sorted by (name, variant). Non-conforming entries and
304    /// symlinks are ignored. Empty only when the plans dir does not exist;
305    /// other errors propagate.
306    pub fn list_plan_files(&self) -> Result<Vec<PlanFileRef>, PlannerError> {
307        let mut out = Vec::new();
308        let Some(plans) = self.open_chain(None, false)? else {
309            return Ok(out);
310        };
311        for n in plans.entries().map_err(backend)? {
312            let n = n.map_err(backend)?;
313            let Some(name) = n.file_name().to_str().map(str::to_string) else {
314                continue;
315            };
316            if !n.file_type().map_err(backend)?.is_dir() || validate_slug("name", &name).is_err() {
317                continue;
318            }
319            let Some(dir) = open_subdir(&plans, &name)? else {
320                continue;
321            };
322            for f in dir.entries().map_err(backend)? {
323                let f = f.map_err(backend)?;
324                let Some(file) = f.file_name().to_str().map(str::to_string) else {
325                    continue;
326                };
327                let Some(variant) = file.strip_suffix(".json") else {
328                    continue;
329                };
330                if !f.file_type().map_err(backend)?.is_file()
331                    || validate_slug("variant", variant).is_err()
332                {
333                    continue;
334                }
335                out.push(PlanFileRef {
336                    name: name.clone(),
337                    variant: variant.to_string(),
338                    rel_path: format!("{PLANNER_DIR}/{PLANS_SUBDIR}/{name}/{variant}.json"),
339                });
340            }
341        }
342        out.sort_by(|a, b| (&a.name, &a.variant).cmp(&(&b.name, &b.variant)));
343        Ok(out)
344    }
345
346    /// Read a plan and the sha256 hex of the exact bytes parsed. Directories
347    /// are walked with no-follow handles; the file is opened no-follow
348    /// relative to the final directory handle, fstat'd to be a regular file,
349    /// and read from that same handle.
350    pub fn read_graph(&self, f: &PlanFileRef) -> Result<(PlanGraph, String), PlannerError> {
351        let bytes = self.read_bytes(f)?;
352        let graph = serde_json::from_slice(&bytes).map_err(|e| PlannerError::InvalidGraph {
353            reason: format!("{}: {e}", f.rel_path),
354        })?;
355        Ok((graph, hash_hex(&bytes)))
356    }
357
358    /// The sha256 hex of a plan file's bytes (the hash [`Self::read_graph`]
359    /// and [`Self::write_graph`] return), without parsing it. Read with the
360    /// same no-follow, regular-file-only rules as [`Self::read_graph`].
361    pub fn file_hash(&self, f: &PlanFileRef) -> Result<String, PlannerError> {
362        Ok(hash_hex(&self.read_bytes(f)?))
363    }
364
365    /// The exact bytes of a plan file, read no-follow from a regular file.
366    /// A file larger than [`MAX_PLAN_FILE_BYTES`] is
367    /// `INVALID_PATH: plan file exceeds 8 MiB`.
368    pub fn read_bytes(&self, f: &PlanFileRef) -> Result<Vec<u8>, PlannerError> {
369        validate_slug("name", &f.name)?;
370        validate_slug("variant", &f.variant)?;
371        let dir = self
372            .open_chain(Some(&f.name), false)?
373            .ok_or_else(|| invalid(format!("{} does not exist", f.rel_path)))?;
374        let fname = format!("{}.json", f.variant);
375        let mut opts = OpenOptions::new();
376        opts.read(true).follow(FollowSymlinks::No);
377        // Opening a FIFO read-only blocks until a writer appears. O_NONBLOCK
378        // makes the open return immediately; regular files ignore it, so the
379        // subsequent fstat + read behave as before. Non-regular handles are
380        // rejected below.
381        #[cfg(unix)]
382        opts.nonblock(true);
383        let file = match dir.open_with(&fname, &opts) {
384            Ok(file) => file,
385            Err(e) => {
386                return Err(match dir.symlink_metadata(&fname) {
387                    Ok(m) if m.file_type().is_symlink() => {
388                        invalid(format!("{} is a symlink", f.rel_path))
389                    }
390                    _ => backend(e),
391                });
392            }
393        };
394        let meta = file.metadata().map_err(backend)?;
395        if !meta.is_file() {
396            return Err(invalid(format!("{} is not a regular file", f.rel_path)));
397        }
398        if meta.len() > MAX_PLAN_FILE_BYTES {
399            return Err(too_large());
400        }
401        let mut bytes = Vec::new();
402        // Bounded even if the file grows after the fstat.
403        file.take(MAX_PLAN_FILE_BYTES + 1)
404            .read_to_end(&mut bytes)
405            .map_err(backend)?;
406        if bytes.len() as u64 > MAX_PLAN_FILE_BYTES {
407            return Err(too_large());
408        }
409        Ok(bytes)
410    }
411
412    /// Best-effort removal of a plan file this process just wrote: removed
413    /// only while its bytes still hash to `expected_hash`, walking the same
414    /// no-follow handles as every other operation. Errors are ignored.
415    pub fn remove_plan_file_if_unchanged(&self, f: &PlanFileRef, expected_hash: &str) {
416        if !matches!(self.file_hash(f), Ok(h) if h == expected_hash) {
417            return;
418        }
419        if let Ok(Some(dir)) = self.open_chain(Some(&f.name), false) {
420            let _ = dir.remove_file(format!("{}.json", f.variant));
421            let _ = sync_dir(&dir);
422        }
423    }
424
425    /// Atomically write pretty JSON plus a trailing newline; returns the
426    /// content hash. All directory steps use no-follow handles and every file
427    /// operation is relative to the final directory handle, so a parent
428    /// swapped for a symlink mid-call cannot redirect the write.
429    pub fn write_graph(&self, f: &PlanFileRef, g: &PlanGraph) -> Result<String, PlannerError> {
430        self.write_graph_inner(f, g, true)
431    }
432
433    /// [`Self::write_graph`] that never replaces an existing file: the new
434    /// file is linked into place atomically, and an existing one (written
435    /// concurrently included) is `INVALID_PATH: <rel_path> already exists`.
436    pub fn write_new_graph(&self, f: &PlanFileRef, g: &PlanGraph) -> Result<String, PlannerError> {
437        self.write_graph_inner(f, g, false)
438    }
439
440    fn write_graph_inner(
441        &self,
442        f: &PlanFileRef,
443        g: &PlanGraph,
444        replace: bool,
445    ) -> Result<String, PlannerError> {
446        validate_slug("name", &f.name)?;
447        validate_slug("variant", &f.variant)?;
448        let mut bytes = serde_json::to_vec_pretty(g).map_err(backend)?;
449        bytes.push(b'\n');
450        let dir = self
451            .open_chain(Some(&f.name), true)?
452            .ok_or_else(|| invalid("plan directory vanished"))?;
453        let target = format!("{}.json", f.variant);
454        match dir.symlink_metadata(&target) {
455            Ok(m) if m.file_type().is_symlink() => {
456                return Err(invalid(format!(
457                    "{} is a symlink; symlinks are not allowed",
458                    f.rel_path
459                )));
460            }
461            Ok(_) => {}
462            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
463            Err(e) => return Err(backend(e)),
464        }
465        let tmp = format!(".{}.tmp-{}", f.variant, uuid::Uuid::new_v4());
466        let result = (|| -> std::io::Result<()> {
467            let mut opts = OpenOptions::new();
468            opts.write(true).create_new(true).follow(FollowSymlinks::No);
469            let mut file = dir.open_with(&tmp, &opts)?;
470            file.write_all(&bytes)?;
471            file.sync_all()?;
472            drop(file);
473            if replace {
474                dir.rename(&tmp, &dir, &target)
475            } else {
476                // link(2) fails with EEXIST instead of replacing.
477                let linked = dir.hard_link(&tmp, &dir, &target);
478                let _ = dir.remove_file(&tmp);
479                match linked {
480                    Err(e) if link_unsupported(&e) => create_exclusive(&dir, &target, &bytes),
481                    other => other,
482                }
483            }
484        })();
485        if let Err(e) = result {
486            let _ = dir.remove_file(&tmp);
487            if e.kind() == std::io::ErrorKind::AlreadyExists {
488                return Err(invalid(format!("{} already exists", f.rel_path)));
489            }
490            return Err(backend(e));
491        }
492        sync_dir(&dir)?;
493        Ok(hash_hex(&bytes))
494    }
495}
496
497fn too_large() -> PlannerError {
498    invalid("plan file exceeds 8 MiB")
499}
500
501/// True when `link(2)` failed because the filesystem cannot hard-link (not
502/// because the target exists or access was denied for another reason).
503fn link_unsupported(e: &std::io::Error) -> bool {
504    use std::io::ErrorKind;
505    if matches!(e.kind(), ErrorKind::Unsupported | ErrorKind::CrossesDevices) {
506        return true;
507    }
508    #[cfg(unix)]
509    {
510        matches!(
511            e.raw_os_error(),
512            Some(c) if c == libc::EPERM || c == libc::EOPNOTSUPP || c == libc::ENOTSUP || c == libc::EXDEV
513        )
514    }
515    #[cfg(windows)]
516    {
517        // ERROR_INVALID_FUNCTION, ERROR_ACCESS_DENIED, ERROR_NOT_SAME_DEVICE,
518        // ERROR_NOT_SUPPORTED.
519        matches!(e.raw_os_error(), Some(1 | 5 | 17 | 50))
520    }
521    #[cfg(not(any(unix, windows)))]
522    {
523        false
524    }
525}
526
527/// Non-atomic create-only write used when hard links are unsupported:
528/// exclusive no-follow create of `target` (an existing file is
529/// `AlreadyExists`), write, fsync, then fsync the directory. A failed write
530/// removes the partially written file.
531fn create_exclusive(dir: &Dir, target: &str, bytes: &[u8]) -> std::io::Result<()> {
532    let mut opts = OpenOptions::new();
533    opts.write(true).create_new(true).follow(FollowSymlinks::No);
534    let mut file = dir.open_with(target, &opts)?;
535    if let Err(e) = file.write_all(bytes).and_then(|()| file.sync_all()) {
536        drop(file);
537        let _ = dir.remove_file(target);
538        return Err(e);
539    }
540    drop(file);
541    #[cfg(unix)]
542    dir.open(".")?.sync_all()?;
543    Ok(())
544}
545
546/// The sha256 hex of `bytes`: the content hash of a plan file.
547pub fn content_hash(bytes: &[u8]) -> String {
548    hash_hex(bytes)
549}
550
551fn hash_hex(bytes: &[u8]) -> String {
552    crate::skills::sha256_hex(bytes)
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    #[test]
560    fn write_new_graph_fallback_creates_exclusively() {
561        let tmp = tempfile::tempdir().unwrap();
562        let dir = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap();
563        create_exclusive(&dir, "v.json", b"{}").expect("first create");
564        let second = create_exclusive(&dir, "v.json", b"[]").unwrap_err();
565        assert_eq!(second.kind(), std::io::ErrorKind::AlreadyExists);
566    }
567}