1use std::path::{Path, PathBuf};
12
13use url::Url;
14
15use crate::error::{Result, SkillsError};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum SourceType {
20 Github,
22 Gitlab,
24 Git,
26 Local,
28 WellKnown,
30 Download,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Source {
37 pub ty: SourceType,
39 pub url: String,
41 pub subpath: Option<String>,
43 pub local_path: Option<PathBuf>,
45 pub r#ref: Option<String>,
47 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 pub fn archive_url(&self) -> Option<String> {
67 match self.ty {
68 SourceType::Github => {
69 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
86pub 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 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
119fn 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 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
155fn 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
172fn 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 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 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 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
245fn 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 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 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
290pub fn parse_source(input: &str) -> Result<Source> {
292 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 Ok(Source::new(SourceType::Git, input))
327}
328
329pub 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 let s = parse_source("https://gitlab.com/group/sub/repo").unwrap();
512 assert_eq!(s.archive_url(), None);
513 let s = parse_source("https://example.com/x.zip").unwrap();
515 assert_eq!(s.archive_url(), None);
516 }
517}