Skip to main content

opys_dev/
gitlab.rs

1//! GitLab's generic package registry.
2//!
3//! A generic package is a name, a version, and some files — the nearest thing
4//! GitLab has to a release asset. The registry reports each file's sha256
5//! through the `package_files` API, which is why a file here is resolved
6//! through two API calls rather than taken at its URL: that hash is what gets
7//! pinned.
8//!
9//! It began in `opys-authliberty`, whose agent jar is published this way, and
10//! moved here when resolving an arbitrary link needed the same lookup.
11
12use serde::{Deserialize, Serialize};
13
14use crate::http::{get_json, JsonGetError};
15use crate::url::encode_uri_component;
16
17/// The public instance. Any other is named by its own base URL.
18pub const GITLAB_BASE: &str = "https://gitlab.com";
19
20/// One file of one version of a generic package.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct GitLabPackageFile {
24    /// Package version, e.g. `0.3` or `latest`.
25    pub version: String,
26    pub filename: String,
27    /// The registry's download URL for the file.
28    pub url: String,
29    pub size: u64,
30    /// sha256 (hex), when GitLab reports one.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub sha256: Option<String>,
33    /// ISO timestamp the package was created.
34    pub created_at: String,
35}
36
37#[derive(Debug, thiserror::Error)]
38pub enum GitLabError {
39    /// A failed JSON GET — transport, a non-2xx status, or an undecodable body.
40    #[error(transparent)]
41    Fetch(#[from] JsonGetError),
42    #[error("Package '{package}' has no version '{version}' in {project}. Available: {available}")]
43    NoVersion {
44        package: String,
45        version: String,
46        project: String,
47        /// Up to eight versions the registry does hold, or `(none)`.
48        available: String,
49    },
50    #[error("Package {project}/{package}@{version} has no {wanted}")]
51    NoFile {
52        project: String,
53        package: String,
54        version: String,
55        /// What was looked for: `file 'x.jar'`, or `.jar file`.
56        wanted: String,
57    },
58}
59
60/// Which file of the package version to take.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum FileSelector<'a> {
63    /// The file with exactly this name.
64    Named(&'a str),
65    /// The file ending in this suffix — `.jar` — for a package known to hold
66    /// one such file under a name that changes with the version.
67    Suffix(&'a str),
68}
69
70#[derive(Deserialize)]
71pub(crate) struct PackageWire {
72    id: u64,
73    name: String,
74    version: String,
75    package_type: String,
76    status: String,
77    created_at: String,
78}
79
80#[derive(Deserialize)]
81pub(crate) struct PackageFileWire {
82    file_name: String,
83    size: u64,
84    #[serde(default)]
85    file_sha256: Option<String>,
86    created_at: String,
87}
88
89/// The newest of `items` by `created_at`. ISO timestamps order as text.
90fn newest<T>(items: impl IntoIterator<Item = T>, created_at: impl Fn(&T) -> &str) -> Option<T> {
91    items.into_iter().reduce(|latest, item| {
92        if created_at(&item) > created_at(&latest) {
93            item
94        } else {
95            latest
96        }
97    })
98}
99
100/// Resolve one file of `package`@`version` in `project`.
101///
102/// `base` is the instance (`https://gitlab.com`); `project` its path
103/// (`group/name`) or numeric id. A version may have been published more than
104/// once and a file uploaded more than once; the most recent of each wins.
105pub fn resolve_gitlab_package_file(
106    base: &str,
107    project: &str,
108    package: &str,
109    version: &str,
110    file: FileSelector<'_>,
111    token: Option<&str>,
112) -> Result<GitLabPackageFile, GitLabError> {
113    let base = base.trim_end_matches('/');
114    // A project path is one path segment to GitLab: `group%2Fname`.
115    let project_segment = encode_uri_component(project);
116    let headers: Vec<(&str, &str)> = token
117        .map(|token| ("PRIVATE-TOKEN", token))
118        .into_iter()
119        .collect();
120
121    let listed: Vec<PackageWire> = get_json(
122        &format!(
123            "{base}/api/v4/projects/{project_segment}/packages?package_type=generic&package_name={}&per_page=100",
124            encode_uri_component(package)
125        ),
126        &headers,
127    )?;
128    // GitLab's `package_name` filter is a fuzzy match, so narrow to the exact
129    // name and the generic registry, and drop anything not `default` — a
130    // package still processing, or one that failed.
131    let packages: Vec<PackageWire> = listed
132        .into_iter()
133        .filter(|p| p.name == package && p.package_type == "generic" && p.status == "default")
134        .collect();
135
136    let mut available: Vec<&str> = Vec::new();
137    for candidate in &packages {
138        if !available.contains(&candidate.version.as_str()) {
139            available.push(&candidate.version);
140        }
141    }
142    let available = if available.is_empty() {
143        "(none)".to_owned()
144    } else {
145        available.into_iter().take(8).collect::<Vec<_>>().join(", ")
146    };
147
148    let found = newest(packages.into_iter().filter(|p| p.version == version), |p| {
149        &p.created_at
150    })
151    .ok_or_else(|| GitLabError::NoVersion {
152        package: package.to_owned(),
153        version: version.to_owned(),
154        project: project.to_owned(),
155        available,
156    })?;
157
158    let files: Vec<PackageFileWire> = get_json(
159        &format!(
160            "{base}/api/v4/projects/{project_segment}/packages/{}/package_files?per_page=100",
161            found.id
162        ),
163        &headers,
164    )?;
165    let chosen = newest(
166        files.into_iter().filter(|f| match file {
167            FileSelector::Named(name) => f.file_name == name,
168            FileSelector::Suffix(suffix) => f.file_name.ends_with(suffix),
169        }),
170        |f| &f.created_at,
171    )
172    .ok_or_else(|| GitLabError::NoFile {
173        project: project.to_owned(),
174        package: package.to_owned(),
175        version: found.version.clone(),
176        wanted: match file {
177            FileSelector::Named(name) => format!("file '{name}'"),
178            FileSelector::Suffix(suffix) => format!("{suffix} file"),
179        },
180    })?;
181
182    Ok(GitLabPackageFile {
183        url: format!(
184            "{base}/api/v4/projects/{project_segment}/packages/generic/{}/{}/{}",
185            encode_uri_component(package),
186            encode_uri_component(&found.version),
187            encode_uri_component(&chosen.file_name),
188        ),
189        version: found.version,
190        filename: chosen.file_name,
191        size: chosen.size,
192        sha256: chosen.file_sha256,
193        created_at: found.created_at,
194    })
195}