1use std::fs;
39use std::io::Read as _;
40use std::path::{Path, PathBuf};
41
42const DEFAULT_BASE_URL: &str = "https://github.com/Canner/Warble/releases/download";
46
47const BASE_URL_ENV: &str = "WARBLE_HUB_BASE_URL";
51
52const CACHE_ROOT_ENV: &str = "WARBLE_HUB_CACHE_ROOT";
57
58pub fn validate_hub_version(version: &str) -> Result<(), String> {
72 let core = version
73 .split(['-', '+'])
74 .next()
75 .expect("split always yields at least one segment");
76 let parts: Vec<&str> = core.split('.').collect();
77 let is_numeric_triplet = parts.len() == 3
78 && parts
79 .iter()
80 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
81 let is_safe_charset = version
82 .chars()
83 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-'));
84 let has_traversal = version.contains("..") || version.contains('/') || version.contains('\\');
85 if is_numeric_triplet && is_safe_charset && !has_traversal {
86 Ok(())
87 } else {
88 Err(format!(
89 "'{version}' is not a valid Hub version (expected a fixed MAJOR.MINOR.PATCH, e.g. \
90 '0.7.0', with an optional '-prerelease' or '+build' suffix restricted to \
91 [0-9A-Za-z.+-] and containing no '..', '/', or '\\\\') — a mutable ref such as 'main' \
92 or 'latest' cannot be checksum-verified and is rejected, and a crafted suffix cannot \
93 be used to escape the cache directory; pass a released Warble version, or use \
94 --hub-dir to point at a local Hub checkout instead"
95 ))
96 }
97}
98
99fn base_url() -> String {
102 std::env::var(BASE_URL_ENV).unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
103}
104
105fn archive_urls(version: &str) -> (String, String) {
109 let base = base_url();
110 let archive = format!("{base}/v{version}/hub-{version}.tar.gz");
111 let checksum = format!("{archive}.sha256");
112 (archive, checksum)
113}
114
115fn user_cache_root() -> Result<PathBuf, String> {
120 if let Ok(root) = std::env::var(CACHE_ROOT_ENV) {
121 return Ok(PathBuf::from(root));
122 }
123 #[cfg(target_os = "macos")]
124 {
125 let home = std::env::var("HOME").map_err(|_| {
126 "cannot determine a cache directory: $HOME is unset — set WARBLE_HUB_CACHE_ROOT to a \
127 writable directory, or use --hub-dir to point at a local Hub checkout instead"
128 .to_string()
129 })?;
130 Ok(PathBuf::from(home).join("Library").join("Caches"))
131 }
132 #[cfg(all(unix, not(target_os = "macos")))]
133 {
134 if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
135 if !xdg.is_empty() {
136 return Ok(PathBuf::from(xdg));
137 }
138 }
139 let home = std::env::var("HOME").map_err(|_| {
140 "cannot determine a cache directory: neither $XDG_CACHE_HOME nor $HOME is set — set \
141 WARBLE_HUB_CACHE_ROOT to a writable directory, or use --hub-dir to point at a local \
142 Hub checkout instead"
143 .to_string()
144 })?;
145 Ok(PathBuf::from(home).join(".cache"))
146 }
147 #[cfg(windows)]
148 {
149 let local_app_data = std::env::var("LOCALAPPDATA").map_err(|_| {
150 "cannot determine a cache directory: %LOCALAPPDATA% is unset — set \
151 WARBLE_HUB_CACHE_ROOT to a writable directory, or use --hub-dir to point at a local \
152 Hub checkout instead"
153 .to_string()
154 })?;
155 Ok(PathBuf::from(local_app_data))
156 }
157 #[cfg(not(any(unix, windows)))]
158 {
159 Err(
160 "cannot determine a cache directory on this platform — set WARBLE_HUB_CACHE_ROOT to a \
161 writable directory, or use --hub-dir to point at a local Hub checkout instead"
162 .to_string(),
163 )
164 }
165}
166
167fn create_private_dir_all(dir: &Path) -> Result<(), String> {
173 fs::create_dir_all(dir).map_err(|e| {
174 format!(
175 "could not create the Hub cache directory {} ({e}) — check that the location is \
176 writable, set {CACHE_ROOT_ENV} to a writable directory, or use --hub-dir to point at \
177 a local Hub checkout instead",
178 dir.display()
179 )
180 })?;
181 #[cfg(unix)]
182 {
183 use std::os::unix::fs::PermissionsExt;
184 fs::set_permissions(dir, fs::Permissions::from_mode(0o700)).map_err(|e| {
185 format!(
186 "could not set owner-only permissions on the Hub cache directory {} ({e}) — check \
187 that you own the directory, set {CACHE_ROOT_ENV} to a writable directory, or use \
188 --hub-dir to point at a local Hub checkout instead",
189 dir.display()
190 )
191 })?;
192 }
193 Ok(())
194}
195
196fn sha256_of_file(path: &Path) -> Option<String> {
199 use sha2::{Digest, Sha256};
200 let mut file = fs::File::open(path).ok()?;
201 let mut hasher = Sha256::new();
202 std::io::copy(&mut file, &mut hasher).ok()?;
203 Some(format!("{:x}", hasher.finalize()))
204}
205
206fn parse_checksum_sidecar(content: &str) -> Option<String> {
210 let digest = content.split_whitespace().next()?;
211 let is_hex =
212 !digest.is_empty() && digest.len() == 64 && digest.chars().all(|c| c.is_ascii_hexdigit());
213 is_hex.then(|| digest.to_ascii_lowercase())
214}
215
216fn fetch_bytes(url: &str) -> Result<Vec<u8>, FetchError> {
220 match ureq::get(url).call() {
221 Ok(mut response) => {
222 let mut bytes = Vec::new();
223 response
224 .body_mut()
225 .as_reader()
226 .read_to_end(&mut bytes)
227 .map_err(|e| FetchError::Unreachable(e.to_string()))?;
228 Ok(bytes)
229 }
230 Err(ureq::Error::StatusCode(code)) => Err(FetchError::Status(code)),
231 Err(e) => Err(FetchError::Unreachable(e.to_string())),
232 }
233}
234
235enum FetchError {
236 Status(u16),
237 Unreachable(String),
238}
239
240fn write_atomically(dest: &Path, bytes: &[u8]) -> Result<(), String> {
244 let dir = dest
245 .parent()
246 .ok_or_else(|| format!("{} has no parent directory to write into", dest.display()))?;
247 let tmp = dir.join(format!(
248 ".{}.tmp-{}",
249 dest.file_name()
250 .and_then(|n| n.to_str())
251 .unwrap_or("download"),
252 std::process::id()
253 ));
254 fs::write(&tmp, bytes).map_err(|e| format!("failed to write {} ({e})", tmp.display()))?;
255 fs::rename(&tmp, dest).map_err(|e| {
256 let _ = fs::remove_file(&tmp);
257 format!("failed to finalize {} ({e})", dest.display())
258 })
259}
260
261pub fn ensure_cached_hub(version: &str) -> Result<PathBuf, String> {
271 validate_hub_version(version)?;
272
273 let cache_root = user_cache_root()?;
274 let version_dir = cache_root.join("warble").join("hub").join(version);
275 create_private_dir_all(&version_dir)?;
276
277 let archive_path = version_dir.join(format!("hub-{version}.tar.gz"));
278 let checksum_path = version_dir.join(format!("hub-{version}.tar.gz.sha256"));
279 let (archive_url, checksum_url) = archive_urls(version);
280
281 let cached_digest = fs::read_to_string(&checksum_path)
282 .ok()
283 .and_then(|s| parse_checksum_sidecar(&s));
284 let on_disk_digest = sha256_of_file(&archive_path);
285 let cache_hit = matches!((&cached_digest, &on_disk_digest), (Some(a), Some(b)) if a == b);
286
287 if !cache_hit {
288 let archive_bytes = fetch_bytes(&archive_url).map_err(|e| match e {
289 FetchError::Status(404) => format!(
290 "no Hub archive is published for version {version} at {archive_url} (HTTP 404) — \
291 this Warble release may predate the Hub-fetch archive, or --hub-version points at \
292 a version that never shipped one; use --hub-dir to point at a local Hub checkout \
293 instead"
294 ),
295 FetchError::Status(code) => format!(
296 "fetching the Hub archive for v{version} from {archive_url} failed with HTTP \
297 {code} — use --hub-dir to point at a local Hub checkout instead"
298 ),
299 FetchError::Unreachable(detail) => format!(
300 "could not reach {archive_url} to fetch Hub v{version} ({detail}) — check your \
301 network connection, or use --hub-dir to point at a local Hub checkout instead"
302 ),
303 })?;
304 let checksum_text = fetch_bytes(&checksum_url)
305 .map_err(|e| match e {
306 FetchError::Status(404) => format!(
307 "no checksum is published for Hub version {version} at {checksum_url} (HTTP \
308 404) — this Warble release may predate the Hub-fetch archive, or \
309 --hub-version points at a version that never shipped one; use --hub-dir to \
310 point at a local Hub checkout instead"
311 ),
312 FetchError::Status(code) => format!(
313 "fetching the Hub checksum for v{version} from {checksum_url} failed with \
314 HTTP {code} — use --hub-dir to point at a local Hub checkout instead"
315 ),
316 FetchError::Unreachable(detail) => format!(
317 "could not reach {checksum_url} to fetch the Hub v{version} checksum ({detail}) \
318 — check your network connection, or use --hub-dir to point at a local Hub \
319 checkout instead"
320 ),
321 })
322 .and_then(|bytes| {
323 String::from_utf8(bytes)
324 .map_err(|e| format!("the published checksum for Hub v{version} is not valid text ({e})"))
325 })?;
326 let expected_digest = parse_checksum_sidecar(&checksum_text).ok_or_else(|| {
327 format!(
328 "the published checksum for Hub v{version} at {checksum_url} is not a recognizable \
329 SHA-256 digest — this looks like a publishing problem upstream; use --hub-dir to \
330 point at a local Hub checkout instead"
331 )
332 })?;
333
334 use sha2::{Digest, Sha256};
335 let actual_digest = format!("{:x}", Sha256::digest(&archive_bytes));
336 if actual_digest != expected_digest {
337 return Err(format!(
338 "downloaded Hub archive for v{version} does not match its published checksum \
339 (expected {expected_digest}, got {actual_digest}) — the download may have been \
340 corrupted or tampered with; retrying the fetch, or using --hub-dir to point at a \
341 local Hub checkout instead, are both safe next steps"
342 ));
343 }
344
345 write_atomically(&archive_path, &archive_bytes)?;
346 write_atomically(&checksum_path, expected_digest.as_bytes())?;
347 }
348
349 let extracted_dir = version_dir.join("extracted");
354 if extracted_dir.is_dir() {
355 fs::remove_dir_all(&extracted_dir).map_err(|e| {
356 format!(
357 "could not clear the stale extracted Hub directory {} before re-extracting ({e}) \
358 — check that the location is writable, or use --hub-dir to point at a local Hub \
359 checkout instead",
360 extracted_dir.display()
361 )
362 })?;
363 }
364 let extracting_dir = version_dir.join(format!(".extracted.tmp-{}", std::process::id()));
365 if extracting_dir.exists() {
366 let _ = fs::remove_dir_all(&extracting_dir);
367 }
368 fs::create_dir_all(&extracting_dir).map_err(|e| {
369 format!(
370 "could not create {} to extract the Hub archive into ({e}) — check that the location \
371 is writable, or use --hub-dir to point at a local Hub checkout instead",
372 extracting_dir.display()
373 )
374 })?;
375 let archive_bytes = fs::read(&archive_path).map_err(|e| {
376 format!(
377 "could not read the cached Hub archive {} ({e}) — try removing {} and re-running, or \
378 use --hub-dir to point at a local Hub checkout instead",
379 archive_path.display(),
380 version_dir.display()
381 )
382 })?;
383 let decoder = flate2::read::GzDecoder::new(archive_bytes.as_slice());
384 let mut archive = tar::Archive::new(decoder);
385 archive.unpack(&extracting_dir).map_err(|e| {
386 format!(
387 "the cached Hub archive for v{version} could not be extracted ({e}) — it may be \
388 corrupted; try removing {} and re-running, or use --hub-dir to point at a local Hub \
389 checkout instead",
390 version_dir.display()
391 )
392 })?;
393 fs::rename(&extracting_dir, &extracted_dir).map_err(|e| {
394 format!(
395 "could not finalize the extracted Hub directory {} ({e}) — check that the location is \
396 writable, or use --hub-dir to point at a local Hub checkout instead",
397 extracted_dir.display()
398 )
399 })?;
400
401 Ok(extracted_dir)
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 #[test]
409 fn validate_hub_version_accepts_a_plain_triplet() {
410 assert!(validate_hub_version("0.7.0").is_ok());
411 assert!(validate_hub_version("12.34.56").is_ok());
412 }
413
414 #[test]
415 fn validate_hub_version_accepts_prerelease_and_build_suffixes() {
416 assert!(validate_hub_version("0.7.0-rc.1").is_ok());
417 assert!(validate_hub_version("0.7.0+build.5").is_ok());
418 }
419
420 #[test]
421 fn validate_hub_version_rejects_mutable_refs() {
422 for bad in ["main", "latest", "HEAD", ""] {
423 let err = validate_hub_version(bad).expect_err(bad);
424 assert!(err.contains("not a valid Hub version"), "{bad}: {err}");
425 assert!(err.contains("--hub-dir"), "{bad}: {err}");
426 }
427 }
428
429 #[test]
430 fn validate_hub_version_rejects_partial_versions() {
431 assert!(validate_hub_version("0.7").is_err());
432 assert!(validate_hub_version("0.7.0.1").is_err());
433 assert!(validate_hub_version("v0.7.0").is_err());
434 }
435
436 #[test]
437 fn validate_hub_version_rejects_path_traversal_in_suffix() {
438 for bad in [
443 "1.0.0-../../evil",
444 "1.0.0-../../../ESCAPED",
445 "1.0.0/../../evil",
446 "1.0.0-evil/../../..",
447 "1.0.0-a\\..\\evil",
448 ] {
449 let err = validate_hub_version(bad).expect_err(bad);
450 assert!(err.contains("not a valid Hub version"), "{bad}: {err}");
451 }
452
453 let err = ensure_cached_hub("1.0.0-../../evil").expect_err("must be rejected");
458 assert!(err.contains("not a valid Hub version"), "{err}");
459 }
460
461 #[test]
462 fn parse_checksum_sidecar_accepts_sha256sum_format() {
463 let digest = "a".repeat(64);
464 let sidecar = format!("{digest} hub-0.7.0.tar.gz\n");
465 assert_eq!(parse_checksum_sidecar(&sidecar), Some(digest));
466 }
467
468 #[test]
469 fn parse_checksum_sidecar_accepts_bare_digest() {
470 let digest = "b".repeat(64);
471 assert_eq!(parse_checksum_sidecar(&digest), Some(digest));
472 }
473
474 #[test]
475 fn parse_checksum_sidecar_rejects_garbage() {
476 assert_eq!(parse_checksum_sidecar("not a checksum"), None);
477 assert_eq!(parse_checksum_sidecar(""), None);
478 }
479
480 #[test]
481 fn archive_urls_match_the_release_asset_contract() {
482 unsafe {
485 std::env::set_var(BASE_URL_ENV, "https://example.test/dl");
486 }
487 let (archive, checksum) = archive_urls("0.7.0");
488 unsafe {
489 std::env::remove_var(BASE_URL_ENV);
490 }
491 assert_eq!(archive, "https://example.test/dl/v0.7.0/hub-0.7.0.tar.gz");
492 assert_eq!(
493 checksum,
494 "https://example.test/dl/v0.7.0/hub-0.7.0.tar.gz.sha256"
495 );
496 }
497}