1#![allow(unsafe_code)]
44
45use std::sync::Arc;
46
47use async_trait::async_trait;
48use linkme::distributed_slice;
49use secrecy::{ExposeSecret, SecretString};
50use tokio::io::AsyncRead;
51
52use crate::config::{BitbucketParams, ReleaseSourceConfig};
53use crate::http;
54use crate::release::{
55 ProviderError, ProviderFactory, ProviderRegistration, RegisteredProvider, Release,
56 ReleaseAsset, ReleaseProvider, RELEASE_PROVIDERS,
57};
58
59pub struct BitbucketProvider {
61 client: reqwest::Client,
62 scheme: &'static str,
63 host: String,
64 workspace: String,
65 repo_slug: String,
66 username: Option<String>,
67 token: Option<SecretString>,
68}
69
70fn normalise_host(raw: &str) -> String {
71 raw.trim_end_matches('/')
72 .trim_start_matches("https://")
73 .trim_start_matches("http://")
74 .to_string()
75}
76
77pub fn factory(
88 cfg: &ReleaseSourceConfig,
89 token: Option<SecretString>,
90) -> Result<Arc<dyn ReleaseProvider>, ProviderError> {
91 let ReleaseSourceConfig::Bitbucket(params) = cfg else {
92 return Err(ProviderError::InvalidConfig(format!(
93 "bitbucket factory called with non-bitbucket config: source_type={}",
94 cfg.source_type()
95 )));
96 };
97 validate(params)?;
98
99 let client = http::build_client(params.timeout_seconds, params.allow_insecure_base_url)?;
100 let scheme = http::scheme_for(params.allow_insecure_base_url);
101
102 Ok(Arc::new(BitbucketProvider {
103 client,
104 scheme,
105 host: normalise_host(¶ms.host),
106 workspace: params.workspace.clone(),
107 repo_slug: params.repo_slug.clone(),
108 username: params.username.clone(),
109 token,
110 }))
111}
112
113fn validate(p: &BitbucketParams) -> Result<(), ProviderError> {
114 if p.host.trim().is_empty() {
115 return Err(ProviderError::InvalidConfig("bitbucket host must not be empty".into()));
116 }
117 if !p.allow_insecure_base_url && p.host.starts_with("http://") {
118 return Err(ProviderError::InvalidConfig(format!(
119 "bitbucket host must be https; got {}",
120 p.host
121 )));
122 }
123 if p.workspace.trim().is_empty() || p.repo_slug.trim().is_empty() {
124 return Err(ProviderError::InvalidConfig(
125 "bitbucket workspace and repo_slug must not be empty".into(),
126 ));
127 }
128 Ok(())
131}
132
133#[distributed_slice(RELEASE_PROVIDERS)]
134fn __register_bitbucket() -> Box<dyn ProviderRegistration> {
135 Box::new(RegisteredProvider { source_type: "bitbucket", factory: factory as ProviderFactory })
136}
137
138#[async_trait]
143impl ReleaseProvider for BitbucketProvider {
144 async fn latest_release(&self) -> Result<Release, ProviderError> {
145 let tags: Vec<ApiTag> = self.get_tags_sorted_newest_first().await?;
146 let Some(tag) = tags.into_iter().next() else {
147 return Err(ProviderError::NotFound {
148 what: "no tags available for latest release".into(),
149 });
150 };
151 self.release_from_tag(tag).await
152 }
153
154 async fn release_by_tag(&self, tag: &str) -> Result<Release, ProviderError> {
155 let url = format!(
156 "{scheme}://{host}/repositories/{workspace}/{repo}/refs/tags/{tag}",
157 scheme = self.scheme,
158 host = self.host,
159 workspace = self.workspace,
160 repo = self.repo_slug,
161 tag = http::urlencode(tag),
162 );
163 let api_tag: ApiTag = self.get_json(&url).await?;
164 self.release_from_tag(api_tag).await
165 }
166
167 async fn list_releases(&self, _limit: usize) -> Result<Vec<Release>, ProviderError> {
168 Err(ProviderError::Unsupported)
175 }
176
177 async fn download_asset(
178 &self,
179 asset: &ReleaseAsset,
180 ) -> Result<(Box<dyn AsyncRead + Send + Unpin>, u64), ProviderError> {
181 let mut req = self.client.get(&asset.download_url);
182 if let (Some(user), Some(tok)) = (&self.username, &self.token) {
183 req = req.basic_auth(user, Some(tok.expose_secret()));
184 }
185 let resp = req.send().await.map_err(|e| ProviderError::Transport(e.to_string()))?;
186 http::map_status_to_error(&resp, &self.host, false)?;
187 Ok(http::stream_body(resp))
188 }
189}
190
191impl BitbucketProvider {
196 async fn get_tags_sorted_newest_first(&self) -> Result<Vec<ApiTag>, ProviderError> {
197 let url = format!(
198 "{scheme}://{host}/repositories/{workspace}/{repo}/refs/tags?sort=-target.date&pagelen=100",
199 scheme = self.scheme,
200 host = self.host,
201 workspace = self.workspace,
202 repo = self.repo_slug,
203 );
204 let page: ApiTagsPage = self.get_json(&url).await?;
205 Ok(page.values)
206 }
207
208 async fn release_from_tag(&self, tag: ApiTag) -> Result<Release, ProviderError> {
209 let created_at = parse_iso8601(&tag.target.date)
210 .or_else(|| parse_iso8601(&tag.date))
211 .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
212
213 let mut release = Release::new(tag.name.clone(), tag.name.clone(), created_at);
214 release.body = tag.message.unwrap_or_default();
215 release.published_at = Some(created_at);
216 release.assets = self.assets_matching_tag(&tag.name).await?;
217 Ok(release)
218 }
219
220 async fn assets_matching_tag(&self, tag: &str) -> Result<Vec<ReleaseAsset>, ProviderError> {
224 let url = format!(
225 "{scheme}://{host}/repositories/{workspace}/{repo}/downloads?pagelen=100",
226 scheme = self.scheme,
227 host = self.host,
228 workspace = self.workspace,
229 repo = self.repo_slug,
230 );
231 let page: ApiDownloadsPage = self.get_json(&url).await?;
232 let needle = tag.to_ascii_lowercase();
233 let tag_without_v = needle.strip_prefix('v').unwrap_or(&needle);
234 Ok(page
235 .values
236 .into_iter()
237 .filter(|d| {
238 let name_lower = d.name.to_ascii_lowercase();
239 name_lower.contains(&needle) || name_lower.contains(tag_without_v)
240 })
241 .map(|d| {
242 let mut asset = ReleaseAsset::new(
243 d.name.clone(),
244 d.name,
245 d.links.self_link.map(|l| l.href).unwrap_or_default(),
246 );
247 asset.size = d.size.unwrap_or(0);
248 asset
249 })
250 .collect())
251 }
252
253 async fn get_json<T: serde::de::DeserializeOwned>(
254 &self,
255 url: &str,
256 ) -> Result<T, ProviderError> {
257 let mut req = self.client.get(url).header("Accept", "application/json");
258 if let (Some(user), Some(tok)) = (&self.username, &self.token) {
259 req = req.basic_auth(user, Some(tok.expose_secret()));
260 }
261 let resp = req.send().await.map_err(|e| ProviderError::Transport(e.to_string()))?;
262 http::map_status_to_error(&resp, &self.host, false)?;
263 http::parse_json::<T>(resp).await
264 }
265}
266
267#[derive(Debug, serde::Deserialize)]
272struct ApiTagsPage {
273 #[serde(default)]
274 values: Vec<ApiTag>,
275}
276
277#[derive(Debug, serde::Deserialize)]
278struct ApiTag {
279 name: String,
280 #[serde(default)]
281 message: Option<String>,
282 #[serde(default)]
283 target: ApiTagTarget,
284 #[serde(default)]
287 date: String,
288}
289
290#[derive(Debug, Default, serde::Deserialize)]
291struct ApiTagTarget {
292 #[serde(default)]
293 date: String,
294}
295
296#[derive(Debug, serde::Deserialize)]
297struct ApiDownloadsPage {
298 #[serde(default)]
299 values: Vec<ApiDownload>,
300}
301
302#[derive(Debug, serde::Deserialize)]
303struct ApiDownload {
304 name: String,
305 #[serde(default)]
306 size: Option<u64>,
307 #[serde(default)]
308 links: ApiDownloadLinks,
309}
310
311#[derive(Debug, Default, serde::Deserialize)]
312struct ApiDownloadLinks {
313 #[serde(rename = "self", default)]
314 self_link: Option<ApiLink>,
315}
316
317#[derive(Debug, serde::Deserialize)]
318struct ApiLink {
319 href: String,
320}
321
322fn parse_iso8601(s: &str) -> Option<time::OffsetDateTime> {
323 if s.is_empty() {
324 return None;
325 }
326 time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
327}