specfold_core/assets/
sync.rs1use crate::authoring::{Change, Plan};
6use crate::config::Config;
7use anyhow::{Result, bail};
8use std::collections::BTreeSet;
9use std::path::{Component, Path};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Pair {
14 pub source: String,
15 pub copy: String,
16}
17
18pub fn pairs(cfg: &Config) -> Result<Vec<Pair>> {
20 let mut out = vec![];
21 for r in &cfg.raw.assets {
22 let Some(src) = &r.copy_of else { continue };
23 let copy = clean(&r.path, "path")?;
24 let source = clean(src, "copy_of")?;
25 if inside(©, &source) || inside(&source, ©) {
26 bail!("[[assets]] path = \"{copy}\" and copy_of = \"{source}\" overlap; a copy must sit beside its source");
27 }
28 no_links(&cfg.root, &source)?;
29 no_links(&cfg.root, ©)?;
30 if !cfg.root.join(&source).is_dir() {
31 bail!("[[assets]] copy_of = \"{source}\" is not a directory");
32 }
33 out.push(Pair { source, copy });
34 }
35 for (i, a) in out.iter().enumerate() {
36 if let Some(b) = out[..i].iter().find(|b| inside(&a.copy, &b.copy) || inside(&b.copy, &a.copy)) {
37 bail!("[[assets]] copy roots \"{}\" and \"{}\" overlap", b.copy, a.copy);
38 }
39 if let Some(b) = out.iter().find(|b| inside(&b.source, &a.copy) || inside(&a.copy, &b.source)) {
40 bail!("[[assets]] copy root \"{}\" overlaps the source \"{}\"", a.copy, b.source);
41 }
42 }
43 Ok(out)
44}
45
46pub fn plan(cfg: &Config, prune: bool) -> Result<(Vec<Pair>, Plan)> {
48 let pairs = pairs(cfg)?;
49 let mut plan = Plan::default();
50 for p in &pairs {
51 let src = files(&cfg.root, &p.source)?;
52 for rel in &src {
53 let (from, to) = (format!("{}/{rel}", p.source), format!("{}/{rel}", p.copy));
54 no_links(&cfg.root, &to)?;
55 let same = std::fs::read(cfg.root.join(&from)).ok() == std::fs::read(cfg.root.join(&to)).ok();
56 if !same {
57 plan.changes.push(Change::Copy { from, to });
58 }
59 }
60 if prune && cfg.root.join(&p.copy).is_dir() {
61 for rel in files(&cfg.root, &p.copy)?.difference(&src) {
62 plan.changes.push(Change::Remove { path: format!("{}/{rel}", p.copy) });
63 }
64 }
65 }
66 Ok((pairs, plan))
67}
68
69fn clean(p: &str, key: &str) -> Result<String> {
71 let t = p.trim().trim_end_matches('/').replace('\\', "/");
72 let path = Path::new(&t);
73 let ok = !t.is_empty()
74 && !t.contains(['*', '?', '[', '{'])
75 && path.components().all(|c| matches!(c, Component::Normal(_)) || c == Component::CurDir)
76 && path.components().any(|c| matches!(c, Component::Normal(_)));
77 if !ok {
78 bail!("[[assets]] {key} = \"{p}\" must be a directory inside the repository (relative, no '..', no glob)");
79 }
80 Ok(t.trim_start_matches("./").to_string())
81}
82
83fn inside(a: &str, b: &str) -> bool {
84 a == b || a.starts_with(&format!("{b}/"))
85}
86
87fn no_links(root: &Path, rel: &str) -> Result<()> {
89 let mut cur = root.to_path_buf();
90 for part in rel.split('/') {
91 cur.push(part);
92 match std::fs::symlink_metadata(&cur) {
93 Ok(m) if m.file_type().is_symlink() => {
94 bail!("{rel}: passes through a symbolic link; sync does not follow links")
95 }
96 Ok(_) => {}
97 Err(_) => break,
98 }
99 }
100 Ok(())
101}
102
103fn files(root: &Path, dir: &str) -> Result<BTreeSet<String>> {
105 let mut out = BTreeSet::new();
106 let mut stack = vec![String::new()];
107 while let Some(sub) = stack.pop() {
108 let Ok(rd) = std::fs::read_dir(root.join(dir).join(&sub)) else { continue };
109 for e in rd.flatten() {
110 let name = e.file_name().to_string_lossy().to_string();
111 let rel = if sub.is_empty() { name } else { format!("{sub}/{name}") };
112 let ft = e.file_type()?;
113 if ft.is_symlink() {
114 bail!("{dir}/{rel}: is a symbolic link; sync does not follow links");
115 } else if ft.is_dir() {
116 stack.push(rel);
117 } else {
118 out.insert(rel);
119 }
120 }
121 }
122 Ok(out)
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn only_relative_paths_inside_the_repository() {
131 assert_eq!(clean("./.claude/skills/", "path").unwrap(), ".claude/skills");
132 for bad in ["", ".", "/etc", "../x", "a/../../b", ".claude/*"] {
133 assert!(clean(bad, "path").is_err(), "{bad}");
134 }
135 assert!(inside(".agents/skills/x", ".agents/skills") && !inside(".agents/skillsx", ".agents/skills"));
136 }
137}