use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use crate::catalog::CatalogItem;
use crate::error::BadRefReason::NoMatch;
use crate::error::{ItemKind, MindError, Result};
use crate::manifest::InstalledItem;
use crate::namespace;
use crate::paths::{Paths, mkdir_p};
pub fn install(
paths: &Paths,
item: &CatalogItem,
commit: &str,
siblings: &[CatalogItem],
force: bool,
dangerously_skip_build: bool,
) -> Result<InstalledItem> {
let kind = item.kind;
let name = item.effective_name();
{
use std::path::Component;
let effective_safe = !name.is_empty()
&& !name.contains('\0')
&& std::path::Path::new(&name).components().all(|c| {
!matches!(
c,
Component::RootDir | Component::ParentDir | Component::Prefix(..)
)
});
if !effective_safe {
return Err(MindError::UnsafeName { name: name.clone() });
}
}
let store = paths.store_item(kind, &name);
let staging = paths.staging_path(kind, &name);
let backup = paths.backup_path(kind, &name);
let link_name = if kind == ItemKind::Agent {
item.agent_harness_name()
.unwrap_or_else(|| item.name.clone())
} else {
name.clone()
};
let link_rel = item
.link_rel
.clone()
.or_else(|| paths.default_link_rel(kind, &link_name));
let store_root = paths.store_dir();
let planned_links: Vec<std::path::PathBuf> = match &link_rel {
Some(rel) => {
let mut planned = Vec::new();
for home in paths.agent_homes()?.iter().filter(|home| home.admits(kind)) {
if home.reachable() {
planned.push(home.path.join(rel));
} else {
note_unreachable_lobe_once(&home.path);
}
}
planned
}
None => Vec::new(),
};
if !force {
for link in &planned_links {
ensure_unoccupied(&store_root, link)?;
}
}
remove_path(&staging)?;
if let Some(parent) = staging.parent() {
mkdir_p(parent)?;
}
let ignore = item.ignore_set()?;
copy_recursive_ignoring(&item.path, &staging, &ignore)?;
let hash = crate::hash::hash_path_ignoring(&item.path, &ignore)?;
if let Err(e) = expand_references(&staging, item, siblings, &store_root) {
let _ = remove_path(&staging);
return Err(e);
}
if let Some(build) = &item.build
&& let Err(e) = run_build_hook(item, build, &staging, commit, dangerously_skip_build)
{
let _ = remove_path(&staging);
return Err(e);
}
if let Some(parent) = store.parent() {
mkdir_p(parent)?;
}
let had_backup = store.exists();
if had_backup {
remove_path(&backup)?;
if let Some(parent) = backup.parent() {
mkdir_p(parent)?;
}
rename(&store, &backup)?;
}
if let Err(e) = rename(&staging, &store) {
if had_backup {
let _ = rename(&backup, &store); }
return Err(e);
}
let mut links: Vec<std::path::PathBuf> = Vec::new();
let mut stashes: Vec<Option<std::path::PathBuf>> = Vec::new();
for (i, link) in planned_links.into_iter().enumerate() {
let stash = if force {
let sp = paths.tmp_dir().join("foreign-stash").join(i.to_string());
match maybe_stash_foreign(&store_root, &link, &sp) {
Ok(true) => Some(sp),
Ok(false) => None,
Err(e) => {
for (made, s) in links.iter().zip(stashes.iter()) {
let _ = remove_path(made);
if let Some(s) = s {
let _ = rename(s, made);
}
}
let _ = remove_path(&store);
if had_backup {
let _ = rename(&backup, &store);
}
return Err(e);
}
}
} else {
None
};
if let Err(e) = ensure_link(&store, &link) {
for (made, s) in links.iter().zip(stashes.iter()) {
let _ = remove_path(made);
if let Some(s) = s {
let _ = rename(s, made);
}
}
if let Some(s) = &stash {
let _ = rename(s, &link);
}
let _ = remove_path(&store);
if had_backup {
let _ = rename(&backup, &store);
}
return Err(e);
}
links.push(link);
stashes.push(stash);
}
if had_backup {
let _ = remove_path(&backup);
}
for s in stashes.iter().flatten() {
let _ = remove_path(s);
}
Ok(InstalledItem {
kind,
name,
bare_name: item.name.clone(),
source: item.source.clone(),
commit: commit.to_string(),
hash,
store: paths.store_rel(kind, &item.effective_name()),
links: links
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect(),
description: item.description.clone(),
install_hooks: Vec::new(),
dropped_requires: Vec::new(),
})
}
pub fn uninstall(paths: &Paths, item: &InstalledItem) -> Result<()> {
let store_root = paths.store_dir();
let agent_homes = paths.agent_homes()?;
let canonical_roots: Vec<PathBuf> = agent_homes
.iter()
.map(|h| h.path.canonicalize().unwrap_or_else(|_| h.path.clone()))
.collect();
let root_refs: Vec<&Path> = canonical_roots.iter().map(|p| p.as_path()).collect();
for link in &item.links {
let p = Path::new(link);
let canon_p = canonicalize_link_parent(p);
if !is_confined_under_any(&canon_p, &root_refs) {
if !p.is_absolute() || p.components().any(|c| c == std::path::Component::ParentDir) {
eprintln!(
"mind: warning: skipping removal of '{}' -- path is outside all configured agent homes",
crate::sanitize::display_path(p)
);
continue;
}
}
remove_path(p)?;
}
let store_path = paths.mind_home.join(&item.store);
if !is_confined_under(&store_path, &store_root) {
eprintln!(
"mind: warning: skipping removal of '{}' -- path is outside the mind store root",
crate::sanitize::display_path(&store_path)
);
return Ok(());
}
remove_path(&store_path)?;
Ok(())
}
fn canonicalize_link_parent(link: &Path) -> PathBuf {
link.parent()
.zip(link.file_name())
.map(|(parent, name)| {
parent
.canonicalize()
.unwrap_or_else(|_| parent.to_path_buf())
.join(name)
})
.unwrap_or_else(|| link.to_path_buf())
}
pub(crate) fn is_confined_under(path: &Path, root: &Path) -> bool {
use std::path::Component;
if path.components().any(|c| c == Component::ParentDir) {
return false;
}
path.starts_with(root)
}
fn is_confined_under_any(path: &Path, roots: &[&Path]) -> bool {
roots.iter().any(|root| is_confined_under(path, root))
}
pub fn relink(paths: &Paths, item: &InstalledItem) -> Result<usize> {
let store = paths.mind_home.join(&item.store);
if !store.exists() {
return Ok(0);
}
let agent_homes = paths.agent_homes()?;
let mut fixed = 0;
for link_str in &item.links {
let link = Path::new(link_str);
let reachable = agent_homes
.iter()
.find(|lobe| link.starts_with(&lobe.path))
.is_none_or(|lobe| lobe.reachable());
if !reachable {
continue;
}
if std::fs::symlink_metadata(link).is_err() {
ensure_link(&store, link)?;
fixed += 1;
}
}
Ok(fixed)
}
pub fn link_into_new_lobes(
paths: &Paths,
item: &InstalledItem,
new_lobes: &[crate::paths::Lobe],
force: bool,
) -> (
Vec<std::path::PathBuf>,
Vec<(std::path::PathBuf, MindError)>,
) {
let mut created = Vec::new();
let mut failed = Vec::new();
let agent_homes = paths.agent_homes().unwrap_or_default();
let link_rel = item.links.iter().find_map(|link_str| {
let link = Path::new(link_str);
agent_homes
.iter()
.find_map(|lobe| link.strip_prefix(&lobe.path).ok())
.map(|rel| rel.to_string_lossy().into_owned())
});
let link_rel = match link_rel.or_else(|| paths.default_link_rel(item.kind, &item.name)) {
Some(rel) => rel,
None => return (created, failed),
};
let store = paths.mind_home.join(&item.store);
let store_root = paths.store_dir();
for lobe in new_lobes {
if !lobe.admits(item.kind) {
continue;
}
let expected = lobe.path.join(&link_rel);
let expected_str = expected.to_string_lossy().into_owned();
if item.links.iter().any(|l| l == &expected_str) {
continue;
}
if !force && let Err(e) = ensure_unoccupied(&store_root, &expected) {
failed.push((expected, e));
continue;
}
match ensure_link(&store, &expected) {
Ok(()) => created.push(expected),
Err(e) => failed.push((expected, e)),
}
}
(created, failed)
}
fn maybe_stash_foreign(store_root: &Path, link: &Path, stash: &Path) -> Result<bool> {
let meta = match std::fs::symlink_metadata(link) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(MindError::io(link, e)),
Ok(m) => m,
};
if meta.file_type().is_symlink()
&& std::fs::read_link(link).is_ok_and(|t| t.starts_with(store_root))
{
return Ok(false);
}
if let Some(parent) = stash.parent() {
mkdir_p(parent)?;
}
rename(link, stash)?;
Ok(true)
}
static WARNED_UNREACHABLE_LOBES: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
fn note_unreachable_lobe_once(path: &Path) {
let seen = WARNED_UNREACHABLE_LOBES.get_or_init(|| Mutex::new(HashSet::new()));
let mut seen = seen
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if seen.insert(path.to_path_buf()) {
let quoted_path = crate::error::shell_quote(&path.display().to_string());
crate::render::note(format!(
"note: lobe '{}' is unreachable (its parent directory does not exist); \
create the missing directory, or run `mind config lobes remove {quoted_path}` to drop it",
path.display()
));
}
}
fn ensure_unoccupied(store_root: &Path, link: &Path) -> Result<()> {
match std::fs::symlink_metadata(link) {
Ok(meta) => {
let ours = meta.file_type().is_symlink()
&& std::fs::read_link(link).is_ok_and(|t| t.starts_with(store_root));
if ours {
Ok(())
} else {
Err(MindError::LinkOccupied {
path: link.display().to_string(),
})
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(MindError::io(link, e)),
}
}
pub(crate) fn ensure_link(store: &Path, link: &Path) -> Result<()> {
if let Some(parent) = link.parent() {
mkdir_p(parent)?;
}
remove_path(link)?;
symlink(store, link)
}
fn expand_references(
root: &Path,
item: &CatalogItem,
siblings: &[CatalogItem],
store_root: &Path,
) -> Result<()> {
let names: std::collections::HashSet<String> =
siblings.iter().map(|s| s.name.clone()).collect();
let agent_names: std::collections::HashSet<String> = siblings
.iter()
.filter(|s| s.kind == crate::error::ItemKind::Agent)
.map(|s| s.name.clone())
.collect();
let non_agent_names: std::collections::HashSet<String> = siblings
.iter()
.filter(|s| s.kind != crate::error::ItemKind::Agent)
.map(|s| s.name.clone())
.collect();
let bare_names: std::collections::HashSet<String> =
agent_names.difference(&non_agent_names).cloned().collect();
let path_siblings: Vec<namespace::PathSibling> =
siblings.iter().map(CatalogItem::as_path_sibling).collect();
let home = dirs::home_dir();
let ctx = namespace::PathCtx {
store_root,
home: home.as_deref(),
prefix: &item.prefix,
self_kind: item.kind,
self_name: &item.name,
siblings: &path_siblings,
};
let ctx_abs = namespace::PathCtx { home: None, ..ctx };
let bad_ref = |referent: String, reason: crate::error::BadRefReason| MindError::BadReference {
item: item.key().display(),
referent,
reason,
in_source: crate::sanitize::strip_ansi(&item.source),
};
let bad_requires = |entry: &str, reason| {
let entry = crate::sanitize::strip_ansi(entry);
bad_ref(format!("requires: {entry}"), reason)
};
for entry in &item.requires {
use crate::error::BadRefReason::{AmbiguousKind, CrossSource, InvalidRef};
let r =
crate::resolve::parse_item_ref(entry).map_err(|_| bad_requires(entry, InvalidRef))?;
if r.source.is_some() {
return Err(bad_requires(entry, CrossSource));
}
let matches: Vec<&CatalogItem> = siblings
.iter()
.filter(|s| s.name == r.name && r.kind.is_none_or(|k| s.kind == k))
.collect();
if matches.is_empty() {
return Err(bad_requires(entry, NoMatch));
}
if matches.len() > 1 && r.kind.is_none() {
return Err(bad_requires(entry, AmbiguousKind));
}
}
let bad_expand = |entry: &str, reason| {
let entry = crate::sanitize::strip_ansi(entry);
bad_ref(format!("expand: {entry}"), reason)
};
let mut expand_set: std::collections::HashSet<std::path::PathBuf> =
std::collections::HashSet::new();
for entry in &item.expand {
use crate::error::BadRefReason::InvalidRef;
let rel = Path::new(entry);
let unsafe_path = rel.components().any(|c| {
matches!(
c,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
});
if unsafe_path {
return Err(bad_expand(entry, InvalidRef));
}
if !(root.is_dir() && root.join(rel).is_file()) {
return Err(bad_expand(entry, NoMatch));
}
expand_set.insert(rel.to_path_buf());
}
let mut files = Vec::new();
if root.is_dir() {
collect_files(root, &mut files)?;
} else {
files.push(root.to_path_buf());
}
for file in files {
let source_like: &Path = if root.is_dir() { &file } else { &item.path };
let is_md = namespace::is_markdown(source_like);
let is_listed = root.is_dir()
&& file
.strip_prefix(root)
.map(|rel| expand_set.contains(rel))
.unwrap_or(false);
if !is_md && !is_listed {
continue;
}
let Ok(content) = std::fs::read_to_string(&file) else {
continue;
};
if !content.contains("{{") {
continue;
}
let path_ctx = if is_listed && !is_md { &ctx_abs } else { &ctx };
let expanded =
namespace::expand(&content, &item.prefix, &names, &bare_names).map_err(|name| {
let name = crate::sanitize::strip_ansi(&name);
bad_ref(format!("{{{{ns:{name}}}}}"), NoMatch)
})?;
let expanded =
namespace::expand_paths(&expanded, path_ctx).map_err(|(referent, reason)| {
bad_ref(crate::sanitize::strip_ansi(&referent), reason)
})?;
std::fs::write(&file, expanded).map_err(|e| MindError::io(&file, e))?;
}
Ok(())
}
fn run_build_hook(
item: &CatalogItem,
build: &str,
staging: &Path,
commit: &str,
dangerously_skip: bool,
) -> Result<()> {
let run = if dangerously_skip {
true
} else if !crate::hook::is_tty() {
crate::render::note(format!(
"note: skipped build hook for {} in a non-interactive context; its tooling is not built",
item.display_key()
));
false
} else {
let disclosure = crate::hook::disclosure_text(
&item.source,
"(per-item build)",
commit,
&staging.to_string_lossy(),
build,
None,
None, );
matches!(
crate::hook::prompt_choice_optional(&disclosure)?,
crate::hook::OptionalChoice::Run
)
};
if run {
println!("running build hook for {}", item.display_key());
crate::hook::run_hook(build, staging, &item.source, "build", "build")?;
} else if crate::hook::is_tty() && !dangerously_skip {
crate::render::note(format!(
"note: skipped build hook for {}; its tooling is not built",
item.display_key()
));
}
Ok(())
}
fn hook_cwd(store: &Path) -> std::path::PathBuf {
if store.is_dir() {
store.to_path_buf()
} else {
store
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| store.to_path_buf())
}
}
fn run_item_hook(
event: &'static str,
display_key: &str,
source: &str,
cmd: &str,
store: &Path,
commit: &str,
dangerously_skip: bool,
) -> Result<bool> {
let effect = if event == "install" {
"its side effect is not applied"
} else {
"its cleanup is not run"
};
let cwd = hook_cwd(store);
let run = if dangerously_skip {
true
} else if !crate::hook::is_tty() {
crate::render::note(format!(
"note: skipped {event} hook for {display_key} in a non-interactive context; {effect}"
));
false
} else {
let disclosure = crate::hook::disclosure_text(
source,
&format!("(per-item {event})"),
commit,
&cwd.to_string_lossy(),
cmd,
None,
None, );
matches!(
crate::hook::prompt_choice_optional(&disclosure)?,
crate::hook::OptionalChoice::Run
)
};
if run {
println!("running {event} hook for {display_key}");
crate::hook::run_hook(cmd, &cwd, source, event, event)?;
} else if crate::hook::is_tty() && !dangerously_skip {
crate::render::note(format!(
"note: skipped {event} hook for {display_key}; {effect}"
));
}
Ok(run)
}
pub fn run_item_install_hooks(
item: &CatalogItem,
hooks: &[&crate::mindfile::ResolvedHook],
store: &Path,
commit: &str,
dangerously_skip: bool,
) -> Result<Vec<crate::source::RecordedHook>> {
let mut recorded = Vec::with_capacity(hooks.len());
run_item_install_hooks_recording(item, hooks, store, commit, dangerously_skip, &mut recorded)?;
Ok(recorded)
}
pub fn run_item_install_hooks_partial(
item: &CatalogItem,
hooks: &[&crate::mindfile::ResolvedHook],
store: &Path,
commit: &str,
dangerously_skip: bool,
) -> (Vec<crate::source::RecordedHook>, Result<()>) {
let mut recorded = Vec::with_capacity(hooks.len());
let result = run_item_install_hooks_recording(
item,
hooks,
store,
commit,
dangerously_skip,
&mut recorded,
);
(recorded, result)
}
fn run_item_install_hooks_recording(
item: &CatalogItem,
hooks: &[&crate::mindfile::ResolvedHook],
store: &Path,
commit: &str,
dangerously_skip: bool,
recorded: &mut Vec<crate::source::RecordedHook>,
) -> Result<()> {
for hook in hooks {
let ran = run_item_hook(
"install",
&item.display_key(),
&item.source,
&hook.run,
store,
commit,
dangerously_skip,
)?;
recorded.push(crate::source::RecordedHook {
command: hook.run.clone(),
ran_at: if ran { Some(commit.to_string()) } else { None },
});
}
Ok(())
}
pub fn run_item_uninstall_hooks(
item: &InstalledItem,
hooks: &[&crate::mindfile::ResolvedHook],
store: &Path,
commit: &str,
dangerously_skip: bool,
) -> Result<()> {
for hook in hooks {
run_item_hook(
"uninstall",
&item.display_key(),
&item.source,
&hook.run,
store,
commit,
dangerously_skip,
)?;
}
Ok(())
}
pub(crate) const MAX_ITEM_TREE_DEPTH: usize = 128;
pub(crate) fn depth_exceeded(path: &Path) -> MindError {
MindError::io(
path,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("item tree exceeds {MAX_ITEM_TREE_DEPTH} nested directories"),
),
)
}
pub(crate) fn collect_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) -> Result<()> {
collect_files_at(dir, out, 0)
}
fn collect_files_at(dir: &Path, out: &mut Vec<std::path::PathBuf>, depth: usize) -> Result<()> {
if depth > MAX_ITEM_TREE_DEPTH {
return Err(depth_exceeded(dir));
}
let rd = std::fs::read_dir(dir).map_err(|e| MindError::io(dir, e))?;
for entry in rd {
let entry = entry.map_err(|e| MindError::io(dir, e))?;
let path = entry.path();
if path.is_dir() {
collect_files_at(&path, out, depth + 1)?;
} else {
out.push(path);
}
}
Ok(())
}
fn rename(from: &Path, to: &Path) -> Result<()> {
std::fs::rename(from, to).map_err(|e| MindError::io(to, e))
}
pub(crate) fn remove_path(path: &Path) -> Result<()> {
let meta = match std::fs::symlink_metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(MindError::io(path, e)),
};
let res = if meta.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
};
res.map_err(|e| MindError::io(path, e))
}
pub(crate) fn copy_recursive(src: &Path, dst: &Path) -> Result<()> {
copy_recursive_at(src, dst, 0, src, &crate::ignore::IgnoreSet::default())
}
pub(crate) fn copy_recursive_ignoring(
src: &Path,
dst: &Path,
ignore: &crate::ignore::IgnoreSet,
) -> Result<()> {
copy_recursive_at(src, dst, 0, src, ignore)
}
fn copy_recursive_at(
src: &Path,
dst: &Path,
depth: usize,
root: &Path,
ignore: &crate::ignore::IgnoreSet,
) -> Result<()> {
if depth > MAX_ITEM_TREE_DEPTH {
return Err(depth_exceeded(src));
}
let meta = std::fs::symlink_metadata(src).map_err(|e| MindError::io(src, e))?;
if meta.file_type().is_symlink() {
return Err(MindError::io(
src,
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"source item trees must not contain symlinks",
),
));
}
if meta.is_dir() {
mkdir_p(dst)?;
let rd = std::fs::read_dir(src).map_err(|e| MindError::io(src, e))?;
for entry in rd {
let entry = entry.map_err(|e| MindError::io(src, e))?;
let from = entry.path();
let rel = from.strip_prefix(root).unwrap_or(&from);
let from_meta =
std::fs::symlink_metadata(&from).map_err(|e| MindError::io(&from, e))?;
let is_dir = from_meta.is_dir();
if ignore.is_ignored(rel, is_dir) {
continue;
}
let to = dst.join(entry.file_name());
copy_recursive_at(&from, &to, depth + 1, root, ignore)?;
}
} else {
if let Some(parent) = dst.parent() {
mkdir_p(parent)?;
}
std::fs::copy(src, dst).map_err(|e| MindError::io(dst, e))?;
}
Ok(())
}
#[cfg(unix)]
fn symlink(target: &Path, link: &Path) -> Result<()> {
std::os::unix::fs::symlink(target, link).map_err(|e| MindError::io(link, e))
}
#[cfg(not(unix))]
fn symlink(target: &Path, link: &Path) -> Result<()> {
copy_recursive(target, link)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ItemKind;
use crate::hash::hash_path_ignoring;
use crate::ignore::IgnoreSet;
use crate::paths::Lobe;
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
#[test]
fn lobe_reachable_gate_covers_planned_links_and_relink() {
let n = N.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-reach-gate-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let existing_parent = base.join("existing-project");
std::fs::create_dir_all(&existing_parent).unwrap();
let reachable_lobe = Lobe {
path: existing_parent.join(".windsurf"),
kinds: Some(vec![ItemKind::Skill]),
};
assert!(
reachable_lobe.reachable(),
"lobe is reachable when parent exists"
);
let gone_parent = base.join("vanished-project");
let unreachable_lobe = Lobe {
path: gone_parent.join(".windsurf"),
kinds: Some(vec![ItemKind::Skill]),
};
assert!(
!unreachable_lobe.reachable(),
"lobe is unreachable when parent is missing (STO-56)"
);
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(unix)]
#[test]
fn relink_skips_link_in_unreachable_lobe() {
let n = N.fetch_add(1, Ordering::SeqCst);
let base =
std::env::temp_dir().join(format!("mind-reach-relink-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let mind_home = base.join("mind");
let claude_home = base.join("claude");
std::fs::create_dir_all(&mind_home).unwrap();
std::fs::create_dir_all(&claude_home).unwrap();
let store_rel = "store/skill/myskill";
let store_path = mind_home.join(store_rel);
std::fs::create_dir_all(&store_path).unwrap();
std::fs::write(store_path.join("SKILL.md"), b"# My Skill\n").unwrap();
let vanished_project = base.join("vanished");
let project_lobe = vanished_project.join(".windsurf");
let project_link = project_lobe.join("skills").join("myskill");
let cfg = format!(
"lobes = [\"{claude}\", \"{project}\"]\n",
claude = claude_home.display(),
project = project_lobe.display(),
);
std::fs::write(mind_home.join("config.toml"), cfg.as_bytes()).unwrap();
let paths = crate::paths::Paths {
mind_home: mind_home.clone(),
claude_home: claude_home.clone(),
};
let claude_link = claude_home.join("skills").join("myskill");
let item = InstalledItem {
kind: ItemKind::Skill,
name: "myskill".to_string(),
bare_name: "myskill".to_string(),
source: "local/test".to_string(),
commit: "abc".to_string(),
hash: "deadbeef".to_string(),
store: store_rel.to_string(),
links: vec![
claude_link.to_string_lossy().into_owned(),
project_link.to_string_lossy().into_owned(),
],
description: None,
install_hooks: Vec::new(),
dropped_requires: Vec::new(),
};
let fixed = relink(&paths, &item).unwrap();
assert_eq!(
fixed, 1,
"relink must repair exactly 1 link (claude_home); project lobe must be skipped (STO-56)"
);
assert!(
claude_link.exists() || std::fs::symlink_metadata(&claude_link).is_ok(),
"claude_home link must have been created by relink"
);
assert!(
!project_link.exists(),
"project lobe link must not be created for an unreachable lobe (STO-56)"
);
let _ = std::fs::remove_dir_all(&base);
}
fn tool_item(build: &str, path: std::path::PathBuf) -> CatalogItem {
CatalogItem {
kind: ItemKind::Tool,
name: "t".to_string(),
source: "local/test/repo".to_string(),
prefix: None,
path,
description: None,
link_rel: None,
bin: None,
build: Some(build.to_string()),
install: None,
uninstall: None,
requires: Vec::new(),
expand: Vec::new(),
hooks: Vec::new(),
ignore: None,
}
}
#[test]
fn build_hook_is_skipped_without_a_tty() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-build-skip-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
let marker = staging.join("built");
let item = tool_item(
&format!("touch {}", marker.display()),
std::path::PathBuf::from("/src/tools/t"),
);
run_build_hook(
&item,
item.build.as_deref().unwrap(),
&staging,
"abc123",
false,
)
.unwrap();
assert!(
!marker.exists(),
"a non-TTY context must skip the build hook (HOOK-72)"
);
let _ = std::fs::remove_dir_all(&staging);
}
fn skill_item_at(name: &str, path: std::path::PathBuf, requires: Vec<String>) -> CatalogItem {
CatalogItem {
kind: ItemKind::Skill,
name: name.to_string(),
source: "local/test/repo".to_string(),
prefix: None,
path,
description: None,
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
requires,
expand: Vec::new(),
hooks: Vec::new(),
ignore: None,
}
}
fn agent_item_at(name: &str, path: std::path::PathBuf) -> CatalogItem {
CatalogItem {
kind: ItemKind::Agent,
name: name.to_string(),
source: "local/test/repo".to_string(),
prefix: None,
path,
description: None,
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
requires: Vec::new(),
expand: Vec::new(),
hooks: Vec::new(),
ignore: None,
}
}
#[test]
fn requires_valid_entry_passes_validation() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging = std::env::temp_dir().join(format!("mind-req-ok-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hello\n").unwrap();
let item = skill_item_at(
"review",
std::path::PathBuf::from("/src/skills/review"),
vec!["agent:test".to_string()],
);
let test_agent = agent_item_at("test", std::path::PathBuf::from("/src/agents/test.md"));
let siblings = vec![item.clone(), test_agent];
let result = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"));
assert!(
result.is_ok(),
"valid requires entry must not error: {result:?}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn requires_typo_entry_is_bad_reference() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-req-typo-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hello\n").unwrap();
let item = skill_item_at(
"review",
std::path::PathBuf::from("/src/skills/review"),
vec!["agent:nonexistent".to_string()],
);
let siblings = vec![item.clone()];
let err = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"))
.unwrap_err();
assert!(
matches!(err, crate::error::MindError::BadReference { ref referent, .. }
if referent.contains("agent:nonexistent")),
"typo requires entry must be BadReference: {err}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn requires_source_qualified_entry_is_bad_reference() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-req-cross-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hello\n").unwrap();
let item = skill_item_at(
"review",
std::path::PathBuf::from("/src/skills/review"),
vec!["owner/repo#agent:test".to_string()],
);
let test_agent = agent_item_at("test", std::path::PathBuf::from("/src/agents/test.md"));
let siblings = vec![item.clone(), test_agent];
let err = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"))
.unwrap_err();
assert!(
matches!(err, crate::error::MindError::BadReference { .. }),
"source-qualified requires entry must be BadReference: {err}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn requires_ambiguous_bare_name_is_bad_reference() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-req-ambig-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hello\n").unwrap();
let item = skill_item_at(
"review",
std::path::PathBuf::from("/src/skills/review"),
vec!["shared".to_string()], );
let agent = CatalogItem {
kind: ItemKind::Agent,
name: "shared".to_string(),
source: "local/test/repo".to_string(),
prefix: None,
path: std::path::PathBuf::from("/src/agents/shared.md"),
description: None,
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
requires: Vec::new(),
expand: Vec::new(),
hooks: Vec::new(),
ignore: None,
};
let rule = CatalogItem {
kind: ItemKind::Rule,
name: "shared".to_string(),
source: "local/test/repo".to_string(),
prefix: None,
path: std::path::PathBuf::from("/src/rules/shared.md"),
description: None,
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
requires: Vec::new(),
expand: Vec::new(),
hooks: Vec::new(),
ignore: None,
};
let siblings = vec![item.clone(), agent, rule];
let err = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"))
.unwrap_err();
assert!(
matches!(err, crate::error::MindError::BadReference { ref referent, .. }
if referent.contains("shared")),
"ambiguous bare-name requires entry must be BadReference: {err}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn requires_kind_qualified_among_two_same_name_siblings_is_accepted() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-req-kindok-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hello\n").unwrap();
let item = skill_item_at(
"review",
std::path::PathBuf::from("/src/skills/review"),
vec!["agent:shared".to_string()],
);
let agent = CatalogItem {
kind: ItemKind::Agent,
name: "shared".to_string(),
source: "local/test/repo".to_string(),
prefix: None,
path: std::path::PathBuf::from("/src/agents/shared.md"),
description: None,
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
requires: Vec::new(),
expand: Vec::new(),
hooks: Vec::new(),
ignore: None,
};
let rule = CatalogItem {
kind: ItemKind::Rule,
name: "shared".to_string(),
source: "local/test/repo".to_string(),
prefix: None,
path: std::path::PathBuf::from("/src/rules/shared.md"),
description: None,
link_rel: None,
bin: None,
build: None,
install: None,
uninstall: None,
requires: Vec::new(),
expand: Vec::new(),
hooks: Vec::new(),
ignore: None,
};
let siblings = vec![item.clone(), agent, rule];
let result = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"));
assert!(
result.is_ok(),
"a kind-qualified ref that uniquely matches one of two same-name \
siblings must pass validation, not be flagged ambiguous: {result:?}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn requires_self_reference_resolves_and_is_accepted() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-req-self-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hello\n").unwrap();
let item = skill_item_at(
"solo",
std::path::PathBuf::from("/src/skills/solo"),
vec!["skill:solo".to_string()],
);
let siblings = vec![item.clone()];
let result = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"));
assert!(
result.is_ok(),
"a self-requires must resolve to the item itself and not error: {result:?}"
);
let _ = std::fs::remove_dir_all(&staging);
}
fn skill_item_expand(name: &str, path: std::path::PathBuf, expand: Vec<String>) -> CatalogItem {
let mut item = skill_item_at(name, path, Vec::new());
item.expand = expand;
item
}
#[test]
fn expand_listed_non_markdown_file_expands_with_absolute_path() {
let home = dirs::home_dir().expect("home dir for the tilde-vs-absolute check");
let store_root = home.join("mind-expand-store");
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-expand-abs-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(staging.join("resources")).unwrap();
std::fs::write(staging.join("SKILL.md"), "see {{self}}/x\n").unwrap();
std::fs::write(staging.join("resources/pr.py"), "p = \"{{self}}/x\"\n").unwrap();
let item = skill_item_expand(
"review",
std::path::PathBuf::from("/src/skills/review"),
vec!["resources/pr.py".to_string()],
);
let siblings = vec![item.clone()];
expand_references(&staging, &item, &siblings, &store_root).unwrap();
let md = std::fs::read_to_string(staging.join("SKILL.md")).unwrap();
let py = std::fs::read_to_string(staging.join("resources/pr.py")).unwrap();
assert!(
md.contains("~/mind-expand-store/skill/review/x"),
"a markdown file keeps the ~ form (TOOL-16): {md:?}"
);
let abs = store_root.join("skill/review/x");
assert!(
py.contains(&abs.to_string_lossy().into_owned()),
"an expand-listed file renders the absolute store path (TOOL-20): {py:?}"
);
assert!(
!py.contains("~/mind-expand-store"),
"an expand-listed file must not carry the ~ form: {py:?}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn unlisted_non_markdown_file_is_left_literal() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-expand-literal-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(staging.join("resources")).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hi\n").unwrap();
std::fs::write(staging.join("resources/other.py"), "p = \"{{self}}/x\"\n").unwrap();
let item = skill_item_expand(
"review",
std::path::PathBuf::from("/src/skills/review"),
Vec::new(),
);
let siblings = vec![item.clone()];
expand_references(&staging, &item, &siblings, std::path::Path::new("/store")).unwrap();
let py = std::fs::read_to_string(staging.join("resources/other.py")).unwrap();
assert_eq!(
py, "p = \"{{self}}/x\"\n",
"an unlisted non-markdown file must be left literal (NS-53)"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn expand_entry_naming_a_missing_file_is_bad_reference() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-expand-miss-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hi\n").unwrap();
let item = skill_item_expand(
"review",
std::path::PathBuf::from("/src/skills/review"),
vec!["resources/nope.py".to_string()],
);
let siblings = vec![item.clone()];
let err = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"))
.unwrap_err();
assert!(
matches!(err, crate::error::MindError::BadReference { .. }),
"a missing expand target must be BadReference: {err}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn expand_entry_escaping_the_item_is_bad_reference() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-expand-esc-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hi\n").unwrap();
let item = skill_item_expand(
"review",
std::path::PathBuf::from("/src/skills/review"),
vec!["../escape.py".to_string()],
);
let siblings = vec![item.clone()];
let err = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"))
.unwrap_err();
assert!(
matches!(err, crate::error::MindError::BadReference { .. }),
"an escaping expand entry must be BadReference: {err}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn unresolved_token_in_listed_file_is_a_hard_failure() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-expand-bad-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(staging.join("resources")).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hi\n").unwrap();
std::fs::write(staging.join("resources/pr.py"), "p = \"{{tools:nope}}\"\n").unwrap();
let item = skill_item_expand(
"review",
std::path::PathBuf::from("/src/skills/review"),
vec!["resources/pr.py".to_string()],
);
let siblings = vec![item.clone()];
let err = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"))
.unwrap_err();
assert!(
matches!(err, crate::error::MindError::BadReference { .. }),
"an unresolved token in a listed file must be a hard BadReference: {err}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn hostile_ns_token_referent_is_sanitized_in_bad_reference() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-ns-hostile-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
let payload = "\x1b]52;c;UEFZTE9BRA==\x07evil";
std::fs::write(
staging.join("SKILL.md"),
format!("# hello\n{{{{ns:{payload}}}}}\n"),
)
.unwrap();
let item = skill_item_at(
"review",
std::path::PathBuf::from("/src/skills/review"),
Vec::new(),
);
let siblings = vec![item.clone()];
let err = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"))
.unwrap_err();
let crate::error::MindError::BadReference { referent, .. } = &err else {
panic!("expected a BadReference, got {err:?}");
};
assert!(
!referent.contains('\x1b') && !referent.contains('\u{07}'),
"the referent must not carry the raw ESC/BEL bytes of the OSC \
payload: {referent:?}"
);
assert!(
referent.contains("evil"),
"the referent must still name enough of the token to be \
actionable: {referent:?}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn hostile_requires_entry_referent_is_sanitized_in_bad_reference() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-requires-hostile-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hello\n").unwrap();
let hostile_entry = "agent:nonexistent\x1b[31mRED\x1b[0m";
let item = skill_item_at(
"review",
std::path::PathBuf::from("/src/skills/review"),
vec![hostile_entry.to_string()],
);
let siblings = vec![item.clone()];
let err = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"))
.unwrap_err();
let crate::error::MindError::BadReference { referent, .. } = &err else {
panic!("expected a BadReference, got {err:?}");
};
assert!(
!referent.contains('\x1b'),
"a hostile requires entry must not leak a raw ESC byte into the \
referent: {referent:?}"
);
assert!(
referent.contains("agent:nonexistent") && referent.contains("RED"),
"the referent must still name the entry: {referent:?}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn hostile_expand_entry_referent_is_sanitized_in_bad_reference() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-expand-hostile-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hi\n").unwrap();
let hostile_entry = "resources/missing\x1b[31mRED\x1b[0m.py";
let item = skill_item_expand(
"review",
std::path::PathBuf::from("/src/skills/review"),
vec![hostile_entry.to_string()],
);
let siblings = vec![item.clone()];
let err = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"))
.unwrap_err();
let crate::error::MindError::BadReference { referent, .. } = &err else {
panic!("expected a BadReference, got {err:?}");
};
assert!(
!referent.contains('\x1b'),
"a hostile expand entry must not leak a raw ESC byte into the \
referent: {referent:?}"
);
assert!(
referent.contains("resources/missing") && referent.contains("RED"),
"the referent must still name the entry: {referent:?}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[test]
fn hostile_source_name_is_sanitized_as_in_source_in_bad_reference() {
let n = N.fetch_add(1, Ordering::SeqCst);
let staging =
std::env::temp_dir().join(format!("mind-in-source-hostile-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).unwrap();
std::fs::write(staging.join("SKILL.md"), "# hello\n").unwrap();
let mut item = skill_item_at(
"review",
std::path::PathBuf::from("/src/skills/review"),
vec!["agent:nonexistent".to_string()],
);
item.source = "local\x1b[31m/test/repo\x1b[0m".to_string();
let siblings = vec![item.clone()];
let err = expand_references(&staging, &item, &siblings, std::path::Path::new("/store"))
.unwrap_err();
let crate::error::MindError::BadReference { in_source, .. } = &err else {
panic!("expected a BadReference, got {err:?}");
};
assert!(
!in_source.contains('\x1b'),
"in_source must not leak a raw ESC byte from the source name: \
{in_source:?}"
);
assert!(
in_source.contains("local") && in_source.contains("test/repo"),
"in_source must still name the source: {in_source:?}"
);
let _ = std::fs::remove_dir_all(&staging);
}
#[cfg(unix)]
#[test]
fn maybe_stash_foreign_moves_foreign_file_and_restores_correctly() {
let n = N.fetch_add(1, Ordering::SeqCst);
let base =
std::env::temp_dir().join(format!("mind-stash-helper-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let store_root = base.join("store");
let link = base.join("link-target.md");
let stash0 = base.join("stash").join("0");
let got = maybe_stash_foreign(&store_root, &link, &stash0).unwrap();
assert!(!got, "absent link must not be stashed (LIFE-43)");
assert!(
!stash0.exists(),
"no stash must be created for an absent link"
);
let foreign_content = b"original foreign content";
std::fs::write(&link, foreign_content).unwrap();
let got = maybe_stash_foreign(&store_root, &link, &stash0).unwrap();
assert!(got, "foreign file must be stashed (LIFE-43)");
assert!(!link.exists(), "original path must be vacated after stash");
assert!(stash0.exists(), "stash file must exist");
assert_eq!(
std::fs::read(&stash0).unwrap(),
foreign_content,
"stash must preserve original content"
);
rename(&stash0, &link).unwrap();
assert!(link.exists(), "restored path must exist");
assert_eq!(
std::fs::read(&link).unwrap(),
foreign_content,
"restored file must have original content (LIFE-43)"
);
std::fs::create_dir_all(&store_root).unwrap();
let store_file = store_root.join("agent").join("myagent");
std::fs::create_dir_all(store_file.parent().unwrap()).unwrap();
std::fs::write(&store_file, b"mind managed").unwrap();
std::fs::remove_file(&link).unwrap();
std::os::unix::fs::symlink(&store_file, &link).unwrap();
let stash1 = base.join("stash").join("1");
let got = maybe_stash_foreign(&store_root, &link, &stash1).unwrap();
assert!(!got, "mind's own symlink must not be stashed (LIFE-43)");
assert!(!stash1.exists(), "no stash for mind's own symlink");
assert!(std::fs::symlink_metadata(&link).is_ok());
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(unix)]
#[test]
fn force_install_rollback_restores_stashed_foreign_target() {
let n = N.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-life43-e2e-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let mind_home = base.join("mind");
let lobe1 = base.join("lobe1");
let blocker = base.join("not-a-dir");
std::fs::create_dir_all(&mind_home).unwrap();
std::fs::create_dir_all(&lobe1).unwrap();
std::fs::write(&blocker, b"i-am-a-regular-file").unwrap();
let lobe2 = blocker.join("lobe2");
let cfg = format!(
"lobes = [\"{}\", \"{}\"]\n",
lobe1.to_str().unwrap(),
lobe2.to_str().unwrap(),
);
std::fs::write(mind_home.join("config.toml"), cfg.as_bytes()).unwrap();
let paths = Paths {
mind_home: mind_home.clone(),
claude_home: lobe1.clone(),
};
let agents_dir = lobe1.join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
let foreign_path = agents_dir.join("myagent.md");
let foreign_content = b"foreign content - must survive the rollback";
std::fs::write(&foreign_path, foreign_content).unwrap();
let src_file = base.join("myagent.md");
std::fs::write(&src_file, b"# My Agent\n").unwrap();
let item = CatalogItem {
kind: ItemKind::Agent,
name: "myagent".to_string(),
source: "local/test".to_string(),
prefix: None,
path: src_file,
description: None,
link_rel: None, bin: None,
build: None,
install: None,
uninstall: None,
requires: Vec::new(),
expand: Vec::new(),
hooks: Vec::new(),
ignore: None,
};
let result = install(
&paths,
&item,
"abc",
std::slice::from_ref(&item),
true,
false,
);
assert!(
result.is_err(),
"install must fail when a later link cannot be created: {result:?}"
);
let meta = std::fs::symlink_metadata(&foreign_path)
.expect("foreign file must exist at original path after rollback (LIFE-43)");
assert!(
!meta.file_type().is_symlink(),
"restored path must be a regular file, not a symlink (LIFE-43)"
);
assert_eq!(
std::fs::read(&foreign_path).unwrap(),
foreign_content,
"restored file must have original content (LIFE-43)"
);
let store_path = mind_home.join("store").join("agent").join("myagent");
assert!(
!store_path.exists(),
"store copy must be absent after rollback: {store_path:?}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(unix)]
#[test]
fn canonicalize_link_parent_resolves_the_parent_not_the_symlink_target() {
let n = N.fetch_add(1, Ordering::SeqCst);
let base = std::env::temp_dir().join(format!("mind-life44-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let lobe = base.join("lobe");
let store = base.join("mind/store/skill/x");
std::fs::create_dir_all(&lobe).unwrap();
std::fs::create_dir_all(&store).unwrap();
std::fs::write(store.join("SKILL.md"), b"x").unwrap();
let link = lobe.join("x");
std::os::unix::fs::symlink(&store, &link).unwrap();
let canon_lobe = lobe.canonicalize().unwrap();
let canon = canonicalize_link_parent(&link);
assert!(
canon.starts_with(&canon_lobe),
"canonicalize_link_parent must stay under the lobe (the link's \
parent), not follow the symlink into the store: {canon:?}"
);
assert_eq!(canon.file_name(), Some(std::ffi::OsStr::new("x")));
let old_buggy_approach = link.canonicalize().unwrap();
assert!(
!old_buggy_approach.starts_with(&canon_lobe),
"sanity: canonicalizing straight through the symlink must NOT stay \
under the lobe -- this is the LIFE-44 bug the fix avoids: {old_buggy_approach:?}"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn is_confined_under_accepts_child_paths() {
let root = std::path::Path::new("/home/user/.mind/store");
assert!(
is_confined_under(
std::path::Path::new("/home/user/.mind/store/skill/review"),
root
),
"a child of the root must be confined"
);
assert!(
is_confined_under(root, root),
"the root itself must be accepted"
);
}
#[test]
fn is_confined_under_rejects_parent_dir_components() {
let root = std::path::Path::new("/home/user/.mind/store");
assert!(
!is_confined_under(
std::path::Path::new("/home/user/.mind/store/skill/../../../etc/passwd"),
root
),
"path with .. must be rejected even if it starts with the root"
);
assert!(
!is_confined_under(std::path::Path::new("../outside"), root),
"relative path with leading .. must be rejected"
);
}
#[test]
fn is_confined_under_rejects_sibling_paths() {
let root = std::path::Path::new("/home/user/.mind/store");
assert!(
!is_confined_under(std::path::Path::new("/home/user/.claude/skills/x"), root),
"a path outside the root must be rejected"
);
assert!(
!is_confined_under(std::path::Path::new("/home/user/.mind"), root),
"a parent of the root must be rejected"
);
}
#[cfg(unix)]
#[test]
fn copy_recursive_rejects_symlink_in_source_tree() {
let n = N.fetch_add(1, Ordering::SeqCst);
let src = std::env::temp_dir().join(format!("mind-cplink-src-{}-{n}", std::process::id()));
let dst = std::env::temp_dir().join(format!("mind-cplink-dst-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst);
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("ok.txt"), b"normal").unwrap();
std::os::unix::fs::symlink("/etc/passwd", src.join("evil")).unwrap();
let result = copy_recursive(&src, &dst);
assert!(
result.is_err(),
"copy_recursive must reject a source tree containing a symlink"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("symlink"),
"error message must mention 'symlink': {msg}"
);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst);
}
#[test]
fn copy_recursive_depth_caps_a_pathological_tree() {
let n = N.fetch_add(1, Ordering::SeqCst);
let src = std::env::temp_dir().join(format!("mind-cpdeep-src-{}-{n}", std::process::id()));
let dst = std::env::temp_dir().join(format!("mind-cpdeep-dst-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst);
let mut deep = src.clone();
for _ in 0..(MAX_ITEM_TREE_DEPTH + 2) {
deep.push("d");
}
std::fs::create_dir_all(&deep).unwrap();
let result = copy_recursive(&src, &dst);
assert!(
result.is_err(),
"copy_recursive must refuse a tree deeper than the cap"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("nested directories"),
"error message must name the depth cap: {msg}"
);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst);
}
#[cfg(unix)]
#[test]
fn symlink_to_dir_ignored_by_no_slash_pattern_is_skipped_by_copy_and_hash() {
let n = N.fetch_add(1, Ordering::SeqCst);
let src = std::env::temp_dir().join(format!(
"mind-symdir-noslash-src-{}-{n}",
std::process::id()
));
let dst = std::env::temp_dir().join(format!(
"mind-symdir-noslash-dst-{}-{n}",
std::process::id()
));
let target = std::env::temp_dir().join(format!(
"mind-symdir-noslash-tgt-{}-{n}",
std::process::id()
));
let reference = std::env::temp_dir().join(format!(
"mind-symdir-noslash-ref-{}-{n}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst);
let _ = std::fs::remove_dir_all(&target);
let _ = std::fs::remove_dir_all(&reference);
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("keep.txt"), b"kept").unwrap();
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join("inner.txt"), b"outside the item").unwrap();
std::os::unix::fs::symlink(&target, src.join("scratch")).unwrap();
std::fs::create_dir_all(&reference).unwrap();
std::fs::write(reference.join("keep.txt"), b"kept").unwrap();
let ignore = IgnoreSet::new(&["scratch".to_string()], "skill:t").unwrap();
copy_recursive_ignoring(&src, &dst, &ignore)
.expect("a no-slash pattern excludes the symlink before LIFE-42's check ever sees it");
assert!(
std::fs::symlink_metadata(dst.join("scratch")).is_err(),
"the ignored symlink must not be copied"
);
assert!(dst.join("keep.txt").is_file());
let hash_with_scratch =
hash_path_ignoring(&src, &ignore).expect("hash walk must also skip the symlink");
let hash_without_scratch = hash_path_ignoring(&reference, &IgnoreSet::default())
.expect("reference tree hashes cleanly");
assert_eq!(
hash_with_scratch, hash_without_scratch,
"an ignored symlink must contribute nothing to the hash (IGN-10)"
);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst);
let _ = std::fs::remove_dir_all(&target);
let _ = std::fs::remove_dir_all(&reference);
}
#[cfg(unix)]
#[test]
fn symlink_to_dir_not_matched_by_dir_only_pattern_hard_fails_life42() {
let n = N.fetch_add(1, Ordering::SeqCst);
let src =
std::env::temp_dir().join(format!("mind-symdir-slash-src-{}-{n}", std::process::id()));
let dst =
std::env::temp_dir().join(format!("mind-symdir-slash-dst-{}-{n}", std::process::id()));
let target =
std::env::temp_dir().join(format!("mind-symdir-slash-tgt-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst);
let _ = std::fs::remove_dir_all(&target);
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("keep.txt"), b"kept").unwrap();
std::fs::create_dir_all(&target).unwrap();
std::os::unix::fs::symlink(&target, src.join("scratch")).unwrap();
let ignore = IgnoreSet::new(&["scratch/".to_string()], "skill:t").unwrap();
let result = copy_recursive_ignoring(&src, &dst, &ignore);
assert!(
result.is_err(),
"a directory-only rule must not exclude a symlink named `scratch`; the \
install must hard-fail on it via LIFE-42 rather than silently install it"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("symlink"),
"the failure must be the LIFE-42 symlink rejection: {msg}"
);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst);
let _ = std::fs::remove_dir_all(&target);
}
#[cfg(unix)]
#[test]
fn copy_and_hash_agree_on_file_set_for_mixed_tree() {
let n = N.fetch_add(1, Ordering::SeqCst);
let src = std::env::temp_dir().join(format!("mind-mixed-src-{}-{n}", std::process::id()));
let dst = std::env::temp_dir().join(format!("mind-mixed-dst-{}-{n}", std::process::id()));
let target =
std::env::temp_dir().join(format!("mind-mixed-tgt-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst);
let _ = std::fs::remove_dir_all(&target);
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("top.txt"), b"top level").unwrap();
std::fs::create_dir_all(src.join("sub")).unwrap();
std::fs::write(src.join("sub/nested.txt"), b"nested content").unwrap();
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join("outside.txt"), b"must never appear").unwrap();
std::os::unix::fs::symlink(&target, src.join("link")).unwrap();
let ignore = IgnoreSet::new(&["link".to_string()], "skill:t").unwrap();
copy_recursive_ignoring(&src, &dst, &ignore)
.expect("the symlink is ignored, so it never reaches the LIFE-42 check");
assert!(
std::fs::symlink_metadata(dst.join("link")).is_err(),
"the ignored symlink must not be copied"
);
assert!(dst.join("top.txt").is_file());
assert!(dst.join("sub").join("nested.txt").is_file());
let hash_src = hash_path_ignoring(&src, &ignore)
.expect("source hashes cleanly with the symlink excluded");
let hash_dst = hash_path_ignoring(&dst, &IgnoreSet::default())
.expect("copied output has no symlink left to trip over");
assert_eq!(
hash_src, hash_dst,
"the copy and the hash walk must agree on the resulting file set (IGN-10)"
);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst);
let _ = std::fs::remove_dir_all(&target);
}
}