Skip to main content

yapi_core/chest/
place.rs

1//! Where installed skills and agents go: a canonical copy in the first
2//! configured folder, relative links to it from the others, and the
3//! `.gitignore` block that keeps them out of git.
4
5use std::io;
6use std::path::{Component, Path, PathBuf};
7
8use yapi_ai::auth::lock::write_atomic;
9
10use super::manifest::valid_name;
11use super::{ChestError, invalid, io};
12use crate::packages::npm;
13use crate::tools::path::relative;
14
15/// Copies the folder or file `source` to `<folders[0]>/<name>`, then links
16/// `<folder>/<name>` in each other folder to that copy, copying it where a
17/// link cannot be made. What is at those paths is replaced, so the caller
18/// must own them. Returns the placed paths, the canonical copy first.
19///
20/// Every path must stay within `root`, the project folder or, for the
21/// global manifest, the home folder: a folder that is a link must resolve
22/// inside it. `name` must be a valid entry name. Either refusal comes
23/// before anything is written.
24pub fn place(
25    source: &Path,
26    root: &Path,
27    folders: &[PathBuf],
28    name: &str,
29) -> Result<Vec<PathBuf>, ChestError> {
30    place_with(source, root, folders, name, link)
31}
32
33/// [`place`], making each link with `link(target, path)`, where `target` is
34/// relative to the link's folder.
35pub fn place_with(
36    source: &Path,
37    root: &Path,
38    folders: &[PathBuf],
39    name: &str,
40    link: impl Fn(&Path, &Path) -> io::Result<()>,
41) -> Result<Vec<PathBuf>, ChestError> {
42    let Some((first, others)) = folders.split_first() else {
43        return Err(invalid(format!("No folder to place {name} in")));
44    };
45    let targets: Vec<PathBuf> = folders.iter().map(|folder| folder.join(name)).collect();
46    check_name(name)?;
47    targets.iter().try_for_each(|target| within(root, target))?;
48    let canonical = first.join(name);
49    put(source, &canonical)?;
50    let mut placed = vec![canonical.clone()];
51    // Real paths, so the link holds when a folder is itself a link.
52    let real = |path: &Path| path.canonicalize().map_err(io(path));
53    let first_real = real(first)?;
54    for folder in others {
55        let path = folder.join(name);
56        // A folder that is the first one under another name already holds it.
57        if folder
58            .canonicalize()
59            .is_ok_and(|folder| folder == first_real)
60        {
61            continue;
62        }
63        remove(&path)?;
64        std::fs::create_dir_all(folder).map_err(io(folder))?;
65        let target = relative(&real(folder)?, &real(&canonical)?);
66        if link(Path::new(&target), &path).is_err() {
67            put(&canonical, &path)?;
68        }
69        placed.push(path);
70    }
71    Ok(placed)
72}
73
74/// Removes `paths`, links themselves rather than what they point to, and
75/// nothing else. Refuses, before removing anything, a path outside `root`
76/// as [`place`] judges it, or one whose last part is not a valid entry
77/// name.
78pub fn unplace(root: &Path, paths: &[PathBuf]) -> Result<(), ChestError> {
79    for path in paths {
80        check_name(&path.file_name().unwrap_or_default().to_string_lossy())?;
81        within(root, path)?;
82    }
83    paths.iter().try_for_each(|path| remove(path))
84}
85
86/// Copies `source` to `target` through a staged sibling, so that `target`
87/// holds the old copy or the new one, never part of either. A link at
88/// `target`, which the caller owns, goes first.
89fn put(source: &Path, target: &Path) -> Result<(), ChestError> {
90    if target
91        .symlink_metadata()
92        .is_ok_and(|metadata| metadata.is_symlink())
93    {
94        remove(target)?;
95    }
96    if source.symlink_metadata().map_err(io(source))?.is_dir() {
97        let staging = npm::stage(target)?;
98        let built = copy(source, &staging);
99        return npm::swap(&staging, target, built);
100    }
101    let name = target.file_name().unwrap_or_default().to_string_lossy();
102    let staging = target.with_file_name(format!(".{name}-staging"));
103    remove(&staging)?;
104    copy(source, &staging)?;
105    std::fs::rename(&staging, target).map_err(|err| {
106        let _ = remove(&staging);
107        io(target)(err)
108    })
109}
110
111fn check_name(name: &str) -> Result<(), ChestError> {
112    if valid_name(name) {
113        Ok(())
114    } else {
115        Err(invalid(format!("Invalid entry name: {name}")))
116    }
117}
118
119/// Fails unless `path` has no `..` and the deepest part of its folder that
120/// exists resolves inside `root`. A link at `path` itself is not followed:
121/// placing replaces the link, and removing removes it.
122pub(super) fn within(root: &Path, path: &Path) -> Result<(), ChestError> {
123    let outside = || {
124        invalid(format!(
125            "Refusing {}: it is outside {}",
126            path.display(),
127            root.display()
128        ))
129    };
130    if path.components().any(|part| part == Component::ParentDir) {
131        return Err(outside());
132    }
133    let root = root.canonicalize().map_err(io(root))?;
134    let mut folder = path.parent();
135    while let Some(dir) = folder {
136        match dir.symlink_metadata() {
137            // A link that resolves nowhere fails here, so nothing is ever
138            // created through it.
139            Ok(_) => match dir.canonicalize() {
140                Ok(real) if real.starts_with(&root) => return Ok(()),
141                _ => return Err(outside()),
142            },
143            Err(err) if err.kind() == io::ErrorKind::NotFound => folder = dir.parent(),
144            Err(err) => return Err(io(dir)(err)),
145        }
146    }
147    Err(outside())
148}
149
150#[cfg(unix)]
151fn link(target: &Path, path: &Path) -> io::Result<()> {
152    std::os::unix::fs::symlink(target, path)
153}
154
155#[cfg(windows)]
156fn link(target: &Path, path: &Path) -> io::Result<()> {
157    let resolved = path.parent().map(|parent| parent.join(target));
158    if resolved.is_some_and(|resolved| resolved.is_dir()) {
159        std::os::windows::fs::symlink_dir(target, path)
160    } else {
161        std::os::windows::fs::symlink_file(target, path)
162    }
163}
164
165#[cfg(not(any(unix, windows)))]
166fn link(_: &Path, _: &Path) -> io::Result<()> {
167    Err(io::ErrorKind::Unsupported.into())
168}
169
170fn remove(path: &Path) -> Result<(), ChestError> {
171    npm::remove_path(path).map_err(io(path))
172}
173
174/// Copies the folder or file `from` to `to`, leaving out `.git` folders and
175/// refusing links.
176pub(super) fn copy(from: &Path, to: &Path) -> Result<(), ChestError> {
177    let metadata = from.symlink_metadata().map_err(io(from))?;
178    if metadata.is_symlink() {
179        return Err(invalid(format!(
180            "{}: symbolic links are not allowed in a package",
181            from.display()
182        )));
183    }
184    if !metadata.is_dir() {
185        if let Some(parent) = to.parent() {
186            std::fs::create_dir_all(parent).map_err(io(parent))?;
187        }
188        return std::fs::copy(from, to).map(drop).map_err(io(to));
189    }
190    std::fs::create_dir_all(to).map_err(io(to))?;
191    for entry in std::fs::read_dir(from).map_err(io(from))? {
192        let entry = entry.map_err(io(from))?;
193        if entry.file_name() != ".git" {
194            copy(&entry.path(), &to.join(entry.file_name()))?;
195        }
196    }
197    Ok(())
198}
199
200/// Rewrites the block of manifest `manifest` (a file name such as
201/// `chest.toml`) in `root/.gitignore` to list `entries`, sorted. The block
202/// runs from `# BEGIN chest (<manifest>)` to `# END chest`, and every line
203/// outside it stays as it was. With no entries the block goes.
204///
205/// Each entry must be a path relative to `root` with no `.` or `..` part
206/// and no line break. Its line matches that path alone, with `*` in its
207/// last part a wildcard. Fails, writing nothing, on another entry, when the
208/// block has no end, or when `root/.gitignore` is a link.
209pub fn write_ignore(root: &Path, manifest: &str, entries: &[String]) -> Result<(), ChestError> {
210    let path = &root.join(".gitignore");
211    if path
212        .symlink_metadata()
213        .is_ok_and(|metadata| metadata.is_symlink())
214    {
215        return Err(invalid(format!(
216            "Refusing to write {} through a link",
217            path.display()
218        )));
219    }
220    let plain = |entry: &str| {
221        let parts = Path::new(entry).components();
222        !entry.is_empty()
223            && !entry.contains(['\n', '\r'])
224            && parts
225                .into_iter()
226                .all(|part| matches!(part, Component::Normal(_)))
227    };
228    if let Some(entry) = entries.iter().find(|entry| !plain(entry)) {
229        return Err(invalid(format!(
230            "Refusing the .gitignore entry {entry:?}: it must be a plain path inside {}",
231            root.display()
232        )));
233    }
234    let begin = format!("# BEGIN chest ({manifest})");
235    let text = match std::fs::read_to_string(path) {
236        Ok(text) => text,
237        Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
238        Err(err) => return Err(io(path)(err)),
239    };
240    let lines: Vec<&str> = text.split_inclusive('\n').collect();
241    let is = |line: &str, marker: &str| line.trim_end_matches(['\n', '\r']) == marker;
242    let (before, after) = match lines.iter().position(|line| is(line, &begin)) {
243        Some(start) => {
244            let end = lines[start..]
245                .iter()
246                .position(|line| is(line, "# END chest"))
247                .ok_or_else(|| {
248                    invalid(format!(
249                        "{}: `{begin}` has no `# END chest`",
250                        path.display()
251                    ))
252                })?;
253            (&lines[..start], &lines[start + end + 1..])
254        }
255        None => (&lines[..], &[][..]),
256    };
257    let mut entries = entries.to_vec();
258    entries.sort();
259    entries.dedup();
260    let mut out = before.concat();
261    if !entries.is_empty() {
262        if !out.is_empty() && !out.ends_with('\n') {
263            out.push('\n');
264        }
265        out.push_str(&begin);
266        out.push('\n');
267        for entry in &entries {
268            out.push_str(&literal(entry));
269            out.push('\n');
270        }
271        out.push_str("# END chest\n");
272    }
273    out.push_str(&after.concat());
274    if out == text {
275        return Ok(());
276    }
277    write_atomic(path, out.as_bytes(), 0o666).map_err(io(path))
278}
279
280/// `entry` as a `.gitignore` line that matches that path alone: `\`, `?`,
281/// `[`, a leading `!` or `#` and trailing spaces escaped, and `*` too
282/// outside the last part. A `*` in the last part stays a wildcard, so that
283/// `folder/.*-staging` names what stopped installs leave beside a folder's
284/// items. Item names never hold one.
285fn literal(entry: &str) -> String {
286    let last = entry.rfind('/').map_or(0, |slash| slash + 1);
287    let mut line = String::new();
288    for (index, c) in entry.char_indices() {
289        let special = matches!(c, '\\' | '?' | '[') || c == '*' && index < last;
290        if special || index == 0 && matches!(c, '!' | '#') {
291            line.push('\\');
292        }
293        line.push(c);
294    }
295    let kept = line.trim_end_matches(' ');
296    format!("{kept}{}", "\\ ".repeat(line.len() - kept.len()))
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn scratch(name: &str) -> PathBuf {
304        let dir =
305            std::env::temp_dir().join(format!("yapi-chest-place-{name}-{}", std::process::id()));
306        let _ = std::fs::remove_dir_all(&dir);
307        std::fs::create_dir_all(&dir).unwrap();
308        dir
309    }
310
311    #[test]
312    fn placement_links_copies() {
313        let dir = scratch("links");
314        let root = dir.join("project");
315        let outside = dir.join("outside");
316        std::fs::create_dir_all(&outside).unwrap();
317        let source = dir.join("checkout/skills/pdf");
318        std::fs::create_dir_all(source.join("scripts")).unwrap();
319        std::fs::write(source.join("SKILL.md"), "# pdf\n").unwrap();
320        std::fs::write(source.join("scripts/run.sh"), "echo\n").unwrap();
321        std::fs::create_dir_all(source.join(".git")).unwrap();
322        std::fs::write(source.join(".git/HEAD"), "x").unwrap();
323        let folders = [".agents/skills", ".claude/skills", ".cursor/skills"].map(|f| root.join(f));
324        // A hand-written skill beside the placed one stays.
325        std::fs::create_dir_all(folders[1].join("mine")).unwrap();
326        std::fs::write(folders[1].join("mine/SKILL.md"), "mine").unwrap();
327
328        let placed = place(&source, &root, &folders[..2], "pdf").unwrap();
329        assert_eq!(placed, [folders[0].join("pdf"), folders[1].join("pdf")]);
330        let canonical = &placed[0];
331        assert!(canonical.symlink_metadata().unwrap().is_dir());
332        assert_eq!(
333            std::fs::read_to_string(canonical.join("scripts/run.sh")).unwrap(),
334            "echo\n"
335        );
336        assert!(!canonical.join(".git").exists());
337        #[cfg(unix)]
338        {
339            let link = std::fs::read_link(&placed[1]).unwrap();
340            assert_eq!(link, Path::new("../../.agents/skills/pdf"));
341        }
342        assert_eq!(
343            std::fs::read_to_string(placed[1].join("SKILL.md")).unwrap(),
344            "# pdf\n"
345        );
346
347        // A folder where links fail gets a copy, and placing again replaces.
348        std::fs::write(source.join("SKILL.md"), "# pdf 2\n").unwrap();
349        let failing = |_: &Path, _: &Path| Err(io::Error::other("no links here"));
350        let pair = [folders[0].clone(), folders[2].clone()];
351        let placed = place_with(&source, &root, &pair, "pdf", failing).unwrap();
352        assert!(placed[1].symlink_metadata().unwrap().is_dir());
353        assert_eq!(
354            std::fs::read_to_string(placed[1].join("SKILL.md")).unwrap(),
355            "# pdf 2\n"
356        );
357        // A copy that fails partway leaves nothing at the path or beside it.
358        #[cfg(unix)]
359        {
360            let breaking = |target: &Path, path: &Path| {
361                let canonical = path.parent().unwrap().join(target);
362                std::os::unix::fs::symlink("SKILL.md", canonical.join("link"))?;
363                Err(io::Error::other("no links here"))
364            };
365            assert!(place_with(&source, &root, &pair, "pdf", breaking).is_err());
366            assert!(placed[1].symlink_metadata().is_err());
367            assert!(folders[2].join(".pdf-staging").symlink_metadata().is_err());
368            std::fs::remove_file(folders[0].join("pdf/link")).unwrap();
369        }
370
371        // Nothing goes outside the root: not through a folder that links out,
372        // nor under a name that climbs, nor by removing what lies outside.
373        #[cfg(unix)]
374        {
375            std::fs::create_dir_all(root.join(".evil")).unwrap();
376            std::os::unix::fs::symlink(&outside, root.join(".evil/skills")).unwrap();
377            std::os::unix::fs::symlink(dir.join("nowhere"), root.join(".dangling")).unwrap();
378            for folder in [
379                root.join(".evil/skills"),
380                root.join(".evil/skills/deep"),
381                root.join(".dangling/skills"),
382            ] {
383                let pair = [folders[0].clone(), folder];
384                assert!(place(&source, &root, &pair, "evil").is_err(), "{pair:?}");
385            }
386            assert!(std::fs::read_dir(&outside).unwrap().next().is_none());
387            assert!(!dir.join("nowhere").exists());
388            assert!(!folders[0].join("evil").exists());
389            // A folder that links within the root is fine.
390            std::fs::create_dir_all(root.join("shared/skills")).unwrap();
391            std::os::unix::fs::symlink("shared", root.join(".codex")).unwrap();
392            let pair = [folders[0].clone(), root.join(".codex/skills")];
393            place(&source, &root, &pair, "pdf").unwrap();
394            assert!(root.join("shared/skills/pdf/SKILL.md").exists());
395        }
396        std::fs::write(outside.join("keep"), "").unwrap();
397        assert!(unplace(&root, &[outside.join("keep")]).is_err());
398        assert!(unplace(&root, &[folders[0].join("../../outside/keep")]).is_err());
399        assert!(outside.join("keep").exists());
400        for name in ["../x", "Bad", ""] {
401            assert!(
402                place(&source, &root, &folders[..1], name).is_err(),
403                "{name}"
404            );
405        }
406        assert!(unplace(&root, &[folders[0].join("Bad")]).is_err());
407
408        unplace(&root, &[folders[1].join("pdf"), folders[2].join("pdf")]).unwrap();
409        assert!(folders[1].join("pdf").symlink_metadata().is_err());
410        assert!(!folders[2].join("pdf").exists());
411        // Unplacing the link left its target and the hand-written skill alone.
412        assert!(folders[0].join("pdf/SKILL.md").exists());
413        assert!(folders[1].join("mine/SKILL.md").exists());
414
415        // The block is rebuilt in place, and lines around it, CRLF ones too, stay.
416        let ignore = root.join(".gitignore");
417        let handwritten = "target/\r\n# mine\n# BEGIN chest (chest.toml)\nold/path\n# END chest\n.env\n# BEGIN chest (chest.dev.toml)\ndev/path\n# END chest\nlast";
418        std::fs::write(&ignore, handwritten).unwrap();
419        let entries = [
420            ".claude/skills/pdf".to_owned(),
421            ".agents/skills/pdf".into(),
422            ".agents/skills/pdf".into(),
423        ];
424        write_ignore(&root, "chest.toml", &entries).unwrap();
425        assert_eq!(
426            std::fs::read_to_string(&ignore).unwrap(),
427            "target/\r\n# mine\n# BEGIN chest (chest.toml)\n.agents/skills/pdf\n.claude/skills/pdf\n# END chest\n.env\n# BEGIN chest (chest.dev.toml)\ndev/path\n# END chest\nlast"
428        );
429        write_ignore(&root, "chest.toml", &[]).unwrap();
430        assert_eq!(
431            std::fs::read_to_string(&ignore).unwrap(),
432            "target/\r\n# mine\n.env\n# BEGIN chest (chest.dev.toml)\ndev/path\n# END chest\nlast"
433        );
434        // A new block goes at the end, after a final line without a newline.
435        write_ignore(&root, "chest.toml", &["a".into()]).unwrap();
436        assert!(
437            std::fs::read_to_string(&ignore)
438                .unwrap()
439                .ends_with("# END chest\nlast\n# BEGIN chest (chest.toml)\na\n# END chest\n")
440        );
441        // Entries are plain paths inside the root, one per line.
442        let before = std::fs::read_to_string(&ignore).unwrap();
443        for entry in ["../x", "/abs", "./a", "a/../b", "a\nb", ""] {
444            assert!(
445                write_ignore(&root, "chest.toml", &[entry.into()]).is_err(),
446                "{entry:?}"
447            );
448        }
449        assert_eq!(std::fs::read_to_string(&ignore).unwrap(), before);
450        // Each line matches its path alone, as git reads it, whatever the
451        // path holds. A `*` in the last part names siblings.
452        #[cfg(unix)]
453        {
454            let repo = dir.join("repo");
455            std::fs::create_dir_all(&repo).unwrap();
456            let git = |args: &[&str]| {
457                std::process::Command::new("git")
458                    .args(args)
459                    .current_dir(&repo)
460                    .env("GIT_CONFIG_GLOBAL", "/dev/null")
461                    .env("GIT_CONFIG_NOSYSTEM", "1")
462                    .output()
463                    .unwrap()
464                    .status
465                    .success()
466            };
467            assert!(git(&["init", "--quiet"]));
468            let paths = [
469                "s*[x]/pdf",
470                "!keep/pdf",
471                "#note/pdf",
472                "a\\b/pdf",
473                "tail/pdf ",
474            ];
475            let mut entries = paths.map(String::from).to_vec();
476            entries.push("s*[x]/.*-staging".into());
477            write_ignore(&repo, "chest.toml", &entries).unwrap();
478            let ignored = |path: &str| git(&["check-ignore", "--quiet", "--no-index", path]);
479            for path in paths.into_iter().chain(["s*[x]/.pdf-staging"]) {
480                assert!(ignored(path), "{path:?}");
481            }
482            let others = [
483                "sx/pdf",
484                "s*x/pdf",
485                "keep/pdf",
486                "ab/pdf",
487                "tail/pdf",
488                "sx/.pdf-staging",
489            ];
490            for path in others {
491                assert!(!ignored(path), "{path:?}");
492            }
493        }
494        std::fs::write(&ignore, "# BEGIN chest (chest.toml)\nkept\n").unwrap();
495        assert!(write_ignore(&root, "chest.toml", &[]).is_err());
496        assert_eq!(
497            std::fs::read_to_string(&ignore).unwrap(),
498            "# BEGIN chest (chest.toml)\nkept\n"
499        );
500        #[cfg(unix)]
501        {
502            let linked = dir.join("linked");
503            std::fs::create_dir_all(&linked).unwrap();
504            std::os::unix::fs::symlink(outside.join("keep"), linked.join(".gitignore")).unwrap();
505            assert!(write_ignore(&linked, "chest.toml", &["a".into()]).is_err());
506            assert_eq!(std::fs::read_to_string(outside.join("keep")).unwrap(), "");
507        }
508        let empty = dir.join("empty");
509        std::fs::create_dir_all(&empty).unwrap();
510        write_ignore(&empty, "chest.toml", &[]).unwrap();
511        assert!(!empty.join(".gitignore").exists());
512        std::fs::remove_dir_all(&dir).unwrap();
513    }
514}