Skip to main content

reddb_server/server/
ui_bundle_resolver.rs

1//! `red ui` bundle resolver — pin→URL resolution, HTTPS download,
2//! SHA-256 verification, tgz extraction, and local cache management.
3//!
4//! Implements the runtime download path described in ADR 0050:
5//!
6//!   1. The pinned `red-ui` version and its bundle SHA-256 are build-time
7//!      constants set by CI (`RED_UI_PINNED_VERSION` / `RED_UI_PINNED_SHA256`).
8//!   2. [`resolve_ui_bundle`] checks `~/.cache/reddb/ui/<version>/` for a
9//!      cached bundle whose manifest matches the pin. Cache hit → returns
10//!      the directory immediately (no network call).
11//!   3. On a cache miss it downloads the GitHub release asset, verifies the
12//!      SHA-256 (refuses on mismatch), extracts the tgz into a staging
13//!      directory, writes a manifest, and atomically promotes the staging
14//!      directory to the live version directory.
15//!   4. Offline first-run with no cached bundle fails with a clear,
16//!      actionable `io::Error` message.
17//!
18//! The [`UiBundleFetcher`] trait makes the network layer injectable for
19//! tests.
20
21use 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
35// ---------------------------------------------------------------------------
36// Build-time pin (CI replaces these)
37// ---------------------------------------------------------------------------
38
39/// Exact `red-ui` version this `red` binary was tested against. CI that
40/// validates the `red`↔`red-ui` pair sets this before the release build.
41pub const RED_UI_PINNED_VERSION: &str = "0.0.0-dev";
42
43/// SHA-256 (lower-case hex) of the `ui-dist.tgz` for the pinned version.
44/// CI sets this alongside `RED_UI_PINNED_VERSION`.
45pub const RED_UI_PINNED_SHA256: &str =
46    "0000000000000000000000000000000000000000000000000000000000000000";
47
48// ---------------------------------------------------------------------------
49// URL resolution
50// ---------------------------------------------------------------------------
51
52/// GitHub release asset URL for a given `red-ui` version.
53///
54/// Maps `"1.2.3"` → `https://github.com/reddb-io/red-ui/releases/download/v1.2.3/ui-dist.tgz`
55pub 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
59// ---------------------------------------------------------------------------
60// Fetcher abstraction
61// ---------------------------------------------------------------------------
62
63/// Abstraction over the network layer so tests can inject a fake without
64/// making real HTTP calls.
65pub trait UiBundleFetcher: Send + Sync {
66    /// Fetch the URL and return the raw response bytes. Returns an
67    /// `io::Error` on any network or HTTP-level failure.
68    fn fetch_bytes(&self, url: &str) -> io::Result<Vec<u8>>;
69}
70
71/// Production fetcher — HTTPS via `ureq` with rustls, per ADR 0050.
72pub 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
103// ---------------------------------------------------------------------------
104// Cache root
105// ---------------------------------------------------------------------------
106
107/// Compute `~/.cache/reddb` (XDG-aware on Linux, standard on macOS/Windows).
108///
109/// The returned path is not created; callers are responsible for
110/// `fs::create_dir_all` as needed.
111pub 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
131// ---------------------------------------------------------------------------
132// Main resolver
133// ---------------------------------------------------------------------------
134
135/// Ensure the pinned `red-ui` bundle is cached and return its directory.
136///
137/// # Behaviour
138///
139/// 1. **Cache hit**: if `<reddb_cache_root>/ui/<version>/manifest.json`
140///    exists and records the pinned version and SHA-256, return the
141///    directory immediately — no network call.
142/// 2. **Cache miss**: download the release asset via `fetcher`, verify
143///    SHA-256 (reject on mismatch), extract the tgz into a staging
144///    directory, write the manifest, and atomically promote to the live
145///    version directory.
146/// 3. **Offline first-run**: if `fetcher` returns an error and no cached
147///    bundle exists, propagate a clear `io::Error` with an actionable
148///    message.
149///
150/// `reddb_cache_root` is the `~/.cache/reddb` base (use
151/// [`reddb_user_cache_root`] to derive it). In tests, pass a `TempDir`
152/// path to keep caches isolated.
153pub 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    // Cache hit: manifest present, version and checksum match the pin.
165    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        // Manifest exists but is stale or corrupt — fall through to re-download.
174    }
175
176    // Download.
177    let url = release_asset_url(version);
178    let tgz_bytes = fetcher.fetch_bytes(&url).map_err(|err| {
179        // Distinguish between "never cached" and "cached but stale":
180        // both produce an actionable offline message, but only "never
181        // cached" has no fallback, so we surface the download URL.
182        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    // Verify SHA-256 before writing anything to disk.
193    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    // Unique token for staging/purge directory names; avoids a collision
205    // if two processes race to download the same version.
206    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    // Clean up any leftover staging directory from a prior crashed download.
217    if staging_dir.exists() {
218        let _ = fs::remove_dir_all(&staging_dir);
219    }
220
221    // Extract into staging.
222    extract_tgz(&tgz_bytes, &staging_dir)?;
223
224    // Write the manifest atomically inside staging before promotion.
225    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    // Atomically promote staging → live version directory.
239    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
245// ---------------------------------------------------------------------------
246// tgz extraction
247// ---------------------------------------------------------------------------
248
249/// Extract `tgz_bytes` into `dest`, creating `dest` if needed.
250///
251/// Path traversal safety: any archive entry whose path contains a `..`
252/// component is rejected. Symlinks and other special entry types are
253/// skipped (only regular files and directories are extracted).
254///
255/// Top-level directory stripping: if every non-empty path in the archive
256/// shares a common first component (e.g. `dist/`), that component is
257/// stripped so files land directly inside `dest`.
258fn extract_tgz(tgz_bytes: &[u8], dest: &Path) -> io::Result<()> {
259    fs::create_dir_all(dest)?;
260
261    // Two-pass: first decide whether to strip the common root directory,
262    // then extract. Re-reading from bytes is cheap (already in memory).
263    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        // Compute the relative path, optionally stripping the common root.
274        let rel: PathBuf = if strip_prefix {
275            raw_path.components().skip(1).collect()
276        } else {
277            raw_path.components().collect()
278        };
279
280        // Skip the (now-empty) root directory itself.
281        if rel.as_os_str().is_empty() {
282            continue;
283        }
284
285        // Reject path traversal.
286        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            _ => {} // skip symlinks, hard links, etc. — security boundary
313        }
314    }
315    Ok(())
316}
317
318/// Return `true` if every non-empty path in the archive shares a single
319/// common first component (implying the archive was created with a
320/// top-level wrapper directory that should be stripped).
321///
322/// A single-component **directory** entry (e.g. `dist/`) is the root
323/// directory itself and does NOT signal "file at the archive root". A
324/// single-component **regular-file** entry (e.g. `index.html`) does —
325/// there is no prefix to strip in that case.
326fn 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        // A single-component, non-directory entry is a regular file placed
348        // directly at the archive root — no common prefix to strip.
349        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
362// ---------------------------------------------------------------------------
363// SHA-256 helper
364// ---------------------------------------------------------------------------
365
366fn sha256_hex(bytes: &[u8]) -> String {
367    let mut hasher = Sha256::new();
368    hasher.update(bytes);
369    hex::encode(hasher.finalize())
370}
371
372// ---------------------------------------------------------------------------
373// Tests
374// ---------------------------------------------------------------------------
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use std::collections::HashSet;
380
381    // ------------------------------------------------------------------
382    // Test helpers
383    // ------------------------------------------------------------------
384
385    /// Build a minimal `.tgz` archive in memory.
386    ///
387    /// Names ending in `/` are written as directory entries
388    /// (`EntryType::Directory`); all others are regular files.
389    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    // Resolve using a known tgz + SHA-256, bypassing the build-time pin.
448    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    // ------------------------------------------------------------------
505    // Tests
506    // ------------------------------------------------------------------
507
508    #[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        // First call populates the cache.
564        resolve_with_pin(dir.path(), &fetcher, "2.0.0", &sha256).unwrap();
565        assert_eq!(fetcher.calls(), 1);
566
567        // Second call must return immediately — no fetch.
568        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        // Simulate a GitHub release archive: dist/index.html, dist/app.js
596        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        // After stripping "dist/", files should be at the root.
607        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}