use std::fs;
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result, bail};
use flate2::read::GzDecoder;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::network_policy::{Decision, NetworkPolicy, host_from_url};
pub fn default_cache_skills_dir() -> PathBuf {
dirs::home_dir().map_or_else(
|| PathBuf::from("/tmp/deepseek/cache/skills"),
|p| p.join(".deepseek").join("cache").join("skills"),
)
}
pub const DEFAULT_REGISTRY_URL: &str =
"https://raw.githubusercontent.com/Hmbown/deepseek-skills/main/index.json";
pub const DEFAULT_MAX_SIZE_BYTES: u64 = 5 * 1024 * 1024;
pub const INSTALLED_FROM_MARKER: &str = ".installed-from";
pub const TRUSTED_MARKER: &str = ".trusted";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallSource {
GitHubRepo(String),
DirectUrl(String),
Registry(String),
}
impl InstallSource {
pub fn parse(spec: &str) -> Result<Self> {
let trimmed = spec.trim();
if trimmed.is_empty() {
bail!("install source must not be empty");
}
if let Some(rest) = trimmed.strip_prefix("github:") {
let rest = rest.trim();
let (owner, repo) = rest.split_once('/').with_context(|| {
format!("github source must be 'github:owner/repo' (got {spec})")
})?;
let owner = owner.trim();
let repo = repo.trim().trim_end_matches('/');
if owner.is_empty() || repo.is_empty() {
bail!("github source must be 'github:owner/repo' (got {spec})");
}
if owner.contains('/') || repo.contains('/') {
bail!("github source must be 'github:owner/repo' (got {spec})");
}
return Ok(Self::GitHubRepo(format!("{owner}/{repo}")));
}
if trimmed.starts_with("https://") || trimmed.starts_with("http://") {
if let Some(repo) = parse_github_browser_url(trimmed) {
return Ok(Self::GitHubRepo(repo));
}
return Ok(Self::DirectUrl(trimmed.to_string()));
}
Ok(Self::Registry(trimmed.to_string()))
}
}
fn parse_github_browser_url(url: &str) -> Option<String> {
let after_scheme = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))?;
let (host, rest) = after_scheme.split_once('/')?;
if !host.eq_ignore_ascii_case("github.com") && !host.eq_ignore_ascii_case("www.github.com") {
return None;
}
let trimmed = rest.trim_end_matches('/');
let mut parts = trimmed.splitn(3, '/');
let owner = parts.next()?.trim();
let repo = parts.next()?.trim().trim_end_matches(".git");
if owner.is_empty() || repo.is_empty() {
return None;
}
if parts.next().is_some() {
return None;
}
Some(format!("{owner}/{repo}"))
}
#[derive(Debug)]
pub enum InstallOutcome {
Installed(InstalledSkill),
NeedsApproval(String),
NetworkDenied(String),
}
#[derive(Debug, Clone)]
pub struct InstalledSkill {
pub name: String,
pub path: PathBuf,
#[allow(dead_code)]
pub source_checksum: String,
}
#[derive(Debug)]
pub enum UpdateResult {
NoChange,
Updated(InstalledSkill),
NeedsApproval(String),
NetworkDenied(String),
}
#[derive(Debug, Error)]
pub enum InstallError {
#[error("entry escapes destination directory: {0}")]
PathTraversal(String),
#[error("entry is too large; uncompressed total would exceed {limit} bytes")]
OversizedTarball { limit: u64 },
#[error("missing SKILL.md in archive")]
MissingSkillMd,
#[error("SKILL.md frontmatter missing required field: {0}")]
MissingFrontmatterField(&'static str),
#[error("symlinks are not allowed in skill tarballs")]
SymlinkRejected,
#[error("skill '{0}' is already installed; use update or remove it first")]
AlreadyInstalled(String),
#[error("skill '{0}' was not installed via /skill install (no .installed-from marker)")]
NotInstalledHere(String),
}
#[allow(dead_code)]
pub async fn install(
source: InstallSource,
skills_dir: &Path,
max_size: u64,
network: &NetworkPolicy,
update: bool,
) -> Result<InstallOutcome> {
install_with_registry(
source,
skills_dir,
max_size,
network,
update,
DEFAULT_REGISTRY_URL,
)
.await
}
pub async fn install_with_registry(
source: InstallSource,
skills_dir: &Path,
max_size: u64,
network: &NetworkPolicy,
update: bool,
registry_url: &str,
) -> Result<InstallOutcome> {
let urls = candidate_urls(&source, network, registry_url).await?;
let urls = match urls {
UrlResolution::Resolved(urls) => urls,
UrlResolution::NeedsApproval(host) => return Ok(InstallOutcome::NeedsApproval(host)),
UrlResolution::Denied(host) => return Ok(InstallOutcome::NetworkDenied(host)),
};
let (bytes, source_url) = match download_first_success(&urls, network, max_size).await? {
DownloadOutcome::Bytes { bytes, url } => (bytes, url),
DownloadOutcome::NeedsApproval(host) => return Ok(InstallOutcome::NeedsApproval(host)),
DownloadOutcome::Denied(host) => return Ok(InstallOutcome::NetworkDenied(host)),
};
let mut hasher = Sha256::new();
hasher.update(&bytes);
let checksum = format!("{:x}", hasher.finalize());
let staged = stage_tarball(&bytes, skills_dir, max_size)?;
let final_path = skills_dir.join(&staged.skill_name);
if final_path.exists() {
if !update {
let _ = fs::remove_dir_all(&staged.staged_path);
return Err(InstallError::AlreadyInstalled(staged.skill_name).into());
}
let backup = skills_dir.join(format!("{}.bak", staged.skill_name));
if backup.exists() {
fs::remove_dir_all(&backup).ok();
}
fs::rename(&final_path, &backup).with_context(|| {
format!(
"failed to backup existing skill at {}",
final_path.display()
)
})?;
if let Err(err) = fs::rename(&staged.staged_path, &final_path) {
fs::rename(&backup, &final_path).ok();
return Err(err).context("failed to install staged skill");
}
fs::remove_dir_all(&backup).ok();
} else {
if let Some(parent) = final_path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("failed to create skills directory {}", parent.display())
})?;
}
fs::rename(&staged.staged_path, &final_path).context("failed to install staged skill")?;
}
let marker_body = serde_json::json!({
"spec": source_spec_string(&source),
"url": source_url,
"checksum": checksum,
})
.to_string();
fs::write(final_path.join(INSTALLED_FROM_MARKER), marker_body).with_context(|| {
format!(
"failed to write {} marker for skill {}",
INSTALLED_FROM_MARKER, staged.skill_name
)
})?;
Ok(InstallOutcome::Installed(InstalledSkill {
name: staged.skill_name,
path: final_path,
source_checksum: checksum,
}))
}
#[allow(dead_code)]
pub async fn update(
name: &str,
skills_dir: &Path,
max_size: u64,
network: &NetworkPolicy,
) -> Result<UpdateResult> {
update_with_registry(name, skills_dir, max_size, network, DEFAULT_REGISTRY_URL).await
}
pub async fn update_with_registry(
name: &str,
skills_dir: &Path,
max_size: u64,
network: &NetworkPolicy,
registry_url: &str,
) -> Result<UpdateResult> {
let target = skills_dir.join(name);
let marker_path = target.join(INSTALLED_FROM_MARKER);
if !marker_path.exists() {
return Err(InstallError::NotInstalledHere(name.to_string()).into());
}
let marker_body = fs::read_to_string(&marker_path)
.with_context(|| format!("failed to read {}", marker_path.display()))?;
let marker: InstalledFromMarker = serde_json::from_str(&marker_body)
.with_context(|| format!("malformed {} for {name}", INSTALLED_FROM_MARKER))?;
let source = InstallSource::parse(&marker.spec)?;
let urls = match candidate_urls(&source, network, registry_url).await? {
UrlResolution::Resolved(urls) => urls,
UrlResolution::NeedsApproval(host) => return Ok(UpdateResult::NeedsApproval(host)),
UrlResolution::Denied(host) => return Ok(UpdateResult::NetworkDenied(host)),
};
let (bytes, _url) = match download_first_success(&urls, network, max_size).await? {
DownloadOutcome::Bytes { bytes, url } => (bytes, url),
DownloadOutcome::NeedsApproval(host) => return Ok(UpdateResult::NeedsApproval(host)),
DownloadOutcome::Denied(host) => return Ok(UpdateResult::NetworkDenied(host)),
};
let mut hasher = Sha256::new();
hasher.update(&bytes);
let checksum = format!("{:x}", hasher.finalize());
if checksum == marker.checksum {
return Ok(UpdateResult::NoChange);
}
let outcome =
install_with_registry(source, skills_dir, max_size, network, true, registry_url).await?;
match outcome {
InstallOutcome::Installed(installed) => Ok(UpdateResult::Updated(installed)),
InstallOutcome::NeedsApproval(host) => Ok(UpdateResult::NeedsApproval(host)),
InstallOutcome::NetworkDenied(host) => Ok(UpdateResult::NetworkDenied(host)),
}
}
pub fn uninstall(name: &str, skills_dir: &Path) -> Result<()> {
let target = skills_dir.join(name);
if !target.exists() {
bail!("skill '{name}' is not installed at {}", target.display());
}
if !target.join(INSTALLED_FROM_MARKER).exists() {
return Err(InstallError::NotInstalledHere(name.to_string()).into());
}
fs::remove_dir_all(&target)
.with_context(|| format!("failed to remove {}", target.display()))?;
Ok(())
}
pub fn trust(name: &str, skills_dir: &Path) -> Result<()> {
let target = skills_dir.join(name);
if !target.exists() {
bail!("skill '{name}' is not installed at {}", target.display());
}
if !target.join(INSTALLED_FROM_MARKER).exists() {
return Err(InstallError::NotInstalledHere(name.to_string()).into());
}
let marker = target.join(TRUSTED_MARKER);
if !marker.exists() {
fs::write(
&marker,
"Skill scripts/ are user-trusted. Delete this file to revoke.\n",
)
.with_context(|| format!("failed to write {}", marker.display()))?;
}
Ok(())
}
pub async fn fetch_registry(
network: &NetworkPolicy,
registry_url: &str,
) -> Result<RegistryFetchResult> {
let host = match host_from_url(registry_url) {
Some(host) => host,
None => bail!("invalid registry url: {registry_url}"),
};
match network.decide(&host) {
Decision::Allow => {}
Decision::Deny => return Ok(RegistryFetchResult::Denied(host)),
Decision::Prompt => return Ok(RegistryFetchResult::NeedsApproval(host)),
}
let body = reqwest::get(registry_url)
.await
.with_context(|| format!("failed to fetch registry {registry_url}"))?
.error_for_status()
.with_context(|| format!("registry {registry_url} returned an error status"))?
.text()
.await
.with_context(|| format!("failed to read registry body from {registry_url}"))?;
let parsed: RegistryDocument = serde_json::from_str(&body)
.with_context(|| format!("failed to parse registry json from {registry_url}"))?;
Ok(RegistryFetchResult::Loaded(parsed))
}
#[derive(Debug, Clone)]
pub enum SkillSyncOutcome {
Downloaded { name: String, path: PathBuf },
Fresh { name: String },
Failed { name: String, reason: String },
Denied { name: String, host: String },
NeedsApproval { name: String, host: String },
}
#[derive(Debug)]
pub enum SyncResult {
Done { outcomes: Vec<SkillSyncOutcome> },
RegistryDenied(String),
RegistryNeedsApproval(String),
}
#[derive(Debug, Serialize, Deserialize)]
struct CacheMeta {
#[serde(default)]
etag: Option<String>,
sha256: String,
url: String,
}
pub async fn sync_registry(
network: &NetworkPolicy,
registry_url: &str,
cache_dir: &Path,
max_size: u64,
) -> Result<SyncResult> {
let doc = match fetch_registry(network, registry_url).await? {
RegistryFetchResult::Loaded(doc) => doc,
RegistryFetchResult::Denied(host) => return Ok(SyncResult::RegistryDenied(host)),
RegistryFetchResult::NeedsApproval(host) => {
return Ok(SyncResult::RegistryNeedsApproval(host));
}
};
let mut outcomes = Vec::new();
for (name, entry) in &doc.skills {
let outcome = sync_one_skill(name, entry, network, cache_dir, max_size).await;
outcomes.push(outcome);
}
Ok(SyncResult::Done { outcomes })
}
async fn sync_one_skill(
name: &str,
entry: &RegistryEntry,
network: &NetworkPolicy,
cache_dir: &Path,
max_size: u64,
) -> SkillSyncOutcome {
let source = match InstallSource::parse(&entry.source) {
Ok(s) => s,
Err(err) => {
return SkillSyncOutcome::Failed {
name: name.to_string(),
reason: format!("invalid source spec '{}': {err:#}", entry.source),
};
}
};
if matches!(source, InstallSource::Registry(_)) {
return SkillSyncOutcome::Failed {
name: name.to_string(),
reason: format!("registry entry for '{name}' must not point to another registry entry"),
};
}
let urls = match &source {
InstallSource::GitHubRepo(repo) => vec![
format!("https://github.com/{repo}/archive/refs/heads/main.tar.gz"),
format!("https://github.com/{repo}/archive/refs/heads/master.tar.gz"),
],
InstallSource::DirectUrl(url) => vec![url.clone()],
InstallSource::Registry(_) => unreachable!("guarded above"),
};
let skill_cache_dir = cache_dir.join(name);
let meta_path = skill_cache_dir.join(".cache-meta.json");
for url in &urls {
let host = match host_from_url(url) {
Some(h) => h,
None => continue,
};
match network.decide(&host) {
Decision::Allow => {}
Decision::Deny => {
return SkillSyncOutcome::Denied {
name: name.to_string(),
host,
};
}
Decision::Prompt => {
return SkillSyncOutcome::NeedsApproval {
name: name.to_string(),
host,
};
}
}
let existing_meta: Option<CacheMeta> = meta_path
.exists()
.then(|| {
fs::read_to_string(&meta_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
})
.flatten();
let client = reqwest::Client::new();
let mut req = client.get(url);
if let Some(ref meta) = existing_meta
&& let Some(ref etag) = meta.etag
{
req = req.header("If-None-Match", etag);
}
let resp = match req.send().await {
Ok(r) => r,
Err(err) => {
let _ = err;
continue;
}
};
let status = resp.status();
if status == reqwest::StatusCode::NOT_MODIFIED {
return SkillSyncOutcome::Fresh {
name: name.to_string(),
};
}
if status == reqwest::StatusCode::NOT_FOUND {
continue;
}
if !status.is_success() {
return SkillSyncOutcome::Failed {
name: name.to_string(),
reason: format!("GET {url} returned HTTP {status}"),
};
}
let etag = resp
.headers()
.get(reqwest::header::ETAG)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let compressed_cap = max_size.saturating_mul(4);
let bytes = match resp.bytes().await {
Ok(b) => b,
Err(err) => {
return SkillSyncOutcome::Failed {
name: name.to_string(),
reason: format!("failed to read body from {url}: {err:#}"),
};
}
};
if bytes.len() as u64 > compressed_cap {
return SkillSyncOutcome::Failed {
name: name.to_string(),
reason: format!(
"download from {url} exceeds compressed size cap ({} bytes)",
compressed_cap
),
};
}
let mut hasher = Sha256::new();
hasher.update(&bytes);
let sha256 = format!("{:x}", hasher.finalize());
if let Some(ref meta) = existing_meta
&& meta.sha256 == sha256
&& meta.url == *url
{
return SkillSyncOutcome::Fresh {
name: name.to_string(),
};
}
let is_tarball =
url.ends_with(".tar.gz") || url.ends_with(".tgz") || bytes.starts_with(&[0x1f, 0x8b]);
let final_path: PathBuf = if is_tarball {
let staged = match stage_tarball(&bytes, cache_dir, max_size) {
Ok(s) => s,
Err(err) => {
return SkillSyncOutcome::Failed {
name: name.to_string(),
reason: format!("tarball extraction failed: {err:#}"),
};
}
};
let dest = cache_dir.join(name);
if dest.exists() {
let _ = fs::remove_dir_all(&dest);
}
if let Err(err) = fs::rename(&staged.staged_path, &dest) {
let _ = fs::remove_dir_all(&staged.staged_path);
return SkillSyncOutcome::Failed {
name: name.to_string(),
reason: format!("failed to move staged skill into cache: {err:#}"),
};
}
dest
} else {
if let Err(err) = fs::create_dir_all(&skill_cache_dir) {
return SkillSyncOutcome::Failed {
name: name.to_string(),
reason: format!("failed to create cache dir: {err:#}"),
};
}
let skill_md_path = skill_cache_dir.join("SKILL.md");
if let Err(err) = fs::write(&skill_md_path, &bytes) {
return SkillSyncOutcome::Failed {
name: name.to_string(),
reason: format!("failed to write SKILL.md to cache: {err:#}"),
};
}
skill_cache_dir.clone()
};
let meta = CacheMeta {
etag,
sha256,
url: url.clone(),
};
let meta_json = serde_json::to_string(&meta).unwrap_or_default();
let _ = fs::write(final_path.join(".cache-meta.json"), meta_json);
return SkillSyncOutcome::Downloaded {
name: name.to_string(),
path: final_path,
};
}
SkillSyncOutcome::Failed {
name: name.to_string(),
reason: format!(
"all candidate URLs for '{}' failed or were not found",
entry.source
),
}
}
#[derive(Debug, Deserialize)]
struct InstalledFromMarker {
spec: String,
#[serde(default)]
checksum: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RegistryDocument {
#[serde(default)]
pub skills: std::collections::BTreeMap<String, RegistryEntry>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RegistryEntry {
pub source: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug)]
pub enum RegistryFetchResult {
Loaded(RegistryDocument),
NeedsApproval(String),
Denied(String),
}
enum UrlResolution {
Resolved(Vec<String>),
NeedsApproval(String),
Denied(String),
}
enum DownloadOutcome {
Bytes { bytes: Vec<u8>, url: String },
NeedsApproval(String),
Denied(String),
}
async fn candidate_urls(
source: &InstallSource,
network: &NetworkPolicy,
registry_url: &str,
) -> Result<UrlResolution> {
match source {
InstallSource::GitHubRepo(repo) => {
Ok(UrlResolution::Resolved(vec![
format!("https://github.com/{repo}/archive/refs/heads/main.tar.gz"),
format!("https://github.com/{repo}/archive/refs/heads/master.tar.gz"),
]))
}
InstallSource::DirectUrl(url) => Ok(UrlResolution::Resolved(vec![url.clone()])),
InstallSource::Registry(name) => {
match fetch_registry(network, registry_url).await? {
RegistryFetchResult::Loaded(doc) => {
let entry = doc
.skills
.get(name)
.with_context(|| format!("skill '{name}' not found in registry"))?
.clone();
let inner = InstallSource::parse(&entry.source).with_context(|| {
format!(
"registry entry for '{name}' has invalid source: {}",
entry.source
)
})?;
if matches!(inner, InstallSource::Registry(_)) {
bail!("registry entry for '{name}' must not point to another registry");
}
Box::pin(candidate_urls(&inner, network, registry_url)).await
}
RegistryFetchResult::NeedsApproval(host) => Ok(UrlResolution::NeedsApproval(host)),
RegistryFetchResult::Denied(host) => Ok(UrlResolution::Denied(host)),
}
}
}
}
async fn download_first_success(
urls: &[String],
network: &NetworkPolicy,
max_size: u64,
) -> Result<DownloadOutcome> {
let mut last_status: Option<reqwest::StatusCode> = None;
let mut prompt_host: Option<String> = None;
let mut denied_host: Option<String> = None;
for url in urls {
let host = match host_from_url(url) {
Some(h) => h,
None => bail!("invalid download url: {url}"),
};
match network.decide(&host) {
Decision::Allow => {}
Decision::Deny => {
denied_host.get_or_insert(host);
continue;
}
Decision::Prompt => {
prompt_host.get_or_insert(host);
continue;
}
}
match download_with_cap(url, max_size).await? {
DownloadAttempt::Bytes(bytes) => {
return Ok(DownloadOutcome::Bytes {
bytes,
url: url.clone(),
});
}
DownloadAttempt::NotFound(status) => {
last_status = Some(status);
continue;
}
}
}
if let Some(host) = denied_host {
return Ok(DownloadOutcome::Denied(host));
}
if let Some(host) = prompt_host {
return Ok(DownloadOutcome::NeedsApproval(host));
}
bail!(
"failed to download skill (last status: {})",
last_status
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string())
);
}
enum DownloadAttempt {
Bytes(Vec<u8>),
NotFound(reqwest::StatusCode),
}
async fn download_with_cap(url: &str, max_size: u64) -> Result<DownloadAttempt> {
let resp = reqwest::get(url)
.await
.with_context(|| format!("failed to GET {url}"))?;
let status = resp.status();
if !status.is_success() {
if status == reqwest::StatusCode::NOT_FOUND {
return Ok(DownloadAttempt::NotFound(status));
}
bail!("download {url} returned {status}");
}
let compressed_cap = max_size.saturating_mul(4);
let bytes = resp
.bytes()
.await
.with_context(|| format!("failed to read body of {url}"))?;
if (bytes.len() as u64) > compressed_cap {
bail!("download {url} exceeds compressed size cap of {compressed_cap} bytes");
}
Ok(DownloadAttempt::Bytes(bytes.to_vec()))
}
struct StagedSkill {
skill_name: String,
staged_path: PathBuf,
}
fn stage_tarball(bytes: &[u8], skills_dir: &Path, max_size: u64) -> Result<StagedSkill> {
fs::create_dir_all(skills_dir)
.with_context(|| format!("failed to create skills directory {}", skills_dir.display()))?;
let scan = scan_tarball(bytes, max_size)?;
let staged_path = skills_dir.join(format!("{}.tmp", scan.skill_name));
if staged_path.exists() {
fs::remove_dir_all(&staged_path).with_context(|| {
format!(
"failed to clean stale staging dir {}",
staged_path.display()
)
})?;
}
fs::create_dir_all(&staged_path)
.with_context(|| format!("failed to create staging dir {}", staged_path.display()))?;
let result = extract_into(&scan, bytes, &staged_path, max_size);
if let Err(err) = result {
let _ = fs::remove_dir_all(&staged_path);
return Err(err);
}
Ok(StagedSkill {
skill_name: scan.skill_name,
staged_path,
})
}
struct TarballScan {
skill_name: String,
prefix: String,
skill_root: String,
}
fn scan_tarball(bytes: &[u8], max_size: u64) -> Result<TarballScan> {
let cursor = std::io::Cursor::new(bytes);
let gz = GzDecoder::new(cursor);
let mut archive = tar::Archive::new(gz);
let mut total_size: u64 = 0;
let mut prefix: Option<String> = None;
let mut skill_md_relative: Option<(SkillMdCandidate, Vec<u8>)> = None;
let mut link_paths: Vec<String> = Vec::new();
for entry in archive
.entries()
.context("failed to read tar entries (corrupt archive?)")?
{
let mut entry = entry.context("failed to read tar entry")?;
let header = entry.header().clone();
let entry_type = header.entry_type();
let path = entry
.path()
.context("tar entry has invalid path")?
.to_path_buf();
let path_str = path.to_string_lossy().into_owned();
if !is_safe_path(&path) {
return Err(InstallError::PathTraversal(path_str).into());
}
if let Ok(size) = header.size() {
total_size = total_size.saturating_add(size);
if total_size > max_size {
return Err(InstallError::OversizedTarball { limit: max_size }.into());
}
}
if prefix.is_none() {
if let Some(Component::Normal(first)) = path.components().next() {
let candidate = first.to_string_lossy().into_owned();
if path.components().count() > 1 {
prefix = Some(candidate);
} else {
prefix = Some(String::new());
}
} else {
prefix = Some(String::new());
}
}
if entry_type.is_symlink() || entry_type.is_hard_link() {
link_paths.push(path_str);
continue;
}
if entry_type.is_file() {
let stripped = strip_prefix(&path_str, prefix.as_deref().unwrap_or(""));
if let Some(candidate) = skill_md_candidate(&stripped) {
let mut buf = Vec::new();
entry
.read_to_end(&mut buf)
.context("failed to read SKILL.md from archive")?;
let replace = skill_md_relative
.as_ref()
.is_none_or(|(current, _)| candidate.rank < current.rank);
if replace {
skill_md_relative = Some((candidate, buf));
}
}
}
}
let prefix = prefix.unwrap_or_default();
let (skill_md, skill_md_bytes) = skill_md_relative
.ok_or(InstallError::MissingSkillMd)
.map_err(anyhow::Error::from)?;
for link_path in link_paths {
if is_within_selected_root(&link_path, &prefix, &skill_md.skill_root) {
return Err(InstallError::SymlinkRejected.into());
}
}
let name = parse_frontmatter_name(&skill_md_bytes)?;
Ok(TarballScan {
skill_name: name,
prefix,
skill_root: skill_md.skill_root,
})
}
struct SkillMdCandidate {
rank: u8,
skill_root: String,
}
fn skill_md_candidate(stripped_path: &str) -> Option<SkillMdCandidate> {
if stripped_path.eq_ignore_ascii_case("SKILL.md") {
return Some(SkillMdCandidate {
rank: 0,
skill_root: String::new(),
});
}
let parts: Vec<&str> = stripped_path.split('/').collect();
if parts
.last()
.is_none_or(|last| !last.eq_ignore_ascii_case("SKILL.md"))
{
return None;
}
if parts.len() >= 3 {
let container = parts[parts.len() - 3];
let name = parts[parts.len() - 2];
if container.eq_ignore_ascii_case("skills") && !name.is_empty() {
return Some(SkillMdCandidate {
rank: 1,
skill_root: parts[..parts.len() - 1].join("/"),
});
}
}
if parts.len() == 2 && !parts[0].is_empty() {
return Some(SkillMdCandidate {
rank: 2,
skill_root: parts[0].to_string(),
});
}
None
}
fn extract_into(scan: &TarballScan, bytes: &[u8], dest: &Path, max_size: u64) -> Result<()> {
let cursor = std::io::Cursor::new(bytes);
let gz = GzDecoder::new(cursor);
let mut archive = tar::Archive::new(gz);
let mut total_size: u64 = 0;
let prefix_with_root = if scan.skill_root.is_empty() {
scan.prefix.clone()
} else if scan.prefix.is_empty() {
scan.skill_root.clone()
} else {
format!("{}/{}", scan.prefix, scan.skill_root)
};
for entry in archive
.entries()
.context("failed to read tar entries (corrupt archive?)")?
{
let mut entry = entry.context("failed to read tar entry")?;
let header = entry.header().clone();
let entry_type = header.entry_type();
let path = entry
.path()
.context("tar entry has invalid path")?
.to_path_buf();
let path_str = path.to_string_lossy().into_owned();
if !is_safe_path(&path) {
return Err(InstallError::PathTraversal(path_str).into());
}
let stripped = strip_prefix(&path_str, &prefix_with_root).into_owned();
if stripped.is_empty() && entry_type.is_dir() {
continue;
}
if stripped == path_str && !prefix_with_root.is_empty() {
continue;
}
let stripped_path = Path::new(&stripped);
if !is_safe_path(stripped_path) {
return Err(InstallError::PathTraversal(stripped).into());
}
if entry_type.is_symlink() || entry_type.is_hard_link() {
return Err(InstallError::SymlinkRejected.into());
}
let target = dest.join(stripped_path);
let target_components: Vec<_> = target.components().collect();
let dest_components: Vec<_> = dest.components().collect();
if !target_components.starts_with(dest_components.as_slice()) {
return Err(InstallError::PathTraversal(stripped).into());
}
if entry_type.is_dir() {
fs::create_dir_all(&target)
.with_context(|| format!("failed to create dir {}", target.display()))?;
continue;
}
if entry_type.is_file() {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create dir {}", parent.display()))?;
}
let mut buf = Vec::new();
entry
.read_to_end(&mut buf)
.with_context(|| format!("failed to read {}", path.display()))?;
total_size = total_size.saturating_add(buf.len() as u64);
if total_size > max_size {
return Err(InstallError::OversizedTarball { limit: max_size }.into());
}
let mut out = fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&target)
.with_context(|| format!("failed to create {}", target.display()))?;
out.write_all(&buf)
.with_context(|| format!("failed to write {}", target.display()))?;
}
}
Ok(())
}
fn selected_root(prefix: &str, skill_root: &str) -> String {
if skill_root.is_empty() {
prefix.to_string()
} else if prefix.is_empty() {
skill_root.to_string()
} else {
format!("{prefix}/{skill_root}")
}
}
fn is_within_selected_root(path: &str, prefix: &str, skill_root: &str) -> bool {
let root = selected_root(prefix, skill_root);
if root.is_empty() {
return true;
}
path == root || path.starts_with(&format!("{root}/"))
}
fn is_safe_path(path: &Path) -> bool {
if path.is_absolute() {
return false;
}
for component in path.components() {
match component {
Component::ParentDir => return false,
Component::Prefix(_) | Component::RootDir => return false,
_ => {}
}
}
true
}
fn strip_prefix<'a>(path: &'a str, prefix: &str) -> std::borrow::Cow<'a, str> {
if prefix.is_empty() {
return std::borrow::Cow::Borrowed(path);
}
let with_slash = format!("{prefix}/");
if let Some(rest) = path.strip_prefix(&with_slash) {
std::borrow::Cow::Owned(rest.to_string())
} else if path == prefix {
std::borrow::Cow::Borrowed("")
} else {
std::borrow::Cow::Borrowed(path)
}
}
fn parse_frontmatter_name(bytes: &[u8]) -> Result<String> {
let content = std::str::from_utf8(bytes).context("SKILL.md is not valid UTF-8")?;
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
bail!("SKILL.md is missing the leading '---' frontmatter fence");
}
let after_open = &trimmed[3..];
let close = after_open.find("---").ok_or_else(|| {
anyhow::anyhow!("SKILL.md is missing the closing '---' frontmatter fence")
})?;
let frontmatter = &after_open[..close];
let mut name: Option<String> = None;
let mut has_description = false;
for raw in frontmatter.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once(':') {
let key = key.trim().to_ascii_lowercase();
let value = value.trim().to_string();
match key.as_str() {
"name" if !value.is_empty() => name = Some(value),
"description" if !value.is_empty() => has_description = true,
_ => {}
}
}
}
let name = name.ok_or(InstallError::MissingFrontmatterField("name"))?;
if !has_description {
return Err(InstallError::MissingFrontmatterField("description").into());
}
if name.contains('/')
|| name.contains('\\')
|| name == "."
|| name == ".."
|| name.contains(' ')
{
bail!("SKILL.md `name` must be a single path-safe segment (got '{name}')");
}
Ok(name)
}
fn source_spec_string(source: &InstallSource) -> String {
match source {
InstallSource::GitHubRepo(repo) => format!("github:{repo}"),
InstallSource::DirectUrl(url) => url.clone(),
InstallSource::Registry(name) => name.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_github_source() {
let s = InstallSource::parse("github:Hmbown/test-skill").unwrap();
assert_eq!(
s,
InstallSource::GitHubRepo("Hmbown/test-skill".to_string())
);
}
#[test]
fn parse_github_source_rejects_missing_repo() {
let err = InstallSource::parse("github:Hmbown").unwrap_err();
assert!(err.to_string().contains("github source must"), "got: {err}");
}
#[test]
fn parse_github_source_rejects_extra_slashes() {
let err = InstallSource::parse("github:Hmbown/repo/extra").unwrap_err();
assert!(err.to_string().contains("github source must"), "got: {err}");
}
#[test]
fn parse_direct_url_source() {
let s = InstallSource::parse("https://example.com/skill.tar.gz").unwrap();
assert_eq!(
s,
InstallSource::DirectUrl("https://example.com/skill.tar.gz".to_string())
);
let s = InstallSource::parse("http://example.com/skill.tar.gz").unwrap();
assert_eq!(
s,
InstallSource::DirectUrl("http://example.com/skill.tar.gz".to_string())
);
}
#[test]
fn parse_github_browser_url_routes_to_github_repo() {
for spec in [
"https://github.com/obra/superpowers",
"https://github.com/obra/superpowers/",
"https://github.com/obra/superpowers.git",
"https://github.com/obra/superpowers.git/",
"https://www.github.com/obra/superpowers",
"http://github.com/obra/superpowers",
" https://github.com/obra/superpowers ",
] {
let parsed = InstallSource::parse(spec)
.unwrap_or_else(|err| panic!("parse({spec}) failed: {err}"));
assert_eq!(
parsed,
InstallSource::GitHubRepo("obra/superpowers".to_string()),
"spec {spec} must route to GitHubRepo",
);
}
}
#[test]
fn parse_github_archive_url_stays_direct() {
for spec in [
"https://github.com/obra/superpowers/archive/refs/heads/main.tar.gz",
"https://github.com/obra/superpowers/blob/main/README.md",
"https://github.com/obra/superpowers/tree/main",
] {
let parsed = InstallSource::parse(spec).unwrap();
assert!(
matches!(parsed, InstallSource::DirectUrl(_)),
"spec {spec} must stay DirectUrl, got {parsed:?}",
);
}
}
#[test]
fn parse_registry_source() {
let s = InstallSource::parse("my-skill").unwrap();
assert_eq!(s, InstallSource::Registry("my-skill".to_string()));
}
#[test]
fn parse_rejects_empty() {
assert!(InstallSource::parse("").is_err());
assert!(InstallSource::parse(" ").is_err());
}
#[test]
fn is_safe_path_rejects_traversal() {
assert!(!is_safe_path(Path::new("../etc/passwd")));
assert!(!is_safe_path(Path::new("foo/../bar")));
assert!(!is_safe_path(Path::new("/etc/passwd")));
assert!(is_safe_path(Path::new("foo/bar/baz")));
assert!(is_safe_path(Path::new("SKILL.md")));
}
#[test]
fn parse_frontmatter_extracts_name() {
let body = b"---\nname: hello\ndescription: greeter\n---\nbody\n";
assert_eq!(parse_frontmatter_name(body).unwrap(), "hello");
}
#[test]
fn parse_frontmatter_missing_name_fails() {
let body = b"---\ndescription: x\n---\n";
let err = parse_frontmatter_name(body).unwrap_err();
assert!(format!("{err}").contains("name"));
}
#[test]
fn parse_frontmatter_missing_description_fails() {
let body = b"---\nname: x\n---\n";
let err = parse_frontmatter_name(body).unwrap_err();
assert!(format!("{err}").contains("description"));
}
#[test]
fn parse_frontmatter_rejects_unsafe_name() {
let body = b"---\nname: ../evil\ndescription: x\n---\n";
assert!(parse_frontmatter_name(body).is_err());
let body = b"---\nname: a name with spaces\ndescription: x\n---\n";
assert!(parse_frontmatter_name(body).is_err());
}
#[test]
fn parse_frontmatter_requires_opening_fence() {
let body = b"name: hello\ndescription: x\n";
assert!(parse_frontmatter_name(body).is_err());
}
#[test]
fn strip_prefix_handles_all_cases() {
assert_eq!(strip_prefix("foo/bar", "foo"), "bar");
assert_eq!(strip_prefix("foo", "foo"), "");
assert_eq!(strip_prefix("baz/bar", "foo"), "baz/bar");
assert_eq!(strip_prefix("foo/bar", ""), "foo/bar");
}
#[test]
fn source_spec_string_roundtrips() {
assert_eq!(
source_spec_string(&InstallSource::GitHubRepo("a/b".into())),
"github:a/b"
);
assert_eq!(
source_spec_string(&InstallSource::DirectUrl("https://x".into())),
"https://x"
);
assert_eq!(
source_spec_string(&InstallSource::Registry("x".into())),
"x"
);
}
}