1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct BundleConfig {
34 pub include_recent_errors: bool,
36 pub redaction_level: RedactionLevel,
38 pub include_logs: bool,
40 pub include_settings: bool,
42 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#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct BundleRef {
66 pub id: String,
68 pub path: PathBuf,
70 pub size_bytes: u64,
72 pub redaction_applied: bool,
74}
75
76pub struct AppPaths {
81 pub app_config_dir: PathBuf,
83 pub app_log_dir: PathBuf,
85 pub app_cache_dir: PathBuf,
87}
88
89pub 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 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 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 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 if config.include_logs {
136 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 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
172pub fn export_bundle(bundle_ref: &BundleRef, dest_path: &Path) -> Result<(), AppError> {
181 if !bundle_ref.path.exists() {
183 return Err(AppError::NotFound {
184 resource: bundle_ref.path.display().to_string(),
185 });
186 }
187
188 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 std::fs::copy(&bundle_ref.path, dest_path).map_err(|_| AppError::internal_new())?;
197
198 if let Some(temp_dir) = bundle_ref.path.parent() {
200 let _ = std::fs::remove_dir_all(temp_dir);
201 }
202
203 Ok(())
204}
205
206fn 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(()), };
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#[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 #[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 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 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 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 #[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 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 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 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 #[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 #[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 #[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}