1use 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
63pub const PROJECT_ROOT_ENV: &str = "CPM_PROJECT_ROOT";
65
66pub const MAX_PLAN_FILE_BYTES: u64 = 8 * 1024 * 1024;
68
69const PLANNER_DIR: &str = ".cpm-planner";
70const PLANS_SUBDIR: &str = "plans";
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct PlanFileRef {
75 pub name: String,
76 pub variant: String,
77 pub rel_path: String,
79}
80
81#[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
102pub 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
128fn 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 Err(e) => Err(invalid(format!(
149 "cannot open {name} without following links: {e}"
150 ))),
151 }
152}
153
154fn 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
165fn 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 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 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 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 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 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 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 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 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 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 pub fn file_hash(&self, f: &PlanFileRef) -> Result<String, PlannerError> {
362 Ok(hash_hex(&self.read_bytes(f)?))
363 }
364
365 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 #[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 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 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 pub fn write_graph(&self, f: &PlanFileRef, g: &PlanGraph) -> Result<String, PlannerError> {
430 self.write_graph_inner(f, g, true)
431 }
432
433 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 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
501fn 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 matches!(e.raw_os_error(), Some(1 | 5 | 17 | 50))
520 }
521 #[cfg(not(any(unix, windows)))]
522 {
523 false
524 }
525}
526
527fn 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
546pub 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}