use super::LibraryError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SourceScheme {
Manifest { url: String },
Git {
repo: String,
git_ref: String,
subpath: Option<String>,
},
}
pub fn parse_source(url: &str) -> Result<SourceScheme, LibraryError> {
if let Some(rest) = url.strip_prefix("git+https://") {
return parse_git(&format!("https://{rest}"), url);
}
if let Some(rest) = url.strip_prefix("git+file://") {
if std::env::var_os("JARVY_LIBRARY_ALLOW_INSECURE_GIT").is_none() {
return Err(LibraryError::Parse {
url: url.to_string(),
source: serde::de::Error::custom(
"git+file:// is refused in production; \
set JARVY_LIBRARY_ALLOW_INSECURE_GIT=1 for local tests only",
),
});
}
return parse_git(&format!("file://{rest}"), url);
}
if let Some(rest) = url.strip_prefix("github:") {
let (path, suffix) = split_suffix(rest);
if suffix.is_empty() {
return Err(LibraryError::Parse {
url: url.to_string(),
source: serde::de::Error::custom(
"github: shorthand requires an @<ref> pin, e.g. \
github:owner/repo@v1.0.0",
),
});
}
let repo_path = if path.ends_with(".git") {
path.to_string()
} else {
format!("{path}.git")
};
let canonical = format!("https://github.com/{repo_path}{suffix}");
return parse_git(&canonical, url);
}
if url.starts_with("https://") {
return Ok(SourceScheme::Manifest {
url: url.to_string(),
});
}
Err(LibraryError::Parse {
url: url.to_string(),
source: serde::de::Error::custom(
"library_sources.url must start with https:// (manifest), \
git+https:// (git clone), or github: (GitHub shorthand)",
),
})
}
fn parse_git(https_url: &str, original_url: &str) -> Result<SourceScheme, LibraryError> {
let (head, subpath) = match https_url.find('#') {
Some(idx) => (&https_url[..idx], Some(https_url[idx + 1..].to_string())),
None => (https_url, None),
};
if let Some(ref sp) = subpath {
if sp.starts_with('/') || sp.split('/').any(|seg| seg == "..") {
return Err(LibraryError::Parse {
url: original_url.to_string(),
source: serde::de::Error::custom(
"git source subpath must be relative and contain no `..` segments",
),
});
}
}
let (repo, git_ref) = match head.rsplit_once('@') {
Some((repo, git_ref)) if !git_ref.is_empty() => (repo, git_ref),
_ => {
return Err(LibraryError::Parse {
url: original_url.to_string(),
source: serde::de::Error::custom(
"git source requires an @<ref> pin (tag / branch / commit SHA); \
unpinned refs are refused so silent updates can't ship through",
),
});
}
};
if git_ref.starts_with('-') {
return Err(LibraryError::Parse {
url: original_url.to_string(),
source: serde::de::Error::custom(
"git ref must not start with `-` (refused as potential argv-flag injection \
against `git fetch --upload-pack=...` and similar)",
),
});
}
if git_ref
.chars()
.any(|c| c.is_whitespace() || c.is_control() || c == '\u{7f}')
{
return Err(LibraryError::Parse {
url: original_url.to_string(),
source: serde::de::Error::custom(
"git ref must not contain whitespace or control bytes",
),
});
}
let loopback_allowed = std::env::var_os("JARVY_LIBRARY_ALLOW_INSECURE_GIT").is_some();
let is_https = repo.starts_with("https://");
let is_local_file = loopback_allowed && repo.starts_with("file://");
if !is_https && !is_local_file {
return Err(LibraryError::Parse {
url: original_url.to_string(),
source: serde::de::Error::custom("git source repo URL must be HTTPS"),
});
}
Ok(SourceScheme::Git {
repo: repo.to_string(),
git_ref: git_ref.to_string(),
subpath,
})
}
fn split_suffix(rest: &str) -> (&str, &str) {
match rest.find(['@', '#']) {
Some(idx) => (&rest[..idx], &rest[idx..]),
None => (rest, ""),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_plain_manifest_url() {
let s = parse_source("https://cdn.example.com/manifest.json").unwrap();
assert_eq!(
s,
SourceScheme::Manifest {
url: "https://cdn.example.com/manifest.json".into()
}
);
}
#[test]
fn parses_git_https_with_tag() {
let s = parse_source("git+https://github.com/myorg/skills.git@v1.2.0").unwrap();
assert_eq!(
s,
SourceScheme::Git {
repo: "https://github.com/myorg/skills.git".into(),
git_ref: "v1.2.0".into(),
subpath: None,
}
);
}
#[test]
fn parses_git_https_with_subpath() {
let s = parse_source("git+https://github.com/myorg/skills.git@main#skills/").unwrap();
assert_eq!(
s,
SourceScheme::Git {
repo: "https://github.com/myorg/skills.git".into(),
git_ref: "main".into(),
subpath: Some("skills/".into()),
}
);
}
#[test]
fn parses_github_shorthand() {
let s = parse_source("github:myorg/skills@v1.2.0").unwrap();
assert_eq!(
s,
SourceScheme::Git {
repo: "https://github.com/myorg/skills.git".into(),
git_ref: "v1.2.0".into(),
subpath: None,
}
);
}
#[test]
fn parses_github_shorthand_with_subpath() {
let s = parse_source("github:myorg/skills@abc1234#skills/code-review").unwrap();
assert_eq!(
s,
SourceScheme::Git {
repo: "https://github.com/myorg/skills.git".into(),
git_ref: "abc1234".into(),
subpath: Some("skills/code-review".into()),
}
);
}
#[test]
fn github_shorthand_preserves_existing_git_suffix() {
let s = parse_source("github:myorg/skills.git@v1.0.0").unwrap();
if let SourceScheme::Git { repo, .. } = s {
assert_eq!(repo, "https://github.com/myorg/skills.git");
} else {
panic!("expected Git variant");
}
}
#[test]
fn refuses_unpinned_git_url() {
let err = parse_source("git+https://github.com/myorg/skills.git").unwrap_err();
assert!(format!("{err}").contains("@<ref>"));
}
#[test]
fn refuses_unpinned_github_shorthand() {
let err = parse_source("github:myorg/skills").unwrap_err();
assert!(format!("{err}").contains("@<ref>"));
}
#[test]
fn refuses_dash_prefixed_ref() {
for hostile in [
"git+https://github.com/myorg/skills.git@--upload-pack=curl",
"git+https://github.com/myorg/skills.git@-X",
"github:myorg/skills@-",
"github:myorg/skills@--receive-pack=evil",
] {
let err = parse_source(hostile).expect_err(hostile);
let msg = format!("{err}");
assert!(
msg.contains("argv-flag injection") || msg.contains("must not start"),
"expected argv-injection refusal for {hostile}, got {msg}"
);
}
}
#[test]
fn refuses_whitespace_or_control_in_ref() {
for hostile in [
"github:myorg/skills@v1.0.0\n",
"github:myorg/skills@v1 0",
"github:myorg/skills@v1\u{0007}", "github:myorg/skills@v1\u{001b}[31m",
] {
let err = parse_source(hostile).expect_err(hostile);
let msg = format!("{err}");
assert!(
msg.contains("whitespace") || msg.contains("control"),
"expected whitespace/control refusal for {hostile:?}, got {msg}"
);
}
}
#[test]
fn refuses_subpath_with_dotdot() {
let err =
parse_source("git+https://github.com/myorg/skills.git@v1#../etc/passwd").unwrap_err();
assert!(format!("{err}").contains(".."));
}
#[test]
fn refuses_absolute_subpath() {
let err =
parse_source("git+https://github.com/myorg/skills.git@v1#/etc/passwd").unwrap_err();
assert!(format!("{err}").contains("relative"));
}
#[test]
fn refuses_unknown_scheme() {
let err = parse_source("ftp://example.com/skills.json").unwrap_err();
assert!(format!("{err}").contains("https://"));
}
#[test]
fn refuses_plain_http() {
let err = parse_source("http://example.com/skills.json").unwrap_err();
assert!(format!("{err}").contains("https://"));
}
}