#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RefPrefix {
None,
Tags,
Heads,
}
impl RefPrefix {
fn as_str(self) -> &'static str {
match self {
RefPrefix::None => "",
RefPrefix::Tags => "refs/tags/",
RefPrefix::Heads => "refs/heads/",
}
}
}
const ARCHIVE_EXTENSIONS: [&str; 4] = [".tar.gz", ".tar.xz", ".tar.bz2", ".zip"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ArchiveUrl {
scheme: String,
authority: String,
owner: String,
repo: String,
ref_prefix: RefPrefix,
ref_token: String,
ext: String,
tail: String,
}
impl ArchiveUrl {
pub(crate) fn parse(uri: &str) -> Option<ArchiveUrl> {
let (scheme, rest) = uri.split_once("://")?;
if scheme != "http" && scheme != "https" {
return None;
}
let (authority, path_and_tail) = rest.split_once('/')?;
if authority.is_empty() {
return None;
}
let (path, tail) = match path_and_tail.find('?') {
Some(idx) => (&path_and_tail[..idx], &path_and_tail[idx..]),
None => (path_and_tail, ""),
};
let parts: Vec<&str> = path.split('/').collect();
if parts.len() < 4 || parts[2] != "archive" {
return None;
}
let owner = parts[0];
let repo = parts[1];
if owner.is_empty() || repo.is_empty() {
return None;
}
let rest_segments = &parts[3..];
let (ref_prefix, ref_segments): (RefPrefix, &[&str]) =
if rest_segments.len() >= 2 && rest_segments[0] == "refs" && rest_segments[1] == "tags"
{
(RefPrefix::Tags, &rest_segments[2..])
} else if rest_segments.len() >= 2
&& rest_segments[0] == "refs"
&& rest_segments[1] == "heads"
{
(RefPrefix::Heads, &rest_segments[2..])
} else {
(RefPrefix::None, rest_segments)
};
if ref_segments.len() != 1 {
return None;
}
let ref_with_ext = ref_segments[0];
let ext = ARCHIVE_EXTENSIONS
.into_iter()
.find(|candidate| ref_with_ext.ends_with(*candidate))?;
let ref_token = &ref_with_ext[..ref_with_ext.len() - ext.len()];
if ref_token.is_empty() {
return None;
}
Some(ArchiveUrl {
scheme: scheme.to_string(),
authority: authority.to_string(),
owner: owner.to_string(),
repo: repo.to_string(),
ref_prefix,
ref_token: ref_token.to_string(),
ext: ext.to_string(),
tail: tail.to_string(),
})
}
pub(crate) fn with_ref(&self, new_ref: &str) -> String {
format!(
"{}://{}/{}/{}/archive/{}{}{}{}",
self.scheme,
self.authority,
self.owner,
self.repo,
self.ref_prefix.as_str(),
new_ref,
self.ext,
self.tail,
)
}
pub(crate) fn owner(&self) -> &str {
&self.owner
}
pub(crate) fn repo(&self) -> &str {
&self.repo
}
pub(crate) fn host(&self) -> &str {
&self.authority
}
pub(crate) fn ref_token(&self) -> &str {
&self.ref_token
}
pub(crate) fn ref_prefix_str(&self) -> &str {
self.ref_prefix.as_str()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gitea_bare_date_channel() {
let url = ArchiveUrl::parse("https://forge.example.com/owner/repo/archive/25.11.tar.gz")
.expect("bare date-channel archive URL should parse");
assert_eq!(url.scheme, "https");
assert_eq!(url.authority, "forge.example.com");
assert_eq!(url.owner, "owner");
assert_eq!(url.repo, "repo");
assert_eq!(url.ref_prefix, RefPrefix::None);
assert_eq!(url.ref_token, "25.11");
assert_eq!(url.ext, ".tar.gz");
assert_eq!(url.tail, "");
}
#[test]
fn github_bare_semver() {
let url = ArchiveUrl::parse("https://github.com/owner/repo/archive/v1.0.0.tar.gz")
.expect("github bare archive URL should parse");
assert_eq!(url.authority, "github.com");
assert_eq!(url.owner, "owner");
assert_eq!(url.repo, "repo");
assert_eq!(url.ref_prefix, RefPrefix::None);
assert_eq!(url.ref_token, "v1.0.0");
assert_eq!(url.ext, ".tar.gz");
}
#[test]
fn github_refs_tags_prefix() {
let url =
ArchiveUrl::parse("https://github.com/owner/repo/archive/refs/tags/v1.0.0.tar.gz")
.expect("github refs/tags archive URL should parse");
assert_eq!(url.ref_prefix, RefPrefix::Tags);
assert_eq!(url.ref_token, "v1.0.0");
assert_eq!(url.ext, ".tar.gz");
}
#[test]
fn github_refs_heads_prefix() {
let url = ArchiveUrl::parse(
"https://github.com/owner/repo/archive/refs/heads/nixos-24.05.tar.gz",
)
.expect("github refs/heads archive URL should parse");
assert_eq!(url.ref_prefix, RefPrefix::Heads);
assert_eq!(url.ref_token, "nixos-24.05");
assert_eq!(url.ext, ".tar.gz");
}
#[test]
fn http_scheme_and_port_preserved() {
let url = ArchiveUrl::parse("http://forge:3000/test/project1/archive/v1.0.0.tar.gz")
.expect("http forge archive URL should parse");
assert_eq!(url.scheme, "http");
assert_eq!(url.authority, "forge:3000");
assert_eq!(url.host(), "forge:3000");
assert_eq!(url.owner, "test");
assert_eq!(url.repo, "project1");
assert_eq!(url.ref_token, "v1.0.0");
}
#[test]
fn every_archive_extension_is_recognised() {
for ext in [".tar.gz", ".tar.xz", ".tar.bz2", ".zip"] {
let uri = format!("https://github.com/owner/repo/archive/v1.0.0{ext}");
let url = ArchiveUrl::parse(&uri)
.unwrap_or_else(|| panic!("extension {ext} should be recognised"));
assert_eq!(url.ext, ext);
assert_eq!(url.ref_token, "v1.0.0");
}
}
#[test]
fn with_ref_swaps_only_the_ref_token() {
let url =
ArchiveUrl::parse("https://forge.example.com/owner/repo/archive/25.11.tar.gz").unwrap();
assert_eq!(
url.with_ref("26.05"),
"https://forge.example.com/owner/repo/archive/26.05.tar.gz"
);
}
#[test]
fn with_ref_reapplies_refs_tags_prefix() {
let url =
ArchiveUrl::parse("https://github.com/owner/repo/archive/refs/tags/v1.0.0.tar.gz")
.unwrap();
assert_eq!(
url.with_ref("v2.0.0"),
"https://github.com/owner/repo/archive/refs/tags/v2.0.0.tar.gz"
);
}
#[test]
fn with_ref_reapplies_refs_heads_prefix_and_extension() {
let url =
ArchiveUrl::parse("http://forge:3000/nixos/nixpkgs/archive/refs/heads/nixos-24.05.zip")
.unwrap();
assert_eq!(
url.with_ref("nixos-24.11"),
"http://forge:3000/nixos/nixpkgs/archive/refs/heads/nixos-24.11.zip"
);
}
#[test]
fn parse_then_with_ref_is_identity_for_same_ref() {
let uri = "https://github.com/owner/repo/archive/refs/heads/nixos-24.05.tar.xz";
let url = ArchiveUrl::parse(uri).unwrap();
assert_eq!(url.with_ref(url.ref_token()), uri);
}
#[test]
fn query_shallow_is_preserved_across_ref_bump() {
let url = ArchiveUrl::parse(
"https://forge.example.com/owner/repo/archive/25.11.tar.gz?shallow=1",
)
.expect("archive URL with ?shallow=1 should parse");
assert_eq!(url.ref_token, "25.11");
assert_eq!(url.ext, ".tar.gz");
assert_eq!(url.tail, "?shallow=1");
assert_eq!(
url.with_ref("26.05"),
"https://forge.example.com/owner/repo/archive/26.05.tar.gz?shallow=1"
);
}
#[test]
fn query_narhash_with_slash_is_preserved_byte_for_byte() {
let uri =
"https://github.com/owner/repo/archive/v1.0.0.tar.gz?narHash=sha256-Ab%2BCd/Ef0123%3D";
let url = ArchiveUrl::parse(uri).expect("archive URL with ?narHash should parse");
assert_eq!(url.ref_token, "v1.0.0");
assert_eq!(url.ext, ".tar.gz");
assert_eq!(url.tail, "?narHash=sha256-Ab%2BCd/Ef0123%3D");
assert_eq!(url.with_ref(url.ref_token()), uri);
assert_eq!(
url.with_ref("v2.0.0"),
"https://github.com/owner/repo/archive/v2.0.0.tar.gz?narHash=sha256-Ab%2BCd/Ef0123%3D"
);
}
#[test]
fn parse_does_not_judge_the_ref() {
let url = ArchiveUrl::parse("https://github.com/owner/repo/archive/main.tar.gz").unwrap();
assert_eq!(url.ref_token, "main");
assert_eq!(url.ref_prefix, RefPrefix::None);
}
#[test]
fn rejects_non_archive_flake_refs() {
assert!(ArchiveUrl::parse("github:owner/repo").is_none());
assert!(ArchiveUrl::parse("git+https://github.com/owner/repo").is_none());
assert!(ArchiveUrl::parse("https://github.com/owner/repo").is_none());
}
#[test]
fn rejects_non_http_scheme() {
assert!(ArchiveUrl::parse("ftp://github.com/owner/repo/archive/v1.0.0.tar.gz").is_none());
assert!(
ArchiveUrl::parse("git+https://github.com/owner/repo/archive/v1.0.0.tar.gz").is_none()
);
}
#[test]
fn rejects_unknown_extension() {
assert!(ArchiveUrl::parse("https://github.com/owner/repo/archive/v1.0.0.tgz").is_none());
assert!(ArchiveUrl::parse("https://github.com/owner/repo/archive/v1.0.0.tar").is_none());
}
#[test]
fn rejects_missing_archive_segment() {
assert!(ArchiveUrl::parse("https://github.com/owner/archive/v1.0.0.tar.gz").is_none());
assert!(
ArchiveUrl::parse("https://github.com/owner/repo/releases/v1.0.0.tar.gz").is_none()
);
}
#[test]
fn rejects_empty_ref_token() {
assert!(ArchiveUrl::parse("https://github.com/owner/repo/archive/.tar.gz").is_none());
}
#[test]
fn rejects_multi_segment_ref_path() {
assert!(
ArchiveUrl::parse(
"https://github.com/owner/repo/archive/refs/tags/nested/v1.0.0.tar.gz"
)
.is_none()
);
}
#[test]
fn rejects_codeload_form() {
assert!(
ArchiveUrl::parse("https://codeload.github.com/owner/repo/tar.gz/v1.0.0").is_none()
);
}
}