1#![allow(unsafe_code)]
23
24use std::sync::Arc;
25
26use async_trait::async_trait;
27use linkme::distributed_slice;
28use secrecy::{ExposeSecret, SecretString};
29use tokio::io::AsyncRead;
30
31use crate::config::{GitlabParams, ReleaseSourceConfig};
32use crate::http;
33use crate::release::{
34 ProviderError, ProviderFactory, ProviderRegistration, RegisteredProvider, Release,
35 ReleaseAsset, ReleaseProvider, RELEASE_PROVIDERS,
36};
37
38pub struct GitlabProvider {
40 client: reqwest::Client,
41 scheme: &'static str,
42 host: String,
43 project_path: String,
44 token: Option<SecretString>,
45}
46
47fn normalise_host(raw: &str) -> String {
54 raw.trim_end_matches('/')
55 .trim_start_matches("https://")
56 .trim_start_matches("http://")
57 .to_string()
58}
59
60pub fn factory(
72 cfg: &ReleaseSourceConfig,
73 token: Option<SecretString>,
74) -> Result<Arc<dyn ReleaseProvider>, ProviderError> {
75 let ReleaseSourceConfig::Gitlab(params) = cfg else {
76 return Err(ProviderError::InvalidConfig(format!(
77 "gitlab factory called with non-gitlab config: source_type={}",
78 cfg.source_type()
79 )));
80 };
81 validate(params)?;
82
83 let host = normalise_host(¶ms.host);
84 let scheme = http::scheme_for(params.allow_insecure_base_url);
85 let project_path =
86 format!("{}%2F{}", http::urlencode(¶ms.owner), http::urlencode(¶ms.repo));
87 let client = http::build_client(params.timeout_seconds, params.allow_insecure_base_url)?;
88
89 Ok(Arc::new(GitlabProvider { client, scheme, host, project_path, token }))
90}
91
92fn validate(p: &GitlabParams) -> Result<(), ProviderError> {
93 if p.host.trim().is_empty() {
94 return Err(ProviderError::InvalidConfig("gitlab host must not be empty".into()));
95 }
96 if !p.allow_insecure_base_url && p.host.starts_with("http://") {
97 return Err(ProviderError::InvalidConfig(format!(
98 "gitlab host must be https; got {}",
99 p.host
100 )));
101 }
102 if p.owner.trim().is_empty() || p.repo.trim().is_empty() {
103 return Err(ProviderError::InvalidConfig("gitlab owner and repo must not be empty".into()));
104 }
105 Ok(())
106}
107
108#[distributed_slice(RELEASE_PROVIDERS)]
110fn __register_gitlab() -> Box<dyn ProviderRegistration> {
111 Box::new(RegisteredProvider { source_type: "gitlab", factory: factory as ProviderFactory })
112}
113
114#[async_trait]
119impl ReleaseProvider for GitlabProvider {
120 async fn latest_release(&self) -> Result<Release, ProviderError> {
121 let list: Vec<ApiRelease> = self.get_json(&self.releases_url(1)).await?;
124 let first = list
125 .into_iter()
126 .find(|r| !r.upcoming_release)
127 .ok_or_else(|| ProviderError::NotFound { what: "latest release".into() })?;
128 Ok(first.into_release())
129 }
130
131 async fn release_by_tag(&self, tag: &str) -> Result<Release, ProviderError> {
132 let url = format!("{}/{}", self.base_releases(), http::urlencode(tag));
133 let dto: ApiRelease = self.get_json(&url).await?;
134 Ok(dto.into_release())
135 }
136
137 async fn list_releases(&self, limit: usize) -> Result<Vec<Release>, ProviderError> {
138 let list: Vec<ApiRelease> = self.get_json(&self.releases_url(limit)).await?;
140 Ok(list.into_iter().take(limit).map(ApiRelease::into_release).collect())
141 }
142
143 async fn download_asset(
144 &self,
145 asset: &ReleaseAsset,
146 ) -> Result<(Box<dyn AsyncRead + Send + Unpin>, u64), ProviderError> {
147 let mut req = self.client.get(&asset.download_url);
148 if let Some(tok) = &self.token {
149 req = req.header("PRIVATE-TOKEN", tok.expose_secret());
150 }
151 let resp = req.send().await.map_err(|e| ProviderError::Transport(e.to_string()))?;
152 http::map_status_to_error(&resp, &self.host, false)?;
153 Ok(http::stream_body(resp))
154 }
155}
156
157impl GitlabProvider {
162 fn base_releases(&self) -> String {
163 format!(
164 "{scheme}://{host}/api/v4/projects/{project}/releases",
165 scheme = self.scheme,
166 host = self.host,
167 project = self.project_path,
168 )
169 }
170
171 fn releases_url(&self, per_page: usize) -> String {
172 let per_page = per_page.clamp(1, 100);
173 format!("{}?per_page={}", self.base_releases(), per_page)
174 }
175
176 async fn get_json<T: serde::de::DeserializeOwned>(
177 &self,
178 url: &str,
179 ) -> Result<T, ProviderError> {
180 let mut req = self.client.get(url).header("Accept", "application/json");
181 if let Some(tok) = &self.token {
182 req = req.header("PRIVATE-TOKEN", tok.expose_secret());
183 }
184 let resp = req.send().await.map_err(|e| ProviderError::Transport(e.to_string()))?;
185 http::map_status_to_error(&resp, &self.host, false)?;
186 http::parse_json::<T>(resp).await
187 }
188}
189
190#[derive(Debug, serde::Deserialize)]
195struct ApiRelease {
196 name: Option<String>,
197 tag_name: String,
198 #[serde(default)]
199 description: Option<String>,
200 #[serde(default)]
201 upcoming_release: bool,
202 created_at: String,
203 #[serde(default)]
204 released_at: Option<String>,
205 #[serde(default)]
206 assets: ApiAssets,
207}
208
209#[derive(Debug, Default, serde::Deserialize)]
210struct ApiAssets {
211 #[serde(default)]
212 links: Vec<ApiAssetLink>,
213}
214
215#[derive(Debug, serde::Deserialize)]
216struct ApiAssetLink {
217 id: u64,
218 name: String,
219 url: String,
220 #[serde(default)]
221 link_type: Option<String>,
222}
223
224impl ApiRelease {
225 fn into_release(self) -> Release {
226 let created_at =
227 parse_iso8601(&self.created_at).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
228 let published_at = self.released_at.as_deref().and_then(parse_iso8601);
229 let name = self.name.unwrap_or_else(|| self.tag_name.clone());
230 let body = self.description.unwrap_or_default();
231 let mut release = Release::new(name, self.tag_name.clone(), created_at);
232 release.body = body;
233 release.prerelease = self.tag_name.contains('-');
234 release.published_at = published_at;
235 release.assets = self
236 .assets
237 .links
238 .into_iter()
239 .map(|a| {
240 let mut asset = ReleaseAsset::new(a.id.to_string(), a.name, a.url);
241 asset.content_type = a.link_type;
242 asset
243 })
244 .collect();
245 release
246 }
247}
248
249fn parse_iso8601(s: &str) -> Option<time::OffsetDateTime> {
250 time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
251}