1use std::fs;
22use std::io::{self, Read};
23use std::path::{Path, PathBuf};
24use std::time::{Duration, SystemTime, UNIX_EPOCH};
25
26use hex;
27use sha2::{Digest, Sha256};
28
29use reddb_file::{
30 decode_ui_bundle_manifest_json, encode_ui_bundle_manifest_json, promote_ui_bundle_staging,
31 ui_bundle_cache_root, ui_bundle_manifest_path, ui_bundle_staging_dir, ui_bundle_version_dir,
32 write_ui_bundle_manifest, UiBundleManifest,
33};
34
35pub const RED_UI_PINNED_VERSION: &str = "0.0.0-dev";
42
43pub const RED_UI_PINNED_SHA256: &str =
46 "0000000000000000000000000000000000000000000000000000000000000000";
47
48pub fn release_asset_url(version: &str) -> String {
56 format!("https://github.com/reddb-io/red-ui/releases/download/v{version}/ui-dist.tgz")
57}
58
59pub trait UiBundleFetcher: Send + Sync {
66 fn fetch_bytes(&self, url: &str) -> io::Result<Vec<u8>>;
69}
70
71pub struct HttpFetcher;
73
74impl UiBundleFetcher for HttpFetcher {
75 fn fetch_bytes(&self, url: &str) -> io::Result<Vec<u8>> {
76 let agent: ureq::Agent = ureq::Agent::config_builder()
77 .timeout_connect(Some(Duration::from_secs(30)))
78 .timeout_send_request(Some(Duration::from_secs(60)))
79 .timeout_recv_response(Some(Duration::from_secs(60)))
80 .timeout_recv_body(Some(Duration::from_secs(600)))
81 .build()
82 .into();
83
84 let mut resp = agent
85 .get(url)
86 .call()
87 .map_err(|err| io::Error::other(format!("HTTP GET {url}: {err}")))?;
88
89 let status = resp.status().as_u16();
90 if status != 200 {
91 return Err(io::Error::new(
92 io::ErrorKind::NotFound,
93 format!("HTTP GET {url}: status {status}"),
94 ));
95 }
96
97 resp.body_mut()
98 .read_to_vec()
99 .map_err(|err| io::Error::other(format!("read response body from {url}: {err}")))
100 }
101}
102
103pub fn reddb_user_cache_root() -> io::Result<PathBuf> {
112 let base = if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
113 PathBuf::from(xdg)
114 } else if let Ok(home) = std::env::var("HOME") {
115 PathBuf::from(home).join(".cache")
116 } else if cfg!(target_os = "windows") {
117 if let Ok(local) = std::env::var("LOCALAPPDATA") {
118 PathBuf::from(local)
119 } else {
120 std::env::temp_dir()
121 }
122 } else {
123 return Err(io::Error::new(
124 io::ErrorKind::NotFound,
125 "cannot determine home directory: HOME and XDG_CACHE_HOME are both unset",
126 ));
127 };
128 Ok(base.join("reddb"))
129}
130
131pub fn resolve_ui_bundle(
154 reddb_cache_root: &Path,
155 fetcher: &dyn UiBundleFetcher,
156) -> io::Result<PathBuf> {
157 let version = RED_UI_PINNED_VERSION;
158 let expected_sha256 = RED_UI_PINNED_SHA256;
159
160 let cache_root = ui_bundle_cache_root(reddb_cache_root);
161 let version_dir = ui_bundle_version_dir(&cache_root, version);
162 let manifest_path = ui_bundle_manifest_path(&version_dir);
163
164 if manifest_path.exists() {
166 if let Ok(bytes) = fs::read(&manifest_path) {
167 if let Ok(manifest) = decode_ui_bundle_manifest_json(&bytes) {
168 if manifest.version == version && manifest.sha256_hex == expected_sha256 {
169 return Ok(version_dir);
170 }
171 }
172 }
173 }
175
176 let url = release_asset_url(version);
178 let tgz_bytes = fetcher.fetch_bytes(&url).map_err(|err| {
179 io::Error::new(
183 err.kind(),
184 format!(
185 "could not download red-ui bundle v{version} from {url}: {err}\n\
186 hint: run `red ui` while online to populate the cache, \
187 or pass --ui-dir to serve a local bundle directory"
188 ),
189 )
190 })?;
191
192 let actual_sha256 = sha256_hex(&tgz_bytes);
194 if actual_sha256 != expected_sha256 {
195 return Err(io::Error::new(
196 io::ErrorKind::InvalidData,
197 format!(
198 "red-ui bundle SHA-256 mismatch: expected {expected_sha256}, \
199 got {actual_sha256} — refusing to serve a potentially tampered bundle"
200 ),
201 ));
202 }
203
204 let unique = format!(
207 "{:x}",
208 SystemTime::now()
209 .duration_since(UNIX_EPOCH)
210 .unwrap_or_default()
211 .subsec_nanos()
212 );
213
214 let staging_dir = ui_bundle_staging_dir(&cache_root, version, &unique);
215
216 if staging_dir.exists() {
218 let _ = fs::remove_dir_all(&staging_dir);
219 }
220
221 extract_tgz(&tgz_bytes, &staging_dir)?;
223
224 let now_ms = SystemTime::now()
226 .duration_since(UNIX_EPOCH)
227 .unwrap_or_default()
228 .as_millis() as u64;
229 let manifest = UiBundleManifest {
230 version: version.to_string(),
231 sha256_hex: expected_sha256.to_string(),
232 tgz_size_bytes: tgz_bytes.len() as u64,
233 cached_at_unix_ms: now_ms,
234 };
235 let manifest_bytes = encode_ui_bundle_manifest_json(&manifest)?;
236 write_ui_bundle_manifest(&staging_dir, &manifest_bytes)?;
237
238 fs::create_dir_all(&cache_root)?;
240 promote_ui_bundle_staging(&cache_root, version, &unique, &staging_dir, &version_dir)?;
241
242 Ok(version_dir)
243}
244
245fn extract_tgz(tgz_bytes: &[u8], dest: &Path) -> io::Result<()> {
259 fs::create_dir_all(dest)?;
260
261 let strip_prefix = detect_common_root(tgz_bytes)?;
264
265 let cursor = std::io::Cursor::new(tgz_bytes);
266 let gz = flate2::read::GzDecoder::new(cursor);
267 let mut archive = tar::Archive::new(gz);
268
269 for entry in archive.entries()? {
270 let mut entry = entry?;
271 let raw_path = entry.path()?.into_owned();
272
273 let rel: PathBuf = if strip_prefix {
275 raw_path.components().skip(1).collect()
276 } else {
277 raw_path.components().collect()
278 };
279
280 if rel.as_os_str().is_empty() {
282 continue;
283 }
284
285 if rel
287 .components()
288 .any(|c| matches!(c, std::path::Component::ParentDir))
289 {
290 return Err(io::Error::new(
291 io::ErrorKind::InvalidData,
292 format!(
293 "unsafe path in red-ui bundle archive: {}",
294 raw_path.display()
295 ),
296 ));
297 }
298
299 let out_path = dest.join(&rel);
300
301 match entry.header().entry_type() {
302 tar::EntryType::Directory => {
303 fs::create_dir_all(&out_path)?;
304 }
305 tar::EntryType::Regular => {
306 if let Some(parent) = out_path.parent() {
307 fs::create_dir_all(parent)?;
308 }
309 let mut file = fs::File::create(&out_path)?;
310 std::io::copy(&mut entry, &mut file)?;
311 }
312 _ => {} }
314 }
315 Ok(())
316}
317
318fn detect_common_root(tgz_bytes: &[u8]) -> io::Result<bool> {
327 let cursor = std::io::Cursor::new(tgz_bytes);
328 let gz = flate2::read::GzDecoder::new(cursor);
329 let mut archive = tar::Archive::new(gz);
330
331 let mut common: Option<String> = None;
332 for entry in archive.entries()? {
333 let entry = entry?;
334 let path = entry.path()?.into_owned();
335 let is_dir = matches!(entry.header().entry_type(), tar::EntryType::Directory);
336
337 let first = match path.components().next() {
338 Some(c) => match c.as_os_str().to_str() {
339 Some(s) => s.to_string(),
340 None => continue,
341 },
342 None => continue,
343 };
344
345 let component_count = path.components().count();
346
347 if component_count == 1 && !is_dir {
350 return Ok(false);
351 }
352
353 match &common {
354 None => common = Some(first),
355 Some(prev) if prev != &first => return Ok(false),
356 _ => {}
357 }
358 }
359 Ok(common.is_some())
360}
361
362fn sha256_hex(bytes: &[u8]) -> String {
367 let mut hasher = Sha256::new();
368 hasher.update(bytes);
369 hex::encode(hasher.finalize())
370}
371
372#[cfg(test)]
377mod tests {
378 use super::*;
379 use std::collections::HashSet;
380
381 fn make_tgz(files: &[(&str, &[u8])]) -> Vec<u8> {
390 let buf = Vec::new();
391 let gz = flate2::write::GzEncoder::new(buf, flate2::Compression::default());
392 let mut tb = tar::Builder::new(gz);
393 for (name, content) in files {
394 let mut header = tar::Header::new_gnu();
395 if name.ends_with('/') {
396 header.set_entry_type(tar::EntryType::Directory);
397 header.set_size(0);
398 header.set_mode(0o755);
399 } else {
400 header.set_size(content.len() as u64);
401 header.set_mode(0o644);
402 }
403 header.set_cksum();
404 tb.append_data(&mut header, *name, std::io::Cursor::new(*content))
405 .unwrap();
406 }
407 let gz = tb.into_inner().unwrap();
408 gz.finish().unwrap()
409 }
410
411 struct FakeFetcher {
412 bytes: Vec<u8>,
413 call_count: std::sync::Mutex<usize>,
414 }
415
416 impl FakeFetcher {
417 fn new(bytes: Vec<u8>) -> Self {
418 Self {
419 bytes,
420 call_count: std::sync::Mutex::new(0),
421 }
422 }
423
424 fn calls(&self) -> usize {
425 *self.call_count.lock().unwrap()
426 }
427 }
428
429 impl UiBundleFetcher for FakeFetcher {
430 fn fetch_bytes(&self, _url: &str) -> io::Result<Vec<u8>> {
431 *self.call_count.lock().unwrap() += 1;
432 Ok(self.bytes.clone())
433 }
434 }
435
436 struct OfflineFetcher;
437
438 impl UiBundleFetcher for OfflineFetcher {
439 fn fetch_bytes(&self, url: &str) -> io::Result<Vec<u8>> {
440 Err(io::Error::new(
441 io::ErrorKind::ConnectionRefused,
442 format!("no network: {url}"),
443 ))
444 }
445 }
446
447 fn resolve_with_pin(
449 reddb_cache_root: &Path,
450 fetcher: &dyn UiBundleFetcher,
451 version: &str,
452 expected_sha256: &str,
453 ) -> io::Result<PathBuf> {
454 let cache_root = ui_bundle_cache_root(reddb_cache_root);
455 let version_dir = ui_bundle_version_dir(&cache_root, version);
456 let manifest_path = ui_bundle_manifest_path(&version_dir);
457
458 if manifest_path.exists() {
459 if let Ok(bytes) = fs::read(&manifest_path) {
460 if let Ok(manifest) = decode_ui_bundle_manifest_json(&bytes) {
461 if manifest.version == version && manifest.sha256_hex == expected_sha256 {
462 return Ok(version_dir);
463 }
464 }
465 }
466 }
467
468 let url = release_asset_url(version);
469 let tgz_bytes = fetcher.fetch_bytes(&url)?;
470
471 let actual = sha256_hex(&tgz_bytes);
472 if actual != expected_sha256 {
473 return Err(io::Error::new(
474 io::ErrorKind::InvalidData,
475 format!("red-ui bundle SHA-256 mismatch: expected {expected_sha256}, got {actual}"),
476 ));
477 }
478
479 let unique = format!("{}", tgz_bytes.len());
480 let staging_dir = ui_bundle_staging_dir(&cache_root, version, &unique);
481 if staging_dir.exists() {
482 let _ = fs::remove_dir_all(&staging_dir);
483 }
484 extract_tgz(&tgz_bytes, &staging_dir)?;
485
486 let now_ms = SystemTime::now()
487 .duration_since(UNIX_EPOCH)
488 .unwrap_or_default()
489 .as_millis() as u64;
490 let manifest = UiBundleManifest {
491 version: version.to_string(),
492 sha256_hex: expected_sha256.to_string(),
493 tgz_size_bytes: tgz_bytes.len() as u64,
494 cached_at_unix_ms: now_ms,
495 };
496 let manifest_bytes = encode_ui_bundle_manifest_json(&manifest)?;
497 write_ui_bundle_manifest(&staging_dir, &manifest_bytes)?;
498 fs::create_dir_all(&cache_root)?;
499 promote_ui_bundle_staging(&cache_root, version, &unique, &staging_dir, &version_dir)?;
500
501 Ok(version_dir)
502 }
503
504 #[test]
509 fn pin_to_url_resolution() {
510 assert_eq!(
511 release_asset_url("1.2.3"),
512 "https://github.com/reddb-io/red-ui/releases/download/v1.2.3/ui-dist.tgz"
513 );
514 assert_eq!(
515 release_asset_url("0.0.0-dev"),
516 "https://github.com/reddb-io/red-ui/releases/download/v0.0.0-dev/ui-dist.tgz"
517 );
518 }
519
520 #[test]
521 fn checksum_match_produces_cached_path() {
522 let dir = tempfile::tempdir().unwrap();
523 let tgz = make_tgz(&[
524 ("index.html", b"<html></html>"),
525 ("app.js", b"console.log(1)"),
526 ]);
527 let sha256 = sha256_hex(&tgz);
528 let fetcher = FakeFetcher::new(tgz);
529
530 let bundle_dir = resolve_with_pin(dir.path(), &fetcher, "1.0.0", &sha256).expect("resolve");
531
532 assert!(bundle_dir.exists());
533 assert!(bundle_dir.join("index.html").exists());
534 assert!(bundle_dir.join("app.js").exists());
535 assert_eq!(
536 std::fs::read_to_string(bundle_dir.join("index.html")).unwrap(),
537 "<html></html>"
538 );
539 }
540
541 #[test]
542 fn checksum_mismatch_is_rejected() {
543 let dir = tempfile::tempdir().unwrap();
544 let tgz = make_tgz(&[("index.html", b"<html></html>")]);
545 let wrong_sha256 = "aaaa000000000000000000000000000000000000000000000000000000000000";
546 let fetcher = FakeFetcher::new(tgz);
547
548 let err = resolve_with_pin(dir.path(), &fetcher, "1.0.0", wrong_sha256)
549 .expect_err("should reject mismatched checksum");
550
551 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
552 assert!(err.to_string().contains("SHA-256 mismatch"), "{err}");
553 assert!(err.to_string().contains(wrong_sha256), "{err}");
554 }
555
556 #[test]
557 fn cache_hit_skips_fetch() {
558 let dir = tempfile::tempdir().unwrap();
559 let tgz = make_tgz(&[("index.html", b"<html></html>")]);
560 let sha256 = sha256_hex(&tgz);
561 let fetcher = FakeFetcher::new(tgz);
562
563 resolve_with_pin(dir.path(), &fetcher, "2.0.0", &sha256).unwrap();
565 assert_eq!(fetcher.calls(), 1);
566
567 resolve_with_pin(dir.path(), &fetcher, "2.0.0", &sha256).unwrap();
569 assert_eq!(fetcher.calls(), 1, "cache hit must not call the fetcher");
570 }
571
572 #[test]
573 fn offline_first_run_fails_with_clear_message() {
574 let dir = tempfile::tempdir().unwrap();
575 let fetcher = OfflineFetcher;
576
577 let err = resolve_with_pin(
578 dir.path(),
579 &fetcher,
580 "1.0.0",
581 "0000000000000000000000000000000000000000000000000000000000000000",
582 )
583 .expect_err("offline should fail");
584
585 let msg = err.to_string();
586 assert!(
587 msg.contains("no network"),
588 "error should name the cause: {msg}"
589 );
590 }
591
592 #[test]
593 fn tgz_with_top_level_dir_is_stripped() {
594 let dir = tempfile::tempdir().unwrap();
595 let tgz = make_tgz(&[
597 ("dist/", b""),
598 ("dist/index.html", b"<html></html>"),
599 ("dist/app.js", b"console.log(1)"),
600 ]);
601 let sha256 = sha256_hex(&tgz);
602 let fetcher = FakeFetcher::new(tgz);
603
604 let bundle_dir = resolve_with_pin(dir.path(), &fetcher, "3.0.0", &sha256).expect("resolve");
605
606 assert!(
608 bundle_dir.join("index.html").exists(),
609 "index.html should be at bundle root after stripping"
610 );
611 assert!(bundle_dir.join("app.js").exists());
612 }
613
614 #[test]
615 fn extracted_file_set_matches_archive() {
616 let dir = tempfile::tempdir().unwrap();
617 let files: &[(&str, &[u8])] = &[
618 ("index.html", b"<html>"),
619 ("assets/main.js", b"const x=1"),
620 ("assets/style.css", b"body{}"),
621 ];
622 let tgz = make_tgz(files);
623 let sha256 = sha256_hex(&tgz);
624 let fetcher = FakeFetcher::new(tgz);
625
626 let bundle_dir = resolve_with_pin(dir.path(), &fetcher, "4.0.0", &sha256).expect("resolve");
627
628 let expected: HashSet<&str> = files.iter().map(|(n, _)| *n).collect();
629 for name in &expected {
630 let p = bundle_dir.join(name);
631 assert!(p.exists(), "expected {name} at {}", p.display());
632 }
633 }
634}