Skip to main content

rtb_vcs/
direct.rs

1//! Direct backend — a bare-HTTPS-URL release source for private
2//! mirrors, S3-hosted releases, and air-gapped distributions that
3//! don't speak a Git-forge API.
4//!
5//! The provider reads a `version_url` (plain text *or* JSON with
6//! `.version` at the root) to discover the current version, then
7//! constructs asset URLs from `asset_url_template`. See
8//! [`crate::config::DirectParams`] for the template grammar.
9//!
10//! # Lint exception
11//!
12//! Like the other backends: `linkme::distributed_slice` emits
13//! `#[link_section]` that Rust 1.95+ attributes to the `unsafe_code`
14//! lint. No hand-rolled unsafe exists in this module.
15
16#![allow(unsafe_code)]
17
18use std::sync::Arc;
19
20use async_trait::async_trait;
21use linkme::distributed_slice;
22use secrecy::{ExposeSecret, SecretString};
23use tokio::io::AsyncRead;
24
25use crate::config::{DirectParams, ReleaseSourceConfig};
26use crate::http;
27use crate::release::{
28    ProviderError, ProviderFactory, ProviderRegistration, RegisteredProvider, Release,
29    ReleaseAsset, ReleaseProvider, RELEASE_PROVIDERS,
30};
31
32/// Direct-source release provider.
33pub struct DirectProvider {
34    client: reqwest::Client,
35    version_url: String,
36    asset_url_template: String,
37    pinned_version: Option<String>,
38    /// Identifier used in `ProviderError::{Unauthorized,RateLimited,…}`
39    /// — tool authors see "rate limited by `<authority>`" rather than a
40    /// full URL.
41    authority: String,
42    token: Option<SecretString>,
43}
44
45// ---------------------------------------------------------------------
46// Factory + registration
47// ---------------------------------------------------------------------
48
49/// Construct a [`DirectProvider`] from parsed config.
50///
51/// # Errors
52///
53/// Returns [`ProviderError::InvalidConfig`] if either URL is empty or
54/// the template lacks `{version}`.
55pub fn factory(
56    cfg: &ReleaseSourceConfig,
57    token: Option<SecretString>,
58) -> Result<Arc<dyn ReleaseProvider>, ProviderError> {
59    let ReleaseSourceConfig::Direct(params) = cfg else {
60        return Err(ProviderError::InvalidConfig(format!(
61            "direct factory called with non-direct config: source_type={}",
62            cfg.source_type()
63        )));
64    };
65    validate(params)?;
66
67    // `authority` is used purely for diagnostics. Fall back to the
68    // host portion of the version URL if possible.
69    let authority = authority_from(&params.version_url);
70    let client = http::build_client(params.timeout_seconds, params.allow_insecure_base_url)?;
71
72    Ok(Arc::new(DirectProvider {
73        client,
74        version_url: params.version_url.clone(),
75        asset_url_template: params.asset_url_template.clone(),
76        pinned_version: params.pinned_version.clone(),
77        authority,
78        token,
79    }))
80}
81
82fn validate(p: &DirectParams) -> Result<(), ProviderError> {
83    if p.version_url.trim().is_empty() {
84        return Err(ProviderError::InvalidConfig("direct version_url must not be empty".into()));
85    }
86    if !p.version_url.starts_with("https://") && !p.version_url.starts_with("http://") {
87        return Err(ProviderError::InvalidConfig(format!(
88            "direct version_url must be a fully-qualified URL; got {}",
89            p.version_url
90        )));
91    }
92    if !p.allow_insecure_base_url && p.version_url.starts_with("http://") {
93        return Err(ProviderError::InvalidConfig(format!(
94            "direct version_url must be https; got {}",
95            p.version_url
96        )));
97    }
98    if p.asset_url_template.trim().is_empty() {
99        return Err(ProviderError::InvalidConfig(
100            "direct asset_url_template must not be empty".into(),
101        ));
102    }
103    if !p.asset_url_template.contains("{version}") {
104        return Err(ProviderError::InvalidConfig(
105            "direct asset_url_template must contain the {version} placeholder".into(),
106        ));
107    }
108    Ok(())
109}
110
111fn authority_from(url: &str) -> String {
112    url.trim_start_matches("https://")
113        .trim_start_matches("http://")
114        .split('/')
115        .next()
116        .unwrap_or(url)
117        .to_string()
118}
119
120/// Link-time registration entry.
121#[distributed_slice(RELEASE_PROVIDERS)]
122fn __register_direct() -> Box<dyn ProviderRegistration> {
123    Box::new(RegisteredProvider { source_type: "direct", factory: factory as ProviderFactory })
124}
125
126// ---------------------------------------------------------------------
127// Trait impl
128// ---------------------------------------------------------------------
129
130#[async_trait]
131impl ReleaseProvider for DirectProvider {
132    async fn latest_release(&self) -> Result<Release, ProviderError> {
133        let version = self.discover_version().await?;
134        Ok(self.release_for(version))
135    }
136
137    async fn release_by_tag(&self, tag: &str) -> Result<Release, ProviderError> {
138        // With a pinned_version configured, only that version resolves.
139        if let Some(p) = &self.pinned_version {
140            if p != tag {
141                return Err(ProviderError::NotFound { what: tag.to_string() });
142            }
143            return Ok(self.release_for(p.clone()));
144        }
145        // Without a pinned version, trust the caller — the direct
146        // provider can't list historical releases.
147        Ok(self.release_for(tag.to_string()))
148    }
149
150    async fn list_releases(&self, _limit: usize) -> Result<Vec<Release>, ProviderError> {
151        // Direct has no list endpoint; return the latest as a one-
152        // element vector so callers have a consistent shape.
153        Ok(vec![self.latest_release().await?])
154    }
155
156    async fn download_asset(
157        &self,
158        asset: &ReleaseAsset,
159    ) -> Result<(Box<dyn AsyncRead + Send + Unpin>, u64), ProviderError> {
160        let mut req = self.client.get(&asset.download_url);
161        if let Some(tok) = &self.token {
162            req = req.bearer_auth(tok.expose_secret());
163        }
164        let resp = req.send().await.map_err(|e| ProviderError::Transport(e.to_string()))?;
165        http::map_status_to_error(&resp, &self.authority, false)?;
166        Ok(http::stream_body(resp))
167    }
168}
169
170// ---------------------------------------------------------------------
171// Internals
172// ---------------------------------------------------------------------
173
174impl DirectProvider {
175    async fn discover_version(&self) -> Result<String, ProviderError> {
176        if let Some(p) = &self.pinned_version {
177            return Ok(p.clone());
178        }
179        let mut req = self.client.get(&self.version_url);
180        if let Some(tok) = &self.token {
181            req = req.bearer_auth(tok.expose_secret());
182        }
183        let resp = req.send().await.map_err(|e| ProviderError::Transport(e.to_string()))?;
184        http::map_status_to_error(&resp, &self.authority, false)?;
185
186        let content_type =
187            http::header_str(resp.headers(), "content-type").unwrap_or("").to_string();
188        let body =
189            resp.text().await.map_err(|e| ProviderError::MalformedResponse(e.to_string()))?;
190
191        if content_type.contains("application/json") || body.trim_start().starts_with('{') {
192            let doc: serde_json::Value = serde_json::from_str(&body)
193                .map_err(|e| ProviderError::MalformedResponse(e.to_string()))?;
194            doc.get("version").and_then(|v| v.as_str()).map(str::to_string).ok_or_else(|| {
195                ProviderError::MalformedResponse(
196                    "direct version_url JSON missing `.version` string".into(),
197                )
198            })
199        } else {
200            let trimmed = body.trim();
201            if trimmed.is_empty() {
202                Err(ProviderError::MalformedResponse(
203                    "direct version_url returned empty body".into(),
204                ))
205            } else {
206                Ok(trimmed.to_string())
207            }
208        }
209    }
210
211    fn release_for(&self, version: String) -> Release {
212        let asset_url = render_template(&self.asset_url_template, &version);
213        let asset_name = filename_from_url(&asset_url);
214        let mut release = Release::new(version.clone(), version, time::OffsetDateTime::UNIX_EPOCH);
215        release.assets = vec![ReleaseAsset::new(asset_name.clone(), asset_name, asset_url)];
216        release
217    }
218}
219
220/// Substitute the documented placeholders in `template`.
221///
222/// The grammar — `{version}`, `{target}`, `{os}`, `{arch}`, `{ext}` —
223/// is part of the public contract (`DirectParams::asset_url_template`).
224/// Each `.replace` call uses string literals that *look* like format
225/// directives but aren't — the `allow(clippy::literal_string_with_formatting_args)`
226/// acknowledges that and prevents the lint from interpreting the
227/// template syntax as a format-string bug.
228#[must_use]
229#[allow(clippy::literal_string_with_formatting_args)]
230pub fn render_template(template: &str, version: &str) -> String {
231    let (os, arch, target, ext) = host_substitutions();
232    template
233        .replace("{version}", version)
234        .replace("{target}", target)
235        .replace("{os}", os)
236        .replace("{arch}", arch)
237        .replace("{ext}", ext)
238}
239
240/// Returns `(os, arch, target, ext)` for the running binary. The
241/// match on `(os, arch)` covers the six Rust triples RTB ships for;
242/// unknown combinations produce an empty `target` string, which
243/// surfaces as `{target}` staying literal in the rendered URL — a
244/// clear signal for the tool author to configure
245/// `ToolMetadata::update_asset_pattern` in v0.2 `rtb-update`.
246const fn host_substitutions() -> (&'static str, &'static str, &'static str, &'static str) {
247    let os = std::env::consts::OS;
248    let arch = std::env::consts::ARCH;
249    let target = match (os.as_bytes(), arch.as_bytes()) {
250        (b"linux", b"x86_64") => "x86_64-unknown-linux-gnu",
251        (b"linux", b"aarch64") => "aarch64-unknown-linux-gnu",
252        (b"macos", b"x86_64") => "x86_64-apple-darwin",
253        (b"macos", b"aarch64") => "aarch64-apple-darwin",
254        (b"windows", b"x86_64") => "x86_64-pc-windows-msvc",
255        (b"windows", b"aarch64") => "aarch64-pc-windows-msvc",
256        _ => "",
257    };
258    let ext = match os.as_bytes() {
259        b"windows" => ".zip",
260        _ => ".tar.gz",
261    };
262    (os, arch, target, ext)
263}
264
265fn filename_from_url(url: &str) -> String {
266    url.rsplit('/').next().unwrap_or(url).to_string()
267}