Skip to main content

bamboo_server/services/
frontend_package.rs

1use std::fs::File;
2use std::io::{self, Cursor};
3use std::path::{Path, PathBuf};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use zip::ZipArchive;
8
9use bamboo_infrastructure::paths::bamboo_dir;
10
11include!(concat!(env!("OUT_DIR"), "/frontend_package_embedded.rs"));
12
13pub const DUPLICATE_FRONTEND_DIR_NAME: &str = "frontend";
14pub const DUPLICATE_FRONTEND_MANIFEST_NAME: &str = ".frontend-manifest.json";
15pub const FRONTEND_PACKAGE_ENV: &str = "BAMBOO_FRONTEND_PACKAGE";
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub struct FrontendPackageManifest {
19    pub schema_version: u32,
20    pub frontend_name: String,
21    pub frontend_version: String,
22    pub bundle_hash: String,
23    pub built_at: DateTime<Utc>,
24    pub entry: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct FrontendPackageStatus {
29    pub package_path: Option<PathBuf>,
30    pub frontend_dir: PathBuf,
31    pub local_manifest_path: PathBuf,
32    pub bundled_manifest: FrontendPackageManifest,
33    pub local_manifest: Option<FrontendPackageManifest>,
34    pub refreshed: bool,
35}
36
37pub fn duplicate_frontend_dir_in(bamboo_home_dir: &Path) -> PathBuf {
38    bamboo_home_dir.join(DUPLICATE_FRONTEND_DIR_NAME)
39}
40
41pub fn duplicate_frontend_dir() -> PathBuf {
42    duplicate_frontend_dir_in(&bamboo_dir())
43}
44
45pub fn duplicate_frontend_manifest_path_in(bamboo_home_dir: &Path) -> PathBuf {
46    duplicate_frontend_dir_in(bamboo_home_dir).join(DUPLICATE_FRONTEND_MANIFEST_NAME)
47}
48
49pub fn duplicate_frontend_manifest_path() -> PathBuf {
50    duplicate_frontend_manifest_path_in(&bamboo_dir())
51}
52
53pub fn has_embedded_frontend_package() -> bool {
54    DUPLICATE_FRONTEND_PACKAGE_ZIP.is_some() && DUPLICATE_FRONTEND_PACKAGE_MANIFEST.is_some()
55}
56
57fn frontend_package_candidates_under(base_dir: &Path) -> Vec<PathBuf> {
58    vec![
59        base_dir.join("frontend_package/lotus-frontend.zip"),
60        base_dir.join(".frontend-package/lotus-frontend.zip"),
61        base_dir.join("bodhi/.frontend-package/lotus-frontend.zip"),
62        base_dir.join("../frontend_package/lotus-frontend.zip"),
63        base_dir.join("../.frontend-package/lotus-frontend.zip"),
64        base_dir.join("../bodhi/.frontend-package/lotus-frontend.zip"),
65    ]
66}
67
68pub fn resolve_frontend_package_path(explicit_path: Option<&Path>) -> Option<PathBuf> {
69    if let Some(path) = explicit_path {
70        return path.exists().then(|| path.to_path_buf());
71    }
72
73    if let Ok(raw) = std::env::var(FRONTEND_PACKAGE_ENV) {
74        let trimmed = raw.trim();
75        if !trimmed.is_empty() {
76            let candidate = PathBuf::from(trimmed);
77            if candidate.exists() {
78                return Some(candidate);
79            }
80        }
81    }
82
83    let mut candidates = Vec::new();
84    if let Ok(dir) = std::env::current_dir() {
85        candidates.extend(frontend_package_candidates_under(&dir));
86    }
87
88    if let Ok(current_exe) = std::env::current_exe() {
89        if let Some(exe_dir) = current_exe.parent() {
90            candidates.extend(frontend_package_candidates_under(exe_dir));
91            candidates.push(exe_dir.join("../Resources/frontend_package/lotus-frontend.zip"));
92            candidates.push(exe_dir.join("../Resources/.frontend-package/lotus-frontend.zip"));
93        }
94    }
95
96    candidates.into_iter().find(|path| path.exists())
97}
98
99pub fn read_bundled_manifest(
100    package_path: Option<&Path>,
101) -> Result<FrontendPackageManifest, FrontendPackageError> {
102    if let Some(bytes) = DUPLICATE_FRONTEND_PACKAGE_MANIFEST {
103        return serde_json::from_slice(bytes).map_err(FrontendPackageError::Json);
104    }
105
106    let package_path = package_path.ok_or(FrontendPackageError::PackageNotFound)?;
107    let sidecar_manifest = package_path.with_file_name("frontend-manifest.json");
108    if sidecar_manifest.exists() {
109        let file = File::open(sidecar_manifest).map_err(FrontendPackageError::Io)?;
110        return serde_json::from_reader(file).map_err(FrontendPackageError::Json);
111    }
112
113    read_bundled_manifest_from_zip(package_path)
114}
115
116pub fn read_bundled_manifest_from_zip(
117    package_path: &Path,
118) -> Result<FrontendPackageManifest, FrontendPackageError> {
119    let file = File::open(package_path).map_err(FrontendPackageError::Io)?;
120    let mut archive = ZipArchive::new(file).map_err(FrontendPackageError::Zip)?;
121    let mut manifest_file = archive
122        .by_name("frontend-manifest.json")
123        .map_err(FrontendPackageError::Zip)?;
124    let manifest: FrontendPackageManifest =
125        serde_json::from_reader(&mut manifest_file).map_err(FrontendPackageError::Json)?;
126    Ok(manifest)
127}
128
129pub fn read_local_manifest(
130    manifest_path: &Path,
131) -> Result<Option<FrontendPackageManifest>, FrontendPackageError> {
132    if !manifest_path.exists() {
133        return Ok(None);
134    }
135    let file = File::open(manifest_path).map_err(FrontendPackageError::Io)?;
136    let manifest = serde_json::from_reader(file).map_err(FrontendPackageError::Json)?;
137    Ok(Some(manifest))
138}
139
140pub fn should_refresh_frontend(
141    bundled: &FrontendPackageManifest,
142    local: Option<&FrontendPackageManifest>,
143    frontend_dir: &Path,
144) -> bool {
145    let Some(local) = local else {
146        return true;
147    };
148
149    if bundled.schema_version != local.schema_version {
150        return true;
151    }
152    if bundled.frontend_version != local.frontend_version {
153        return true;
154    }
155    if bundled.bundle_hash != local.bundle_hash {
156        return true;
157    }
158    if !frontend_dir.join(&bundled.entry).is_file() {
159        return true;
160    }
161
162    false
163}
164
165pub fn ensure_current_frontend_dir_in(
166    bamboo_home_dir: &Path,
167    explicit_package_path: Option<&Path>,
168) -> Result<FrontendPackageStatus, FrontendPackageError> {
169    let package_path = if has_embedded_frontend_package() {
170        None
171    } else {
172        Some(
173            resolve_frontend_package_path(explicit_package_path)
174                .ok_or(FrontendPackageError::PackageNotFound)?,
175        )
176    };
177
178    let bundled_manifest = read_bundled_manifest(package_path.as_deref())?;
179    let frontend_dir = duplicate_frontend_dir_in(bamboo_home_dir);
180    let local_manifest_path = duplicate_frontend_manifest_path_in(bamboo_home_dir);
181    let local_manifest = read_local_manifest(&local_manifest_path)?;
182
183    let refresh_needed =
184        should_refresh_frontend(&bundled_manifest, local_manifest.as_ref(), &frontend_dir);
185
186    if refresh_needed {
187        refresh_frontend_dir(package_path.as_deref(), &bundled_manifest, &frontend_dir)?;
188    }
189
190    Ok(FrontendPackageStatus {
191        package_path,
192        frontend_dir,
193        local_manifest_path,
194        bundled_manifest,
195        local_manifest,
196        refreshed: refresh_needed,
197    })
198}
199
200pub fn ensure_current_frontend_dir(
201    explicit_package_path: Option<&Path>,
202) -> Result<FrontendPackageStatus, FrontendPackageError> {
203    ensure_current_frontend_dir_in(&bamboo_dir(), explicit_package_path)
204}
205
206fn refresh_frontend_dir(
207    package_path: Option<&Path>,
208    bundled_manifest: &FrontendPackageManifest,
209    frontend_dir: &Path,
210) -> Result<(), FrontendPackageError> {
211    let parent = frontend_dir
212        .parent()
213        .ok_or_else(|| FrontendPackageError::InvalidTarget(frontend_dir.to_path_buf()))?;
214    std::fs::create_dir_all(parent).map_err(FrontendPackageError::Io)?;
215
216    let temp_dir = parent.join(format!(
217        ".{}-tmp-{}",
218        DUPLICATE_FRONTEND_DIR_NAME,
219        bundled_manifest.frontend_version.replace('/', "-")
220    ));
221    if temp_dir.exists() {
222        std::fs::remove_dir_all(&temp_dir).map_err(FrontendPackageError::Io)?;
223    }
224    std::fs::create_dir_all(&temp_dir).map_err(FrontendPackageError::Io)?;
225
226    if let Some(bytes) = DUPLICATE_FRONTEND_PACKAGE_ZIP {
227        extract_frontend_zip_bytes(bytes, &temp_dir)?;
228    } else {
229        let package_path = package_path.ok_or(FrontendPackageError::PackageNotFound)?;
230        extract_frontend_zip(package_path, &temp_dir)?;
231    }
232
233    let extracted_entry = temp_dir.join(&bundled_manifest.entry);
234    if !extracted_entry.is_file() {
235        return Err(FrontendPackageError::MissingEntry(extracted_entry));
236    }
237
238    let manifest_path = temp_dir.join(DUPLICATE_FRONTEND_MANIFEST_NAME);
239    let manifest_file = File::create(&manifest_path).map_err(FrontendPackageError::Io)?;
240    serde_json::to_writer_pretty(manifest_file, bundled_manifest)
241        .map_err(FrontendPackageError::Json)?;
242
243    let old_dir = parent.join(format!("{}.old", DUPLICATE_FRONTEND_DIR_NAME));
244    if old_dir.exists() {
245        std::fs::remove_dir_all(&old_dir).map_err(FrontendPackageError::Io)?;
246    }
247    if frontend_dir.exists() {
248        std::fs::rename(frontend_dir, &old_dir).map_err(FrontendPackageError::Io)?;
249    }
250    std::fs::rename(&temp_dir, frontend_dir).map_err(FrontendPackageError::Io)?;
251    if old_dir.exists() {
252        std::fs::remove_dir_all(&old_dir).map_err(FrontendPackageError::Io)?;
253    }
254
255    Ok(())
256}
257
258fn extract_frontend_zip(
259    package_path: &Path,
260    target_dir: &Path,
261) -> Result<(), FrontendPackageError> {
262    let file = File::open(package_path).map_err(FrontendPackageError::Io)?;
263    let mut archive = ZipArchive::new(file).map_err(FrontendPackageError::Zip)?;
264    extract_archive_entries(&mut archive, target_dir)
265}
266
267fn extract_frontend_zip_bytes(bytes: &[u8], target_dir: &Path) -> Result<(), FrontendPackageError> {
268    let cursor = Cursor::new(bytes);
269    let mut archive = ZipArchive::new(cursor).map_err(FrontendPackageError::Zip)?;
270    extract_archive_entries(&mut archive, target_dir)
271}
272
273fn extract_archive_entries<R: io::Read + io::Seek>(
274    archive: &mut ZipArchive<R>,
275    target_dir: &Path,
276) -> Result<(), FrontendPackageError> {
277    for index in 0..archive.len() {
278        let mut entry = archive.by_index(index).map_err(FrontendPackageError::Zip)?;
279        let enclosed = entry
280            .enclosed_name()
281            .map(|path| path.to_path_buf())
282            .ok_or_else(|| FrontendPackageError::InvalidArchivePath(entry.name().to_string()))?;
283        let out_path = target_dir.join(enclosed);
284
285        if entry.is_dir() {
286            std::fs::create_dir_all(&out_path).map_err(FrontendPackageError::Io)?;
287            continue;
288        }
289
290        if let Some(parent) = out_path.parent() {
291            std::fs::create_dir_all(parent).map_err(FrontendPackageError::Io)?;
292        }
293
294        let mut out_file = File::create(&out_path).map_err(FrontendPackageError::Io)?;
295        io::copy(&mut entry, &mut out_file).map_err(FrontendPackageError::Io)?;
296    }
297
298    Ok(())
299}
300
301#[derive(Debug)]
302pub enum FrontendPackageError {
303    PackageNotFound,
304    InvalidTarget(PathBuf),
305    MissingEntry(PathBuf),
306    InvalidArchivePath(String),
307    Io(io::Error),
308    Json(serde_json::Error),
309    Zip(zip::result::ZipError),
310}
311
312impl std::fmt::Display for FrontendPackageError {
313    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        match self {
315            Self::PackageNotFound => write!(f, "duplicate frontend package not found"),
316            Self::InvalidTarget(path) => {
317                write!(
318                    f,
319                    "invalid duplicate frontend target path: {}",
320                    path.display()
321                )
322            }
323            Self::MissingEntry(path) => {
324                write!(f, "missing extracted frontend entry: {}", path.display())
325            }
326            Self::InvalidArchivePath(path) => write!(f, "invalid archive path: {}", path),
327            Self::Io(error) => write!(f, "i/o error: {}", error),
328            Self::Json(error) => write!(f, "json error: {}", error),
329            Self::Zip(error) => write!(f, "zip error: {}", error),
330        }
331    }
332}
333
334impl std::error::Error for FrontendPackageError {}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use std::io::Write;
340    use tempfile::tempdir;
341
342    fn manifest_fixture() -> FrontendPackageManifest {
343        FrontendPackageManifest {
344            schema_version: 1,
345            frontend_name: "lotus".to_string(),
346            frontend_version: "1.0.0".to_string(),
347            bundle_hash: "sha256:abc".to_string(),
348            built_at: Utc::now(),
349            entry: "index.html".to_string(),
350        }
351    }
352
353    fn write_test_zip(path: &Path, manifest: &FrontendPackageManifest) {
354        let file = File::create(path).expect("zip file should be created");
355        let mut writer = zip::ZipWriter::new(file);
356        let options = zip::write::SimpleFileOptions::default();
357
358        writer
359            .start_file("index.html", options)
360            .expect("index.html entry should start");
361        writer
362            .write_all(b"<html><body>ok</body></html>")
363            .expect("index.html should write");
364
365        writer
366            .start_file("frontend-manifest.json", options)
367            .expect("manifest entry should start");
368        writer
369            .write_all(serde_json::to_string_pretty(manifest).unwrap().as_bytes())
370            .expect("manifest should write");
371
372        writer.finish().expect("zip should finish");
373    }
374
375    #[test]
376    fn refresh_is_required_when_local_manifest_missing() {
377        let bundled = manifest_fixture();
378        let temp = tempdir().unwrap();
379        assert!(should_refresh_frontend(&bundled, None, temp.path()));
380    }
381
382    #[test]
383    fn resolve_frontend_package_path_finds_bodhi_frontend_package_layout() {
384        let temp = tempdir().unwrap();
385        let bodhi_package_dir = temp.path().join("bodhi/.frontend-package");
386        std::fs::create_dir_all(&bodhi_package_dir).unwrap();
387        let package_path = bodhi_package_dir.join("lotus-frontend.zip");
388        std::fs::write(&package_path, b"zip-bytes").unwrap();
389
390        let original_dir = std::env::current_dir().unwrap();
391        std::env::set_current_dir(temp.path()).unwrap();
392
393        let resolved = resolve_frontend_package_path(None);
394
395        std::env::set_current_dir(original_dir).unwrap();
396
397        let resolved = resolved.expect("frontend package path should resolve");
398        let resolved_canonical = resolved.canonicalize().unwrap();
399        let expected_canonical = package_path.canonicalize().unwrap();
400        assert_eq!(resolved_canonical, expected_canonical);
401    }
402
403    #[test]
404    fn refresh_not_required_when_manifest_matches_and_entry_exists() {
405        let bundled = manifest_fixture();
406        let temp = tempdir().unwrap();
407        std::fs::write(temp.path().join("index.html"), "ok").unwrap();
408        assert!(!should_refresh_frontend(
409            &bundled,
410            Some(&bundled),
411            temp.path()
412        ));
413    }
414
415    #[test]
416    fn ensure_current_frontend_dir_extracts_bundle_into_bamboo_frontend() {
417        let package_temp = tempdir().unwrap();
418        let package_path = package_temp.path().join("lotus-frontend.zip");
419        let manifest = manifest_fixture();
420        write_test_zip(&package_path, &manifest);
421
422        let data_temp = tempdir().unwrap();
423        bamboo_infrastructure::paths::init_bamboo_dir(data_temp.path().to_path_buf());
424
425        let status = ensure_current_frontend_dir(Some(&package_path))
426            .expect("frontend extraction should succeed");
427
428        assert!(status.refreshed);
429        assert!(status
430            .frontend_dir
431            .join(&status.bundled_manifest.entry)
432            .is_file());
433        assert!(status
434            .frontend_dir
435            .join(DUPLICATE_FRONTEND_MANIFEST_NAME)
436            .is_file());
437        let local_manifest = read_local_manifest(&status.local_manifest_path)
438            .expect("local manifest should read")
439            .expect("local manifest should exist");
440        assert_eq!(
441            local_manifest.frontend_version,
442            status.bundled_manifest.frontend_version
443        );
444        assert_eq!(
445            local_manifest.bundle_hash,
446            status.bundled_manifest.bundle_hash
447        );
448    }
449}