1use 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#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct UiBundleManifest {
21 pub version: String,
22 pub sha256_hex: String,
24 pub tgz_size_bytes: u64,
26 pub cached_at_unix_ms: u64,
28}
29
30pub 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
66pub 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
76pub 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
105pub 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}