1#![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
32pub struct DirectProvider {
34 client: reqwest::Client,
35 version_url: String,
36 asset_url_template: String,
37 pinned_version: Option<String>,
38 authority: String,
42 token: Option<SecretString>,
43}
44
45pub 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 let authority = authority_from(¶ms.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#[distributed_slice(RELEASE_PROVIDERS)]
122fn __register_direct() -> Box<dyn ProviderRegistration> {
123 Box::new(RegisteredProvider { source_type: "direct", factory: factory as ProviderFactory })
124}
125
126#[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 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 Ok(self.release_for(tag.to_string()))
148 }
149
150 async fn list_releases(&self, _limit: usize) -> Result<Vec<Release>, ProviderError> {
151 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
170impl 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#[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
240const 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}