diff --git a/Cargo.lock b/Cargo.lock index 2d2ef7961..c7586d88e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1922,6 +1922,19 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "releasekit" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6785f50e6f228cf6a6025f4974d57ce80636962a38c6dd35319b66f613b613d5" +dependencies = [ + "fast-glob", + "serde", + "serde_json", + "thiserror", + "urlencoding", +] + [[package]] name = "ring" version = "0.17.14" @@ -2319,6 +2332,7 @@ dependencies = [ "miette", "percent-encoding", "regex", + "releasekit", "serde", "serde_json", "sha2 0.11.0", @@ -2937,6 +2951,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8-zero" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 39edef65e..5cbafd232 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ repository = "https://github.com/pkgforge/soar" license = "MIT" keywords = ["appimage", "binary", "linux", "package-manager", "portable"] categories = ["command-line-utilities"] -rust-version = "1.88" +rust-version = "1.93.1" [workspace.dependencies] blake3 = { version = "1.8.7", features = ["mmap"] } @@ -50,6 +50,7 @@ onelf-format = "0.3.3" once_cell = "1.21" percent-encoding = "2.3.2" rayon = "1.12.0" +releasekit = { version = "0.1.0", default-features = false } regex = { version = "1.13.1", default-features = false, features = [ "std", "unicode-case", diff --git a/crates/soar-cli/src/cli.rs b/crates/soar-cli/src/cli.rs index 5ac58ef5a..4b7882836 100644 --- a/crates/soar-cli/src/cli.rs +++ b/crates/soar-cli/src/cli.rs @@ -411,7 +411,7 @@ pub enum Commands { #[arg(required = false, short, long, value_hint = ValueHint::AnyPath)] output: Option, - /// Regex to select the asset. Only works for github downloads + /// Regex to select the asset. Only works for forge downloads #[arg(required = false, short = 'r', long = "regex")] regexes: Option>, @@ -435,6 +435,15 @@ pub enum Commands { #[arg(required = false, long)] gitlab: Vec, + /// Codeberg project + #[arg(required = false, long)] + codeberg: Vec, + + /// Gitea or Forgejo repository URL, such as + /// https://git.example.com/owner/repo + #[arg(required = false, long, alias = "forgejo")] + gitea: Vec, + /// OCI reference #[arg(required = false, long)] ghcr: Vec, diff --git a/crates/soar-cli/src/download.rs b/crates/soar-cli/src/download.rs index 870e2fe66..bd462b9a3 100644 --- a/crates/soar-cli/src/download.rs +++ b/crates/soar-cli/src/download.rs @@ -8,11 +8,10 @@ use soar_dl::{ download::Download, error::DownloadError, filter::Filter, - github::Github, - gitlab::GitLab, + forge::Forge, oci::OciDownload, - platform::PlatformUrl, - traits::{Asset, Platform as _, Release as _}, + platform::{parse_gitea_target, PlatformUrl}, + releasekit::Asset, types::{OverwriteMode, Progress}, }; use soar_utils::bytes::format_bytes; @@ -63,16 +62,24 @@ pub async fn download( links: Vec, github: Vec, gitlab: Vec, + codeberg: Vec, + gitea: Vec, ghcr: Vec, ) -> SoarResult<()> { handle_direct_downloads(&ctx, links, ctx.output.clone()).await?; - if !github.is_empty() { - handle_github_downloads(&ctx, github).await?; + for (forge, projects) in [ + (Forge::GitHub, github), + (Forge::GitLab, gitlab), + (Forge::Codeberg, codeberg), + ] { + if !projects.is_empty() { + handle_forge_downloads(&ctx, forge, projects).await?; + } } - if !gitlab.is_empty() { - handle_gitlab_downloads(&ctx, gitlab).await?; + if !gitea.is_empty() { + handle_gitea_downloads(&ctx, gitea).await?; } if !ghcr.is_empty() { @@ -120,7 +127,9 @@ pub async fn handle_direct_downloads( tag, }) => { info!("Detected GitHub URL, processing as GitHub release"); - if let Err(err) = handle_github_release(ctx, &project, tag.as_deref()) { + if let Err(err) = + handle_forge_release(ctx, &Forge::GitHub, &project, tag.as_deref()) + { error!("{}", err); } } @@ -129,7 +138,33 @@ pub async fn handle_direct_downloads( tag, }) => { info!("Detected GitLab URL, processing as GitLab release"); - if let Err(err) = handle_gitlab_release(ctx, &project, tag.as_deref()) { + if let Err(err) = + handle_forge_release(ctx, &Forge::GitLab, &project, tag.as_deref()) + { + error!("{}", err); + } + } + Some(PlatformUrl::Codeberg { + project, + tag, + }) => { + info!("Detected Codeberg URL, processing as Codeberg release"); + if let Err(err) = + handle_forge_release(ctx, &Forge::Codeberg, &project, tag.as_deref()) + { + error!("{}", err); + } + } + Some(PlatformUrl::Gitea { + instance, + project, + tag, + }) => { + info!("Detected Gitea URL, processing as Gitea release"); + let forge = Forge::Gitea { + instance, + }; + if let Err(err) = handle_forge_release(ctx, &forge, &project, tag.as_deref()) { error!("{}", err); } } @@ -311,12 +346,16 @@ pub async fn handle_oci_downloads( Ok(()) } -fn handle_github_release( +/// Downloads an asset of a release published on `forge`. +/// +/// Without a tag the newest release that is not a prerelease is taken. +fn handle_forge_release( ctx: &DownloadContext, + forge: &Forge, project: &str, tag: Option<&str>, ) -> SoarResult<()> { - let releases = Github::fetch_releases(project, tag)?; + let releases = forge.fetch_releases(project, tag)?; let release = if let Some(tag) = tag { releases.iter().find(|r| r.tag() == tag) @@ -327,76 +366,7 @@ fn handle_github_release( .or_else(|| releases.first()) }; - let release = release.ok_or_else(|| DownloadError::InvalidResponse)?; - - info!("Found release: {}", release.tag()); - let filter = ctx.create_filter(); - - let assets: Vec<_> = release - .assets() - .iter() - .filter(|a| filter.matches(a.name())) - .collect(); - - if assets.is_empty() { - let available = release - .assets() - .iter() - .map(|a| a.name().to_string()) - .collect::>(); - - Err(DownloadError::NoMatch { - available, - })? - } - - let selected_asset = if assets.len() == 1 || ctx.yes { - assets[0] - } else { - &select_asset_interactively(assets)? - }; - - info!("Downloading asset: {}", selected_asset.name()); - - let mut dl = Download::new(selected_asset.url()) - .overwrite(ctx.get_overwrite_mode()) - .extract(ctx.extract); - - if let Some(ref out) = ctx.output { - dl = dl.output(out); - } - - if let Some(ref extract_dir) = ctx.extract_dir { - dl = dl.extract_to(extract_dir); - } - - let cb = ctx.progress_callback.clone(); - dl = dl.progress(move |p| { - cb(p); - }); - - dl.execute()?; - - Ok(()) -} - -fn handle_gitlab_release( - ctx: &DownloadContext, - project: &str, - tag: Option<&str>, -) -> SoarResult<()> { - let releases = GitLab::fetch_releases(project, tag)?; - - let release = if let Some(tag) = tag { - releases.iter().find(|r| r.tag() == tag) - } else { - releases - .iter() - .find(|r| !r.is_prerelease()) - .or_else(|| releases.first()) - }; - - let release = release.ok_or_else(|| DownloadError::InvalidResponse)?; + let release = release.ok_or(DownloadError::InvalidResponse)?; info!("Found release: {}", release.tag()); let filter = ctx.create_filter(); @@ -461,48 +431,57 @@ pub fn create_regex_patterns(regex_patterns: Option>) -> SoarResult< } } -pub async fn handle_github_downloads( +/// Downloads from each `owner/repo` on `forge`, a tag named after `@` where +/// one is given. +pub async fn handle_forge_downloads( ctx: &DownloadContext, + forge: Forge, projects: Vec, ) -> SoarResult<()> { for project in &projects { - info!("Fetching releases from GitHub: {}", project); + info!("Fetching releases from {}: {}", forge, project); - let (project, tag) = match project.trim().split_once('@') { - Some((proj, tag)) if !tag.trim().is_empty() => (proj, Some(tag.trim())), - _ => (project.trim_end_matches('@'), None), - }; + let (project, tag) = split_tag(project); - if let Err(err) = handle_github_release(ctx, project, tag) { + if let Err(err) = handle_forge_release(ctx, &forge, project, tag) { error!("{}", err); } } Ok(()) } -pub async fn handle_gitlab_downloads( - ctx: &DownloadContext, - projects: Vec, -) -> SoarResult<()> { - for project in &projects { - info!("Fetching releases from GitLab: {}", project); - - let (project, tag) = match project.trim().split_once('@') { - Some((proj, tag)) if !tag.trim().is_empty() => (proj, Some(tag.trim())), - _ => (project.trim_end_matches('@'), None), +/// Downloads from each Gitea or Forgejo repository URL. +/// +/// The instance is not one soar knows by name, so each target carries its own: +/// `https://git.example.com/owner/repo@v1.0`. +pub async fn handle_gitea_downloads(ctx: &DownloadContext, targets: Vec) -> SoarResult<()> { + for target in &targets { + let Some((instance, project, tag)) = parse_gitea_target(target, false) else { + error!("Invalid Gitea repository URL '{}'", target); + continue; }; - if let Err(err) = handle_gitlab_release(ctx, project, tag) { + info!("Fetching releases from {}: {}", instance, project); + + let forge = Forge::Gitea { + instance, + }; + if let Err(err) = handle_forge_release(ctx, &forge, &project, tag.as_deref()) { error!("{}", err); } } Ok(()) } -fn select_asset_interactively(assets: Vec<&A>) -> SoarResult -where - A: Asset + Clone, -{ +/// A project reference split from the tag written after its `@`. +fn split_tag(project: &str) -> (&str, Option<&str>) { + match project.trim().split_once('@') { + Some((proj, tag)) if !tag.trim().is_empty() => (proj, Some(tag.trim())), + _ => (project.trim().trim_end_matches('@'), None), + } +} + +fn select_asset_interactively(assets: Vec<&Asset>) -> SoarResult { info!("\nAvailable assets:"); for (i, asset) in assets.iter().enumerate() { let size = asset diff --git a/crates/soar-cli/src/main.rs b/crates/soar-cli/src/main.rs index 7946fccdd..177ebf6b1 100644 --- a/crates/soar-cli/src/main.rs +++ b/crates/soar-cli/src/main.rs @@ -153,7 +153,7 @@ fn requires_root(command: &cli::Commands) -> bool { .. } | cli::Commands::Config { - edit: Some(_), + edit: Some(_) } | cli::Commands::Repo { action: cli::RepoAction::Add { .. } @@ -317,6 +317,9 @@ async fn handle_cli() -> SoarResult<()> { } => generate_default_config(repositories.as_slice())?, command => { config::init()?; + soar_dl::forge::set_instance_token_vars( + get_config().forge_tokens.clone().unwrap_or_default(), + ); if let Some(ref profile) = args.profile { set_current_profile(profile)?; @@ -458,6 +461,8 @@ async fn handle_cli() -> SoarResult<()> { exclude_keywords, github, gitlab, + codeberg, + gitea, ghcr, exact_case, extract, @@ -493,7 +498,7 @@ async fn handle_cli() -> SoarResult<()> { force_overwrite, }; - download(context, links, github, gitlab, ghcr).await?; + download(context, links, github, gitlab, codeberg, gitea, ghcr).await?; } cli::Commands::Health => display_health(&ctx).await?, cli::Commands::Repo { diff --git a/crates/soar-cli/src/self_actions.rs b/crates/soar-cli/src/self_actions.rs index f0690f84b..af3a41e31 100644 --- a/crates/soar-cli/src/self_actions.rs +++ b/crates/soar-cli/src/self_actions.rs @@ -9,13 +9,7 @@ use soar_core::{ error::{ErrorContext, SoarError}, SoarResult, }; -use soar_dl::{ - download::Download, - github::Github, - http_client::SHARED_AGENT, - traits::{Asset as _, Platform as _, Release as _}, - types::OverwriteMode, -}; +use soar_dl::{download::Download, forge::Forge, http_client::SHARED_AGENT, types::OverwriteMode}; use soar_utils::bytes::format_bytes; use tracing::{debug, error, info, warn}; @@ -44,7 +38,7 @@ pub async fn process_self_action(action: &SelfAction) -> SoarResult<()> { _ => is_nightly, }; - let releases = Github::fetch_releases("pkgforge/soar", None)?; + let releases = Forge::GitHub.fetch_releases("pkgforge/soar", None)?; let release = releases.iter().find(|release| { let is_nightly_release = release.tag().starts_with("nightly"); @@ -59,7 +53,7 @@ pub async fn process_self_action(action: &SelfAction) -> SoarResult<()> { ); if target_nightly { - is_nightly_release && release.name() != self_version + is_nightly_release && release.name() != Some(self_version) } else { let release_version = release.tag().trim_start_matches("v"); let parsed_release_version = Version::parse(release_version).ok(); @@ -137,7 +131,9 @@ pub async fn process_self_action(action: &SelfAction) -> SoarResult<()> { let asset = assets .iter() .find(|a| { - a.name.contains(ARCH) && !a.name.contains("tar") && !a.name.contains("sum") + a.name().contains(ARCH) + && !a.name().contains("tar") + && !a.name().contains("sum") }) .ok_or_else(|| { SoarError::Custom(format!("No matching asset found for {}", ARCH)) diff --git a/crates/soar-config/src/config.rs b/crates/soar-config/src/config.rs index 069a4e726..67e3efd9c 100644 --- a/crates/soar-config/src/config.rs +++ b/crates/soar-config/src/config.rs @@ -105,6 +105,26 @@ pub struct Config { /// Display settings for output formatting pub display: Option, + /// Which environment variable holds the access token for each Gitea or + /// Forgejo host, keyed by the host as the instance URL spells it + /// (including a port, where the instance uses one). + /// + /// The value names the variable, never the token itself, so no secret is + /// written to this file. A host that is not listed here is never sent a + /// token, which is what keeps one instance's credential from reaching + /// another, or reaching a host named by a download URL rather than by you. + /// Tokens are withheld from `http://` instances whatever this says. + /// + /// ```toml + /// [forge_tokens] + /// "git.example.com" = "GITEA_TOKEN" + /// ``` + /// + /// GitHub, GitLab and Codeberg each run on one known host and read their + /// own variable: `GITHUB_TOKEN` or `GH_TOKEN`, `GITLAB_TOKEN` or + /// `GL_TOKEN`, and `CODEBERG_TOKEN`. + pub forge_tokens: Option>, + /// Whether this config is for system mode. /// Not serialized - set programmatically. #[serde(skip)] @@ -299,6 +319,7 @@ impl Config { desktop_integration: None, sync_interval: None, display: None, + forge_tokens: None, system_mode: is_system_mode(), } } @@ -393,6 +414,7 @@ impl Config { desktop_integration: None, sync_interval: None, display: None, + forge_tokens: None, system_mode, } } diff --git a/crates/soar-config/src/packages.rs b/crates/soar-config/src/packages.rs index 1998f8259..15df81df3 100644 --- a/crates/soar-config/src/packages.rs +++ b/crates/soar-config/src/packages.rs @@ -217,8 +217,8 @@ pub struct PackageOptions { /// Direct URL to download the package from (makes it a "local" package). pub url: Option, - /// Expected BLAKE3 checksum (hex) of the downloaded artifact, for `url`/`github`/ - /// `gitlab` packages. When set, soar verifies the download against it and refuses to + /// Expected BLAKE3 checksum (hex) of the downloaded artifact, for `url` and forge + /// packages. When set, soar verifies the download against it and refuses to /// install on mismatch. /// Without it, these user-declared sources install on implicit trust. /// Has no effect on registry packages, which already ship their own checksum. @@ -232,11 +232,22 @@ pub struct PackageOptions { /// When set, soar fetches the latest release and downloads the matching asset. pub gitlab: Option, + /// Codeberg repository in "owner/repo" format for installing from releases. + /// When set, soar fetches the latest release and downloads the matching asset. + pub codeberg: Option, + + /// Gitea or Forgejo repository, as the full repository URL + /// (e.g., "https://git.example.com/owner/repo"), since the instance is not + /// one soar knows by name. `forgejo` is accepted as the same key: the two + /// forges speak the same API. + #[serde(alias = "forgejo")] + pub gitea: Option, + /// Glob pattern to match release asset filename (e.g., "*linux*.AppImage"). - /// Required when github/gitlab is set to select the correct asset. + /// Required when a forge source is set to select the correct asset. pub asset_pattern: Option, - /// Whether to include pre-release versions when using github/gitlab sources. + /// Whether to include pre-release versions when using forge sources. #[serde(default)] pub include_prerelease: Option, @@ -246,7 +257,7 @@ pub struct PackageOptions { /// Custom command to fetch version and download URL. /// Output format: line 1 = version, line 2 = download URL, line 3 = size in bytes (optional). - /// If not set and github/gitlab is used, version is fetched from releases API. + /// If not set and a forge source is used, version is fetched from releases API. pub version_command: Option, /// Package type for URL installs (e.g., appimage, flatimage, archive). @@ -344,6 +355,8 @@ pub struct ResolvedPackage { pub bsum: Option, pub github: Option, pub gitlab: Option, + pub codeberg: Option, + pub gitea: Option, pub asset_pattern: Option, pub include_prerelease: Option, pub tag_pattern: Option, @@ -392,6 +405,8 @@ impl PackageSpec { bsum: None, github: None, gitlab: None, + codeberg: None, + gitea: None, asset_pattern: None, include_prerelease: None, tag_pattern: None, @@ -416,10 +431,13 @@ impl PackageSpec { PackageSpec::Detailed(opts) => { // Treat "*" as None (latest version) let version = opts.version.as_ref().filter(|v| v.as_str() != "*").cloned(); - // URL/GitHub/GitLab packages: only pinned if explicitly set + // URL and forge packages: only pinned if explicitly set // Other packages: pinned if explicitly set or if a specific version is requested - let is_remote = - opts.url.is_some() || opts.github.is_some() || opts.gitlab.is_some(); + let is_remote = opts.url.is_some() + || opts.github.is_some() + || opts.gitlab.is_some() + || opts.codeberg.is_some() + || opts.gitea.is_some(); let pinned = opts.pinned || (version.is_some() && !is_remote); ResolvedPackage { name: name.to_string(), @@ -431,6 +449,8 @@ impl PackageSpec { bsum: opts.bsum.clone(), github: opts.github.clone(), gitlab: opts.gitlab.clone(), + codeberg: opts.codeberg.clone(), + gitea: opts.gitea.clone(), asset_pattern: opts.asset_pattern.clone(), include_prerelease: opts.include_prerelease, tag_pattern: opts.tag_pattern.clone(), @@ -471,6 +491,17 @@ impl PackageSpec { } } +impl ResolvedPackage { + /// Whether this package installs from a forge release rather than a + /// registry or a fixed URL. + pub fn has_forge_source(&self) -> bool { + self.github.is_some() + || self.gitlab.is_some() + || self.codeberg.is_some() + || self.gitea.is_some() + } +} + impl PackagesConfig { /// The packages file a given `--packages` argument names. /// @@ -669,6 +700,47 @@ pub fn generate_default_packages_config() -> Result<()> { mod tests { use super::*; + #[test] + fn forgejo_declares_the_same_source_as_gitea() { + let config: PackagesConfig = toml::from_str( + r#" +[packages.tool] +forgejo = "https://git.example.com/owner/repo" +asset_pattern = "*.AppImage" + +[packages.other] +gitea = "https://git.example.com/owner/other" +asset_pattern = "*.AppImage" + +[packages.cb] +codeberg = "owner/repo" +asset_pattern = "*.AppImage" +"#, + ) + .unwrap(); + + let resolved = config.resolved_packages(); + let pkg = |name: &str| { + resolved + .iter() + .find(|pkg| pkg.name == name) + .unwrap_or_else(|| panic!("{name} missing")) + }; + assert_eq!( + pkg("tool").gitea.as_deref(), + Some("https://git.example.com/owner/repo") + ); + assert_eq!( + pkg("other").gitea.as_deref(), + Some("https://git.example.com/owner/other") + ); + assert_eq!(pkg("cb").codeberg.as_deref(), Some("owner/repo")); + // A forge package takes the version its releases report, so it is not + // pinned by declaring one. + assert!(!pkg("tool").pinned); + assert!(resolved.iter().all(|pkg| pkg.has_forge_source())); + } + #[test] fn a_package_is_system_wide_only_where_it_says_so() { let config: PackagesConfig = toml::from_str( diff --git a/crates/soar-core/src/package/release_source.rs b/crates/soar-core/src/package/release_source.rs index e9d90bc47..7a73f18a4 100644 --- a/crates/soar-core/src/package/release_source.rs +++ b/crates/soar-core/src/package/release_source.rs @@ -1,51 +1,45 @@ -//! Release source resolution for GitHub/GitLab packages. +//! Release source resolution for packages published on a git forge. //! -//! This module provides functionality to resolve package sources from -//! GitHub or GitLab releases, fetching version and download URL automatically. +//! This module resolves a package source to a concrete version and download +//! URL by asking the forge which releases a project has. use std::{collections::HashMap, process::Command}; use soar_config::packages::ResolvedPackage; -use soar_dl::{ - github::{Github, GithubAsset, GithubRelease}, - gitlab::{GitLab, GitLabAsset, GitLabRelease}, - traits::{Asset, Platform, Release}, -}; +use soar_dl::{forge::Forge, platform::parse_gitea_target, releasekit::Asset}; use crate::{ error::SoarError, package::remote_update::is_valid_download_url, utils::substitute_placeholders, SoarResult, }; -/// Source for fetching package releases. +/// Which releases a source will take. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Prerelease { + /// The newest release that is not a prerelease. + #[default] + Exclude, + /// The newest release, prerelease or not. + Include, + /// The newest prerelease, and nothing else. + Only, +} + +/// Where a package's releases come from, and which of its assets to take. #[derive(Debug, Clone)] -pub enum ReleaseSource { - /// GitHub releases source. - GitHub { - /// Repository in "owner/repo" format. - repo: String, - /// Glob pattern to match asset filename. - asset_pattern: String, - /// Whether to include pre-release versions. - include_prerelease: bool, - /// Optional glob pattern to match tag names. - tag_pattern: Option, - /// Custom architecture name mapping. - arch_map: Option>, - }, - /// GitLab releases source. - GitLab { - /// Repository in "owner/repo" format. - repo: String, - /// Glob pattern to match asset filename. - asset_pattern: String, - /// Whether to include pre-release versions. - include_prerelease: bool, - /// Optional glob pattern to match tag names. - tag_pattern: Option, - /// Custom architecture name mapping. - arch_map: Option>, - }, +pub struct ReleaseSource { + /// The forge publishing the releases. + pub forge: Forge, + /// Repository in "owner/repo" format. + pub repo: String, + /// Glob pattern to match asset filename. + pub asset_pattern: String, + /// Which releases to consider. + pub prerelease: Prerelease, + /// Optional glob pattern to match tag names. + pub tag_pattern: Option, + /// Custom architecture name mapping. + pub arch_map: Option>, } /// Result of resolving a release source. @@ -60,6 +54,18 @@ pub struct ResolvedRelease { } impl ReleaseSource { + /// A source taking `asset_pattern` from the releases of `repo` on `forge`. + pub fn new(forge: Forge, repo: impl Into, asset_pattern: impl Into) -> Self { + Self { + forge, + repo: repo.into(), + asset_pattern: asset_pattern.into(), + prerelease: Prerelease::default(), + tag_pattern: None, + arch_map: None, + } + } + /// The releases a download URL came out of, where its host publishes any. /// /// A forge download URL names the project, the release it belongs to and @@ -68,61 +74,47 @@ impl ReleaseSource { /// is reported as such rather than guessed at. pub fn from_download_url(url: &str) -> Option { let ReleaseDownload { - is_github, - owner, + forge, repo, tag, asset, } = ReleaseDownload::parse(url)?; - let (owner, repo, tag, asset) = (&owner, &repo, &tag, &asset); - - let source = if is_github { - Self::GitHub { - repo: format!("{owner}/{repo}"), - asset_pattern: asset_glob(tag, asset), - include_prerelease: false, - tag_pattern: None, - arch_map: None, - } - } else { - Self::GitLab { - repo: format!("{owner}/{repo}"), - asset_pattern: asset_glob(tag, asset), - include_prerelease: false, - tag_pattern: None, - arch_map: None, - } - }; - Some(source) + + Some(Self::new(forge, repo, asset_glob(&tag, &asset))) } /// Create a ReleaseSource from a resolved package configuration. /// - /// Returns `None` if the package doesn't have github/gitlab source configured. + /// Returns `None` if the package has no forge source configured. pub fn from_resolved(pkg: &ResolvedPackage) -> Option { - if let Some(ref repo) = pkg.github { - let asset_pattern = pkg.asset_pattern.clone()?; - return Some(ReleaseSource::GitHub { - repo: repo.clone(), - asset_pattern, - include_prerelease: pkg.include_prerelease.unwrap_or(false), - tag_pattern: pkg.tag_pattern.clone(), - arch_map: pkg.arch_map.clone(), - }); - } - - if let Some(ref repo) = pkg.gitlab { - let asset_pattern = pkg.asset_pattern.clone()?; - return Some(ReleaseSource::GitLab { - repo: repo.clone(), - asset_pattern, - include_prerelease: pkg.include_prerelease.unwrap_or(false), - tag_pattern: pkg.tag_pattern.clone(), - arch_map: pkg.arch_map.clone(), - }); - } + let (forge, repo) = if let Some(ref repo) = pkg.github { + (Forge::GitHub, repo.clone()) + } else if let Some(ref repo) = pkg.gitlab { + (Forge::GitLab, repo.clone()) + } else if let Some(ref repo) = pkg.codeberg { + (Forge::Codeberg, repo.clone()) + } else { + let (instance, repo, _) = parse_gitea_target(pkg.gitea.as_deref()?, false)?; + ( + Forge::Gitea { + instance, + }, + repo, + ) + }; - None + Some(Self { + forge, + repo, + asset_pattern: pkg.asset_pattern.clone()?, + prerelease: if pkg.include_prerelease.unwrap_or(false) { + Prerelease::Include + } else { + Prerelease::Exclude + }, + tag_pattern: pkg.tag_pattern.clone(), + arch_map: pkg.arch_map.clone(), + }) } /// Resolve the release source to get version and download URL. @@ -139,39 +131,64 @@ impl ReleaseSource { /// If `version` is Some, fetches that specific tag instead of the latest. /// The version can be with or without 'v' prefix (both "1.0.0" and "v1.0.0" work). pub fn resolve_version(&self, version: Option<&str>) -> SoarResult { - match self { - ReleaseSource::GitHub { - repo, - asset_pattern, - include_prerelease, - tag_pattern, - arch_map, - } => { - resolve_github( - repo, - asset_pattern, - *include_prerelease, - tag_pattern.as_deref(), - version, - arch_map.as_ref(), - ) - } - ReleaseSource::GitLab { - repo, - asset_pattern, - include_prerelease, - tag_pattern, - arch_map, - } => { - resolve_gitlab( - repo, - asset_pattern, - *include_prerelease, - tag_pattern.as_deref(), - version, - arch_map.as_ref(), - ) - } + let releases = self.forge.fetch_releases(&self.repo, None).map_err(|e| { + SoarError::Custom(format!( + "Failed to fetch {} releases for {}: {}", + self.forge, self.repo, e + )) + })?; + + let release = releases + .iter() + .find(|r| { + // If a specific version is requested, match it exactly (with or without 'v' prefix) + if let Some(ver) = version { + let tag = r.tag(); + let tag_normalized = tag.strip_prefix('v').unwrap_or(tag); + let ver_normalized = ver.strip_prefix('v').unwrap_or(ver); + return tag_normalized == ver_normalized || tag == ver; + } + + let prerelease_ok = match self.prerelease { + Prerelease::Exclude => !r.is_prerelease(), + Prerelease::Include => true, + Prerelease::Only => r.is_prerelease(), + }; + let tag_ok = matches_tag_pattern(r.tag(), self.tag_pattern.as_deref()); + prerelease_ok && tag_ok + }) + .ok_or_else(|| self.no_release_error(version))?; + + let asset_pattern = substitute_placeholders( + &self.asset_pattern, + Some(release.tag()), + self.arch_map.as_ref(), + ); + let asset = find_matching_asset(release.assets(), &asset_pattern)?; + + Ok(ResolvedRelease { + version: release.tag().to_string(), + download_url: asset.url().to_string(), + size: asset.size(), + }) + } + + /// Why nothing the project published fits what was asked for. + fn no_release_error(&self, version: Option<&str>) -> SoarError { + if let Some(ver) = version { + SoarError::Custom(format!( + "No release found for {} with version '{}'", + self.repo, ver + )) + } else if self.prerelease == Prerelease::Only { + SoarError::Custom(format!("No prerelease found for {}", self.repo)) + } else if let Some(ref pattern) = self.tag_pattern { + SoarError::Custom(format!( + "No releases found for {} matching tag pattern '{}'", + self.repo, pattern + )) + } else { + SoarError::Custom(format!("No releases found for {}", self.repo)) } } } @@ -184,11 +201,9 @@ fn matches_tag_pattern(tag: &str, pattern: Option<&str>) -> bool { } } -/// Resolve a GitHub release source. /// A download URL taken apart into the release it came from. struct ReleaseDownload { - is_github: bool, - owner: String, + forge: Forge, repo: String, tag: String, asset: String, @@ -208,23 +223,53 @@ impl ReleaseDownload { .collect(); let segments: Vec<&str> = decoded.iter().map(String::as_str).collect(); - // github.com/{owner}/{repo}/releases/download/{tag}/{asset} // gitlab.com/{owner}/{repo}/-/releases/{tag}/downloads/{asset} - let (is_github, owner, repo, tag, asset) = match segments.as_slice() { - [owner, repo, "releases", "download", tag, asset] if host == "github.com" => { - (true, owner, repo, tag, asset) + if let [owner, repo, "-", "releases", tag, "downloads", asset] = segments.as_slice() { + if host == "gitlab.com" { + return Some(Self { + forge: Forge::GitLab, + repo: format!("{owner}/{repo}"), + tag: tag.to_string(), + asset: asset.to_string(), + }); } - [owner, repo, "-", "releases", tag, "downloads", asset] if host == "gitlab.com" => { - (false, owner, repo, tag, asset) + } + + // {prefix}/{owner}/{repo}/releases/download/{tag}/{asset}, which is + // GitHub's shape and the one Gitea and Forgejo publish. Anything left + // of the project is the path an instance is served under. + let marker = (2..segments.len().saturating_sub(3)) + .find(|&i| segments[i] == "releases" && segments[i + 1] == "download") + .filter(|&i| i + 4 == segments.len())?; + let (prefix, owner, repo) = ( + &segments[..marker - 2], + segments[marker - 2], + segments[marker - 1], + ); + + let forge = match host { + "github.com" | "codeberg.org" if !prefix.is_empty() => return None, + "github.com" => Forge::GitHub, + "codeberg.org" => Forge::Codeberg, + // The port is part of the host, and the prefix part of the path, + // so an instance is named by everything ahead of the project. + _ => { + let mut instance = parsed.origin().ascii_serialization(); + for segment in prefix { + instance.push('/'); + instance.push_str(segment); + } + Forge::Gitea { + instance, + } } - _ => return None, }; + Some(Self { - is_github, - owner: owner.to_string(), - repo: repo.to_string(), - tag: tag.to_string(), - asset: asset.to_string(), + forge, + repo: format!("{owner}/{repo}"), + tag: segments[marker + 2].to_string(), + asset: segments[marker + 3].to_string(), }) } } @@ -262,123 +307,8 @@ fn asset_glob(tag: &str, asset: &str) -> String { asset.to_string() } -fn resolve_github( - repo: &str, - asset_pattern: &str, - include_prerelease: bool, - tag_pattern: Option<&str>, - specific_version: Option<&str>, - arch_map: Option<&HashMap>, -) -> SoarResult { - let releases: Vec = Github::fetch_releases(repo, None).map_err(|e| { - SoarError::Custom(format!( - "Failed to fetch GitHub releases for {}: {}", - repo, e - )) - })?; - - let release = releases - .iter() - .find(|r| { - // If a specific version is requested, match it exactly (with or without 'v' prefix) - if let Some(ver) = specific_version { - let tag = r.tag(); - let tag_normalized = tag.strip_prefix('v').unwrap_or(tag); - let ver_normalized = ver.strip_prefix('v').unwrap_or(ver); - return tag_normalized == ver_normalized || tag == ver; - } - - let prerelease_ok = include_prerelease || !r.is_prerelease(); - let tag_ok = matches_tag_pattern(r.tag(), tag_pattern); - prerelease_ok && tag_ok - }) - .ok_or_else(|| { - if let Some(ver) = specific_version { - SoarError::Custom(format!( - "No release found for {} with version '{}'", - repo, ver - )) - } else if let Some(pattern) = tag_pattern { - SoarError::Custom(format!( - "No releases found for {} matching tag pattern '{}'", - repo, pattern - )) - } else { - SoarError::Custom(format!("No releases found for {}", repo)) - } - })?; - - let assets: &[GithubAsset] = release.assets(); - let asset_pattern = substitute_placeholders(asset_pattern, Some(release.tag()), arch_map); - let asset = find_matching_asset(assets, &asset_pattern)?; - - Ok(ResolvedRelease { - version: release.tag().to_string(), - download_url: asset.url().to_string(), - size: asset.size(), - }) -} - -/// Resolve a GitLab release source. -fn resolve_gitlab( - repo: &str, - asset_pattern: &str, - include_prerelease: bool, - tag_pattern: Option<&str>, - specific_version: Option<&str>, - arch_map: Option<&HashMap>, -) -> SoarResult { - let releases: Vec = GitLab::fetch_releases(repo, None).map_err(|e| { - SoarError::Custom(format!( - "Failed to fetch GitLab releases for {}: {}", - repo, e - )) - })?; - - let release = releases - .iter() - .find(|r| { - // If a specific version is requested, match it exactly (with or without 'v' prefix) - if let Some(ver) = specific_version { - let tag = r.tag(); - let tag_normalized = tag.strip_prefix('v').unwrap_or(tag); - let ver_normalized = ver.strip_prefix('v').unwrap_or(ver); - return tag_normalized == ver_normalized || tag == ver; - } - - let prerelease_ok = include_prerelease || !r.is_prerelease(); - let tag_ok = matches_tag_pattern(r.tag(), tag_pattern); - prerelease_ok && tag_ok - }) - .ok_or_else(|| { - if let Some(ver) = specific_version { - SoarError::Custom(format!( - "No release found for {} with version '{}'", - repo, ver - )) - } else if let Some(pattern) = tag_pattern { - SoarError::Custom(format!( - "No releases found for {} matching tag pattern '{}'", - repo, pattern - )) - } else { - SoarError::Custom(format!("No releases found for {}", repo)) - } - })?; - - let assets: &[GitLabAsset] = release.assets(); - let asset_pattern = substitute_placeholders(asset_pattern, Some(release.tag()), arch_map); - let asset = find_matching_asset(assets, &asset_pattern)?; - - Ok(ResolvedRelease { - version: release.tag().to_string(), - download_url: asset.url().to_string(), - size: asset.size(), - }) -} - /// Find an asset matching the given glob pattern. -fn find_matching_asset<'a, A: Asset>(assets: &'a [A], pattern: &str) -> SoarResult<&'a A> { +fn find_matching_asset<'a>(assets: &'a [Asset], pattern: &str) -> SoarResult<&'a Asset> { if assets.is_empty() { return Err(SoarError::Custom("No assets found in release".into())); } @@ -472,21 +402,11 @@ mod tests { }; let source = ReleaseSource::from_resolved(&pkg).unwrap(); - match source { - ReleaseSource::GitHub { - repo, - asset_pattern, - include_prerelease, - tag_pattern, - .. - } => { - assert_eq!(repo, "user/repo"); - assert_eq!(asset_pattern, "*.AppImage"); - assert!(include_prerelease); - assert!(tag_pattern.is_none()); - } - _ => panic!("Expected GitHub source"), - } + assert_eq!(source.forge, Forge::GitHub); + assert_eq!(source.repo, "user/repo"); + assert_eq!(source.asset_pattern, "*.AppImage"); + assert_eq!(source.prerelease, Prerelease::Include); + assert!(source.tag_pattern.is_none()); } #[test] @@ -499,21 +419,43 @@ mod tests { }; let source = ReleaseSource::from_resolved(&pkg).unwrap(); - match source { - ReleaseSource::GitLab { - repo, - asset_pattern, - include_prerelease, - tag_pattern, - .. - } => { - assert_eq!(repo, "group/project"); - assert_eq!(asset_pattern, "*.tar.gz"); - assert!(!include_prerelease); - assert!(tag_pattern.is_none()); + assert_eq!(source.forge, Forge::GitLab); + assert_eq!(source.repo, "group/project"); + assert_eq!(source.asset_pattern, "*.tar.gz"); + assert_eq!(source.prerelease, Prerelease::Exclude); + } + + #[test] + fn a_codeberg_package_resolves_against_codeberg() { + let pkg = ResolvedPackage { + name: "test".to_string(), + codeberg: Some("user/repo".to_string()), + asset_pattern: Some("*.AppImage".to_string()), + ..Default::default() + }; + + let source = ReleaseSource::from_resolved(&pkg).unwrap(); + assert_eq!(source.forge, Forge::Codeberg); + assert_eq!(source.repo, "user/repo"); + } + + #[test] + fn a_gitea_package_names_its_own_instance() { + let pkg = ResolvedPackage { + name: "test".to_string(), + gitea: Some("https://git.example.com/user/repo".to_string()), + asset_pattern: Some("*.AppImage".to_string()), + ..Default::default() + }; + + let source = ReleaseSource::from_resolved(&pkg).unwrap(); + assert_eq!( + source.forge, + Forge::Gitea { + instance: "https://git.example.com".to_string() } - _ => panic!("Expected GitLab source"), - } + ); + assert_eq!(source.repo, "user/repo"); } #[test] @@ -549,17 +491,65 @@ mod tests { tool-1.2.3-abcdef-linux-x86_64.AppImage", ) .unwrap(); - match source { - ReleaseSource::GitHub { - repo, - asset_pattern, - .. - } => { - assert_eq!(repo, "owner/repo"); - assert_eq!(asset_pattern, "tool-*-linux-x86_64.AppImage"); + assert_eq!(source.forge, Forge::GitHub); + assert_eq!(source.repo, "owner/repo"); + assert_eq!(source.asset_pattern, "tool-*-linux-x86_64.AppImage"); + } + + #[test] + fn a_gitea_download_names_the_instance_it_came_from() { + let source = ReleaseSource::from_download_url( + "https://git.example.com/owner/repo/releases/download/v1.2.3/tool-1.2.3-x86_64.AppImage", + ) + .unwrap(); + assert_eq!( + source.forge, + Forge::Gitea { + instance: "https://git.example.com".to_string() } - other => panic!("expected GitHub, got {other:?}"), - } + ); + assert_eq!(source.repo, "owner/repo"); + assert_eq!(source.asset_pattern, "tool-*-x86_64.AppImage"); + + let codeberg = ReleaseSource::from_download_url( + "https://codeberg.org/owner/repo/releases/download/v1.0/tool-1.0-x86_64.AppImage", + ) + .unwrap(); + assert_eq!(codeberg.forge, Forge::Codeberg); + } + + #[test] + fn a_gitea_instance_keeps_its_port_and_path() { + let source = ReleaseSource::from_download_url( + "https://git.example.com:3000/o/r/releases/download/v1.2.3/tool-1.2.3-x86_64.AppImage", + ) + .unwrap(); + assert_eq!( + source.forge, + Forge::Gitea { + instance: "https://git.example.com:3000".to_string() + } + ); + assert_eq!(source.repo, "o/r"); + + let prefixed = ReleaseSource::from_download_url( + "https://example.com/git/o/r/releases/download/v1.2.3/tool-1.2.3-x86_64.AppImage", + ) + .unwrap(); + assert_eq!( + prefixed.forge, + Forge::Gitea { + instance: "https://example.com/git".to_string() + } + ); + assert_eq!(prefixed.repo, "o/r"); + + // A host soar knows serves its projects at the root, so a prefix + // means the URL is something else. + assert!(ReleaseSource::from_download_url( + "https://github.com/x/o/r/releases/download/v1/tool.AppImage" + ) + .is_none()); } #[test] diff --git a/crates/soar-core/src/package/update_info.rs b/crates/soar-core/src/package/update_info.rs index 6d830779e..829dcb891 100644 --- a/crates/soar-core/src/package/update_info.rs +++ b/crates/soar-core/src/package/update_info.rs @@ -4,10 +4,27 @@ //! section. Every form names a zsync control file, either directly or as an //! asset of a forge release, so resolving one always ends at a URL soar can //! fetch. +//! +//! The forms are the ones [appimageupdate] publishes: +//! +//! - `zsync|` +//! - `gh-releases-zsync||||` +//! - `gl-releases-zsync||||` +//! - `cb-releases-zsync||||` +//! - `gitea-releases-zsync|||||` +//! - `forgejo-releases-zsync|||||` +//! +//! [appimageupdate]: https://github.com/pkgforge-dev/appimageupdate use std::path::Path; -use crate::{error::SoarError, package::release_source::ReleaseSource, SoarResult}; +use soar_dl::forge::Forge; + +use crate::{ + error::SoarError, + package::release_source::{Prerelease, ReleaseSource}, + SoarResult, +}; /// The section an AppImage records its update information in. const SECTION: &str = ".upd_info"; @@ -28,13 +45,6 @@ pub enum UpdateInfo { }, } -/// A forge that publishes releases soar can resolve an asset from. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Forge { - GitHub, - GitLab, -} - impl UpdateInfo { /// Read the update information out of an installed AppImage. /// @@ -72,16 +82,24 @@ impl UpdateInfo { } }) } - // Codeberg and Gitea are GitLab-shaped in the string but not in - // their API, so they are left unresolved rather than resolved - // against the wrong host. - "gh-releases-zsync" | "gl-releases-zsync" => { - let [owner, repo, tag, filename] = rest[..].try_into().ok()?; - let forge = if kind == "gh-releases-zsync" { - Forge::GitHub - } else { - Forge::GitLab + "gh-releases-zsync" + | "gl-releases-zsync" + | "cb-releases-zsync" + | "gitea-releases-zsync" + | "forgejo-releases-zsync" => { + let (forge, fields): (Forge, &[&str]) = match kind { + "gh-releases-zsync" => (Forge::GitHub, &rest), + "gl-releases-zsync" => (Forge::GitLab, &rest), + "cb-releases-zsync" => (Forge::Codeberg, &rest), + _ => { + let (instance, fields) = rest.split_first()?; + let forge = Forge::Gitea { + instance: gitea_instance(instance)?, + }; + (forge, fields) + } }; + let [owner, repo, tag, filename] = fields.try_into().ok()?; (!owner.is_empty() && !repo.is_empty() && !filename.is_empty()).then(|| { Self::Forge { forge, @@ -110,34 +128,12 @@ impl UpdateInfo { tag, filename, } => { - // `latest-pre` is the only form that asks for a prerelease; - // any other tag is matched literally. - let include_prerelease = tag == "latest-pre"; - let tag_pattern = match tag.as_str() { - "latest" | "latest-pre" | "" => None, - other => Some(other.to_string()), + let (prerelease, exact_tag) = release_selection(tag); + let source = ReleaseSource { + prerelease, + ..ReleaseSource::new(forge.clone(), repo, filename) }; - let source = match forge { - Forge::GitHub => { - ReleaseSource::GitHub { - repo: repo.clone(), - asset_pattern: filename.clone(), - include_prerelease, - tag_pattern, - arch_map: None, - } - } - Forge::GitLab => { - ReleaseSource::GitLab { - repo: repo.clone(), - asset_pattern: filename.clone(), - include_prerelease, - tag_pattern, - arch_map: None, - } - } - }; - let release = source.resolve()?; + let release = source.resolve_version(exact_tag)?; if release.download_url.is_empty() { return Err(SoarError::Custom(format!( "no zsync asset matching '{filename}' in {repo}" @@ -149,6 +145,38 @@ impl UpdateInfo { } } +/// Which release a feed's tag field asks for. +/// +/// `latest` takes the newest stable release, `latest-pre` the newest +/// prerelease and `latest-all` whichever of the two is newest. Any other tag +/// names one release, and names it exactly: a tag is not a pattern, and one +/// carrying `*` or `[` would otherwise select a different release. +fn release_selection(tag: &str) -> (Prerelease, Option<&str>) { + match tag { + "latest" | "" => (Prerelease::Exclude, None), + "latest-pre" => (Prerelease::Only, None), + "latest-all" => (Prerelease::Include, None), + other => (Prerelease::Include, Some(other)), + } +} + +/// The base URL of a Gitea or Forgejo instance, as a feed spells it. +/// +/// The scheme is optional there, and https is the only one worth assuming for +/// a host publishing releases. +fn gitea_instance(raw: &str) -> Option { + let instance = raw.trim().trim_end_matches('/'); + if instance.is_empty() { + return None; + } + let lowered = instance.to_ascii_lowercase(); + if lowered.starts_with("https://") || lowered.starts_with("http://") { + Some(instance.to_string()) + } else { + Some(format!("https://{instance}")) + } +} + /// The version implied by what a feed says about the artifact. /// /// A published filename carries the version far more often than the artifact @@ -199,6 +227,24 @@ mod tests { ); } + #[test] + fn the_tag_keywords_pick_which_releases_count() { + assert_eq!(release_selection("latest"), (Prerelease::Exclude, None)); + assert_eq!(release_selection(""), (Prerelease::Exclude, None)); + assert_eq!(release_selection("latest-pre"), (Prerelease::Only, None)); + assert_eq!(release_selection("latest-all"), (Prerelease::Include, None)); + // A named tag is taken as it is, prerelease or not, and is matched + // rather than globbed. + assert_eq!( + release_selection("v1.2.3"), + (Prerelease::Include, Some("v1.2.3")) + ); + assert_eq!( + release_selection("v1.0[beta]"), + (Prerelease::Include, Some("v1.0[beta]")) + ); + } + #[test] fn a_direct_feed_needs_no_network_to_resolve() { let info = UpdateInfo::parse("zsync|https://e.test/a.zsync").unwrap(); @@ -230,10 +276,67 @@ mod tests { assert_eq!(version_from_feed(None, None), None); } + #[test] + fn parses_a_gitea_feed_naming_its_instance() { + assert_eq!( + UpdateInfo::parse( + "gitea-releases-zsync|git.example.com|owner|repo|latest|App*.AppImage.zsync" + ), + Some(UpdateInfo::Forge { + forge: Forge::Gitea { + instance: "https://git.example.com".into() + }, + repo: "owner/repo".into(), + tag: "latest".into(), + filename: "App*.AppImage.zsync".into(), + }) + ); + + // Forgejo is the same API under another name, and an instance may + // spell out its scheme. + assert_eq!( + UpdateInfo::parse( + "forgejo-releases-zsync|http://git.example.com/|owner|repo|latest|App*.zsync" + ), + Some(UpdateInfo::Forge { + forge: Forge::Gitea { + instance: "http://git.example.com".into() + }, + repo: "owner/repo".into(), + tag: "latest".into(), + filename: "App*.zsync".into(), + }) + ); + + // Without an instance there is nothing to ask. + assert_eq!( + UpdateInfo::parse("gitea-releases-zsync||owner|repo|latest|App*.zsync"), + None + ); + // The project alone, in the shape the other forges use, is one field short. + assert_eq!( + UpdateInfo::parse("gitea-releases-zsync|owner|repo|latest|App*.zsync"), + None + ); + } + + #[test] + fn parses_a_codeberg_feed() { + assert_eq!( + UpdateInfo::parse("cb-releases-zsync|owner|repo|latest|App*.AppImage.zsync"), + Some(UpdateInfo::Forge { + forge: Forge::Codeberg, + repo: "owner/repo".into(), + tag: "latest".into(), + filename: "App*.AppImage.zsync".into(), + }) + ); + } + #[test] fn unknown_and_malformed_forms_are_not_a_feed() { // A form soar does not resolve, rather than one it resolves wrongly. - assert_eq!(UpdateInfo::parse("cb-releases-zsync|o|r|latest|f"), None); + assert_eq!(UpdateInfo::parse("gt-releases-zsync|o|r|latest|f"), None); assert_eq!(UpdateInfo::parse("gh-releases-zsync|o|r|latest"), None); assert_eq!(UpdateInfo::parse("zsync|"), None); assert_eq!(UpdateInfo::parse(""), None); diff --git a/crates/soar-dl/Cargo.toml b/crates/soar-dl/Cargo.toml index 692339ea9..dcf76f362 100644 --- a/crates/soar-dl/Cargo.toml +++ b/crates/soar-dl/Cargo.toml @@ -15,6 +15,7 @@ fast-glob = { workspace = true } miette = { workspace = true } percent-encoding = { workspace = true } regex = { workspace = true } +releasekit = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/crates/soar-dl/src/error.rs b/crates/soar-dl/src/error.rs index 4b46b1cfc..ce9408de5 100644 --- a/crates/soar-dl/src/error.rs +++ b/crates/soar-dl/src/error.rs @@ -72,6 +72,13 @@ pub enum DownloadError { #[diagnostic(code(soar_dl::multiple_errors))] Multiple { errors: Vec }, + #[error("{0}")] + #[diagnostic( + code(soar_dl::forge_request), + help("Check your internet connection or try again later") + )] + ForgeRequest(String), + #[error("zsync: {0}")] #[diagnostic( code(soar_dl::zsync), @@ -103,6 +110,29 @@ impl From for DownloadError { } } +impl From for DownloadError { + /// A forge error reported as the download error closest to it. + /// + /// The status of a refused request is what tells a rate limit apart from a + /// project that does not exist, so it is kept rather than flattened into a + /// message. + fn from(err: releasekit::Error) -> Self { + match err { + releasekit::Error::Http { + status, + url, + } => { + Self::HttpError { + status, + url, + } + } + releasekit::Error::Json(_) => Self::InvalidResponse, + other => Self::ForgeRequest(other.to_string()), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/soar-dl/src/forge.rs b/crates/soar-dl/src/forge.rs new file mode 100644 index 000000000..0896da2bc --- /dev/null +++ b/crates/soar-dl/src/forge.rs @@ -0,0 +1,222 @@ +//! Release fetching from git forges. + +use std::{ + collections::HashMap, + fmt, + sync::{LazyLock, RwLock}, +}; + +use releasekit::{ + client::{HeaderMap, HttpClient, Response}, + platform::{GitHub, GitLab, Gitea}, + Forge as _, Release, +}; + +use crate::{error::DownloadError, http_client::SHARED_AGENT}; + +const CODEBERG_URL: &str = "https://codeberg.org"; + +/// Release notes are prose, and a long history of them outgrows the default +/// body limit. +const MAX_RESPONSE_SIZE: u64 = 32 * 1024 * 1024; + +/// The environment variable holding the token for each Gitea or Forgejo host. +static INSTANCE_TOKEN_VARS: LazyLock>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +/// Declares which environment variable holds the token for each Gitea or +/// Forgejo host, keyed by host and, where the instance uses one, port. +/// +/// Nothing else is ever sent a token. GitHub, GitLab and Codeberg each run on +/// one known host, so their variables can be read directly, but a Gitea or +/// Forgejo instance is whatever host names itself one: a URL soar was handed +/// rather than asked for could otherwise collect the credential meant for +/// somewhere else. +pub fn set_instance_token_vars(vars: HashMap) { + let mut tokens = INSTANCE_TOKEN_VARS.write().unwrap(); + *tokens = vars + .into_iter() + .map(|(host, var)| (host.trim().to_ascii_lowercase(), var)) + .collect(); +} + +/// The variable holding `instance`'s token, where one was declared. +/// +/// An `http://` instance has none: a token is not worth sending in the clear. +fn instance_token_var(instance: &str) -> Option { + let authority = instance.strip_prefix("https://")?; + let authority = authority + .split_once('/') + .map_or(authority, |(authority, _)| authority) + .to_ascii_lowercase(); + + INSTANCE_TOKEN_VARS.read().unwrap().get(&authority).cloned() +} + +/// A git forge soar can fetch releases from. +/// +/// Gitea and Forgejo are the same API, so one variant covers both. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Forge { + /// github.com. + GitHub, + /// gitlab.com. + GitLab, + /// codeberg.org. + Codeberg, + /// A Gitea or Forgejo instance, named by its base URL. + Gitea { + /// Base URL of the instance, such as `https://git.example.com`. + instance: String, + }, +} + +impl Forge { + /// Fetches the releases of `project`, or the single release `tag` names. + /// + /// `project` is `owner/repo`, or a numeric project id on GitLab. + pub fn fetch_releases( + &self, + project: &str, + tag: Option<&str>, + ) -> Result, DownloadError> { + let releases = match self { + Self::GitHub => { + GitHub::new(SoarClient) + .with_token_from_env(&["GITHUB_TOKEN", "GH_TOKEN"]) + .fetch_releases(project, tag) + } + Self::GitLab => { + GitLab::new(SoarClient) + .with_token_from_env(&["GITLAB_TOKEN", "GL_TOKEN"]) + .fetch_releases(project, tag) + } + Self::Codeberg => { + Gitea::new(SoarClient, CODEBERG_URL) + .with_token_from_env(&["CODEBERG_TOKEN"]) + .fetch_releases(project, tag) + } + Self::Gitea { + instance, + } => { + let mut gitea = Gitea::new(SoarClient, instance); + if let Some(ref var) = instance_token_var(instance) { + gitea = gitea.with_token_from_env(&[var]); + } + gitea.fetch_releases(project, tag) + } + }; + + releases.map_err(Into::into) + } +} + +impl fmt::Display for Forge { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::GitHub => f.write_str("GitHub"), + Self::GitLab => f.write_str("GitLab"), + Self::Codeberg => f.write_str("Codeberg"), + Self::Gitea { + instance, + } => write!(f, "{instance}"), + } + } +} + +/// The HTTP backend forge requests go out through, so they carry whatever +/// proxy, user agent and timeout soar is configured with. +#[derive(Clone)] +struct SoarClient; + +impl HttpClient for SoarClient { + fn get(&self, url: &str, headers: &HeaderMap) -> releasekit::error::Result { + let mut req = SHARED_AGENT.get(url); + for (key, value) in headers.iter() { + req = req.header(key, value); + } + + let mut resp = req.call().map_err(|err| { + match err { + ureq::Error::StatusCode(status) => { + releasekit::Error::Http { + status, + url: url.to_string(), + } + } + other => releasekit::Error::Network(other.to_string()), + } + })?; + + let status = resp.status().as_u16(); + if !resp.status().is_success() { + return Err(releasekit::Error::Http { + status, + url: url.to_string(), + }); + } + + let body = resp + .body_mut() + .with_config() + .limit(MAX_RESPONSE_SIZE) + .read_to_string() + .map_err(|err| releasekit::Error::Network(err.to_string()))?; + + Ok(Response { + status, + body, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_a_declared_https_instance_is_sent_a_token() { + set_instance_token_vars(HashMap::from([ + ("git.example.com".to_string(), "EXAMPLE_TOKEN".to_string()), + ( + "git.example.com:3000".to_string(), + "PORTED_TOKEN".to_string(), + ), + ])); + + assert_eq!( + instance_token_var("https://git.example.com").as_deref(), + Some("EXAMPLE_TOKEN") + ); + assert_eq!( + instance_token_var("https://GIT.example.com/gitea").as_deref(), + Some("EXAMPLE_TOKEN") + ); + assert_eq!( + instance_token_var("https://git.example.com:3000").as_deref(), + Some("PORTED_TOKEN") + ); + // A host nobody declared, which is what a download URL names. + assert_eq!(instance_token_var("https://evil.test"), None); + // The same host, but in the clear. + assert_eq!(instance_token_var("http://git.example.com"), None); + // A port turns it into a different host. + assert_eq!(instance_token_var("https://git.example.com:8443"), None); + + set_instance_token_vars(HashMap::new()); + assert_eq!(instance_token_var("https://git.example.com"), None); + } + + #[test] + fn a_forge_names_itself_by_what_the_user_wrote() { + assert_eq!(Forge::GitHub.to_string(), "GitHub"); + assert_eq!(Forge::Codeberg.to_string(), "Codeberg"); + assert_eq!( + Forge::Gitea { + instance: "https://git.example.com".into() + } + .to_string(), + "https://git.example.com" + ); + } +} diff --git a/crates/soar-dl/src/github.rs b/crates/soar-dl/src/github.rs deleted file mode 100644 index 32943d6bc..000000000 --- a/crates/soar-dl/src/github.rs +++ /dev/null @@ -1,282 +0,0 @@ -use serde::Deserialize; - -use crate::{ - error::DownloadError, - platform::fetch_releases_json, - traits::{Asset, Platform, Release}, -}; - -pub struct Github; - -#[derive(Debug, Clone, Deserialize)] -pub struct GithubRelease { - pub name: Option, - pub tag_name: String, - pub prerelease: bool, - pub published_at: String, - pub body: Option, - pub assets: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct GithubAsset { - pub name: String, - pub size: u64, - pub browser_download_url: String, -} - -impl Platform for Github { - type Release = GithubRelease; - - const API_BASE: &'static str = "https://api.github.com"; - const TOKEN_ENV: [&str; 2] = ["GITHUB_TOKEN", "GH_TOKEN"]; - - /// Fetches releases for the given GitHub repository, optionally filtered by a specific tag. - /// - /// If `tag` is provided, fetches the release that matches that tag; otherwise fetches the repository's releases (up to 100 per page). - /// - /// # Arguments - /// - /// * `project` — repository identifier in the form "owner/repo". - /// * `tag` — optional release tag to filter the results. - /// - /// # Returns - /// - /// `Ok` with a vector of releases on success, or `Err(DownloadError)` on failure. - /// - /// # Examples - /// - /// ```no_run - /// use soar_dl::github::Github; - /// use soar_dl::traits::{Platform, Release}; - /// - /// let releases = Github::fetch_releases("rust-lang/rust", None).unwrap(); - /// assert!(releases.iter().all(|r| r.tag().len() > 0)); - /// ``` - fn fetch_releases( - project: &str, - tag: Option<&str>, - ) -> Result, DownloadError> { - let path = match tag { - Some(tag) => { - let encoded_tag = - url::form_urlencoded::byte_serialize(tag.as_bytes()).collect::(); - format!( - "/repos/{project}/releases/tags/{}?per_page=100", - encoded_tag - ) - } - None => format!("/repos/{project}/releases?per_page=100"), - }; - - fetch_releases_json::(&path, Self::API_BASE, Self::TOKEN_ENV) - } -} - -impl Release for GithubRelease { - type Asset = GithubAsset; - - /// The release's name, or an empty string if the release has no name. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::github::GithubRelease; - /// use soar_dl::traits::Release; - /// - /// let r = GithubRelease { - /// name: Some("v1.0".into()), - /// tag_name: "v1.0".into(), - /// prerelease: false, - /// published_at: "".into(), - /// body: None, - /// assets: vec![], - /// }; - /// assert_eq!(r.name(), "v1.0"); - /// - /// let unnamed = GithubRelease { - /// name: None, - /// tag_name: "v1.1".into(), - /// prerelease: false, - /// published_at: "".into(), - /// body: None, - /// assets: vec![], - /// }; - /// assert_eq!(unnamed.name(), ""); - /// ``` - fn name(&self) -> &str { - self.name.as_deref().unwrap_or("") - } - - /// Get the release tag as a string slice. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::github::GithubRelease; - /// use soar_dl::traits::Release; - /// - /// let release = GithubRelease { - /// name: None, - /// tag_name: "v1.0.0".into(), - /// prerelease: false, - /// published_at: "".into(), - /// body: None, - /// assets: vec![], - /// }; - /// assert_eq!(release.tag(), "v1.0.0"); - /// ``` - /// - /// # Returns - /// - /// `&str` containing the release tag. - fn tag(&self) -> &str { - &self.tag_name - } - - /// Indicates whether the release is marked as a prerelease. - /// - /// # Returns - /// - /// `true` if the release is marked as a prerelease, `false` otherwise. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::github::GithubRelease; - /// use soar_dl::traits::Release; - /// - /// let r = GithubRelease { - /// name: None, - /// tag_name: "v1.0.0".to_string(), - /// prerelease: true, - /// published_at: "".to_string(), - /// body: None, - /// assets: vec![], - /// }; - /// assert!(r.is_prerelease()); - /// ``` - fn is_prerelease(&self) -> bool { - self.prerelease - } - - /// Returns the release's publication timestamp as an RFC 3339 formatted string. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::github::GithubRelease; - /// use soar_dl::traits::Release; - /// - /// let r = GithubRelease { - /// name: None, - /// tag_name: "v1.0.0".into(), - /// prerelease: false, - /// published_at: "2021-01-01T00:00:00Z".into(), - /// body: None, - /// assets: vec![], - /// }; - /// assert_eq!(r.published_at(), "2021-01-01T00:00:00Z"); - /// ``` - fn published_at(&self) -> &str { - &self.published_at - } - - /// Get a slice of assets associated with the release. - /// - /// The slice contains the release's assets in declaration order. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::github::{GithubRelease, GithubAsset}; - /// use soar_dl::traits::Release; - /// - /// let asset = GithubAsset { - /// name: "example.zip".into(), - /// size: 1024, - /// browser_download_url: "https://example.com/example.zip".into(), - /// }; - /// - /// let release = GithubRelease { - /// name: Some("v1.0".into()), - /// tag_name: "v1.0".into(), - /// prerelease: false, - /// published_at: "2025-01-01T00:00:00Z".into(), - /// body: None, - /// assets: vec![asset], - /// }; - /// - /// assert_eq!(release.assets().len(), 1); - /// ``` - fn assets(&self) -> &[Self::Asset] { - &self.assets - } - - fn body(&self) -> Option<&str> { - self.body.as_deref() - } -} - -impl Asset for GithubAsset { - /// Retrieves the asset's name. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::github::GithubAsset; - /// use soar_dl::traits::Asset; - /// - /// let asset = GithubAsset { - /// name: "file.zip".to_string(), - /// size: 123, - /// browser_download_url: "https://example.com/file.zip".to_string(), - /// }; - /// assert_eq!(asset.name(), "file.zip"); - /// ``` - /// - /// # Returns - /// - /// A `&str` containing the asset's name. - fn name(&self) -> &str { - &self.name - } - - /// Asset size in bytes. - /// - /// # Returns - /// - /// `Some(size)` containing the asset size in bytes. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::github::GithubAsset; - /// use soar_dl::traits::Asset; - /// - /// let asset = GithubAsset { name: "file".into(), size: 12345, browser_download_url: "https://example.com".into() }; - /// assert_eq!(asset.size(), Some(12345)); - /// ``` - fn size(&self) -> Option { - Some(self.size) - } - - /// Returns the asset's browser download URL. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::github::GithubAsset; - /// use soar_dl::traits::Asset; - /// - /// let asset = GithubAsset { - /// name: "example".into(), - /// size: 123, - /// browser_download_url: "https://example.com/download".into(), - /// }; - /// assert_eq!(asset.url(), "https://example.com/download"); - /// ``` - fn url(&self) -> &str { - &self.browser_download_url - } -} diff --git a/crates/soar-dl/src/gitlab.rs b/crates/soar-dl/src/gitlab.rs deleted file mode 100644 index 2e6b838ce..000000000 --- a/crates/soar-dl/src/gitlab.rs +++ /dev/null @@ -1,268 +0,0 @@ -use serde::Deserialize; - -use crate::{ - error::DownloadError, - platform::fetch_releases_json, - traits::{Asset, Platform, Release}, -}; - -pub struct GitLab; - -#[derive(Debug, Clone, Deserialize)] -pub struct GitLabRelease { - pub name: String, - pub tag_name: String, - pub upcoming_release: bool, - pub released_at: String, - pub description: Option, - pub assets: GitLabAssets, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct GitLabAssets { - pub links: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct GitLabAsset { - pub name: String, - pub direct_asset_url: String, -} - -impl Platform for GitLab { - type Release = GitLabRelease; - - const API_BASE: &'static str = "https://gitlab.com"; - const TOKEN_ENV: [&str; 2] = ["GITLAB_TOKEN", "GL_TOKEN"]; - - /// Fetches releases for a GitLab project, optionally narrowing to a specific tag. - /// - /// The `project` is the repository identifier (for example `"group/name"` or a numeric project ID). - /// If `tag` is provided and the `project` consists only of digits, the fetch targets that single release; otherwise the fetch returns the project's release list. - /// - /// # Parameters - /// - /// - `project`: repository identifier or numeric project ID. - /// - `tag`: optional release tag to narrow the request. - /// - /// # Returns - /// - /// `Ok(Vec)` with the fetched releases on success, or a `DownloadError` on failure. - /// - /// # Examples - /// - /// ```no_run - /// use soar_dl::gitlab::GitLab; - /// use soar_dl::traits::Platform; - /// - /// // Fetch all releases for a namespaced project - /// let _ = GitLab::fetch_releases("group/project", None); - /// - /// // Fetch a specific release when using a numeric project ID - /// let _ = GitLab::fetch_releases("123456", Some("v1.0.0")); - /// ``` - fn fetch_releases( - project: &str, - tag: Option<&str>, - ) -> Result, DownloadError> { - let encoded_project = project.replace('/', "%2F"); - let path = match tag { - Some(t) if project.chars().all(char::is_numeric) => { - let encoded_tag = - url::form_urlencoded::byte_serialize(t.as_bytes()).collect::(); - format!( - "/api/v4/projects/{}/releases/{}", - encoded_project, encoded_tag - ) - } - _ => format!("/api/v4/projects/{}/releases", encoded_project), - }; - - fetch_releases_json::(&path, Self::API_BASE, Self::TOKEN_ENV) - } -} - -impl Release for GitLabRelease { - type Asset = GitLabAsset; - - /// The release's name - /// - /// # Examples - /// - /// ``` - /// use soar_dl::gitlab::{GitLabAssets, GitLabRelease}; - /// use soar_dl::traits::Release; - /// - /// let r = GitLabRelease { - /// name: "v1.0".into(), - /// tag_name: "v1.0".into(), - /// upcoming_release: false, - /// released_at: "".into(), - /// description: None, - /// assets: GitLabAssets { links: vec![] }, - /// }; - /// assert_eq!(r.name(), "v1.0"); - /// ``` - fn name(&self) -> &str { - &self.name - } - - /// Get the release's tag name. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::gitlab::{GitLabAssets, GitLabRelease}; - /// use soar_dl::traits::Release; - /// - /// let r = GitLabRelease { - /// name: "Release".into(), - /// tag_name: "v1.0.0".into(), - /// upcoming_release: false, - /// released_at: "2025-01-01T00:00:00Z".into(), - /// description: None, - /// assets: GitLabAssets { links: vec![] }, - /// }; - /// assert_eq!(r.tag(), "v1.0.0"); - /// ``` - fn tag(&self) -> &str { - &self.tag_name - } - - /// Indicates whether the release is marked as upcoming. - /// - /// # Returns - /// - /// `true` if the release is marked as upcoming, `false` otherwise. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::gitlab::{GitLabAssets, GitLabRelease}; - /// use soar_dl::traits::Release; - /// - /// let rel = GitLabRelease { - /// name: "v1".to_string(), - /// tag_name: "v1".to_string(), - /// upcoming_release: true, - /// released_at: "".to_string(), - /// description: None, - /// assets: GitLabAssets { links: vec![] }, - /// }; - /// assert!(rel.is_prerelease()); - /// ``` - fn is_prerelease(&self) -> bool { - self.upcoming_release - } - - /// Get the release's published date/time string. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::gitlab::{GitLabAssets, GitLabRelease}; - /// use soar_dl::traits::Release; - /// - /// let r = GitLabRelease { - /// name: String::from("v1"), - /// tag_name: String::from("v1"), - /// upcoming_release: false, - /// released_at: String::from("2020-01-01T00:00:00Z"), - /// description: None, - /// assets: GitLabAssets { links: vec![] }, - /// }; - /// assert_eq!(r.published_at(), "2020-01-01T00:00:00Z"); - /// ``` - fn published_at(&self) -> &str { - &self.released_at - } - - /// A slice of assets associated with the release. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::gitlab::{GitLabAsset, GitLabAssets, GitLabRelease}; - /// use soar_dl::traits::{Asset, Release}; - /// - /// let asset = GitLabAsset { name: "file.tar.gz".into(), direct_asset_url: "https://example.com/file.tar.gz".into() }; - /// let assets = GitLabAssets { links: vec![asset.clone()] }; - /// let release = GitLabRelease { - /// name: "v1.0".into(), - /// tag_name: "v1.0".into(), - /// upcoming_release: false, - /// released_at: "2025-10-31T00:00:00Z".into(), - /// description: None, - /// assets, - /// }; - /// let slice = release.assets(); - /// assert_eq!(slice.len(), 1); - /// assert_eq!(slice[0].name(), "file.tar.gz"); - /// ``` - /// - /// # Returns - /// - /// A slice of the release's assets. - fn assets(&self) -> &[Self::Asset] { - &self.assets.links - } - - fn body(&self) -> Option<&str> { - self.description.as_deref() - } -} - -impl Asset for GitLabAsset { - /// Gets the asset's name. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::gitlab::GitLabAsset; - /// use soar_dl::traits::Asset; - /// - /// let asset = GitLabAsset { name: String::from("v1.0.0"), direct_asset_url: String::from("https://example") }; - /// assert_eq!(asset.name(), "v1.0.0"); - /// ``` - fn name(&self) -> &str { - &self.name - } - - /// Returns the asset size when available; for GitLab assets this is not provided. - /// - /// This implementation always reports that size information is unavailable. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::gitlab::GitLabAsset; - /// use soar_dl::traits::Asset; - /// - /// let asset = GitLabAsset { - /// name: "example".into(), - /// direct_asset_url: "https://gitlab.com/example".into(), - /// }; - /// assert_eq!(asset.size(), None); - /// ``` - fn size(&self) -> Option { - None - } - - /// Returns the direct URL of the asset. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::gitlab::GitLabAsset; - /// use soar_dl::traits::Asset; - /// - /// let asset = GitLabAsset { - /// name: String::from("example"), - /// direct_asset_url: String::from("https://example.com/download"), - /// }; - /// assert_eq!(asset.url(), "https://example.com/download"); - /// ``` - fn url(&self) -> &str { - &self.direct_asset_url - } -} diff --git a/crates/soar-dl/src/lib.rs b/crates/soar-dl/src/lib.rs index 28281b86d..389982f36 100644 --- a/crates/soar-dl/src/lib.rs +++ b/crates/soar-dl/src/lib.rs @@ -1,14 +1,14 @@ +pub use releasekit; + pub mod download; pub mod error; pub mod filter; -pub mod github; -pub mod gitlab; +pub mod forge; pub mod http; pub mod http_client; pub mod oci; pub mod platform; pub mod release; -pub mod traits; pub mod types; pub mod utils; pub mod xattr; diff --git a/crates/soar-dl/src/platform.rs b/crates/soar-dl/src/platform.rs index d0b2d356a..97cd986e1 100644 --- a/crates/soar-dl/src/platform.rs +++ b/crates/soar-dl/src/platform.rs @@ -1,12 +1,9 @@ -use std::{env, sync::LazyLock}; +use std::sync::LazyLock; use percent_encoding::percent_decode_str; use regex::Regex; -use ureq::http::header::AUTHORIZATION; use url::Url; -use crate::{error::DownloadError, http_client::SHARED_AGENT}; - #[derive(Debug)] pub enum PlatformUrl { Github { @@ -17,6 +14,15 @@ pub enum PlatformUrl { project: String, tag: Option, }, + Codeberg { + project: String, + tag: Option, + }, + Gitea { + instance: String, + project: String, + tag: Option, + }, Oci { reference: String, }, @@ -37,12 +43,20 @@ static GITLAB_RE: LazyLock = LazyLock::new(|| { .expect("unable to compile gitlab release regex") }); +static CODEBERG_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(?i)(?:https?://)?(?:codeberg(?:\.org)?[:/])([^/@]+/[^/@]+)(?:@([^\r\n]+))?$") + .expect("unable to compile codeberg release regex") +}); + impl PlatformUrl { /// Classifies an input string as a platform URL and returns the corresponding `PlatformUrl` variant. /// /// This inspects the input URL (or reference) and returns: /// - `Oci` when the normalized string starts with `ghcr.io/` (treated as an OCI reference). /// - `Github` when it matches the GitHub repository pattern, extracting project and optional tag. + /// - `Codeberg` when it matches the Codeberg repository pattern. + /// - `Gitea` when it is prefixed `gitea:` or `forgejo:`, which is what names an + /// instance soar cannot recognize from its host alone. /// - `Gitlab` when it matches the GitLab repository pattern, extracting project and optional tag /// (except when the project looks like an API path or contains `/-/`, which is treated as `Direct`). /// - `Direct` when the input parses as a valid URL with a scheme and host. @@ -75,6 +89,14 @@ impl PlatformUrl { }); } + if let Some((instance, project, tag)) = parse_gitea_target(url, true) { + return Some(Self::Gitea { + instance, + project, + tag, + }); + } + if let Some((project, tag)) = Self::parse_repo(&GITHUB_RE, url) { return Some(Self::Github { project, @@ -82,6 +104,13 @@ impl PlatformUrl { }); } + if let Some((project, tag)) = Self::parse_repo(&CODEBERG_RE, url) { + return Some(Self::Codeberg { + project, + tag, + }); + } + if let Some((project, tag)) = Self::parse_repo(&GITLAB_RE, url) { if project.starts_with("api/") || project.contains("/-/") { return Url::parse(url).ok().map(|_| { @@ -114,77 +143,88 @@ impl PlatformUrl { fn parse_repo(re: &Regex, url: &str) -> Option<(String, Option)> { let caps = re.captures(url)?; let project = caps.get(1)?.as_str().to_string(); - let tag = caps - .get(2) - .map(|m| m.as_str().trim_matches(&['\'', '"', ' '][..])) - .filter(|s| !s.is_empty()) - .and_then(|s| { - percent_decode_str(s) - .decode_utf8() - .ok() - .map(|cow| cow.into_owned()) - }); + let tag = caps.get(2).and_then(|m| clean_tag(m.as_str())); Some((project, tag)) } } -/// Fetches JSON from an API base URL with an optional Bearer token and returns the deserialized -/// items as a `Vec`. -/// -/// If the environment variable named by `token_env[0]` (or `token_env[1]` as fallback) is set, -/// it is included as an `Authorization: Bearer ` header. The response body must be either -/// a JSON array (mapped to `Vec`) or a single JSON object (mapped to a one-element `Vec`); -/// other shapes produce `DownloadError::InvalidResponse`. Non-success HTTP statuses produce -/// `DownloadError::HttpError`. -pub fn fetch_releases_json( - path: &str, - base: &str, - token_env: [&str; 2], -) -> Result, DownloadError> -where - T: serde::de::DeserializeOwned, -{ - let url = format!("{}{}", base, path); - let mut req = SHARED_AGENT.get(&url); - - // An empty variable is set but says nothing, and sending it as a bearer - // token earns a 401 on every request rather than the anonymous rate limit. - let token = env::var(token_env[0]) - .or_else(|_| env::var(token_env[1])) - .ok() - .map(|token| token.trim().to_string()) - .filter(|token| !token.is_empty()); - if let Some(token) = token { - req = req.header(AUTHORIZATION, &format!("Bearer {token}")); +/// A tag as the publisher spells it, out of how a URL had to carry it. +fn clean_tag(raw: &str) -> Option { + let trimmed = raw.trim_matches(&['\'', '"', ' '][..]); + if trimmed.is_empty() { + return None; } + percent_decode_str(trimmed) + .decode_utf8() + .ok() + .map(|cow| cow.into_owned()) +} - let mut resp = req.call()?; - let status = resp.status(); - - if !status.is_success() { - return Err(DownloadError::HttpError { - status: status.as_u16(), - url: url.clone(), - }); +/// A Gitea or Forgejo target as its instance, the project on it and an +/// optional tag. +/// +/// A bare instance URL looks exactly like a direct download link, so +/// `require_prefix` asks for the explicit `gitea:` or `forgejo:` marker. A +/// value passed to `--gitea` already says which forge it is and does not need +/// one. +/// +/// The last two path segments name the project, so an instance served under a +/// path prefix parses the same as one served at the root. +/// +/// # Examples +/// +/// ``` +/// use soar_dl::platform::parse_gitea_target; +/// +/// let (instance, project, tag) = +/// parse_gitea_target("gitea:git.example.com/owner/repo@v1.0", true).unwrap(); +/// assert_eq!(instance, "https://git.example.com"); +/// assert_eq!(project, "owner/repo"); +/// assert_eq!(tag.as_deref(), Some("v1.0")); +/// ``` +pub fn parse_gitea_target( + input: &str, + require_prefix: bool, +) -> Option<(String, String, Option)> { + let target = input.trim(); + let stripped = + strip_prefix_ci(target, "gitea:").or_else(|| strip_prefix_ci(target, "forgejo:")); + if require_prefix && stripped.is_none() { + return None; } - - let json: serde_json::Value = resp - .body_mut() - .read_json() - .map_err(|_| DownloadError::InvalidResponse)?; - - match json { - serde_json::Value::Array(_) => { - serde_json::from_value(json).map_err(|_| DownloadError::InvalidResponse) - } - serde_json::Value::Object(_) => { - let single: T = - serde_json::from_value(json).map_err(|_| DownloadError::InvalidResponse)?; - Ok(vec![single]) + let target = stripped.unwrap_or(target); + + let (scheme, rest) = match strip_prefix_ci(target, "https://") { + Some(rest) => ("https://", rest), + None => { + match strip_prefix_ci(target, "http://") { + Some(rest) => ("http://", rest), + None => ("https://", target), + } } - _ => Err(DownloadError::InvalidResponse), + }; + + let last_segment = rest.rfind('/').map(|idx| idx + 1).unwrap_or(0); + let (rest, tag) = match rest[last_segment..].split_once('@') { + Some((repo, tag)) => (&rest[..last_segment + repo.len()], clean_tag(tag)), + None => (rest, None), + }; + + let (head, repo) = rest.rsplit_once('/')?; + let (base, owner) = head.rsplit_once('/')?; + if base.is_empty() || owner.is_empty() || repo.is_empty() || base.contains('@') { + return None; } + + Some((format!("{scheme}{base}"), format!("{owner}/{repo}"), tag)) +} + +fn strip_prefix_ci<'a>(input: &'a str, prefix: &str) -> Option<&'a str> { + input + .get(..prefix.len()) + .filter(|head| head.eq_ignore_ascii_case(prefix)) + .map(|head| &input[head.len()..]) } #[cfg(test)] @@ -363,6 +403,66 @@ mod tests { } } + #[test] + fn test_platform_url_parse_codeberg() { + let result = PlatformUrl::parse("https://codeberg.org/owner/repo@v1.0"); + match result { + Some(PlatformUrl::Codeberg { + project, + tag, + }) => { + assert_eq!(project, "owner/repo"); + assert_eq!(tag, Some("v1.0".to_string())); + } + _ => panic!("Expected Codeberg variant"), + } + + assert!(matches!( + PlatformUrl::parse("codeberg:owner/repo"), + Some(PlatformUrl::Codeberg { .. }) + )); + } + + #[test] + fn test_platform_url_parse_gitea() { + let result = PlatformUrl::parse("gitea:git.example.com/owner/repo@v1.0"); + match result { + Some(PlatformUrl::Gitea { + instance, + project, + tag, + }) => { + assert_eq!(instance, "https://git.example.com"); + assert_eq!(project, "owner/repo"); + assert_eq!(tag, Some("v1.0".to_string())); + } + _ => panic!("Expected Gitea variant"), + } + + // An instance served under a path prefix, spelled out in full. + match PlatformUrl::parse("forgejo:https://example.com/git/owner/repo") { + Some(PlatformUrl::Gitea { + instance, + project, + .. + }) => { + assert_eq!(instance, "https://example.com/git"); + assert_eq!(project, "owner/repo"); + } + _ => panic!("Expected Gitea variant"), + } + } + + #[test] + fn an_unmarked_host_is_a_direct_download_not_a_gitea_instance() { + // Nothing tells a Gitea instance apart from any other host, so one is + // only taken as such when the caller says so. + assert!(matches!( + PlatformUrl::parse("https://git.example.com/owner/repo"), + Some(PlatformUrl::Direct { .. }) + )); + } + #[test] fn test_platform_url_parse_direct_url() { let result = PlatformUrl::parse("https://example.com/download/file.tar.gz"); diff --git a/crates/soar-dl/src/release.rs b/crates/soar-dl/src/release.rs index bf5e55bca..f73425900 100644 --- a/crates/soar-dl/src/release.rs +++ b/crates/soar-dl/src/release.rs @@ -1,14 +1,18 @@ +//! Downloading the assets attached to a forge release. + use std::{path::PathBuf, sync::Arc}; use crate::{ download::Download, error::DownloadError, filter::Filter, - traits::{Asset as _, Platform, Release as _}, + forge::Forge, types::{OverwriteMode, Progress}, }; -pub struct ReleaseDownload { +/// A download of the assets a forge release publishes. +pub struct ReleaseDownload { + forge: Forge, project: String, tag: Option, filter: Filter, @@ -17,33 +21,22 @@ pub struct ReleaseDownload { extract: bool, extract_to: Option, on_progress: Option>, - _platform: std::marker::PhantomData

, } -impl ReleaseDownload

{ - /// Creates a new `ReleaseDownload` configured for the given project with sensible defaults. - /// - /// The returned builder is initialized with: - /// - `tag = None` - /// - a default `Filter` - /// - no explicit output path - /// - `overwrite = OverwriteMode::Prompt` - /// - extraction disabled - /// - no extraction path - /// - no progress callback +impl ReleaseDownload { + /// Downloads from `project` on `forge`, taking the latest release and + /// every asset in it unless narrowed further. /// /// # Examples /// /// ``` - /// use soar_dl::release::ReleaseDownload; - /// use soar_dl::github::Github; + /// use soar_dl::{forge::Forge, release::ReleaseDownload}; /// - /// let dl = ReleaseDownload::::new("owner/repo"); - /// // You can then chain further configuration: - /// // let dl = dl.tag("v1.2.3").output("downloads/").extract(true); + /// let dl = ReleaseDownload::new(Forge::GitHub, "owner/repo"); /// ``` - pub fn new(project: impl Into) -> Self { + pub fn new(forge: Forge, project: impl Into) -> Self { Self { + forge, project: project.into(), tag: None, filter: Filter::default(), @@ -52,139 +45,46 @@ impl ReleaseDownload

{ extract: false, extract_to: None, on_progress: None, - _platform: std::marker::PhantomData, } } - /// Sets the release tag to target when selecting a release. - /// - /// The provided tag will be used by `execute` to find a release with a matching tag. - /// Returns the updated builder to allow method chaining. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::release::ReleaseDownload; - /// use soar_dl::github::Github; - /// - /// let builder = ReleaseDownload::::new("owner/repo").tag("v1.2.3"); - /// ``` + /// Takes the release this tag names rather than the latest one. pub fn tag(mut self, tag: impl Into) -> Self { self.tag = Some(tag.into()); self } - /// Sets the asset filter used to select which release assets will be downloaded. - /// - /// The provided `filter` will be used to match asset names when executing the download. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::release::ReleaseDownload; - /// use soar_dl::filter::Filter; - /// use soar_dl::github::Github; - /// - /// let _rd = ReleaseDownload::::new("owner/repo").filter(Filter::default()); - /// ``` + /// Selects which of the release's assets to download. pub fn filter(mut self, filter: Filter) -> Self { self.filter = filter; self } - /// Sets the base output path for downloaded assets. - /// - /// The provided path will be used as the destination directory or base file path when downloads are written. - /// - /// # Returns - /// - /// The modified `ReleaseDownload` builder with the output path set. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::release::ReleaseDownload; - /// use soar_dl::github::Github; - /// - /// let dl = ReleaseDownload::::new("owner/repo").output("downloads"); - /// ``` + /// Sets where the downloaded assets are written. pub fn output(mut self, path: impl Into) -> Self { self.output = Some(path.into()); self } - /// Set the overwrite behavior for downloaded files. - /// - /// `mode` determines how existing files are handled when downloading (for example, overwrite, skip, or prompt). - /// - /// # Examples - /// - /// ``` - /// use soar_dl::release::ReleaseDownload; - /// use soar_dl::types::OverwriteMode; - /// use soar_dl::github::Github; - /// - /// let dl = ReleaseDownload::::new("owner/repo").overwrite(OverwriteMode::Force); - /// ``` + /// Sets how an asset already on disk is handled. pub fn overwrite(mut self, mode: OverwriteMode) -> Self { self.overwrite = mode; self } - /// Enables or disables extraction of downloaded assets. - /// - /// When set to `true`, assets that are archives will be extracted after they are downloaded. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::release::ReleaseDownload; - /// use soar_dl::github::Github; - /// - /// let rd = ReleaseDownload::::new("owner/repo").extract(true); - /// ``` + /// Extracts downloaded archives. pub fn extract(mut self, extract: bool) -> Self { self.extract = extract; self } - /// Sets the destination directory where downloaded archives will be extracted. - /// - /// # Arguments - /// - /// * `path` - Destination path to extract downloaded assets into. - /// - /// # Examples - /// - /// ``` - /// use soar_dl::release::ReleaseDownload; - /// use soar_dl::github::Github; - /// - /// let rd = ReleaseDownload::::new("owner/repo").extract_to("out/artifacts"); - /// ``` + /// Sets the directory downloaded archives are extracted into. pub fn extract_to(mut self, path: impl Into) -> Self { self.extract_to = Some(path.into()); self } - /// Registers a callback that will be invoked with progress updates for each download. - /// - /// The provided callback is stored and called with `Progress` events as assets are downloaded. - /// - /// # Examples - /// - /// ``` - /// use std::sync::Arc; - /// use soar_dl::release::ReleaseDownload; - /// use soar_dl::types::Progress; - /// use soar_dl::github::Github; - /// - /// let _rd = ReleaseDownload::::new("owner/repo") - /// .progress(|progress: Progress| { - /// // handle progress (e.g., log or update UI) - /// println!("{:?}", progress); - /// }); - /// ``` + /// Reports progress for each asset as it downloads. pub fn progress(mut self, f: F) -> Self where F: Fn(Progress) + Send + Sync + 'static, @@ -193,40 +93,15 @@ impl ReleaseDownload

{ self } - /// Downloads matched assets for a project's release and returns their local file paths. - /// - /// Selects a release by the configured tag if provided; otherwise prefers the first non-prerelease - /// release or falls back to the first release. - /// - /// Filters the release's assets using the configured `Filter`, downloads each matching asset with the configured - /// output, overwrite, and extraction options, and returns a vector of the resulting local `PathBuf`s. - /// - /// Returns an error if no release is found or if no assets match the filter. + /// Downloads the matching assets and returns where each was written. /// - /// # Returns - /// - /// A `Vec` containing the local paths of the downloaded assets on success, or a - /// `DownloadError` on failure. - /// - /// # Examples - /// - /// ```no_run - /// use std::path::PathBuf; - /// use soar_dl::release::ReleaseDownload; - /// use soar_dl::github::Github; - /// use soar_dl::filter::Filter; - /// - /// let paths: Vec = ReleaseDownload::::new("owner/repo") - /// .tag("v1.0") - /// .filter(Filter::default()) - /// .output("downloads") - /// .execute() - /// .unwrap(); - /// - /// assert!(!paths.is_empty()); - /// ``` + /// Without a tag the newest release that is not a prerelease is taken, + /// falling back to the newest of all when a project only publishes + /// prereleases. pub fn execute(self) -> Result, DownloadError> { - let releases = P::fetch_releases(&self.project, self.tag.as_deref())?; + let releases = self + .forge + .fetch_releases(&self.project, self.tag.as_deref())?; let release = if let Some(ref tag) = self.tag { releases.iter().find(|r| r.tag() == tag) @@ -237,7 +112,7 @@ impl ReleaseDownload

{ .or_else(|| releases.first()) }; - let release = release.ok_or_else(|| DownloadError::InvalidResponse)?; + let release = release.ok_or(DownloadError::InvalidResponse)?; let assets: Vec<_> = release .assets() @@ -274,9 +149,7 @@ impl ReleaseDownload

{ dl = dl.progress(move |p| cb(p)); } - let path = dl.execute()?; - - paths.push(path); + paths.push(dl.execute()?); } Ok(paths) diff --git a/crates/soar-dl/src/traits.rs b/crates/soar-dl/src/traits.rs deleted file mode 100644 index a24b5809f..000000000 --- a/crates/soar-dl/src/traits.rs +++ /dev/null @@ -1,30 +0,0 @@ -use crate::error::DownloadError; - -pub trait Asset: Clone { - fn name(&self) -> &str; - fn size(&self) -> Option; - fn url(&self) -> &str; -} - -pub trait Release { - type Asset: Asset; - - fn name(&self) -> &str; - fn tag(&self) -> &str; - fn is_prerelease(&self) -> bool; - fn published_at(&self) -> &str; - fn body(&self) -> Option<&str>; - fn assets(&self) -> &[Self::Asset]; -} - -pub trait Platform { - type Release: Release; - - const API_BASE: &'static str; - const TOKEN_ENV: [&str; 2]; - - fn fetch_releases( - project: &str, - tag: Option<&str>, - ) -> Result, DownloadError>; -} diff --git a/crates/soar-operations/src/apply.rs b/crates/soar-operations/src/apply.rs index fad11ee85..0c64f887a 100644 --- a/crates/soar-operations/src/apply.rs +++ b/crates/soar-operations/src/apply.rs @@ -36,10 +36,7 @@ use crate::{ /// the format reads as a pin. These sources have nothing to ask, so what was /// installed is written down instead. fn tracks_own_version(pkg: &ResolvedPackage) -> bool { - pkg.url.is_some() - || pkg.github.is_some() - || pkg.gitlab.is_some() - || pkg.version_command.is_some() + pkg.url.is_some() || pkg.has_forge_source() || pkg.version_command.is_some() } /// Status of a URL package compared against installed packages. @@ -72,9 +69,9 @@ pub async fn compute_diff( for pkg in resolved { declared_keys.insert(declared_key(pkg)); - let is_github_or_gitlab = pkg.github.is_some() || pkg.gitlab.is_some(); - if is_github_or_gitlab || pkg.url.is_some() { - handle_local_package(pkg, is_github_or_gitlab, &diesel_db, &mut diff)?; + let has_forge_source = pkg.has_forge_source(); + if has_forge_source || pkg.url.is_some() { + handle_local_package(pkg, has_forge_source, &diesel_db, &mut diff)?; continue; } @@ -414,7 +411,7 @@ pub async fn execute_apply( }) } -/// Handle local (URL/github/gitlab) packages in apply diff. +/// Handle local (URL and forge) packages in apply diff. /// What a declaration identifies: name, package id, family and repository. type DeclaredKeys = HashSet<(String, Option, Option, Option)>; @@ -438,7 +435,7 @@ fn declared_key(pkg: &ResolvedPackage) -> (String, Option, Option SoarResult<()> { @@ -542,8 +539,8 @@ fn handle_local_package( return Ok(()); } - // Handle github/gitlab packages - if is_github_or_gitlab { + // Handle forge packages + if has_forge_source { if let Some(ref declared) = pkg.version { let normalized = declared.strip_prefix('v').unwrap_or(declared); if let Some(ref existing) = installed { @@ -558,7 +555,7 @@ fn handle_local_package( Some(s) => s, None => { diff.not_found.push(format!( - "{} (missing asset_pattern for github/gitlab source)", + "{} (missing asset_pattern for forge source)", pkg.name )); return Ok(()); diff --git a/crates/soar-operations/src/update.rs b/crates/soar-operations/src/update.rs index 857b46995..9481fd53e 100644 --- a/crates/soar-operations/src/update.rs +++ b/crates/soar-operations/src/update.rs @@ -255,7 +255,7 @@ fn check_local_update( return Ok(None); } - let is_github_or_gitlab = resolved.github.is_some() || resolved.gitlab.is_some(); + let has_forge_source = resolved.has_forge_source(); let (version, download_url, size, update_toml_url) = if let Some(ref cmd) = resolved.version_command { @@ -305,7 +305,7 @@ fn check_local_update( } }; - let toml_url = if is_github_or_gitlab || !should_update_toml_url { + let toml_url = if has_forge_source || !should_update_toml_url { None } else { Some(url.clone()) @@ -344,7 +344,7 @@ fn check_local_update( return Ok(None); } - let url = if is_github_or_gitlab { + let url = if has_forge_source { None } else { Some(release.download_url.clone()) @@ -621,7 +621,7 @@ fn check_update_feed(pkg: &InstalledPackage, ctx: &SoarContext) -> SoarResult bool { - resolved.version_command.is_some() || resolved.github.is_some() || resolved.gitlab.is_some() + resolved.version_command.is_some() || resolved.has_forge_source() } fn get_existing( diff --git a/docs/configuration.md b/docs/configuration.md index c9646cf66..a0ea7dad5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -221,6 +221,25 @@ to different endpoints, so a token is rarely needed. Set `GITLAB_TOKEN` or `GL_TOKEN` if you do meet one; the current figures are listed under [rate limits on GitLab.com](https://docs.gitlab.com/user/gitlab_com/#rate-limits-on-gitlabcom). +**Codeberg** reads `CODEBERG_TOKEN`. What a self-hosted instance allows is up +to whoever runs it. + +**Gitea and Forgejo** have no fixed host, so each instance names the variable +holding its token, and a host that is not listed is never sent one: + +```toml +[forge_tokens] +"git.example.com" = "GITEA_TOKEN" +"git.other.org:3000" = "OTHER_FORGE_TOKEN" +``` + +The key is the host the instance URL spells, including a port where it uses +one, and the value is the name of the environment variable, not the token, so +no secret is written to the file. Soar talks to an instance it was never told +about, since a download URL can name one, but it does so without a credential. +A token is also withheld from an `http://` instance, which would carry it in +the clear. + A token variable may be left unset, and one that is set but empty is ignored rather than sent, since sending an empty token earns a 401 on every request. diff --git a/docs/declarative.md b/docs/declarative.md index 1a5839986..b68711e4f 100644 --- a/docs/declarative.md +++ b/docs/declarative.md @@ -79,10 +79,10 @@ remote-tool = { url = "https://example.com/tool.tar.gz" } **Key points:** - Repository packages with a specific version are always pinned. Setting `pinned = false` does not override this. A versioned non-remote package stays pinned. -- Remote packages (url/github/gitlab) are never auto-pinned unless you explicitly set `pinned = true` +- Remote packages (url and forge sources) are never auto-pinned unless you explicitly set `pinned = true` - Pinned packages are skipped during auto-update operations - Version `*` always resolves to latest and is never pinned -- Only `url`, `github`, `gitlab` and `version_command` packages have the installed version written back to `packages.toml`. A repository package is asked for its version on every apply, so a `"*"` declaration stays `"*"` rather than turning into a pin +- Only `url`, forge (`github`, `gitlab`, `codeberg`, `gitea`) and `version_command` packages have the installed version written back to `packages.toml`. A repository package is asked for its version on every apply, so a `"*"` declaration stays `"*"` rather than turning into a pin ### Detailed Format @@ -104,12 +104,14 @@ portable = { home = "~/.pkg", config = "~/.pkg/config" } | `pkg_id` | String | **Deprecated.** Repositories publishing the declarative format have no package id; use `family` | | `repo` | String | Install from a specific repository | | `url` | String | Install directly from a URL | -| `bsum` | String | Expected BLAKE3 checksum (hex) for `url`/`github`/`gitlab` downloads; install aborts on mismatch | +| `bsum` | String | Expected BLAKE3 checksum (hex) for `url` and forge downloads; install aborts on mismatch | | `pinned` | Boolean | Prevent automatic updates (default: `false`) | | `profile` | String | Install to a specific profile | | `system` | Boolean | Install into the system tree rather than your own (see [System-Wide Packages](#system-wide-packages)) | | `github` | String | GitHub repo in `owner/repo` format | | `gitlab` | String | GitLab repo in `owner/repo` format | +| `codeberg` | String | Codeberg repo in `owner/repo` format | +| `gitea` | String | Gitea or Forgejo repo URL, such as `https://git.example.com/owner/repo`. Also spelled `forgejo` | | `asset_pattern` | String | Glob pattern to match release assets | | `tag_pattern` | String | Glob pattern to match release tags | | `include_prerelease` | Boolean | Include pre-release versions | @@ -250,9 +252,9 @@ used as-is. The `{os}` placeholder resolves to the operating system (e.g. `linux`), and `{version}` resolves to the package version with any leading `v` stripped. -## GitHub/GitLab Integration +## Forge Integration -Install packages directly from GitHub or GitLab releases: +Install packages directly from the releases of GitHub, GitLab, Codeberg, Gitea or Forgejo: ```toml [packages] @@ -274,8 +276,27 @@ gh-beta = { # From GitLab gl-release = { gitlab = "gitlab-org/gitlab" } + +# From Codeberg +cb-release = { codeberg = "owner/repo", asset_pattern = "*x86_64*.AppImage" } + +# From any Gitea or Forgejo instance, named by the full repository URL +self-hosted = { + gitea = "https://git.example.com/owner/repo", + asset_pattern = "*x86_64*.AppImage" +} ``` +Every forge field takes the same asset and tag options. `gitea` and `forgejo` +are the same field under two names: the two forges speak one API, so an +instance running either is asked the same way. Codeberg has its own field +because soar knows the host already. + +Set `GITHUB_TOKEN`, `GITLAB_TOKEN` or `CODEBERG_TOKEN` to raise rate limits or +reach a private repository. A Gitea or Forgejo instance has no fixed host, so +it is sent a token only where you name the variable holding it under +[`forge_tokens`](./configuration.md#forge-rate-limits). + ::: warning Glob patterns, not regex `asset_pattern` and `tag_pattern` use **glob patterns**, not regex. Supported patterns include: - `*` matches any sequence of characters @@ -322,7 +343,7 @@ Use `version_command` for custom URL packages when: 4. **You want to provide a custom download URL** that differs from the template ::: info -For GitHub/GitLab packages, soar already handles version detection automatically. You typically don't need `version_command` unless you have special requirements. +For forge packages, soar already handles version detection automatically. You typically don't need `version_command` unless you have special requirements. ::: ### Examples @@ -445,7 +466,7 @@ pinned = true ``` **Use detailed tables when you need:** -- GitHub/GitLab releases with `asset_pattern`, `tag_pattern`, or `include_prerelease` +- Forge releases with `asset_pattern`, `tag_pattern`, or `include_prerelease` - Direct URL installation with `url` - Version fetching via `version_command` for URL packages - Build from source with `build` commands and dependencies @@ -463,7 +484,7 @@ pinned = true | Latest version from repo | Simple string | `pkg = "*"` | | Specific version from repo | Simple string | `pkg = "1.2.3"` | | From custom repository | Inline table | `pkg = { version = "*", repo = "custom" }` | -| GitHub/GitLab releases | Full table | See GitHub/GitLab sections above | +| Forge releases | Full table | See the Forge Integration section above | | Direct URL download | Full table | `pkg = { url = "https://..." }` | | Build from source | Full table | See BuildConfig section | | Multiple binaries | Full table | See BinaryMapping section | @@ -520,7 +541,7 @@ sandbox = { require = true, network = false } The configuration below uses every available option at least once. Because several options are alternatives to one another, the example spreads them across multiple packages rather than forcing them into a single entry. ::: info Pick one source per package -Each package draws from a single source: a registry entry (`family` and `repo`), a direct `url`, a `github` repo, a `gitlab` repo, or a `version_command`. The source-specific fields follow from that choice, so treat this as a field reference rather than a template to copy verbatim. +Each package draws from a single source: a registry entry (`family` and `repo`), a direct `url`, a forge repo (`github`, `gitlab`, `codeberg` or `gitea`), or a `version_command`. The source-specific fields follow from that choice, so treat this as a field reference rather than a template to copy verbatim. ::: ```toml @@ -563,6 +584,16 @@ gitlab = "gitlab-org/cli" asset_pattern = "*linux_amd64.tar.gz" bsum = "9f2d...hex..." # BLAKE3 checksum; install aborts on mismatch +# Codeberg release. +[packages.cb-tool] +codeberg = "owner/repo" # owner/repo on codeberg.org +asset_pattern = "*x86_64*.AppImage" + +# Gitea or Forgejo release, on an instance soar has to be told about. +[packages.self-hosted-tool] +gitea = "https://git.example.com/owner/repo" # full repository URL; `forgejo` also works +asset_pattern = "*x86_64*.AppImage" + # Direct URL install with custom type, entrypoint, nested archive, and binaries. [packages.custom-tool] url = "https://example.com/tool-1.0.0.tar.gz" # direct download (a "local" package) diff --git a/docs/download.md b/docs/download.md index 8641c7c08..d4a8cde90 100644 --- a/docs/download.md +++ b/docs/download.md @@ -1,11 +1,11 @@ --- title: Download Files -description: Download files with Soar from direct URLs, GitHub and GitLab releases, GHCR, or configured repositories, with filtering and automatic extraction. +description: Download files with Soar from direct URLs, forge releases, GHCR, or configured repositories, with filtering and automatic extraction. --- # Download Files -Soar downloads files from direct URLs, GitHub releases, GitLab releases, and GitHub Container Registry (GHCR). The download command supports filtering to narrow down options, interactive asset selection when several matches remain, and automatic archive extraction. +Soar downloads files from direct URLs, forge releases (GitHub, GitLab, Codeberg, Gitea and Forgejo), and GitHub Container Registry (GHCR). The download command supports filtering to narrow down options, interactive asset selection when several matches remain, and automatic archive extraction. ## Basic Usage @@ -91,6 +91,8 @@ Filters narrow down the available assets. If multiple assets remain after filter |--------|-------------| | `--github` | Download from GitHub releases using format `owner/repo[@tag]` | | `--gitlab` | Download from GitLab releases using format `owner/project[@tag]` | +| `--codeberg` | Download from Codeberg releases using format `owner/repo[@tag]` | +| `--gitea` | Download from a Gitea or Forgejo instance using the repository URL, such as `https://git.example.com/owner/repo[@tag]`. Also spelled `--forgejo` | | `--ghcr` | Download from GitHub Container Registry using format `owner/image[:tag]` | ### Extraction Options @@ -125,7 +127,13 @@ soar download https://example.com/file.tar.gz --extract ``` ::: info URL auto-detection -Soar automatically detects GitHub, GitLab, and GHCR URLs. You do not need the `--github`, `--gitlab`, or `--ghcr` flags when using full URLs. +Soar automatically detects GitHub, GitLab, Codeberg, and GHCR URLs. You do not need the `--github`, `--gitlab`, `--codeberg`, or `--ghcr` flags when using full URLs. + +A Gitea or Forgejo instance is a host Soar has no list of, and its repository URL looks exactly like a direct download link. Pass such a target with `--gitea`, or prefix it with `gitea:` or `forgejo:` to have it read as a repository: + +```sh +soar download gitea:git.example.com/owner/repo@v1.0 +``` ::: ### GitHub Releases @@ -223,6 +231,40 @@ soar download --gitlab gitlab-org/gitlab --glob '*-arm64*' --yes soar download --gitlab gitlab-org/gitlab --exclude 'debug' ``` +### Codeberg Releases + +Download assets from Codeberg releases using the `--codeberg` flag: + +```sh +# Basic format: owner/repo +soar download --codeberg owner/repo + +# Specific tag/release +soar download --codeberg owner/repo@v1.0.0 + +# Filter by architecture +soar download --codeberg owner/repo --glob '*x86_64*.AppImage' +``` + +### Gitea and Forgejo Releases + +Forgejo is a fork of Gitea and speaks the same API, so one flag covers both. The instance is not one Soar knows by name, so each target is the full repository URL: + +```sh +# Basic format: the repository URL +soar download --gitea https://git.example.com/owner/repo + +# Specific tag/release +soar download --gitea https://git.example.com/owner/repo@v1.0.0 + +# Filter by architecture +soar download --forgejo https://git.example.com/owner/repo --glob '*x86_64*' +``` + +::: tip Tokens +GitHub reads `GITHUB_TOKEN` or `GH_TOKEN`, GitLab reads `GITLAB_TOKEN` or `GL_TOKEN`, and Codeberg reads `CODEBERG_TOKEN`. A Gitea or Forgejo instance is sent a token only where you name the variable holding it under [`forge_tokens`](./configuration.md#forge-rate-limits), since an instance URL can come from anywhere. +::: + ### GitHub Container Registry (GHCR) Download container images from GitHub Container Registry: diff --git a/docs/update.md b/docs/update.md index 9f25df64a..e886d265b 100644 --- a/docs/update.md +++ b/docs/update.md @@ -97,10 +97,25 @@ Soar decides what to update to in this order. 1. **The feed the artifact carries.** An AppImage can record where its updates come from in a `.upd_info` section. That is the publisher's own statement of how the package updates, so it is followed first, and the new artifact is - fetched over [zsync](#delta-updates-over-zsync). -2. **The release the download came from.** Where the URL points at a GitHub or - GitLab release, the newest release of that project decides. This is what - covers archives and plain binaries, which have nowhere to carry a feed. + fetched over [zsync](#delta-updates-over-zsync). Soar reads every form + [appimageupdate](https://github.com/pkgforge-dev/appimageupdate) publishes: + + | Form | + |------| + | `zsync\|` | + | `gh-releases-zsync\|\|\|\|` | + | `gl-releases-zsync\|\|\|\|` | + | `cb-releases-zsync\|\|\|\|` | + | `gitea-releases-zsync\|\|\|\|\|` | + | `forgejo-releases-zsync\|\|\|\|\|` | + + `` is a glob. `` is a tag name, or `latest` for the newest + stable release, `latest-pre` for the newest prerelease, or `latest-all` for + whichever of the two is newest. +2. **The release the download came from.** Where the URL points at a release on + GitHub, GitLab, Codeberg, Gitea or Forgejo, the newest release of that + project decides. This is what covers archives and plain binaries, which have + nowhere to carry a feed. 3. **Neither**, for a URL that is not a forge release and whose artifact declares nothing. There is no way to tell a new build from the installed one, so the package is left alone.