Skip to main content

agents_skills/core/
source.rs

1//! Source parsing: parse a user-provided source string into a structured [`Source`].
2//!
3//! Supports: local paths, GitHub URLs (incl. `/tree/<ref>/<path>`), GitLab URLs
4//! (incl. `/-/tree/`), GitHub shorthand (`owner/repo`, `owner/repo@skill`,
5//! `owner/repo/subpath`), SSH / generic git URLs, and arbitrary https (well-known /
6//! direct download).
7//!
8//! Not supported: `github:`/`gitlab:` prefixes, `#ref@skill` fragments, and
9//! SOURCE_ALIASES alias mapping.
10
11use std::path::{Path, PathBuf};
12
13use url::Url;
14
15use crate::error::{Result, SkillsError};
16
17/// The kind of a parsed source.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum SourceType {
20    /// GitHub repository (has an API/blob fast path).
21    Github,
22    /// GitLab repository.
23    Gitlab,
24    /// Generic git repository (SSH / any git host).
25    Git,
26    /// Local filesystem path.
27    Local,
28    /// Arbitrary https endpoint (try well-known discovery first, then direct download).
29    WellKnown,
30    /// Hosted artifact direct link (raw / archive / release asset), must be downloaded directly.
31    Download,
32}
33
34/// Parsed source.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Source {
37    /// Source type.
38    pub ty: SourceType,
39    /// Primary URL for install/download (resolved absolute path for local sources).
40    pub url: String,
41    /// Subpath within the repository (e.g. `skills/pdf`).
42    pub subpath: Option<String>,
43    /// Absolute path when the source is local.
44    pub local_path: Option<PathBuf>,
45    /// Branch or tag ref.
46    pub r#ref: Option<String>,
47    /// Skill name selected via the `@skill` syntax.
48    pub skill_filter: Option<String>,
49}
50
51impl Source {
52    fn new(ty: SourceType, url: impl Into<String>) -> Self {
53        Source {
54            ty,
55            url: url.into(),
56            subpath: None,
57            local_path: None,
58            r#ref: None,
59            skill_filter: None,
60        }
61    }
62
63    /// Whole-repo archive URL for hosts that publish tarballs without a git clone
64    /// (GitHub codeload, GitLab). `None` when the archive would need a ref we cannot
65    /// resolve (e.g. GitLab without an explicit branch/tag).
66    pub fn archive_url(&self) -> Option<String> {
67        match self.ty {
68            SourceType::Github => {
69                // `HEAD` resolves to the default branch on codeload.
70                let r = self.r#ref.clone().unwrap_or_else(|| "HEAD".to_string());
71                Some(format!(
72                    "https://codeload.github.com/{}/zip/{r}",
73                    owner_repo(&self.url)
74                ))
75            }
76            SourceType::Gitlab => {
77                let r = self.r#ref.clone()?;
78                let base = self.url.trim_end_matches(".git");
79                Some(format!("{base}/-/archive/{r}/{r}.zip"))
80            }
81            _ => None,
82        }
83    }
84}
85
86/// Reject subpaths containing `..` segments to prevent path traversal.
87pub fn sanitize_subpath(subpath: &str) -> Result<String> {
88    let normalized = subpath.replace('\\', "/");
89    if normalized.split('/').any(|seg| seg == "..") {
90        return Err(SkillsError::msg(format!(
91            "Unsafe subpath: \"{subpath}\" contains path traversal segments. Subpaths must not contain \"..\" components."
92        )));
93    }
94    Ok(subpath.to_string())
95}
96
97fn is_local_path(input: &str) -> bool {
98    let p = Path::new(input);
99    if p.is_absolute() {
100        return true;
101    }
102    if input.starts_with("./") || input.starts_with("../") {
103        return true;
104    }
105    if input == "." || input == ".." {
106        return true;
107    }
108    // Windows absolute path, e.g. C:\ or D:/
109    let b = input.as_bytes();
110    b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'/' || b[2] == b'\\')
111}
112
113fn host_of(input: &str) -> Option<String> {
114    Url::parse(input)
115        .ok()
116        .and_then(|u| u.host_str().map(|h| h.to_lowercase()))
117}
118
119/// Hosted artifact direct links (raw/archive/release asset) must be downloaded directly,
120/// not normalized into a parent repo clone.
121fn is_hosted_artifact_url(input: &str) -> bool {
122    let Ok(parsed) = Url::parse(input) else {
123        return false;
124    };
125    let host = host_of(input).unwrap_or_default();
126    if matches!(
127        host.as_str(),
128        "raw.githubusercontent.com" | "codeload.github.com" | "objects.githubusercontent.com"
129    ) {
130        return true;
131    }
132    let path = parsed.path();
133    if host == "github.com" {
134        // /<owner>/<repo>/archive/... | /raw/... | /releases/download/... | /releases/latest/download/...
135        let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
136        if segs.len() < 4 {
137            return false;
138        }
139        let kind = segs[2];
140        if kind == "archive" || kind == "raw" {
141            return true;
142        }
143        if kind == "releases" {
144            return segs[3] == "download"
145                || (segs[3] == "latest" && segs.get(4) == Some(&"download"));
146        }
147        return false;
148    }
149    if host == "gitlab.com" {
150        return path.contains("/-/archive/") || path.contains("/-/raw/");
151    }
152    false
153}
154
155/// Arbitrary https endpoint: not a known git host and not ending in `.git` → well-known.
156fn is_well_known_url(input: &str) -> bool {
157    if !input.starts_with("http://") && !input.starts_with("https://") {
158        return false;
159    }
160    let Some(host) = host_of(input) else {
161        return false;
162    };
163    if matches!(
164        host.as_str(),
165        "github.com" | "gitlab.com" | "raw.githubusercontent.com"
166    ) {
167        return false;
168    }
169    !input.ends_with(".git")
170}
171
172/// URL parsers auto-normalize `..` segments, so we must pre-check the raw input for traversal.
173fn reject_traversal(input: &str) -> Result<()> {
174    sanitize_subpath(input).map(|_| ())
175}
176
177fn parse_github_url(input: &str) -> Result<Option<Source>> {
178    reject_traversal(input)?;
179    let Ok(parsed) = Url::parse(input) else {
180        return Ok(None);
181    };
182    if parsed.host_str() != Some("github.com") {
183        return Ok(None);
184    }
185    let segs: Vec<&str> = parsed.path().split('/').filter(|s| !s.is_empty()).collect();
186    if segs.len() < 2 {
187        return Ok(None);
188    }
189    let repo = segs[1].strip_suffix(".git").unwrap_or(segs[1]);
190    let mut s = Source::new(
191        SourceType::Github,
192        format!("https://github.com/{}/{}.git", segs[0], repo),
193    );
194    // /tree/<ref>[/<subpath>]
195    if segs.get(2) == Some(&"tree") && segs.get(3).is_some() {
196        s.r#ref = Some(segs[3].to_string());
197        if segs.len() > 4 {
198            s.subpath = Some(sanitize_subpath(&segs[4..].join("/"))?);
199        }
200    }
201    Ok(Some(s))
202}
203
204fn parse_gitlab_url(input: &str) -> Result<Option<Source>> {
205    reject_traversal(input)?;
206    let Ok(parsed) = Url::parse(input) else {
207        return Ok(None);
208    };
209    let host = parsed.host_str().unwrap_or_default().to_lowercase();
210    let path = parsed.path();
211
212    // Any GitLab instance's /-/tree/<ref>[/<subpath>]
213    if let Some(idx) = path.find("/-/tree/") {
214        if host == "github.com" {
215            return Ok(None);
216        }
217        let repo_path = path[..idx].strip_suffix(".git").unwrap_or(&path[..idx]);
218        let rest = &path[idx + "/-/tree/".len()..];
219        let (ref_part, subpath) = match rest.split_once('/') {
220            Some((r, sp)) => (r, Some(sp.to_string())),
221            None => (rest, None),
222        };
223        let mut s = Source::new(SourceType::Gitlab, format!("https://{host}{repo_path}.git"));
224        s.r#ref = Some(ref_part.to_string());
225        if let Some(sp) = subpath {
226            s.subpath = Some(sanitize_subpath(&sp)?);
227        }
228        return Ok(Some(s));
229    }
230
231    // gitlab.com/<group>/<subgroup>/<repo>
232    if host == "gitlab.com" {
233        let trimmed = path.trim_end_matches('/').strip_prefix('/').unwrap_or(path);
234        let repo_path = trimmed.strip_suffix(".git").unwrap_or(trimmed);
235        if repo_path.contains('/') {
236            return Ok(Some(Source::new(
237                SourceType::Gitlab,
238                format!("https://gitlab.com/{repo_path}.git"),
239            )));
240        }
241    }
242    Ok(None)
243}
244
245/// GitHub shorthand: `owner/repo`, `owner/repo@skill`, `owner/repo/subpath`.
246fn parse_shorthand(input: &str) -> Result<Option<Source>> {
247    if input.contains(':') || input.starts_with('.') || input.starts_with('/') {
248        return Ok(None);
249    }
250    let (owner, rest) = match input.split_once('/') {
251        Some(v) => v,
252        None => return Ok(None),
253    };
254    if owner.is_empty() || rest.is_empty() {
255        return Ok(None);
256    }
257
258    // owner/repo@skill (repo has no / or @)
259    if let Some((repo, skill)) = rest.split_once('@')
260        && !repo.is_empty()
261        && !repo.contains('/')
262        && !repo.contains('@')
263        && !skill.is_empty()
264    {
265        let mut s = Source::new(
266            SourceType::Github,
267            format!("https://github.com/{owner}/{repo}.git"),
268        );
269        s.skill_filter = Some(skill.to_string());
270        return Ok(Some(s));
271    }
272
273    // owner/repo[/subpath]
274    let segs: Vec<&str> = rest.split('/').collect();
275    let repo = segs[0];
276    if repo.is_empty() {
277        return Ok(None);
278    }
279    let mut s = Source::new(
280        SourceType::Github,
281        format!("https://github.com/{owner}/{repo}.git"),
282    );
283    let subpath = segs[1..].join("/").trim_end_matches('/').to_string();
284    if !subpath.is_empty() {
285        s.subpath = Some(sanitize_subpath(&subpath)?);
286    }
287    Ok(Some(s))
288}
289
290/// Parse a source string (pure function).
291pub fn parse_source(input: &str) -> Result<Source> {
292    // Local path: absolute, relative, or current directory.
293    if is_local_path(input) {
294        let resolved = if Path::new(input).is_absolute() {
295            PathBuf::from(input)
296        } else {
297            std::env::current_dir()?.join(input)
298        };
299        return Ok(Source {
300            ty: SourceType::Local,
301            url: resolved.to_string_lossy().into_owned(),
302            local_path: Some(resolved),
303            subpath: None,
304            r#ref: None,
305            skill_filter: None,
306        });
307    }
308
309    if is_hosted_artifact_url(input) {
310        return Ok(Source::new(SourceType::Download, input));
311    }
312    if let Some(s) = parse_github_url(input)? {
313        return Ok(s);
314    }
315    if let Some(s) = parse_gitlab_url(input)? {
316        return Ok(s);
317    }
318    if let Some(s) = parse_shorthand(input)? {
319        return Ok(s);
320    }
321    if is_well_known_url(input) {
322        return Ok(Source::new(SourceType::WellKnown, input));
323    }
324
325    // Fallback: treat as a generic git URL.
326    Ok(Source::new(SourceType::Git, input))
327}
328
329/// Extract `owner/repo` from `https://github.com/<owner>/<repo>.git`.
330pub fn owner_repo(url: &str) -> String {
331    let trimmed = url.trim_end_matches(".git");
332    let parts = trimmed.split('/').filter(|s| !s.is_empty());
333    let mut owner = "";
334    let mut repo = "";
335    let mut found_host = false;
336    for p in parts {
337        if p == "github.com" {
338            found_host = true;
339            continue;
340        }
341        if found_host {
342            if owner.is_empty() {
343                owner = p;
344            } else if repo.is_empty() {
345                repo = p;
346                break;
347            }
348        }
349    }
350    if owner.is_empty() {
351        url.to_string()
352    } else if repo.is_empty() {
353        owner.to_string()
354    } else {
355        format!("{owner}/{repo}")
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn local_relative_path() {
365        let s = parse_source("./skills/pdf").unwrap();
366        assert_eq!(s.ty, SourceType::Local);
367        assert!(s.local_path.is_some());
368    }
369
370    #[test]
371    fn local_absolute_path() {
372        let s = parse_source("/tmp/foo").unwrap();
373        assert_eq!(s.ty, SourceType::Local);
374        assert!(s.url.starts_with('/'));
375    }
376
377    #[test]
378    fn local_windows_drive() {
379        let s = parse_source(r"C:\foo\skill").unwrap();
380        assert_eq!(s.ty, SourceType::Local);
381    }
382
383    #[test]
384    fn github_shorthand() {
385        let s = parse_source("acme/skills").unwrap();
386        assert_eq!(s.ty, SourceType::Github);
387        assert_eq!(s.url, "https://github.com/acme/skills.git");
388        assert_eq!(s.subpath, None);
389        assert_eq!(s.r#ref, None);
390        assert_eq!(s.skill_filter, None);
391    }
392
393    #[test]
394    fn github_shorthand_with_skill() {
395        let s = parse_source("acme/skills@pdf").unwrap();
396        assert_eq!(s.ty, SourceType::Github);
397        assert_eq!(s.skill_filter.as_deref(), Some("pdf"));
398    }
399
400    #[test]
401    fn github_shorthand_with_subpath() {
402        let s = parse_source("acme/skills/skills/pdf").unwrap();
403        assert_eq!(s.ty, SourceType::Github);
404        assert_eq!(s.subpath.as_deref(), Some("skills/pdf"));
405    }
406
407    #[test]
408    fn github_url() {
409        let s = parse_source("https://github.com/acme/skills").unwrap();
410        assert_eq!(s.ty, SourceType::Github);
411        assert_eq!(s.url, "https://github.com/acme/skills.git");
412    }
413
414    #[test]
415    fn github_url_with_git_suffix() {
416        let s = parse_source("https://github.com/acme/skills.git").unwrap();
417        assert_eq!(s.ty, SourceType::Github);
418        assert_eq!(s.url, "https://github.com/acme/skills.git");
419    }
420
421    #[test]
422    fn github_url_tree_branch() {
423        let s = parse_source("https://github.com/acme/skills/tree/main").unwrap();
424        assert_eq!(s.r#ref.as_deref(), Some("main"));
425        assert_eq!(s.subpath, None);
426    }
427
428    #[test]
429    fn github_url_tree_with_subpath() {
430        let s = parse_source("https://github.com/acme/skills/tree/main/skills/pdf").unwrap();
431        assert_eq!(s.r#ref.as_deref(), Some("main"));
432        assert_eq!(s.subpath.as_deref(), Some("skills/pdf"));
433    }
434
435    #[test]
436    fn gitlab_url_with_subgroups() {
437        let s = parse_source("https://gitlab.com/group/subgroup/repo").unwrap();
438        assert_eq!(s.ty, SourceType::Gitlab);
439        assert_eq!(s.url, "https://gitlab.com/group/subgroup/repo.git");
440    }
441
442    #[test]
443    fn gitlab_tree_with_subpath() {
444        let s = parse_source("https://gitlab.com/group/repo/-/tree/main/skills/pdf").unwrap();
445        assert_eq!(s.ty, SourceType::Gitlab);
446        assert_eq!(s.r#ref.as_deref(), Some("main"));
447        assert_eq!(s.subpath.as_deref(), Some("skills/pdf"));
448    }
449
450    #[test]
451    fn ssh_git_url() {
452        let s = parse_source("git@github.com:acme/skills.git").unwrap();
453        assert_eq!(s.ty, SourceType::Git);
454        assert_eq!(s.url, "git@github.com:acme/skills.git");
455    }
456
457    #[test]
458    fn well_known_url() {
459        let s = parse_source("https://example.com/foo/skill").unwrap();
460        assert_eq!(s.ty, SourceType::WellKnown);
461    }
462
463    #[test]
464    fn url_ending_with_git_is_git() {
465        let s = parse_source("https://example.com/foo.git").unwrap();
466        assert_eq!(s.ty, SourceType::Git);
467    }
468
469    #[test]
470    fn raw_github_artifact_is_download() {
471        let s = parse_source("https://raw.githubusercontent.com/x/y/main/SKILL.md").unwrap();
472        assert_eq!(s.ty, SourceType::Download);
473    }
474
475    #[test]
476    fn unsafe_subpath_is_rejected() {
477        assert!(parse_source("acme/skills/a/../b").is_err());
478        assert!(parse_source("https://github.com/x/y/tree/main/a/../b").is_err());
479    }
480
481    #[test]
482    fn archive_url_github_default_branch() {
483        let s = parse_source("acme/skills").unwrap();
484        assert_eq!(
485            s.archive_url().as_deref(),
486            Some("https://codeload.github.com/acme/skills/zip/HEAD")
487        );
488    }
489
490    #[test]
491    fn archive_url_github_with_ref() {
492        let s = parse_source("https://github.com/acme/skills/tree/main").unwrap();
493        assert_eq!(
494            s.archive_url().as_deref(),
495            Some("https://codeload.github.com/acme/skills/zip/main")
496        );
497    }
498
499    #[test]
500    fn archive_url_gitlab_with_ref() {
501        let s = parse_source("https://gitlab.com/group/sub/repo/-/tree/main").unwrap();
502        assert_eq!(
503            s.archive_url().as_deref(),
504            Some("https://gitlab.com/group/sub/repo/-/archive/main/main.zip")
505        );
506    }
507
508    #[test]
509    fn archive_url_none_without_ref_or_for_other_types() {
510        // GitLab without a ref cannot resolve a default branch for the archive.
511        let s = parse_source("https://gitlab.com/group/sub/repo").unwrap();
512        assert_eq!(s.archive_url(), None);
513        // Well-known / download sources do not use archive URLs.
514        let s = parse_source("https://example.com/x.zip").unwrap();
515        assert_eq!(s.archive_url(), None);
516    }
517}