Skip to main content

reddb_file/
ui_bundle_cache.rs

1//! Local `red ui` bundle cache file contracts.
2//!
3//! Parallel to `ai_model_cache`: the server owns download policy and
4//! checksum verification; this module owns the persisted cache layout
5//! and manifest JSON shape. ADR 0050.
6
7use std::fs;
8use std::io;
9use std::path::{Path, PathBuf};
10
11use serde_json::Value as JsonValue;
12
13pub const UI_BUNDLE_CACHE_DIR_NAME: &str = "ui";
14pub const UI_BUNDLE_STAGING_DIR_NAME: &str = ".staging";
15pub const UI_BUNDLE_PURGE_DIR_NAME: &str = ".purge";
16pub const UI_BUNDLE_MANIFEST_FILE: &str = "manifest.json";
17
18/// Persisted record for a cached `red-ui` bundle version.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct UiBundleManifest {
21    pub version: String,
22    /// SHA-256 of the original `.tgz` download, lower-case hex.
23    pub sha256_hex: String,
24    /// Size of the original `.tgz` download in bytes.
25    pub tgz_size_bytes: u64,
26    /// Unix epoch milliseconds when the bundle was cached.
27    pub cached_at_unix_ms: u64,
28}
29
30// ---------------------------------------------------------------------------
31// Path helpers
32// ---------------------------------------------------------------------------
33
34pub fn ui_bundle_cache_root(base: &Path) -> PathBuf {
35    base.join(UI_BUNDLE_CACHE_DIR_NAME)
36}
37
38pub fn ui_bundle_version_dir(cache_root: &Path, version: &str) -> PathBuf {
39    cache_root.join(version)
40}
41
42pub fn ui_bundle_staging_root(cache_root: &Path) -> PathBuf {
43    cache_root.join(UI_BUNDLE_STAGING_DIR_NAME)
44}
45
46pub fn ui_bundle_purge_root(cache_root: &Path) -> PathBuf {
47    cache_root.join(UI_BUNDLE_PURGE_DIR_NAME)
48}
49
50pub fn ui_bundle_staging_dir(cache_root: &Path, version: &str, unique: &str) -> PathBuf {
51    ui_bundle_staging_root(cache_root).join(format!("{version}-{unique}"))
52}
53
54pub fn ui_bundle_purge_dir(cache_root: &Path, version: &str, unique: &str) -> PathBuf {
55    ui_bundle_purge_root(cache_root).join(format!("{version}-{unique}"))
56}
57
58pub fn ui_bundle_manifest_path(version_dir: &Path) -> PathBuf {
59    version_dir.join(UI_BUNDLE_MANIFEST_FILE)
60}
61
62pub fn ui_bundle_manifest_temp_path(dir: &Path) -> PathBuf {
63    dir.join(format!("{UI_BUNDLE_MANIFEST_FILE}.tmp"))
64}
65
66// ---------------------------------------------------------------------------
67// I/O helpers
68// ---------------------------------------------------------------------------
69
70pub fn write_ui_bundle_manifest(dir: &Path, bytes: &[u8]) -> io::Result<()> {
71    let tmp = ui_bundle_manifest_temp_path(dir);
72    fs::write(&tmp, bytes)?;
73    fs::rename(&tmp, ui_bundle_manifest_path(dir))
74}
75
76/// Atomically promote a staging directory to the live version directory.
77/// Rolls back if the rename fails; best-effort removes the purge directory
78/// after a successful promotion.
79pub fn promote_ui_bundle_staging(
80    cache_root: &Path,
81    version: &str,
82    unique: &str,
83    staging_dir: &Path,
84    version_dir: &Path,
85) -> io::Result<()> {
86    let purge_root = ui_bundle_purge_root(cache_root);
87    fs::create_dir_all(&purge_root)?;
88    let purge_dir = ui_bundle_purge_dir(cache_root, version, unique);
89    if version_dir.exists() {
90        fs::rename(version_dir, &purge_dir)?;
91    }
92    if let Err(err) = fs::rename(staging_dir, version_dir) {
93        if purge_dir.exists() {
94            let _ = fs::rename(&purge_dir, version_dir);
95        }
96        let _ = fs::remove_dir_all(staging_dir);
97        return Err(err);
98    }
99    if purge_dir.exists() {
100        let _ = fs::remove_dir_all(&purge_dir);
101    }
102    Ok(())
103}
104
105// ---------------------------------------------------------------------------
106// Manifest JSON codec
107// ---------------------------------------------------------------------------
108
109pub fn encode_ui_bundle_manifest_json(manifest: &UiBundleManifest) -> io::Result<Vec<u8>> {
110    serde_json::to_vec(&manifest_to_json(manifest)).map_err(|err| {
111        io::Error::new(
112            io::ErrorKind::InvalidData,
113            format!("encode UI bundle manifest: {err}"),
114        )
115    })
116}
117
118pub fn decode_ui_bundle_manifest_json(bytes: &[u8]) -> io::Result<UiBundleManifest> {
119    let value: JsonValue = serde_json::from_slice(bytes).map_err(|err| {
120        io::Error::new(
121            io::ErrorKind::InvalidData,
122            format!("UI bundle manifest is not valid JSON: {err}"),
123        )
124    })?;
125    manifest_from_json(&value)
126}
127
128fn manifest_to_json(m: &UiBundleManifest) -> JsonValue {
129    let mut obj = serde_json::Map::new();
130    obj.insert("version".to_string(), JsonValue::String(m.version.clone()));
131    obj.insert(
132        "sha256".to_string(),
133        JsonValue::String(m.sha256_hex.clone()),
134    );
135    obj.insert(
136        "tgz_size_bytes".to_string(),
137        JsonValue::Number(m.tgz_size_bytes.into()),
138    );
139    obj.insert(
140        "cached_at_unix_ms".to_string(),
141        JsonValue::Number(m.cached_at_unix_ms.into()),
142    );
143    JsonValue::Object(obj)
144}
145
146fn manifest_from_json(value: &JsonValue) -> io::Result<UiBundleManifest> {
147    let obj = value
148        .as_object()
149        .ok_or_else(|| invalid("manifest is not an object"))?;
150    Ok(UiBundleManifest {
151        version: required_str(obj, "version")?,
152        sha256_hex: required_str(obj, "sha256")?,
153        tgz_size_bytes: required_u64(obj, "tgz_size_bytes")?,
154        cached_at_unix_ms: required_u64(obj, "cached_at_unix_ms")?,
155    })
156}
157
158fn required_str(obj: &serde_json::Map<String, JsonValue>, key: &str) -> io::Result<String> {
159    obj.get(key)
160        .and_then(JsonValue::as_str)
161        .map(str::to_string)
162        .ok_or_else(|| invalid(format!("manifest field '{key}' missing or not a string")))
163}
164
165fn required_u64(obj: &serde_json::Map<String, JsonValue>, key: &str) -> io::Result<u64> {
166    obj.get(key)
167        .and_then(JsonValue::as_u64)
168        .ok_or_else(|| invalid(format!("manifest field '{key}' missing or not a number")))
169}
170
171fn invalid(message: impl Into<String>) -> io::Error {
172    io::Error::new(io::ErrorKind::InvalidData, message.into())
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn ui_bundle_manifest_round_trips() {
181        let m = UiBundleManifest {
182            version: "1.2.3".to_string(),
183            sha256_hex: "deadbeef".to_string(),
184            tgz_size_bytes: 42_000,
185            cached_at_unix_ms: 1_000_000,
186        };
187        let bytes = encode_ui_bundle_manifest_json(&m).expect("encode");
188        let decoded = decode_ui_bundle_manifest_json(&bytes).expect("decode");
189        assert_eq!(decoded, m);
190        assert!(String::from_utf8(bytes)
191            .unwrap()
192            .contains("\"sha256\":\"deadbeef\""));
193    }
194
195    #[test]
196    fn ui_bundle_cache_paths_are_canonical() {
197        let root = Path::new("/tmp/reddb");
198        assert_eq!(
199            ui_bundle_cache_root(root),
200            Path::new("/tmp/reddb").join("ui")
201        );
202        assert_eq!(
203            ui_bundle_version_dir(&ui_bundle_cache_root(root), "1.2.3"),
204            Path::new("/tmp/reddb/ui/1.2.3")
205        );
206        assert_eq!(
207            ui_bundle_staging_dir(&ui_bundle_cache_root(root), "1.2.3", "abc"),
208            Path::new("/tmp/reddb/ui/.staging/1.2.3-abc")
209        );
210        assert_eq!(
211            ui_bundle_purge_dir(&ui_bundle_cache_root(root), "1.2.3", "abc"),
212            Path::new("/tmp/reddb/ui/.purge/1.2.3-abc")
213        );
214    }
215
216    #[test]
217    fn ui_bundle_manifest_rejects_invalid_json_and_missing_fields() {
218        assert!(decode_ui_bundle_manifest_json(b"not json").is_err());
219        assert!(decode_ui_bundle_manifest_json(b"[]").is_err());
220        assert!(decode_ui_bundle_manifest_json(br#"{"version":"1"}"#).is_err());
221        assert!(decode_ui_bundle_manifest_json(
222            br#"{"version":"1","sha256":"abc","tgz_size_bytes":"big","cached_at_unix_ms":1}"#
223        )
224        .is_err());
225        assert!(decode_ui_bundle_manifest_json(
226            br#"{"version":"1","sha256":"abc","tgz_size_bytes":1,"cached_at_unix_ms":"now"}"#
227        )
228        .is_err());
229    }
230
231    #[test]
232    fn ui_bundle_manifest_write_and_promote_staging_are_atomic() {
233        let dir = tempfile::tempdir().unwrap();
234        let cache_root = ui_bundle_cache_root(dir.path());
235        let version_dir = ui_bundle_version_dir(&cache_root, "1.2.3");
236        fs::create_dir_all(&version_dir).unwrap();
237
238        write_ui_bundle_manifest(&version_dir, br#"{"ok":true}"#).unwrap();
239        assert_eq!(
240            fs::read(ui_bundle_manifest_path(&version_dir)).unwrap(),
241            br#"{"ok":true}"#
242        );
243        assert!(!ui_bundle_manifest_temp_path(&version_dir).exists());
244
245        let staging = ui_bundle_staging_dir(&cache_root, "1.2.3", "new");
246        fs::create_dir_all(&staging).unwrap();
247        fs::write(staging.join("bundle.js"), b"new").unwrap();
248        fs::write(version_dir.join("bundle.js"), b"old").unwrap();
249        promote_ui_bundle_staging(&cache_root, "1.2.3", "new", &staging, &version_dir).unwrap();
250        assert_eq!(fs::read(version_dir.join("bundle.js")).unwrap(), b"new");
251        assert!(!ui_bundle_purge_dir(&cache_root, "1.2.3", "new").exists());
252    }
253
254    #[test]
255    fn promote_ui_bundle_staging_rolls_back_existing_version_on_failure() {
256        let dir = tempfile::tempdir().unwrap();
257        let cache_root = ui_bundle_cache_root(dir.path());
258        let version_dir = ui_bundle_version_dir(&cache_root, "1.2.3");
259        fs::create_dir_all(&version_dir).unwrap();
260        fs::write(version_dir.join("bundle.js"), b"old").unwrap();
261
262        let missing_staging = ui_bundle_staging_dir(&cache_root, "1.2.3", "missing");
263        assert!(promote_ui_bundle_staging(
264            &cache_root,
265            "1.2.3",
266            "missing",
267            &missing_staging,
268            &version_dir
269        )
270        .is_err());
271
272        assert_eq!(fs::read(version_dir.join("bundle.js")).unwrap(), b"old");
273        assert!(!missing_staging.exists());
274    }
275}