use std::io::Read;
use std::path::{Path, PathBuf};
use crate::parse::{parse_skill, peek_frontmatter_name, skill_name_matches_directory};
use crate::skill::{SKILL_MD_MAX_BYTES, Skill};
use crate::skip::{SkillSkip, SkipKind};
use crate::source::SkillSource;
use super::DiscoveryOptions;
use super::path::{IgnorePrefix, path_is_ignored, stays_under};
#[cfg(test)]
thread_local! {
static READ_SKILL_MD_PATHS: std::cell::RefCell<Vec<PathBuf>> =
const { std::cell::RefCell::new(Vec::new()) };
}
#[cfg(test)]
pub(super) fn take_read_skill_md_paths() -> Vec<PathBuf> {
READ_SKILL_MD_PATHS.with(|c| std::mem::take(&mut *c.borrow_mut()))
}
pub(super) fn skip_if_dir_escapes(dir: &Path, confine: &Path, skips: &mut Vec<SkillSkip>) -> bool {
if dir.exists() && !stays_under(dir, confine) {
skips.push(SkillSkip {
path: dir.to_path_buf(),
name: None,
kind: SkipKind::Unreadable,
detail: "skills directory symlink escapes walk root".to_owned(),
winner_path: None,
host_token: None,
});
return true;
}
false
}
pub(super) struct DirLoad<'a> {
source: &'a SkillSource,
ignore: &'a [IgnorePrefix],
opts: &'a DiscoveryOptions,
denylist: &'a [&'a str],
}
pub(super) fn dir_load<'a>(
source: &'a SkillSource,
ignore: &'a [IgnorePrefix],
opts: &'a DiscoveryOptions,
denylist: &'a [&'a str],
) -> DirLoad<'a> {
DirLoad {
source,
ignore,
opts,
denylist,
}
}
fn dangling_symlink(path: &Path) -> bool {
let Ok(meta) = std::fs::symlink_metadata(path) else {
return false;
};
if !meta.file_type().is_symlink() {
return false;
}
match std::fs::metadata(path) {
Err(err) => err.kind() == std::io::ErrorKind::NotFound,
Ok(_) => false,
}
}
pub(super) fn dangling_symlink_detail(path: &Path) -> Option<String> {
if !dangling_symlink(path) {
return None;
}
Some(match std::fs::read_link(path) {
Ok(target) => format!("dangling symlink: {}", target.display()),
Err(_) => "dangling symlink".to_owned(),
})
}
fn symlink_loop_detail(path: &Path) -> Option<String> {
let Ok(meta) = std::fs::symlink_metadata(path) else {
return None;
};
if !meta.file_type().is_symlink() {
return None;
}
match std::fs::metadata(path) {
Err(err) if is_symlink_loop(&err) => Some(loop_detail(path)),
_ => None,
}
}
fn is_symlink_loop(err: &std::io::Error) -> bool {
format!("{:?}", err.kind()) == "FilesystemLoop"
}
fn loop_detail(path: &Path) -> String {
match std::fs::read_link(path) {
Ok(target) => format!("symbolic link loop: {}", target.display()),
Err(_) => "symbolic link loop".to_owned(),
}
}
pub(super) fn unresolved_symlink_detail(path: &Path) -> Option<String> {
dangling_symlink_detail(path).or_else(|| symlink_loop_detail(path))
}
pub(super) fn load_skills_from_dir(
dir: &Path,
load: &DirLoad<'_>,
skip: &[&Path],
skills: &mut Vec<Skill>,
skips: &mut Vec<SkillSkip>,
) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e)
if e.kind() == std::io::ErrorKind::NotFound
|| e.kind() == std::io::ErrorKind::NotADirectory =>
{
return;
}
Err(e) => {
skips.push(SkillSkip {
path: dir.to_path_buf(),
name: None,
kind: SkipKind::Unreadable,
detail: e.to_string(),
winner_path: None,
host_token: None,
});
return;
}
};
let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
entries.sort_by_key(|a| a.file_name());
for entry in entries {
let path = entry.path();
if skip.iter().any(|s| path == *s) {
continue;
}
if !path.is_dir() {
if !is_skill_md_filename(&path) && !path_is_ignored(&path, load.ignore) {
if let Some(detail) = unresolved_symlink_detail(&path) {
let name = path.file_name().and_then(|n| n.to_str()).map(str::to_owned);
skips.push(SkillSkip {
path,
name,
kind: SkipKind::Unreadable,
detail,
winner_path: None,
host_token: None,
});
continue;
}
}
if path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == "SKILL.md" || n == "skill.md")
&& !path_is_ignored(&path, load.ignore)
{
if let Some(detail) = unresolved_symlink_detail(&path) {
skips.push(SkillSkip {
path,
name: None,
kind: SkipKind::Unreadable,
detail,
winner_path: None,
host_token: None,
});
continue;
}
if !stays_under(&path, dir) {
skips.push(SkillSkip {
path,
name: None,
kind: SkipKind::Unreadable,
detail: "SKILL.md symlink escapes walk root".to_owned(),
winner_path: None,
host_token: None,
});
continue;
}
match read_skill_md(&path) {
Ok(content) => {
let name = peek_frontmatter_name(&content);
skips.push(SkillSkip {
path,
name,
kind: SkipKind::RootFile,
detail: "put the file in a named subdirectory.".to_owned(),
winner_path: None,
host_token: None,
});
}
Err(e) => {
skips.push(SkillSkip {
path,
name: None,
kind: SkipKind::Unreadable,
detail: e,
winner_path: None,
host_token: None,
});
}
}
}
continue;
}
let skill_file = match lookup_package_skill_md(&path) {
PackageSkillMd::Found(skill_file) => skill_file,
PackageSkillMd::Missing => continue,
PackageSkillMd::Unreadable(detail) => {
skips.push(SkillSkip {
path,
name: None,
kind: SkipKind::Unreadable,
detail,
winner_path: None,
host_token: None,
});
continue;
}
};
if !stays_under(&path, dir) {
skips.push(SkillSkip {
path: skill_file,
name: None,
kind: SkipKind::Unreadable,
detail: "skill package symlink escapes walk root".to_owned(),
winner_path: None,
host_token: None,
});
continue;
}
if skip_if_skill_md_escapes_package(&skill_file, skips) {
continue;
}
try_load_skill_file(
&skill_file,
load.source,
load.ignore,
load.opts,
load.denylist,
skills,
skips,
);
}
}
pub(super) fn skip_if_skill_md_escapes_package(
skill_file: &Path,
skips: &mut Vec<SkillSkip>,
) -> bool {
if let Some(detail) = unresolved_symlink_detail(skill_file) {
skips.push(SkillSkip {
path: skill_file.to_path_buf(),
name: None,
kind: SkipKind::Unreadable,
detail,
winner_path: None,
host_token: None,
});
return true;
}
if skill_md_stays_in_package(skill_file) {
return false;
}
skips.push(SkillSkip {
path: skill_file.to_path_buf(),
name: None,
kind: SkipKind::Unreadable,
detail: "SKILL.md symlink escapes package root".to_owned(),
winner_path: None,
host_token: None,
});
true
}
pub(super) fn try_load_skill_file(
skill_file: &Path,
source: &SkillSource,
ignore: &[IgnorePrefix],
opts: &DiscoveryOptions,
denylist: &[&str],
skills: &mut Vec<Skill>,
skips: &mut Vec<SkillSkip>,
) {
if path_is_ignored(skill_file, ignore) {
return;
}
let content = match read_skill_md(skill_file) {
Ok(c) => c,
Err(e) => {
skips.push(SkillSkip {
path: skill_file.to_path_buf(),
name: None,
kind: SkipKind::Unreadable,
detail: e,
winner_path: None,
host_token: None,
});
return;
}
};
finish_load_skill_file(skill_file, &content, source, opts, denylist, skills, skips);
}
pub(super) fn finish_load_skill_file(
skill_file: &Path,
content: &str,
source: &SkillSource,
opts: &DiscoveryOptions,
denylist: &[&str],
skills: &mut Vec<Skill>,
skips: &mut Vec<SkillSkip>,
) {
match parse_skill(content) {
Ok(skill) => {
finish_load_parsed_skill(skill_file, skill, source, opts, denylist, skills, skips);
}
Err(e) => {
skips.push(SkillSkip {
path: skill_file.to_path_buf(),
name: peek_frontmatter_name(content),
kind: SkipKind::ParseError,
detail: e.to_string(),
winner_path: None,
host_token: None,
});
}
}
}
pub(super) fn ascii_names_policy_detail() -> String {
"name must be lowercase alphanumeric and hyphens only (omit --ascii-names / ascii_names to allow Unicode)".to_owned()
}
fn same_skill_file(winner: &Path, candidate: &Path) -> bool {
if winner == candidate {
return true;
}
match (winner.canonicalize(), candidate.canonicalize()) {
(Ok(left), Ok(right)) => left == right,
_ => false,
}
}
pub(super) fn finish_load_parsed_skill(
skill_file: &Path,
mut skill: Skill,
source: &SkillSource,
opts: &DiscoveryOptions,
denylist: &[&str],
skills: &mut Vec<Skill>,
skips: &mut Vec<SkillSkip>,
) {
if opts.ascii_names && !crate::parse::skill_name_is_ascii_policy(&skill.name) {
skips.push(SkillSkip {
path: skill_file.to_path_buf(),
name: Some(skill.name.clone()),
kind: SkipKind::ParseError,
detail: ascii_names_policy_detail(),
winner_path: None,
host_token: None,
});
return;
}
if opts
.disabled
.iter()
.any(|d| crate::parse::skill_names_equal(d, &skill.name))
{
return;
}
if denylist
.iter()
.any(|d| crate::parse::skill_names_equal(d, &skill.name))
{
return;
}
if !skill_name_matches_directory(skill_file, &skill.name) {
skips.push(SkillSkip {
path: skill_file.to_path_buf(),
name: Some(skill.name.clone()),
kind: SkipKind::NameDirectoryMismatch,
detail: format!(
"frontmatter name `{}` must match parent directory name",
skill.name
),
winner_path: None,
host_token: None,
});
return;
}
if let Some(winner) = skills
.iter()
.find(|s| crate::parse::skill_names_equal(&s.name, &skill.name))
{
let winner_path = winner
.source_path
.clone()
.unwrap_or_else(|| PathBuf::from("<already-loaded>"));
if same_skill_file(&winner_path, skill_file) {
return;
}
skips.push(SkillSkip {
path: skill_file.to_path_buf(),
name: Some(skill.name.clone()),
kind: SkipKind::NameCollision,
detail: format!("lost to {}", winner_path.display()),
winner_path: Some(winner_path),
host_token: None,
});
return;
}
skill.source = source.clone();
skill.source_path = Some(skill_file.to_path_buf());
skills.push(skill);
}
pub(super) fn is_skill_md_filename(path: &Path) -> bool {
path.file_name()
.is_some_and(|n| n == "SKILL.md" || n == "skill.md")
}
pub(super) fn resolve_validate_target(path: &Path) -> Result<PathBuf, String> {
if !skill_md_is_dir(path) {
if let Some(detail) = symlink_loop_detail(path) {
return Err(detail);
}
if skill_md_inode_exists(path) && !is_skill_md_filename(path) {
let shown = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("file");
let shown = crate::sanitize_error_token(shown);
return Err(format!(
"file must be named SKILL.md (got {shown}). Pass a SKILL.md file or a package directory that contains SKILL.md"
));
}
return Ok(path.to_path_buf());
}
let joined = ["SKILL.md", "skill.md"]
.into_iter()
.map(|name| path.join(name))
.find(|p| skill_md_inode_exists(p))
.ok_or_else(|| "directory is not a skill package (no SKILL.md)".to_owned())?;
if let Some(detail) = unresolved_symlink_detail(&joined) {
return Err(detail);
}
if !skill_md_stays_in_package(&joined) {
return Err("SKILL.md symlink escapes package root".to_owned());
}
Ok(joined)
}
enum PackageSkillMd {
Found(PathBuf),
Missing,
Unreadable(String),
}
fn lookup_package_skill_md(dir: &Path) -> PackageSkillMd {
let mut denied = None;
for name in ["SKILL.md", "skill.md"] {
let candidate = dir.join(name);
match std::fs::symlink_metadata(&candidate) {
Ok(_) => return PackageSkillMd::Found(candidate),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) if e.kind() == std::io::ErrorKind::NotADirectory => {
return PackageSkillMd::Missing;
}
Err(e) => {
denied = Some(e.to_string());
}
}
}
match denied {
Some(detail) => PackageSkillMd::Unreadable(detail),
None => PackageSkillMd::Missing,
}
}
pub(super) fn child_dir_counts_as_skill_package(dir: &Path) -> bool {
matches!(
lookup_package_skill_md(dir),
PackageSkillMd::Found(_) | PackageSkillMd::Unreadable(_)
)
}
pub(super) fn skill_md_inode_exists(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok()
}
pub(super) fn skill_md_is_dir(path: &Path) -> bool {
std::fs::metadata(path).map(|m| m.is_dir()).unwrap_or(false)
}
pub(super) fn read_skill_md(path: &Path) -> Result<String, String> {
#[cfg(test)]
READ_SKILL_MD_PATHS.with(|c| c.borrow_mut().push(path.to_path_buf()));
if let Some(detail) = symlink_loop_detail(path) {
return Err(detail);
}
let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
if !meta.is_file() {
return Err("SKILL.md is not a regular file".to_owned());
}
if meta.len() > SKILL_MD_MAX_BYTES {
return Err(format!("SKILL.md exceeds {SKILL_MD_MAX_BYTES} bytes"));
}
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mut buf = String::new();
let n = file
.take(SKILL_MD_MAX_BYTES.saturating_add(1))
.read_to_string(&mut buf)
.map_err(|e| e.to_string())?;
if n as u64 > SKILL_MD_MAX_BYTES {
return Err(format!("SKILL.md exceeds {SKILL_MD_MAX_BYTES} bytes"));
}
Ok(buf)
}
pub(super) fn skill_md_stays_in_package(skill_file: &Path) -> bool {
let Some(pkg) = skill_file.parent() else {
return false;
};
let Ok(pkg_canon) = pkg.canonicalize() else {
return false;
};
let Ok(file_canon) = skill_file.canonicalize() else {
return false;
};
file_canon.starts_with(&pkg_canon)
}