Skip to main content

rtb_vcs/
bitbucket.rs

1//! Bitbucket backend — Bitbucket Cloud only at v0.1.
2//!
3//! # Why Bitbucket is different
4//!
5//! Bitbucket Cloud has no first-class "releases" concept. Tool
6//! authors distribute binaries via two parallel features:
7//!
8//! - **Git tags** — fetched from
9//!   `/2.0/repositories/{workspace}/{repo_slug}/refs/tags`. These
10//!   carry a `target.date` for ordering.
11//! - **Repository Downloads** — a repo-level list of uploaded files
12//!   at `/2.0/repositories/{workspace}/{repo_slug}/downloads`.
13//!
14//! This backend synthesises releases by pairing the two: a release
15//! for tag `T` is the tag itself plus every Download whose filename
16//! contains the tag string (case-insensitive substring match).
17//! [`ReleaseProvider::list_releases`] returns
18//! [`ProviderError::Unsupported`] because synthesising a listing
19//! would be guessy and slow; callers should prefer
20//! [`ReleaseProvider::latest_release`] (picks the newest tag by
21//! date) or [`ReleaseProvider::release_by_tag`] (matches the tag
22//! name verbatim).
23//!
24//! # Authentication
25//!
26//! Bitbucket Cloud accepts App Passwords via HTTP Basic auth:
27//! `Authorization: Basic base64(username:app_password)`. We carry
28//! the username on [`BitbucketParams::username`] and receive the
29//! app password as the `SecretString` token.
30//!
31//! # Not shipped at v0.1
32//!
33//! - **Bitbucket Data Center / Server.** Its URL shape
34//!   (`<host>/rest/api/1.0`) and response JSON both differ from
35//!   Cloud. Revisit in a follow-up when a real user need surfaces.
36//! - **OAuth 2.0 consumer flow.** Out of scope for a read-only
37//!   release provider; app passwords are fine.
38//!
39//! # Lint exception
40//!
41//! Same as the other REST backends.
42
43#![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
59/// Bitbucket Cloud release provider.
60pub 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
77// ---------------------------------------------------------------------
78// Factory + registration
79// ---------------------------------------------------------------------
80
81/// Construct a [`BitbucketProvider`].
82///
83/// # Errors
84///
85/// [`ProviderError::InvalidConfig`] for empty or http-prefixed hosts,
86/// missing `workspace` / `repo_slug`, or a reqwest build failure.
87pub 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(&params.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    // If a password was supplied without a username we have enough to
129    // catch the most common misconfiguration early.
130    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// ---------------------------------------------------------------------
139// Trait impl
140// ---------------------------------------------------------------------
141
142#[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        // Bitbucket Cloud has no native list-releases endpoint.
169        // Synthesising one from tags would be slow (a `list` of 100
170        // tags = 100 asset-lookup calls) and guessy (which tags have
171        // matching downloads?). Surface `Unsupported` with actionable
172        // help — callers use `latest_release` or `release_by_tag`
173        // instead.
174        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
191// ---------------------------------------------------------------------
192// Internals
193// ---------------------------------------------------------------------
194
195impl 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    /// Return every repo Download whose filename contains the tag
221    /// name (case-insensitive). This matches the common "v1.2.3 in
222    /// the filename" convention used by Bitbucket tool authors.
223    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// ---------------------------------------------------------------------
268// DTOs
269// ---------------------------------------------------------------------
270
271#[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    /// Fallback — some Bitbucket responses expose the tag-creation
285    /// date at the top level.
286    #[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}