1use 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
15pub 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
33pub 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 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 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
74pub 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
86fn 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
119pub(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 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
174pub(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
200pub 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
280fn 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 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 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 #[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 #[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 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 assert!(folders[0].join("pdf/SKILL.md").exists());
413 assert!(folders[1].join("mine/SKILL.md").exists());
414
415 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 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 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 #[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}