use std::path::{Path, PathBuf};
use url::Url;
use crate::error::{Result, SkillsError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceType {
Github,
Gitlab,
Git,
Local,
WellKnown,
Download,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Source {
pub ty: SourceType,
pub url: String,
pub subpath: Option<String>,
pub local_path: Option<PathBuf>,
pub r#ref: Option<String>,
pub skill_filter: Option<String>,
}
impl Source {
fn new(ty: SourceType, url: impl Into<String>) -> Self {
Source {
ty,
url: url.into(),
subpath: None,
local_path: None,
r#ref: None,
skill_filter: None,
}
}
pub fn archive_url(&self) -> Option<String> {
match self.ty {
SourceType::Github => {
let r = self.r#ref.clone().unwrap_or_else(|| "HEAD".to_string());
Some(format!(
"https://codeload.github.com/{}/zip/{r}",
owner_repo(&self.url)
))
}
SourceType::Gitlab => {
let r = self.r#ref.clone()?;
let base = self.url.trim_end_matches(".git");
Some(format!("{base}/-/archive/{r}/{r}.zip"))
}
_ => None,
}
}
}
pub fn sanitize_subpath(subpath: &str) -> Result<String> {
let normalized = subpath.replace('\\', "/");
if normalized.split('/').any(|seg| seg == "..") {
return Err(SkillsError::msg(format!(
"Unsafe subpath: \"{subpath}\" contains path traversal segments. Subpaths must not contain \"..\" components."
)));
}
Ok(subpath.to_string())
}
fn is_local_path(input: &str) -> bool {
let p = Path::new(input);
if p.is_absolute() {
return true;
}
if input.starts_with("./") || input.starts_with("../") {
return true;
}
if input == "." || input == ".." {
return true;
}
let b = input.as_bytes();
b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'/' || b[2] == b'\\')
}
fn host_of(input: &str) -> Option<String> {
Url::parse(input)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_lowercase()))
}
fn is_hosted_artifact_url(input: &str) -> bool {
let Ok(parsed) = Url::parse(input) else {
return false;
};
let host = host_of(input).unwrap_or_default();
if matches!(
host.as_str(),
"raw.githubusercontent.com" | "codeload.github.com" | "objects.githubusercontent.com"
) {
return true;
}
let path = parsed.path();
if host == "github.com" {
let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if segs.len() < 4 {
return false;
}
let kind = segs[2];
if kind == "archive" || kind == "raw" {
return true;
}
if kind == "releases" {
return segs[3] == "download"
|| (segs[3] == "latest" && segs.get(4) == Some(&"download"));
}
return false;
}
if host == "gitlab.com" {
return path.contains("/-/archive/") || path.contains("/-/raw/");
}
false
}
fn is_well_known_url(input: &str) -> bool {
if !input.starts_with("http://") && !input.starts_with("https://") {
return false;
}
let Some(host) = host_of(input) else {
return false;
};
if matches!(
host.as_str(),
"github.com" | "gitlab.com" | "raw.githubusercontent.com"
) {
return false;
}
!input.ends_with(".git")
}
fn reject_traversal(input: &str) -> Result<()> {
if input.split('/').any(|seg| seg == "..") {
return Err(SkillsError::msg(format!(
"Unsafe subpath: \"{input}\" contains path traversal segments. Subpaths must not contain \"..\" components."
)));
}
Ok(())
}
fn parse_github_url(input: &str) -> Result<Option<Source>> {
reject_traversal(input)?;
let Ok(parsed) = Url::parse(input) else {
return Ok(None);
};
if parsed.host_str() != Some("github.com") {
return Ok(None);
}
let segs: Vec<&str> = parsed.path().split('/').filter(|s| !s.is_empty()).collect();
if segs.len() < 2 {
return Ok(None);
}
let repo = segs[1].strip_suffix(".git").unwrap_or(segs[1]);
let mut s = Source::new(
SourceType::Github,
format!("https://github.com/{}/{}.git", segs[0], repo),
);
if segs.get(2) == Some(&"tree") && segs.get(3).is_some() {
s.r#ref = Some(segs[3].to_string());
if segs.len() > 4 {
s.subpath = Some(sanitize_subpath(&segs[4..].join("/"))?);
}
}
Ok(Some(s))
}
fn parse_gitlab_url(input: &str) -> Result<Option<Source>> {
reject_traversal(input)?;
let Ok(parsed) = Url::parse(input) else {
return Ok(None);
};
let host = parsed.host_str().unwrap_or_default().to_lowercase();
let path = parsed.path();
if let Some(idx) = path.find("/-/tree/") {
if host == "github.com" {
return Ok(None);
}
let repo_path = path[..idx].strip_suffix(".git").unwrap_or(&path[..idx]);
let rest = &path[idx + "/-/tree/".len()..];
let (ref_part, subpath) = match rest.split_once('/') {
Some((r, sp)) => (r, Some(sp.to_string())),
None => (rest, None),
};
let mut s = Source::new(SourceType::Gitlab, format!("https://{host}{repo_path}.git"));
s.r#ref = Some(ref_part.to_string());
if let Some(sp) = subpath {
s.subpath = Some(sanitize_subpath(&sp)?);
}
return Ok(Some(s));
}
if host == "gitlab.com" {
let trimmed = path.trim_end_matches('/').strip_prefix('/').unwrap_or(path);
let repo_path = trimmed.strip_suffix(".git").unwrap_or(trimmed);
if repo_path.contains('/') {
return Ok(Some(Source::new(
SourceType::Gitlab,
format!("https://gitlab.com/{repo_path}.git"),
)));
}
}
Ok(None)
}
fn parse_shorthand(input: &str) -> Result<Option<Source>> {
if input.contains(':') || input.starts_with('.') || input.starts_with('/') {
return Ok(None);
}
let (owner, rest) = match input.split_once('/') {
Some(v) => v,
None => return Ok(None),
};
if owner.is_empty() || rest.is_empty() {
return Ok(None);
}
if let Some((repo, skill)) = rest.split_once('@')
&& !repo.is_empty()
&& !repo.contains('/')
&& !repo.contains('@')
&& !skill.is_empty()
{
let mut s = Source::new(
SourceType::Github,
format!("https://github.com/{owner}/{repo}.git"),
);
s.skill_filter = Some(skill.to_string());
return Ok(Some(s));
}
let segs: Vec<&str> = rest.split('/').collect();
let repo = segs[0];
if repo.is_empty() {
return Ok(None);
}
let mut s = Source::new(
SourceType::Github,
format!("https://github.com/{owner}/{repo}.git"),
);
let subpath = segs[1..].join("/").trim_end_matches('/').to_string();
if !subpath.is_empty() {
s.subpath = Some(sanitize_subpath(&subpath)?);
}
Ok(Some(s))
}
pub fn parse_source(input: &str) -> Result<Source> {
if is_local_path(input) {
let resolved = if Path::new(input).is_absolute() {
PathBuf::from(input)
} else {
std::env::current_dir()?.join(input)
};
return Ok(Source {
ty: SourceType::Local,
url: resolved.to_string_lossy().into_owned(),
local_path: Some(resolved),
subpath: None,
r#ref: None,
skill_filter: None,
});
}
if is_hosted_artifact_url(input) {
return Ok(Source::new(SourceType::Download, input));
}
if let Some(s) = parse_github_url(input)? {
return Ok(s);
}
if let Some(s) = parse_gitlab_url(input)? {
return Ok(s);
}
if let Some(s) = parse_shorthand(input)? {
return Ok(s);
}
if is_well_known_url(input) {
return Ok(Source::new(SourceType::WellKnown, input));
}
Ok(Source::new(SourceType::Git, input))
}
pub fn owner_repo(url: &str) -> String {
let trimmed = url.trim_end_matches(".git");
let parts = trimmed.split('/').filter(|s| !s.is_empty());
let mut owner = "";
let mut repo = "";
let mut found_host = false;
for p in parts {
if p == "github.com" {
found_host = true;
continue;
}
if found_host {
if owner.is_empty() {
owner = p;
} else if repo.is_empty() {
repo = p;
break;
}
}
}
if owner.is_empty() {
url.to_string()
} else if repo.is_empty() {
owner.to_string()
} else {
format!("{owner}/{repo}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_relative_path() {
let s = parse_source("./skills/pdf").unwrap();
assert_eq!(s.ty, SourceType::Local);
assert!(s.local_path.is_some());
}
#[test]
fn local_absolute_path() {
let s = parse_source("/tmp/foo").unwrap();
assert_eq!(s.ty, SourceType::Local);
assert!(s.url.starts_with('/'));
}
#[test]
fn local_windows_drive() {
let s = parse_source(r"C:\foo\skill").unwrap();
assert_eq!(s.ty, SourceType::Local);
}
#[test]
fn github_shorthand() {
let s = parse_source("acme/skills").unwrap();
assert_eq!(s.ty, SourceType::Github);
assert_eq!(s.url, "https://github.com/acme/skills.git");
assert_eq!(s.subpath, None);
assert_eq!(s.r#ref, None);
assert_eq!(s.skill_filter, None);
}
#[test]
fn github_shorthand_with_skill() {
let s = parse_source("acme/skills@pdf").unwrap();
assert_eq!(s.ty, SourceType::Github);
assert_eq!(s.skill_filter.as_deref(), Some("pdf"));
}
#[test]
fn github_shorthand_with_subpath() {
let s = parse_source("acme/skills/skills/pdf").unwrap();
assert_eq!(s.ty, SourceType::Github);
assert_eq!(s.subpath.as_deref(), Some("skills/pdf"));
}
#[test]
fn github_url() {
let s = parse_source("https://github.com/acme/skills").unwrap();
assert_eq!(s.ty, SourceType::Github);
assert_eq!(s.url, "https://github.com/acme/skills.git");
}
#[test]
fn github_url_with_git_suffix() {
let s = parse_source("https://github.com/acme/skills.git").unwrap();
assert_eq!(s.ty, SourceType::Github);
assert_eq!(s.url, "https://github.com/acme/skills.git");
}
#[test]
fn github_url_tree_branch() {
let s = parse_source("https://github.com/acme/skills/tree/main").unwrap();
assert_eq!(s.r#ref.as_deref(), Some("main"));
assert_eq!(s.subpath, None);
}
#[test]
fn github_url_tree_with_subpath() {
let s = parse_source("https://github.com/acme/skills/tree/main/skills/pdf").unwrap();
assert_eq!(s.r#ref.as_deref(), Some("main"));
assert_eq!(s.subpath.as_deref(), Some("skills/pdf"));
}
#[test]
fn gitlab_url_with_subgroups() {
let s = parse_source("https://gitlab.com/group/subgroup/repo").unwrap();
assert_eq!(s.ty, SourceType::Gitlab);
assert_eq!(s.url, "https://gitlab.com/group/subgroup/repo.git");
}
#[test]
fn gitlab_tree_with_subpath() {
let s = parse_source("https://gitlab.com/group/repo/-/tree/main/skills/pdf").unwrap();
assert_eq!(s.ty, SourceType::Gitlab);
assert_eq!(s.r#ref.as_deref(), Some("main"));
assert_eq!(s.subpath.as_deref(), Some("skills/pdf"));
}
#[test]
fn ssh_git_url() {
let s = parse_source("git@github.com:acme/skills.git").unwrap();
assert_eq!(s.ty, SourceType::Git);
assert_eq!(s.url, "git@github.com:acme/skills.git");
}
#[test]
fn well_known_url() {
let s = parse_source("https://example.com/foo/skill").unwrap();
assert_eq!(s.ty, SourceType::WellKnown);
}
#[test]
fn url_ending_with_git_is_git() {
let s = parse_source("https://example.com/foo.git").unwrap();
assert_eq!(s.ty, SourceType::Git);
}
#[test]
fn raw_github_artifact_is_download() {
let s = parse_source("https://raw.githubusercontent.com/x/y/main/SKILL.md").unwrap();
assert_eq!(s.ty, SourceType::Download);
}
#[test]
fn unsafe_subpath_is_rejected() {
assert!(parse_source("acme/skills/a/../b").is_err());
assert!(parse_source("https://github.com/x/y/tree/main/a/../b").is_err());
}
#[test]
fn archive_url_github_default_branch() {
let s = parse_source("acme/skills").unwrap();
assert_eq!(
s.archive_url().as_deref(),
Some("https://codeload.github.com/acme/skills/zip/HEAD")
);
}
#[test]
fn archive_url_github_with_ref() {
let s = parse_source("https://github.com/acme/skills/tree/main").unwrap();
assert_eq!(
s.archive_url().as_deref(),
Some("https://codeload.github.com/acme/skills/zip/main")
);
}
#[test]
fn archive_url_gitlab_with_ref() {
let s = parse_source("https://gitlab.com/group/sub/repo/-/tree/main").unwrap();
assert_eq!(
s.archive_url().as_deref(),
Some("https://gitlab.com/group/sub/repo/-/archive/main/main.zip")
);
}
#[test]
fn archive_url_none_without_ref_or_for_other_types() {
let s = parse_source("https://gitlab.com/group/sub/repo").unwrap();
assert_eq!(s.archive_url(), None);
let s = parse_source("https://example.com/x.zip").unwrap();
assert_eq!(s.archive_url(), None);
}
}