Skip to main content

specfold_core/assets/
sync.rs

1//! `spec sync assets`: copies the source of each `[[assets]]` root that declares `copy_of` over
2//! that root. The only place specfold writes an asset. Writes stay inside the declared copy root,
3//! never touch the source, never follow a symbolic link, and delete only with `prune`.
4
5use crate::authoring::{Change, Plan};
6use crate::config::Config;
7use anyhow::{Result, bail};
8use std::collections::BTreeSet;
9use std::path::{Component, Path};
10
11/// One root that will be synced: the copy and its source, both repository-relative.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Pair {
14    pub source: String,
15    pub copy: String,
16}
17
18/// The pairs declared in spec.toml, validated; an unsafe declaration fails the whole sync.
19pub 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(&copy, &source) || inside(&source, &copy) {
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, &copy)?;
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
46/// The changes that make every copy root equal to its source, and the copies among them that
47/// would overwrite a file with no sign sync wrote it (CLI-4): an existing copy is sync's own when
48/// its content is a version its source has had (in git history or now).
49pub fn plan(cfg: &Config, prune: bool) -> Result<(Vec<Pair>, Plan, Vec<String>)> {
50    let pairs = pairs(cfg)?;
51    let mut plan = Plan::default();
52    let mut unowned = vec![];
53    for p in &pairs {
54        let src = files(&cfg.root, &p.source)?;
55        for rel in &src {
56            let (from, to) = (format!("{}/{rel}", p.source), format!("{}/{rel}", p.copy));
57            no_links(&cfg.root, &to)?;
58            let same = std::fs::read(cfg.root.join(&from)).ok() == std::fs::read(cfg.root.join(&to)).ok();
59            if !same {
60                if cfg.root.join(&to).is_file() && !owned(&cfg.root, &from, &to) {
61                    unowned.push(to.clone());
62                }
63                plan.changes.push(Change::Copy { from, to });
64            }
65        }
66        if prune && cfg.root.join(&p.copy).is_dir() {
67            for rel in files(&cfg.root, &p.copy)?.difference(&src) {
68                plan.changes.push(Change::Remove { path: format!("{}/{rel}", p.copy) });
69            }
70        }
71    }
72    Ok((pairs, plan, unowned))
73}
74
75/// Whether the copy at `to` holds a version of `from`: what sync wrote and nobody changed since.
76fn owned(root: &Path, from: &str, to: &str) -> bool {
77    use crate::workspace::vcs;
78    let Some(id) = vcs::blob_id(root, to) else { return false };
79    vcs::past_blobs(root, from).contains(&id)
80}
81
82/// A relative path inside the repository: no root, no `..`, no glob, not the repository itself.
83fn clean(p: &str, key: &str) -> Result<String> {
84    let t = p.trim().trim_end_matches('/').replace('\\', "/");
85    let path = Path::new(&t);
86    let ok = !t.is_empty()
87        && !t.contains(['*', '?', '[', '{'])
88        && path.components().all(|c| matches!(c, Component::Normal(_)) || c == Component::CurDir)
89        && path.components().any(|c| matches!(c, Component::Normal(_)));
90    if !ok {
91        bail!("[[assets]] {key} = \"{p}\" must be a directory inside the repository (relative, no '..', no glob)");
92    }
93    Ok(t.trim_start_matches("./").to_string())
94}
95
96fn inside(a: &str, b: &str) -> bool {
97    a == b || a.starts_with(&format!("{b}/"))
98}
99
100/// Refuses a path whose existing part passes through a symbolic link, which could lead outside.
101fn no_links(root: &Path, rel: &str) -> Result<()> {
102    let mut cur = root.to_path_buf();
103    for part in rel.split('/') {
104        cur.push(part);
105        match std::fs::symlink_metadata(&cur) {
106            Ok(m) if m.file_type().is_symlink() => {
107                bail!("{rel}: passes through a symbolic link; sync does not follow links")
108            }
109            Ok(_) => {}
110            Err(_) => break,
111        }
112    }
113    Ok(())
114}
115
116/// Every file below `dir`, relative to it; a symbolic link anywhere below is refused.
117fn files(root: &Path, dir: &str) -> Result<BTreeSet<String>> {
118    let mut out = BTreeSet::new();
119    let mut stack = vec![String::new()];
120    while let Some(sub) = stack.pop() {
121        let Ok(rd) = std::fs::read_dir(root.join(dir).join(&sub)) else { continue };
122        for e in rd.flatten() {
123            let name = e.file_name().to_string_lossy().to_string();
124            let rel = if sub.is_empty() { name } else { format!("{sub}/{name}") };
125            let ft = e.file_type()?;
126            if ft.is_symlink() {
127                bail!("{dir}/{rel}: is a symbolic link; sync does not follow links");
128            } else if ft.is_dir() {
129                stack.push(rel);
130            } else {
131                out.insert(rel);
132            }
133        }
134    }
135    Ok(out)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn only_relative_paths_inside_the_repository() {
144        assert_eq!(clean("./.claude/skills/", "path").unwrap(), ".claude/skills");
145        for bad in ["", ".", "/etc", "../x", "a/../../b", ".claude/*"] {
146            assert!(clean(bad, "path").is_err(), "{bad}");
147        }
148        assert!(inside(".agents/skills/x", ".agents/skills") && !inside(".agents/skillsx", ".agents/skills"));
149    }
150}