#![allow(dead_code)]
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DistroSpec {
pub org: String,
pub repo: String,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum Error {
#[error("unsupported distro specifier `{spec}`: {reason}")]
UnsupportedSpec { spec: String, reason: &'static str },
}
pub fn parse(spec: &str) -> Result<DistroSpec, Error> {
if spec.starts_with("file://") {
return Err(Error::UnsupportedSpec {
spec: spec.to_string(),
reason: "file:// URLs are not supported; use a GitHub org/repo specifier",
});
}
if let Some(rest) = spec.strip_prefix("https://") {
let path = rest
.strip_prefix("github.com/")
.ok_or_else(|| Error::UnsupportedSpec {
spec: spec.to_string(),
reason: "only github.com HTTPS URLs are supported",
})?;
return parse_org_repo_path(spec, path);
}
if let Some(rest) = spec.strip_prefix("git@") {
let path = rest
.strip_prefix("github.com:")
.ok_or_else(|| Error::UnsupportedSpec {
spec: spec.to_string(),
reason: "only git@github.com: SSH URLs are supported",
})?;
return parse_org_repo_path(spec, path);
}
if spec.is_empty() {
return Err(Error::UnsupportedSpec {
spec: spec.to_string(),
reason: "distro specifier must not be empty",
});
}
if let Some((org, repo)) = spec.split_once('/') {
if org.is_empty() || repo.is_empty() || repo.contains('/') {
return Err(Error::UnsupportedSpec {
spec: spec.to_string(),
reason: "expected `<org>/<repo>` with no extra path components",
});
}
let repo = strip_git_suffix_required(repo, spec)?;
validate_segment(org, spec)?;
validate_segment(repo, spec)?;
return Ok(DistroSpec {
org: org.to_string(),
repo: repo.to_string(),
});
}
let repo = strip_git_suffix_required(spec, spec)?;
validate_segment(repo, spec)?;
Ok(DistroSpec {
org: "omne-org".to_string(),
repo: repo.to_string(),
})
}
fn parse_org_repo_path(spec: &str, path: &str) -> Result<DistroSpec, Error> {
let (org, repo) = path.split_once('/').ok_or_else(|| Error::UnsupportedSpec {
spec: spec.to_string(),
reason: "expected `<org>/<repo>` in URL path",
})?;
if org.is_empty() || repo.is_empty() {
return Err(Error::UnsupportedSpec {
spec: spec.to_string(),
reason: "org and repo must be non-empty",
});
}
let repo = strip_git_suffix_required(repo, spec)?;
if repo.contains('/') {
return Err(Error::UnsupportedSpec {
spec: spec.to_string(),
reason: "URL path must be exactly `<org>/<repo>(.git)?`",
});
}
validate_segment(org, spec)?;
validate_segment(repo, spec)?;
Ok(DistroSpec {
org: org.to_string(),
repo: repo.to_string(),
})
}
fn strip_git_suffix(s: &str) -> &str {
s.strip_suffix(".git").unwrap_or(s)
}
fn strip_git_suffix_required<'a>(repo: &'a str, spec: &str) -> Result<&'a str, Error> {
let stripped = strip_git_suffix(repo);
if stripped.is_empty() {
return Err(Error::UnsupportedSpec {
spec: spec.to_string(),
reason: "repo name must not be empty after stripping `.git` suffix",
});
}
Ok(stripped)
}
fn validate_segment(segment: &str, spec: &str) -> Result<(), Error> {
let bad = |reason: &'static str| Error::UnsupportedSpec {
spec: spec.to_string(),
reason,
};
if segment.is_empty() {
return Err(bad("segment must not be empty"));
}
if segment == "." || segment == ".." {
return Err(bad("segment must not be `.` or `..`"));
}
if segment.starts_with('-') {
return Err(bad("segment must not start with `-`"));
}
for c in segment.chars() {
if c == '@' {
return Err(bad(
"`@` is not supported in distro segments (no @ref version pins)",
));
}
if !matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.') {
return Err(bad(
"segment may only contain ASCII alphanumerics, `-`, `_`, and `.`",
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_bare_name_defaults_to_omne_org() {
let spec = parse("omne-faber").expect("bare name should parse");
assert_eq!(
spec,
DistroSpec {
org: "omne-org".to_string(),
repo: "omne-faber".to_string(),
}
);
}
#[test]
fn parse_full_spec_with_org() {
let spec = parse("omne-org/omne-faber").expect("org/repo should parse");
assert_eq!(
spec,
DistroSpec {
org: "omne-org".to_string(),
repo: "omne-faber".to_string(),
}
);
}
#[test]
fn parse_different_org() {
let spec = parse("acme-corp/omne-custom").expect("any org should parse");
assert_eq!(
spec,
DistroSpec {
org: "acme-corp".to_string(),
repo: "omne-custom".to_string(),
}
);
}
#[test]
fn parse_https_url() {
let spec =
parse("https://github.com/myorg/my-distro.git").expect("github HTTPS URL should parse");
assert_eq!(
spec,
DistroSpec {
org: "myorg".to_string(),
repo: "my-distro".to_string(),
}
);
}
#[test]
fn parse_https_url_without_dotgit_suffix() {
let spec = parse("https://github.com/myorg/my-distro")
.expect("github HTTPS URL without .git should parse");
assert_eq!(
spec,
DistroSpec {
org: "myorg".to_string(),
repo: "my-distro".to_string(),
}
);
}
#[test]
fn parse_ssh_url() {
let spec =
parse("git@github.com:omne-org/omne-faber.git").expect("github SSH URL should parse");
assert_eq!(
spec,
DistroSpec {
org: "omne-org".to_string(),
repo: "omne-faber".to_string(),
}
);
}
#[test]
fn parse_org_repo_strips_trailing_dotgit() {
let spec =
parse("omne-org/omne-faber.git").expect("trailing .git on org/repo should strip");
assert_eq!(spec.repo, "omne-faber");
}
#[test]
fn parse_file_url_is_rejected() {
let err = parse("file:///tmp/distro").expect_err("file:// should be rejected");
match err {
Error::UnsupportedSpec { spec, .. } => {
assert_eq!(spec, "file:///tmp/distro");
}
}
}
#[test]
fn parse_non_github_https_is_rejected() {
let err = parse("https://gitlab.com/org/repo")
.expect_err("non-github HTTPS host should be rejected");
match err {
Error::UnsupportedSpec { spec, .. } => {
assert_eq!(spec, "https://gitlab.com/org/repo");
}
}
}
#[test]
fn parse_non_github_ssh_is_rejected() {
let err = parse("git@gitlab.com:org/repo.git")
.expect_err("non-github SSH host should be rejected");
match err {
Error::UnsupportedSpec { spec, .. } => {
assert_eq!(spec, "git@gitlab.com:org/repo.git");
}
}
}
#[test]
fn parse_empty_string_is_rejected() {
let err = parse("").expect_err("empty specifier should be rejected");
match err {
Error::UnsupportedSpec { .. } => {}
}
}
#[test]
fn parse_url_with_extra_path_components_is_rejected() {
let err = parse("https://github.com/myorg/my-distro/extra")
.expect_err("extra path components should be rejected");
match err {
Error::UnsupportedSpec { .. } => {}
}
}
#[test]
fn parse_bare_dot_git_is_rejected() {
let err = parse(".git").expect_err("bare `.git` should be rejected");
match err {
Error::UnsupportedSpec { .. } => {}
}
}
#[test]
fn parse_org_slash_dot_git_is_rejected() {
let err = parse("org/.git").expect_err("`org/.git` should be rejected");
match err {
Error::UnsupportedSpec { .. } => {}
}
}
#[test]
fn parse_https_url_with_dot_git_only_repo_is_rejected() {
let err = parse("https://github.com/org/.git")
.expect_err("`https://github.com/org/.git` should be rejected");
match err {
Error::UnsupportedSpec { .. } => {}
}
}
#[test]
fn parse_dotdot_org_segment_is_rejected() {
let err = parse("../evil").expect_err("`..` org segment should be rejected");
match err {
Error::UnsupportedSpec { .. } => {}
}
}
#[test]
fn parse_at_ref_version_pin_is_rejected() {
let err =
parse("omne-nosce@v1.0").expect_err("`@ref` version pin should be rejected for now");
match err {
Error::UnsupportedSpec { .. } => {}
}
}
#[test]
fn parse_crlf_in_repo_segment_is_rejected() {
let err = parse("omne-org/repo\r\n").expect_err("CRLF in repo should be rejected");
match err {
Error::UnsupportedSpec { .. } => {}
}
}
#[test]
fn parse_nul_in_repo_segment_is_rejected() {
let err = parse("omne-org/repo\0extra").expect_err("NUL in repo should be rejected");
match err {
Error::UnsupportedSpec { .. } => {}
}
}
#[test]
fn parse_percent_encoding_in_url_segment_is_rejected() {
let err = parse("https://github.com/%2E%2E/repo")
.expect_err("percent-encoded `..` should be rejected");
match err {
Error::UnsupportedSpec { .. } => {}
}
}
#[test]
fn parse_leading_dash_org_segment_is_rejected() {
let err = parse("-invalid/repo").expect_err("leading `-` org segment should be rejected");
match err {
Error::UnsupportedSpec { .. } => {}
}
}
#[test]
fn parse_repo_with_dashes_is_accepted() {
let spec = parse("omne-org/repo-with-dashes").expect("dashes in repo should be accepted");
assert_eq!(spec.repo, "repo-with-dashes");
}
#[test]
fn parse_repo_with_underscores_is_accepted() {
let spec = parse("omne-org/repo_with_underscores")
.expect("underscores in repo should be accepted");
assert_eq!(spec.repo, "repo_with_underscores");
}
#[test]
fn parse_repo_with_internal_dots_is_accepted() {
let spec =
parse("omne-org/repo.with.dots").expect("internal dots in repo should be accepted");
assert_eq!(spec.repo, "repo.with.dots");
}
#[test]
fn parse_numeric_leading_org_and_repo_are_accepted() {
let spec = parse("123org/456repo").expect("numeric-leading names should be accepted");
assert_eq!(spec.org, "123org");
assert_eq!(spec.repo, "456repo");
}
#[test]
fn parse_url_with_query_string_is_rejected() {
let err = parse("https://github.com/org/repo.git?ref=foo")
.expect_err("URL query string should be rejected");
match err {
Error::UnsupportedSpec { .. } => {}
}
}
#[test]
fn parse_url_with_fragment_is_rejected() {
let err =
parse("https://github.com/org/repo#frag").expect_err("URL fragment should be rejected");
match err {
Error::UnsupportedSpec { .. } => {}
}
}
#[test]
fn parse_segment_with_whitespace_is_rejected() {
let err = parse("omne-org/repo with space")
.expect_err("whitespace in repo segment should be rejected");
match err {
Error::UnsupportedSpec { .. } => {}
}
}
}