Skip to main content

brows3r_lib/diagnostics/
bundle.rs

1//! Diagnostic bundle collection and ZIP export.
2//!
3//! `collect_bundle` gathers relevant files, applies redaction to every text
4//! file, compresses them into a ZIP, and returns a `BundleRef` that the
5//! caller can pass to `export_bundle` to move the ZIP to a user-chosen path.
6//!
7//! # OCP
8//!
9//! - `BundleConfig` is open for new fields (e.g. `include_perf_traces`).
10//! - Bundle composition is one function — extending content sources is one
11//!   new branch in `collect_bundle`.
12//! - Redaction is applied once per file before zipping — no leak path.
13
14use std::io::Write as _;
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19use crate::diagnostics::redact::{RedactionLevel, Redactor};
20use crate::error::AppError;
21
22// ---------------------------------------------------------------------------
23// Public types
24// ---------------------------------------------------------------------------
25
26/// Which files / data categories to include in the bundle.
27///
28/// All `include_*` fields default to `true` so a zero-argument `Default::default()`
29/// produces the most complete bundle.  The caller (UI) exposes per-toggle controls
30/// mapped 1-to-1 to these fields.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct BundleConfig {
34    /// Include the recent-errors log buffer.
35    pub include_recent_errors: bool,
36    /// How aggressively to redact credentials and paths.
37    pub redaction_level: RedactionLevel,
38    /// Include the app notification log dump.
39    pub include_logs: bool,
40    /// Include `settings.json` (secrets are never in settings; safe to include).
41    pub include_settings: bool,
42    /// Include `profiles.json` metadata **without** secrets (enforced by serde
43    /// skip annotations on the secret fields at the data-model layer).
44    pub include_profiles_metadata: bool,
45}
46
47impl Default for BundleConfig {
48    fn default() -> Self {
49        Self {
50            include_recent_errors: true,
51            redaction_level: RedactionLevel::Full,
52            include_logs: true,
53            include_settings: true,
54            include_profiles_metadata: true,
55        }
56    }
57}
58
59/// Reference to a collected (but not yet exported) bundle.
60///
61/// The frontend holds this value between the "Generate" and "Save" steps.
62/// `path` points to the ZIP inside the app's cache dir.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct BundleRef {
66    /// Unique identifier for this collection run (UUID v4).
67    pub id: String,
68    /// Absolute path to the ZIP file on disk.
69    pub path: PathBuf,
70    /// Size of the ZIP in bytes.
71    pub size_bytes: u64,
72    /// Whether redaction was applied (i.e. `redaction_level != None`).
73    pub redaction_applied: bool,
74}
75
76/// Resolved file-system paths used by the bundle collector.
77///
78/// Constructed from a Tauri `AppHandle` in the command handler so the
79/// core collection logic stays testable without a Tauri runtime.
80pub struct AppPaths {
81    /// `${app_config_dir}` — contains `settings.json`, `profiles.json`.
82    pub app_config_dir: PathBuf,
83    /// `${app_log_dir}` — contains log files written by the app.
84    pub app_log_dir: PathBuf,
85    /// `${app_cache_dir}` — where temporary bundle directories are created.
86    pub app_cache_dir: PathBuf,
87}
88
89// ---------------------------------------------------------------------------
90// Bundle collection
91// ---------------------------------------------------------------------------
92
93/// Collect diagnostic files, redact them, zip them, and return a `BundleRef`.
94///
95/// Creates a temp dir at `${app_cache_dir}/diagnostics/<uuid>/` and writes
96/// `bundle.zip` there.  The caller is responsible for eventually cleaning up
97/// the temp dir (via `export_bundle` or explicit cleanup).
98pub fn collect_bundle(
99    config: &BundleConfig,
100    app_paths: &AppPaths,
101    redactor: &Redactor,
102) -> Result<BundleRef, AppError> {
103    let bundle_id = uuid::Uuid::new_v4().to_string();
104
105    // Create the per-run temp directory.
106    let temp_dir = app_paths.app_cache_dir.join("diagnostics").join(&bundle_id);
107    std::fs::create_dir_all(&temp_dir).map_err(|_| AppError::internal_new())?;
108
109    let zip_path = temp_dir.join("bundle.zip");
110    let zip_file = std::fs::File::create(&zip_path).map_err(|_| AppError::internal_new())?;
111    let mut zip = zip::ZipWriter::new(zip_file);
112
113    let options = zip::write::SimpleFileOptions::default()
114        .compression_method(zip::CompressionMethod::Deflated);
115
116    // ------------------------------------------------------------------
117    // settings.json
118    // ------------------------------------------------------------------
119    if config.include_settings {
120        let src = app_paths.app_config_dir.join("settings.json");
121        add_text_file_to_zip(&mut zip, &src, "settings.json", options, redactor)?;
122    }
123
124    // ------------------------------------------------------------------
125    // profiles.json (metadata only — secrets skipped by serde skip attrs)
126    // ------------------------------------------------------------------
127    if config.include_profiles_metadata {
128        let src = app_paths.app_config_dir.join("profiles.json");
129        add_text_file_to_zip(&mut zip, &src, "profiles.json", options, redactor)?;
130    }
131
132    // ------------------------------------------------------------------
133    // Notification log dump (brows3r.log or similar in log dir)
134    // ------------------------------------------------------------------
135    if config.include_logs {
136        // Include all *.log files found in app_log_dir (non-recursive).
137        if let Ok(entries) = std::fs::read_dir(&app_paths.app_log_dir) {
138            for entry in entries.flatten() {
139                let p = entry.path();
140                if p.extension().and_then(|e| e.to_str()) == Some("log") {
141                    let name = p
142                        .file_name()
143                        .and_then(|n| n.to_str())
144                        .unwrap_or("app.log")
145                        .to_owned();
146                    add_text_file_to_zip(&mut zip, &p, &name, options, redactor)?;
147                }
148            }
149        }
150    }
151
152    // ------------------------------------------------------------------
153    // Recent-error buffer (a synthetic errors.json in the log dir)
154    // ------------------------------------------------------------------
155    if config.include_recent_errors {
156        let src = app_paths.app_log_dir.join("errors.json");
157        add_text_file_to_zip(&mut zip, &src, "errors.json", options, redactor)?;
158    }
159
160    zip.finish().map_err(|_| AppError::internal_new())?;
161
162    let size_bytes = std::fs::metadata(&zip_path).map(|m| m.len()).unwrap_or(0);
163
164    Ok(BundleRef {
165        id: bundle_id,
166        path: zip_path,
167        size_bytes,
168        redaction_applied: config.redaction_level != RedactionLevel::None,
169    })
170}
171
172// ---------------------------------------------------------------------------
173// Bundle export
174// ---------------------------------------------------------------------------
175
176/// Copy (and then clean up) the collected ZIP to a user-chosen destination.
177///
178/// After a successful copy the temp directory is removed.  On failure the
179/// temp dir is left in place so the user can retry without re-collecting.
180pub fn export_bundle(bundle_ref: &BundleRef, dest_path: &Path) -> Result<(), AppError> {
181    // Verify the bundle still exists.
182    if !bundle_ref.path.exists() {
183        return Err(AppError::NotFound {
184            resource: bundle_ref.path.display().to_string(),
185        });
186    }
187
188    // Ensure destination parent directory exists.
189    if let Some(parent) = dest_path.parent() {
190        if !parent.as_os_str().is_empty() {
191            std::fs::create_dir_all(parent).map_err(|_| AppError::internal_new())?;
192        }
193    }
194
195    // Copy ZIP to the user destination.
196    std::fs::copy(&bundle_ref.path, dest_path).map_err(|_| AppError::internal_new())?;
197
198    // Clean up the temp dir (best-effort — failure is not an error for the user).
199    if let Some(temp_dir) = bundle_ref.path.parent() {
200        let _ = std::fs::remove_dir_all(temp_dir);
201    }
202
203    Ok(())
204}
205
206// ---------------------------------------------------------------------------
207// Private helpers
208// ---------------------------------------------------------------------------
209
210/// Read a file as UTF-8 text, apply redaction, and write it into the ZIP.
211///
212/// If the source file does not exist the entry is silently skipped (the file
213/// may be absent in a fresh install or on a sandboxed filesystem).
214fn add_text_file_to_zip<W: std::io::Write + std::io::Seek>(
215    zip: &mut zip::ZipWriter<W>,
216    src: &Path,
217    entry_name: &str,
218    options: zip::write::SimpleFileOptions,
219    redactor: &Redactor,
220) -> Result<(), AppError> {
221    let raw = match std::fs::read_to_string(src) {
222        Ok(s) => s,
223        Err(_) => return Ok(()), // file absent → silently skip
224    };
225
226    let redacted = redactor.redact_text(&raw);
227
228    zip.start_file(entry_name, options)
229        .map_err(|_| AppError::internal_new())?;
230    zip.write_all(redacted.as_bytes())
231        .map_err(|_| AppError::internal_new())?;
232
233    Ok(())
234}
235
236// ---------------------------------------------------------------------------
237// Tests
238// ---------------------------------------------------------------------------
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    fn make_paths(root: &Path) -> AppPaths {
245        AppPaths {
246            app_config_dir: root.join("config"),
247            app_log_dir: root.join("logs"),
248            app_cache_dir: root.join("cache"),
249        }
250    }
251
252    fn make_redactor() -> Redactor {
253        Redactor::new()
254    }
255
256    // -------------------------------------------------------------------------
257    // collect_bundle writes a ZIP with expected entries
258    // -------------------------------------------------------------------------
259
260    #[test]
261    fn collect_bundle_produces_zip_with_expected_entries() {
262        let tmp = tempfile::tempdir().unwrap();
263        let root = tmp.path();
264
265        let config_dir = root.join("config");
266        let log_dir = root.join("logs");
267        std::fs::create_dir_all(&config_dir).unwrap();
268        std::fs::create_dir_all(&log_dir).unwrap();
269
270        // Write synthetic source files.
271        std::fs::write(config_dir.join("settings.json"), r#"{"schemaVersion":1}"#).unwrap();
272        std::fs::write(config_dir.join("profiles.json"), r#"[]"#).unwrap();
273        std::fs::write(log_dir.join("brows3r.log"), "info: app started\n").unwrap();
274        std::fs::write(log_dir.join("errors.json"), "[]").unwrap();
275
276        let paths = make_paths(root);
277        let config = BundleConfig::default();
278        let redactor = make_redactor();
279
280        let bundle_ref = collect_bundle(&config, &paths, &redactor).unwrap();
281
282        // Zip must exist and be non-empty.
283        assert!(
284            bundle_ref.path.exists(),
285            "zip should exist at {:?}",
286            bundle_ref.path
287        );
288        assert!(bundle_ref.size_bytes > 0, "zip should not be empty");
289        assert!(
290            bundle_ref.redaction_applied,
291            "Full level → redaction_applied=true"
292        );
293
294        // Open the ZIP and verify all expected entry names are present.
295        let zip_file = std::fs::File::open(&bundle_ref.path).unwrap();
296        let mut archive = zip::ZipArchive::new(zip_file).unwrap();
297        let names: Vec<String> = (0..archive.len())
298            .map(|i| archive.by_index(i).unwrap().name().to_owned())
299            .collect();
300
301        assert!(
302            names.contains(&"settings.json".to_owned()),
303            "zip must contain settings.json; got {names:?}"
304        );
305        assert!(
306            names.contains(&"profiles.json".to_owned()),
307            "zip must contain profiles.json; got {names:?}"
308        );
309        assert!(
310            names.contains(&"brows3r.log".to_owned()),
311            "zip must contain brows3r.log; got {names:?}"
312        );
313        assert!(
314            names.contains(&"errors.json".to_owned()),
315            "zip must contain errors.json; got {names:?}"
316        );
317    }
318
319    // -------------------------------------------------------------------------
320    // Integration: redaction applied to every included file
321    // -------------------------------------------------------------------------
322
323    #[test]
324    fn collect_bundle_redacts_credential_in_log_file() {
325        let tmp = tempfile::tempdir().unwrap();
326        let root = tmp.path();
327
328        let config_dir = root.join("config");
329        let log_dir = root.join("logs");
330        std::fs::create_dir_all(&config_dir).unwrap();
331        std::fs::create_dir_all(&log_dir).unwrap();
332
333        // Place an AWS access key ID inside the log file (uses the
334        // aws_key_id.positive.txt content from the task-59 fixture).
335        let raw_key = "AKIAIOSFODNN7EXAMPLE";
336        std::fs::write(
337            log_dir.join("brows3r.log"),
338            format!("Found {raw_key} in the logs\n"),
339        )
340        .unwrap();
341        // Empty required files so collect_bundle doesn't bail on missing them.
342        std::fs::write(config_dir.join("settings.json"), "{}").unwrap();
343        std::fs::write(config_dir.join("profiles.json"), "[]").unwrap();
344        std::fs::write(log_dir.join("errors.json"), "[]").unwrap();
345
346        let paths = make_paths(root);
347        let config = BundleConfig::default();
348        let redactor = make_redactor();
349
350        let bundle_ref = collect_bundle(&config, &paths, &redactor).unwrap();
351
352        // Extract `brows3r.log` from the ZIP and assert the raw key is gone.
353        let zip_file = std::fs::File::open(&bundle_ref.path).unwrap();
354        let mut archive = zip::ZipArchive::new(zip_file).unwrap();
355        let mut log_entry = archive.by_name("brows3r.log").unwrap();
356        let mut content = String::new();
357        std::io::Read::read_to_string(&mut log_entry, &mut content).unwrap();
358
359        assert!(
360            content.contains("<REDACTED:AWS_KEY_ID>"),
361            "redacted marker must be present; got: {content}"
362        );
363        assert!(
364            !content.contains(raw_key),
365            "raw key must not appear in the zip entry; got: {content}"
366        );
367    }
368
369    // -------------------------------------------------------------------------
370    // export_bundle moves the ZIP to dest
371    // -------------------------------------------------------------------------
372
373    #[test]
374    fn export_bundle_copies_zip_to_destination() {
375        let tmp = tempfile::tempdir().unwrap();
376        let root = tmp.path();
377        let config_dir = root.join("config");
378        let log_dir = root.join("logs");
379        std::fs::create_dir_all(&config_dir).unwrap();
380        std::fs::create_dir_all(&log_dir).unwrap();
381        std::fs::write(config_dir.join("settings.json"), "{}").unwrap();
382        std::fs::write(config_dir.join("profiles.json"), "[]").unwrap();
383
384        let paths = make_paths(root);
385        let config = BundleConfig::default();
386        let redactor = make_redactor();
387
388        let bundle_ref = collect_bundle(&config, &paths, &redactor).unwrap();
389        let dest = root.join("output").join("my-bundle.zip");
390
391        export_bundle(&bundle_ref, &dest).unwrap();
392
393        assert!(dest.exists(), "zip should exist at destination");
394    }
395
396    // -------------------------------------------------------------------------
397    // collect_bundle with RedactionLevel::None does not redact
398    // -------------------------------------------------------------------------
399
400    #[test]
401    fn collect_bundle_with_none_level_does_not_redact() {
402        let tmp = tempfile::tempdir().unwrap();
403        let root = tmp.path();
404        let config_dir = root.join("config");
405        let log_dir = root.join("logs");
406        std::fs::create_dir_all(&config_dir).unwrap();
407        std::fs::create_dir_all(&log_dir).unwrap();
408
409        let raw_key = "AKIAIOSFODNN7EXAMPLE";
410        std::fs::write(log_dir.join("brows3r.log"), format!("key: {raw_key}\n")).unwrap();
411        std::fs::write(config_dir.join("settings.json"), "{}").unwrap();
412        std::fs::write(config_dir.join("profiles.json"), "[]").unwrap();
413
414        let paths = make_paths(root);
415        let config = BundleConfig {
416            redaction_level: RedactionLevel::None,
417            ..BundleConfig::default()
418        };
419        let redactor = Redactor::with_level(RedactionLevel::None);
420
421        let bundle_ref = collect_bundle(&config, &paths, &redactor).unwrap();
422        assert!(!bundle_ref.redaction_applied);
423
424        let zip_file = std::fs::File::open(&bundle_ref.path).unwrap();
425        let mut archive = zip::ZipArchive::new(zip_file).unwrap();
426        let mut log_entry = archive.by_name("brows3r.log").unwrap();
427        let mut content = String::new();
428        std::io::Read::read_to_string(&mut log_entry, &mut content).unwrap();
429
430        assert!(
431            content.contains(raw_key),
432            "None level must not redact; got: {content}"
433        );
434    }
435
436    // -------------------------------------------------------------------------
437    // collect_bundle respects include_settings=false
438    // -------------------------------------------------------------------------
439
440    #[test]
441    fn collect_bundle_respects_include_settings_false() {
442        let tmp = tempfile::tempdir().unwrap();
443        let root = tmp.path();
444        let config_dir = root.join("config");
445        let log_dir = root.join("logs");
446        std::fs::create_dir_all(&config_dir).unwrap();
447        std::fs::create_dir_all(&log_dir).unwrap();
448        std::fs::write(config_dir.join("settings.json"), "{}").unwrap();
449        std::fs::write(config_dir.join("profiles.json"), "[]").unwrap();
450
451        let paths = make_paths(root);
452        let config = BundleConfig {
453            include_settings: false,
454            ..BundleConfig::default()
455        };
456        let redactor = make_redactor();
457
458        let bundle_ref = collect_bundle(&config, &paths, &redactor).unwrap();
459
460        let zip_file = std::fs::File::open(&bundle_ref.path).unwrap();
461        let mut archive = zip::ZipArchive::new(zip_file).unwrap();
462        let names: Vec<String> = (0..archive.len())
463            .map(|i| archive.by_index(i).unwrap().name().to_owned())
464            .collect();
465        assert!(
466            !names.contains(&"settings.json".to_owned()),
467            "settings.json must be absent when include_settings=false; got {names:?}"
468        );
469    }
470}