diff --git a/data/forges.yml b/data/forges.yml new file mode 100644 index 0000000..5489c94 --- /dev/null +++ b/data/forges.yml @@ -0,0 +1,31 @@ +## Forge hosts recognized by `pkh new` origin detection, with the +## release-tarball URL templates of each: `Forge::parse` +## (src/new/origin.rs) matches a git remote's host against the map keys, +## and the tarball download substitutes {owner}, {repo} and {tag} into the +## templates. Like host_keys.yml, this file exists so that static +## endpoints are data: adding a forge is a YAML entry, not a code change +## (self-hosted instances are deliberately absent — the download URL +## shapes differ per instance). +## +## tarball_templates are tried sequentially in file order, best candidate +## first (GitHub prefers the codeload direct link: no redirect). +## `kind` documents the forge family the URL shapes belong to; the +## templates fully describe the URLs, so nothing branches on it (yet) — +## but it must be one of the known kinds, enforced at load time. +## +## Where the values come from: each forge's release-archive download URL +## shapes, verified against the live forges — +## github: codeload.github.com///tar.gz/refs/tags/ +## and github.com///archive/refs/tags/.tar.gz +## gitlab: gitlab.com///-/archive//-.tar.gz + +forges: + github.com: + kind: github + tarball_templates: + - https://codeload.github.com/{owner}/{repo}/tar.gz/refs/tags/{tag} + - https://github.com/{owner}/{repo}/archive/refs/tags/{tag}.tar.gz + gitlab.com: + kind: gitlab + tarball_templates: + - https://gitlab.com/{owner}/{repo}/-/archive/{tag}/{repo}-{tag}.tar.gz diff --git a/src/data.rs b/src/data.rs index 0b561ba..e750ec7 100644 --- a/src/data.rs +++ b/src/data.rs @@ -8,9 +8,9 @@ //! This module is deliberately not a central registry: each file is //! embedded by the module that owns it (distro_info.rs owns //! data/distro_info.yml, launchpad.rs owns data/launchpad.yml, -//! apt/keyring.rs owns data/keyserver.yml, put/ssh.rs owns -//! data/host_keys.yml, quirks.rs owns data/quirks.yml) through the -//! [`embed_data!`] macro below, so data +//! apt/keyring.rs owns data/keyserver.yml, new/origin.rs owns +//! data/forges.yml, put/ssh.rs owns data/host_keys.yml, quirks.rs owns +//! data/quirks.yml) through the [`embed_data!`] macro below, so data //! and its accessors stay together and a diff touching one domain cannot //! half-touch another. The macro embeds the file at compile time and //! parses it once into a `lazy_static` on first use; since the data ships diff --git a/src/new/options.rs b/src/new/options.rs index 6a878df..0c9504a 100644 --- a/src/new/options.rs +++ b/src/new/options.rs @@ -1537,10 +1537,7 @@ mod tests { resolve(cli).await.unwrap().orig, Some(OrigOrigin::Release { tag: "v1.2.3".to_string(), - forge: crate::new::origin::Forge::GitHub { - owner: "foo".into(), - repo: "bar".into() - } + forge: crate::new::origin::Forge::parse("https://github.com/foo/bar.git").unwrap(), }) ); diff --git a/src/new/orig.rs b/src/new/orig.rs index 4d250dd..4636f12 100644 --- a/src/new/orig.rs +++ b/src/new/orig.rs @@ -1535,10 +1535,8 @@ mod tests { "0.1.0", &OrigOrigin::Release { tag: "v0.1.0".to_string(), - forge: Forge::GitHub { - owner: "pkh-nonexistent-org".into(), - repo: "pkh-nonexistent-repo".into(), - }, + forge: Forge::parse("https://github.com/pkh-nonexistent-org/pkh-nonexistent-repo") + .unwrap(), }, false, ) diff --git a/src/new/origin.rs b/src/new/origin.rs index 4930eef..e6d55c6 100644 --- a/src/new/origin.rs +++ b/src/new/origin.rs @@ -8,48 +8,85 @@ //! - is HEAD exactly on a tag, and which upstream version does it name, //! - which is the last tag reachable from HEAD (for the //! `+git.` version scheme), -//! - where does the `origin` remote point: only `github.com` and -//! `gitlab.com` are recognized as forges — self-hosted GitLab instances -//! are deliberately not (the release-download URL shapes differ). +//! - where does the `origin` remote point: only the hosts of the bundled +//! forge table (`data/forges.yml`) are recognized as forges — self-hosted +//! GitLab instances are deliberately not part of it (the +//! release-download URL shapes differ), and the table also carries each +//! forge's tarball URL templates. //! //! Detection never touches the network: adding a remote stores its URL in //! the local config only, which is all this module reads. +use std::collections::HashMap; use std::path::Path; use std::process::Command; -/// A forge hosting the project, parsed from the `origin` remote URL. +use serde::Deserialize; + +use crate::data::embed_data; + +/// Family a forge belongs to. Documentation of which URL-shape family a +/// table entry belongs to — the tarball templates fully describe the +/// URLs, so nothing branches on the kind (yet). Parsed strictly: an +/// unknown kind fails the load rather than being silently ignored. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum ForgeKind { + /// `github.com` and its codeload archive service + GitHub, + /// `gitlab.com` + GitLab, +} + +/// One entry of the bundled forge table: the release-tarball URL +/// templates of a forge host +#[derive(Debug, Deserialize)] +struct ForgeEntry { + /// Family of the forge (see [`ForgeKind`]). Kept even though nothing + /// branches on it (the templates fully describe the URLs): it + /// documents the entry's URL-shape family and the strict enum + /// validates it at load time. + #[allow(dead_code)] + kind: ForgeKind, + /// Release-tarball URL templates, best candidate first (tried + /// sequentially by the download) + tarball_templates: Vec, +} + +/// The bundled forge table (`data/forges.yml`): host name → entry +#[derive(Debug, Deserialize)] +struct ForgesData { + /// Recognized forge hosts + forges: HashMap, +} + +embed_data! { + static ref FORGES_DATA: ForgesData = "../../data/forges.yml" +} + +/// A forge hosting the project, parsed from the `origin` remote URL: one +/// of the hosts of the bundled forge table, plus the repository it points +/// at. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum Forge { - /// `github.com//` - GitHub { - /// Repository owner (user or organization). - owner: String, - /// Repository name, without the `.git` suffix. - repo: String, - }, - /// `gitlab.com//` - GitLab { - /// Repository owner (user or group). - owner: String, - /// Repository name, without the `.git` suffix. - repo: String, - }, +pub struct Forge { + /// Host name of the forge (the key of its `forges.yml` entry). + host: &'static str, + /// Repository owner (user or organization). + owner: String, + /// Repository name, without the `.git` suffix. + repo: String, } impl Forge { /// Host name of the forge. pub fn host(&self) -> &'static str { - match self { - Forge::GitHub { .. } => "github.com", - Forge::GitLab { .. } => "gitlab.com", - } + self.host } /// Parse a remote URL into a [`Forge`], accepting the `https://`, - /// `http://`, `git://` and `git@host:` spellings. Only `github.com` and - /// `gitlab.com` are recognized; anything else (self-hosted GitLab, - /// Bitbucket, plain URLs…) yields `None`. + /// `http://`, `git://` and `git@host:` spellings. Only the hosts + /// listed in the bundled forge table are recognized; anything else + /// (self-hosted GitLab, Bitbucket, plain URLs…) yields `None`. pub fn parse(url: &str) -> Option { let url = url.trim(); // Normalize `git@host:path` to `host/path` and strip any scheme. @@ -74,34 +111,34 @@ impl Forge { if owner.is_empty() || repo.is_empty() { return None; } - match host { - "github.com" => Some(Forge::GitHub { - owner: owner.to_string(), - repo: repo.to_string(), - }), - "gitlab.com" => Some(Forge::GitLab { - owner: owner.to_string(), - repo: repo.to_string(), - }), - _ => None, - } + // The table key doubles as the Forge's host, so the two can never + // disagree about how the forge is spelled. + let (host, _entry) = FORGES_DATA.forges.get_key_value(host)?; + Some(Forge { + host, + owner: owner.to_string(), + repo: repo.to_string(), + }) } - /// Release-tarball URLs of `tag`, best candidate first. GitHub prefers - /// the codeload direct link (no redirect) and falls back to the - /// `github.com` archive URL; GitLab has a single archive URL. + /// Release-tarball URLs of `tag`, best candidate first: the templates + /// of the forge's table entry, with `{owner}`, `{repo}` and `{tag}` + /// substituted, in file order (the download tries them sequentially). pub fn release_tarball_urls(&self, tag: &str) -> Vec { - match self { - Forge::GitHub { owner, repo } => vec![ - format!("https://codeload.github.com/{owner}/{repo}/tar.gz/refs/tags/{tag}"), - format!("https://github.com/{owner}/{repo}/archive/refs/tags/{tag}.tar.gz"), - ], - Forge::GitLab { owner, repo } => { - vec![format!( - "https://gitlab.com/{owner}/{repo}/-/archive/{tag}/{repo}-{tag}.tar.gz" - )] - } - } + let entry = FORGES_DATA + .forges + .get(self.host) + .expect("the host of a parsed Forge is a key of the forge table"); + entry + .tarball_templates + .iter() + .map(|template| { + template + .replace("{owner}", &self.owner) + .replace("{repo}", &self.repo) + .replace("{tag}", tag) + }) + .collect() } } @@ -299,35 +336,40 @@ mod tests { fn forge_parses_remote_url_shapes() { assert_eq!( Forge::parse("https://github.com/foo/bar.git"), - Some(Forge::GitHub { + Some(Forge { + host: "github.com", owner: "foo".into(), repo: "bar".into() }) ); assert_eq!( Forge::parse("git@github.com:foo/bar.git"), - Some(Forge::GitHub { + Some(Forge { + host: "github.com", owner: "foo".into(), repo: "bar".into() }) ); assert_eq!( Forge::parse("git://github.com/foo/bar"), - Some(Forge::GitHub { + Some(Forge { + host: "github.com", owner: "foo".into(), repo: "bar".into() }) ); assert_eq!( Forge::parse("https://gitlab.com/foo/bar/-/tree/main"), - Some(Forge::GitLab { + Some(Forge { + host: "gitlab.com", owner: "foo".into(), repo: "bar".into() }) ); assert_eq!( Forge::parse("ssh://git@gitlab.com/foo/bar.git"), - Some(Forge::GitLab { + Some(Forge { + host: "gitlab.com", owner: "foo".into(), repo: "bar".into() }) @@ -342,7 +384,8 @@ mod tests { #[test] fn forge_release_tarball_urls() { - let gh = Forge::GitHub { + let gh = Forge { + host: "github.com", owner: "foo".into(), repo: "bar".into(), }; @@ -353,7 +396,8 @@ mod tests { "https://github.com/foo/bar/archive/refs/tags/v1.2.3.tar.gz".to_string(), ] ); - let gl = Forge::GitLab { + let gl = Forge { + host: "gitlab.com", owner: "foo".into(), repo: "bar".into(), }; @@ -363,6 +407,45 @@ mod tests { ); } + /// Every table entry must be substitutable: a template missing one of + /// the placeholders would download from a literal `{name}` URL. + #[test] + fn forge_templates_carry_every_placeholder() { + for (host, entry) in &FORGES_DATA.forges { + assert!( + !entry.tarball_templates.is_empty(), + "forge '{host}' has no tarball template" + ); + for template in &entry.tarball_templates { + for placeholder in ["{owner}", "{repo}", "{tag}"] { + assert!( + template.contains(placeholder), + "template '{template}' of forge '{host}' lacks {placeholder}" + ); + } + } + } + } + + /// The `kind` field is validated at load time: an unknown forge family + /// fails the parse instead of being silently accepted. + #[test] + fn forge_kind_is_validated() { + assert!(serde_yaml::from_str::("kind: github").is_err()); + assert!( + serde_yaml::from_str::( + "kind: github\ntarball_templates: ['https://h/{owner}/{repo}/{tag}']" + ) + .is_ok() + ); + assert!( + serde_yaml::from_str::( + "kind: codeberg\ntarball_templates: ['https://h/{owner}/{repo}/{tag}']" + ) + .is_err() + ); + } + #[test] fn sanitized_tag_versions() { assert_eq!(sanitized_tag_version("v1.2.3"), Some("1.2.3".to_string())); @@ -466,7 +549,8 @@ mod tests { let origin = GitOrigin::detect(dir.path()).expect("detected"); assert_eq!( origin.forge, - Some(Forge::GitHub { + Some(Forge { + host: "github.com", owner: "foo".into(), repo: "bar".into() }) diff --git a/src/new/questions.rs b/src/new/questions.rs index e7a6c05..fa82212 100644 --- a/src/new/questions.rs +++ b/src/new/questions.rs @@ -1295,10 +1295,7 @@ mod tests { fn orig_origin_choices_preorder_by_detection() { use crate::new::origin::Forge; let tagged_forge = GitOrigin { - forge: Some(Forge::GitHub { - owner: "foo".into(), - repo: "bar".into(), - }), + forge: Some(Forge::parse("https://github.com/foo/bar").unwrap()), head_tag: Some("v1.4.0".into()), ..Default::default() }; @@ -1362,10 +1359,7 @@ mod tests { let mut release = quilt.clone(); release.orig = Some(options::OrigOrigin::Release { tag: "v0.14.0".to_string(), - forge: crate::new::origin::Forge::GitLab { - owner: "foo".into(), - repo: "bar".into(), - }, + forge: crate::new::origin::Forge::parse("https://gitlab.com/foo/bar").unwrap(), }); let text = summary_text(&release, None); assert!(