use std::io::{Read as _, Write as _};
use std::path::{Path, PathBuf};
#[cfg(unix)]
use cap_fs_ext::OpenOptionsSyncExt as _;
use cap_fs_ext::{DirExt as _, FollowSymlinks, OpenOptionsFollowExt as _};
use cap_std::ambient_authority;
use cap_std::fs::{Dir, OpenOptions};
use crate::plan::{PlanGraph, PlannerError};
pub const PROJECT_ROOT_ENV: &str = "CPM_PROJECT_ROOT";
pub const MAX_PLAN_FILE_BYTES: u64 = 8 * 1024 * 1024;
const PLANNER_DIR: &str = ".cpm-planner";
const PLANS_SUBDIR: &str = "plans";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlanFileRef {
pub name: String,
pub variant: String,
pub rel_path: String,
}
#[derive(Debug, Clone)]
pub struct ProjectRoot {
root: PathBuf,
}
fn invalid(reason: impl Into<String>) -> PlannerError {
PlannerError::InvalidPath {
reason: reason.into(),
}
}
fn backend(err: impl Into<anyhow::Error>) -> PlannerError {
PlannerError::BackendError(err.into())
}
const WINDOWS_RESERVED: &[&str] = &[
"con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
"com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
];
pub fn validate_slug(kind: &str, s: &str) -> Result<(), PlannerError> {
let alnum = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit();
let bytes = s.as_bytes();
let shape_ok = !bytes.is_empty()
&& bytes.len() <= 64
&& alnum(bytes[0])
&& alnum(bytes[bytes.len() - 1])
&& bytes
.iter()
.all(|&b| alnum(b) || matches!(b, b'.' | b'_' | b'-'));
if !shape_ok {
return Err(invalid(format!(
"{kind} {s:?} must match ^[a-z0-9][a-z0-9._-]{{0,63}}$ and end in [a-z0-9]"
)));
}
let stem = s.split('.').next().unwrap_or(s);
if WINDOWS_RESERVED.contains(&stem) {
return Err(invalid(format!("{kind} {s:?} is a reserved device name")));
}
Ok(())
}
fn open_subdir(parent: &Dir, name: &str) -> Result<Option<Dir>, PlannerError> {
match parent.symlink_metadata(name) {
Ok(m) if m.file_type().is_symlink() => {
return Err(invalid(format!(
"{name} is a symlink; symlinks are not allowed"
)));
}
Ok(m) if !m.is_dir() => {
return Err(invalid(format!("{name} is not a directory")));
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(backend(e)),
}
match parent.open_dir_nofollow(name) {
Ok(d) => Ok(Some(d)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(invalid(format!(
"cannot open {name} without following links: {e}"
))),
}
}
fn ensure_subdir(parent: &Dir, name: &str) -> Result<Dir, PlannerError> {
match parent.create_dir(name) {
Ok(()) => sync_dir(parent)?,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(backend(e)),
}
open_subdir(parent, name)?.ok_or_else(|| invalid(format!("{name} vanished while being opened")))
}
fn sync_dir(dir: &Dir) -> Result<(), PlannerError> {
#[cfg(unix)]
{
dir.open(".").and_then(|f| f.sync_all()).map_err(backend)?;
}
#[cfg(not(unix))]
{
let _ = dir;
}
Ok(())
}
impl ProjectRoot {
pub fn discover(cwd: &Path) -> Option<ProjectRoot> {
if let Some(v) = std::env::var_os(PROJECT_ROOT_ENV).filter(|v| !v.is_empty()) {
return Self::from_path(Path::new(&v)).ok();
}
let start = cwd.canonicalize().ok()?;
start
.ancestors()
.find(|d| d.join(PLANNER_DIR).is_dir() || d.join(".git").exists())
.map(|d| ProjectRoot {
root: d.to_path_buf(),
})
}
pub fn from_path(root: &Path) -> Result<ProjectRoot, PlannerError> {
let root = root
.canonicalize()
.map_err(|e| invalid(format!("project root {}: {e}", root.display())))?;
if !root.is_dir() {
return Err(invalid(format!(
"project root {} is not a directory",
root.display()
)));
}
Ok(ProjectRoot { root })
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn project_key(&self) -> String {
self.root.to_string_lossy().into_owned()
}
pub fn plans_dir(&self) -> PathBuf {
self.root.join(PLANNER_DIR).join(PLANS_SUBDIR)
}
pub fn resolve_plan_file(&self, rel: &str) -> Result<PlanFileRef, PlannerError> {
if rel.contains('\0') || rel.contains('\\') {
return Err(invalid("path contains NUL or backslash"));
}
if rel.starts_with('/') || Path::new(rel).is_absolute() {
return Err(invalid("absolute paths are not allowed"));
}
let parts: Vec<&str> = rel.split('/').collect();
if parts.contains(&"..") {
return Err(invalid("`..` is not allowed"));
}
let [planner, plans, name, file] = parts.as_slice() else {
return Err(invalid(format!(
"path must be {PLANNER_DIR}/{PLANS_SUBDIR}/<name>/<variant>.json, got {rel:?}"
)));
};
if *planner != PLANNER_DIR || *plans != PLANS_SUBDIR {
return Err(invalid(format!(
"path must be inside {PLANNER_DIR}/{PLANS_SUBDIR}/, got {rel:?}"
)));
}
let Some(variant) = file.strip_suffix(".json") else {
return Err(invalid(format!("plan file {file:?} must end in .json")));
};
self.plan_file(name, variant)
}
pub fn plan_file(&self, name: &str, variant: &str) -> Result<PlanFileRef, PlannerError> {
validate_slug("name", name)?;
validate_slug("variant", variant)?;
let f = PlanFileRef {
name: name.to_string(),
variant: variant.to_string(),
rel_path: format!("{PLANNER_DIR}/{PLANS_SUBDIR}/{name}/{variant}.json"),
};
self.check_confined(&f)?;
Ok(f)
}
fn open_chain(&self, name: Option<&str>, create: bool) -> Result<Option<Dir>, PlannerError> {
let root = Dir::open_ambient_dir(&self.root, ambient_authority()).map_err(backend)?;
let mut cur = root;
let mut parts = vec![PLANNER_DIR, PLANS_SUBDIR];
parts.extend(name);
for part in parts {
cur = if create {
ensure_subdir(&cur, part)?
} else {
match open_subdir(&cur, part)? {
Some(d) => d,
None => return Ok(None),
}
};
}
Ok(Some(cur))
}
fn check_confined(&self, f: &PlanFileRef) -> Result<(), PlannerError> {
if let Some(dir) = self.open_chain(Some(&f.name), false)? {
match dir.symlink_metadata(format!("{}.json", f.variant)) {
Ok(m) if m.file_type().is_symlink() => {
return Err(invalid(format!(
"{} is a symlink; symlinks are not allowed",
f.rel_path
)));
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(backend(e)),
}
}
Ok(())
}
pub fn list_plan_files(&self) -> Result<Vec<PlanFileRef>, PlannerError> {
let mut out = Vec::new();
let Some(plans) = self.open_chain(None, false)? else {
return Ok(out);
};
for n in plans.entries().map_err(backend)? {
let n = n.map_err(backend)?;
let Some(name) = n.file_name().to_str().map(str::to_string) else {
continue;
};
if !n.file_type().map_err(backend)?.is_dir() || validate_slug("name", &name).is_err() {
continue;
}
let Some(dir) = open_subdir(&plans, &name)? else {
continue;
};
for f in dir.entries().map_err(backend)? {
let f = f.map_err(backend)?;
let Some(file) = f.file_name().to_str().map(str::to_string) else {
continue;
};
let Some(variant) = file.strip_suffix(".json") else {
continue;
};
if !f.file_type().map_err(backend)?.is_file()
|| validate_slug("variant", variant).is_err()
{
continue;
}
out.push(PlanFileRef {
name: name.clone(),
variant: variant.to_string(),
rel_path: format!("{PLANNER_DIR}/{PLANS_SUBDIR}/{name}/{variant}.json"),
});
}
}
out.sort_by(|a, b| (&a.name, &a.variant).cmp(&(&b.name, &b.variant)));
Ok(out)
}
pub fn read_graph(&self, f: &PlanFileRef) -> Result<(PlanGraph, String), PlannerError> {
let bytes = self.read_bytes(f)?;
let graph = serde_json::from_slice(&bytes).map_err(|e| PlannerError::InvalidGraph {
reason: format!("{}: {e}", f.rel_path),
})?;
Ok((graph, hash_hex(&bytes)))
}
pub fn file_hash(&self, f: &PlanFileRef) -> Result<String, PlannerError> {
Ok(hash_hex(&self.read_bytes(f)?))
}
pub fn read_bytes(&self, f: &PlanFileRef) -> Result<Vec<u8>, PlannerError> {
validate_slug("name", &f.name)?;
validate_slug("variant", &f.variant)?;
let dir = self
.open_chain(Some(&f.name), false)?
.ok_or_else(|| invalid(format!("{} does not exist", f.rel_path)))?;
let fname = format!("{}.json", f.variant);
let mut opts = OpenOptions::new();
opts.read(true).follow(FollowSymlinks::No);
#[cfg(unix)]
opts.nonblock(true);
let file = match dir.open_with(&fname, &opts) {
Ok(file) => file,
Err(e) => {
return Err(match dir.symlink_metadata(&fname) {
Ok(m) if m.file_type().is_symlink() => {
invalid(format!("{} is a symlink", f.rel_path))
}
_ => backend(e),
});
}
};
let meta = file.metadata().map_err(backend)?;
if !meta.is_file() {
return Err(invalid(format!("{} is not a regular file", f.rel_path)));
}
if meta.len() > MAX_PLAN_FILE_BYTES {
return Err(too_large());
}
let mut bytes = Vec::new();
file.take(MAX_PLAN_FILE_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(backend)?;
if bytes.len() as u64 > MAX_PLAN_FILE_BYTES {
return Err(too_large());
}
Ok(bytes)
}
pub fn remove_plan_file_if_unchanged(&self, f: &PlanFileRef, expected_hash: &str) {
if !matches!(self.file_hash(f), Ok(h) if h == expected_hash) {
return;
}
if let Ok(Some(dir)) = self.open_chain(Some(&f.name), false) {
let _ = dir.remove_file(format!("{}.json", f.variant));
let _ = sync_dir(&dir);
}
}
pub fn write_graph(&self, f: &PlanFileRef, g: &PlanGraph) -> Result<String, PlannerError> {
self.write_graph_inner(f, g, true)
}
pub fn write_new_graph(&self, f: &PlanFileRef, g: &PlanGraph) -> Result<String, PlannerError> {
self.write_graph_inner(f, g, false)
}
fn write_graph_inner(
&self,
f: &PlanFileRef,
g: &PlanGraph,
replace: bool,
) -> Result<String, PlannerError> {
validate_slug("name", &f.name)?;
validate_slug("variant", &f.variant)?;
let mut bytes = serde_json::to_vec_pretty(g).map_err(backend)?;
bytes.push(b'\n');
let dir = self
.open_chain(Some(&f.name), true)?
.ok_or_else(|| invalid("plan directory vanished"))?;
let target = format!("{}.json", f.variant);
match dir.symlink_metadata(&target) {
Ok(m) if m.file_type().is_symlink() => {
return Err(invalid(format!(
"{} is a symlink; symlinks are not allowed",
f.rel_path
)));
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(backend(e)),
}
let tmp = format!(".{}.tmp-{}", f.variant, uuid::Uuid::new_v4());
let result = (|| -> std::io::Result<()> {
let mut opts = OpenOptions::new();
opts.write(true).create_new(true).follow(FollowSymlinks::No);
let mut file = dir.open_with(&tmp, &opts)?;
file.write_all(&bytes)?;
file.sync_all()?;
drop(file);
if replace {
dir.rename(&tmp, &dir, &target)
} else {
let linked = dir.hard_link(&tmp, &dir, &target);
let _ = dir.remove_file(&tmp);
match linked {
Err(e) if link_unsupported(&e) => create_exclusive(&dir, &target, &bytes),
other => other,
}
}
})();
if let Err(e) = result {
let _ = dir.remove_file(&tmp);
if e.kind() == std::io::ErrorKind::AlreadyExists {
return Err(invalid(format!("{} already exists", f.rel_path)));
}
return Err(backend(e));
}
sync_dir(&dir)?;
Ok(hash_hex(&bytes))
}
}
fn too_large() -> PlannerError {
invalid("plan file exceeds 8 MiB")
}
fn link_unsupported(e: &std::io::Error) -> bool {
use std::io::ErrorKind;
if matches!(e.kind(), ErrorKind::Unsupported | ErrorKind::CrossesDevices) {
return true;
}
#[cfg(unix)]
{
matches!(
e.raw_os_error(),
Some(c) if c == libc::EPERM || c == libc::EOPNOTSUPP || c == libc::ENOTSUP || c == libc::EXDEV
)
}
#[cfg(windows)]
{
matches!(e.raw_os_error(), Some(1 | 5 | 17 | 50))
}
#[cfg(not(any(unix, windows)))]
{
false
}
}
fn create_exclusive(dir: &Dir, target: &str, bytes: &[u8]) -> std::io::Result<()> {
let mut opts = OpenOptions::new();
opts.write(true).create_new(true).follow(FollowSymlinks::No);
let mut file = dir.open_with(target, &opts)?;
if let Err(e) = file.write_all(bytes).and_then(|()| file.sync_all()) {
drop(file);
let _ = dir.remove_file(target);
return Err(e);
}
drop(file);
#[cfg(unix)]
dir.open(".")?.sync_all()?;
Ok(())
}
pub fn content_hash(bytes: &[u8]) -> String {
hash_hex(bytes)
}
fn hash_hex(bytes: &[u8]) -> String {
crate::skills::sha256_hex(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_new_graph_fallback_creates_exclusively() {
let tmp = tempfile::tempdir().unwrap();
let dir = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap();
create_exclusive(&dir, "v.json", b"{}").expect("first create");
let second = create_exclusive(&dir, "v.json", b"[]").unwrap_err();
assert_eq!(second.kind(), std::io::ErrorKind::AlreadyExists);
}
}