1use crate::config::{Config, EffectiveConfigDiagnostic};
7use crate::doctor::{run_doctor, DoctorReport};
8use crate::error::{Result, UserError};
9use crate::observability::{process_metrics, MetricsSnapshot};
10use serde::{Deserialize, Serialize};
11use std::env;
12use std::path::{Path, PathBuf};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15pub const SUPPORT_BUNDLE_VERSION: u32 = 1;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct SupportBundle {
21 pub schema_version: u32,
22 pub created_at_unix: u64,
23 pub aurum_version: String,
24 pub os: String,
25 pub arch: String,
26 pub family: String,
27 pub doctor: DoctorReport,
28 pub metrics: MetricsSnapshot,
29 pub config: EffectiveConfigDiagnostic,
30 pub redaction_notes: Vec<String>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub user_notes: Option<String>,
35}
36
37impl SupportBundle {
38 pub fn to_json_pretty(&self) -> Result<String> {
39 serde_json::to_string_pretty(self).map_err(|e| {
40 UserError::Other {
41 message: format!("support bundle json: {e}"),
42 }
43 .into()
44 })
45 }
46
47 pub fn write_to_path(&self, path: &Path) -> Result<()> {
48 let json = self.to_json_pretty()?;
52 crate::output::OutputTransaction::new(path, crate::output::CommitMode::NoClobber)
53 .commit_bytes(json.as_bytes())
54 }
55}
56
57pub fn build_support_bundle(cfg: &Config, user_notes: Option<String>) -> SupportBundle {
59 let mut doctor = run_doctor(cfg);
60 for c in &mut doctor.checks {
62 c.summary = redact_path_str(&c.summary);
63 if let Some(d) = c.detail.as_mut() {
64 *d = redact_path_str(d);
65 }
66 if let Some(h) = c.hint.as_mut() {
67 *h = redact_path_str(h);
68 }
69 }
70 let mut diag = cfg.effective_diagnostic();
71 diag.cache_dir = redact_path_str(&diag.cache_dir);
73 if let Some(p) = diag.config_path.as_mut() {
74 *p = redact_path_str(p);
75 }
76 if let Some(p) = diag.tts_pack_dir.as_mut() {
77 *p = redact_path_str(p);
78 }
79 if diag.openrouter_api_key.is_some() {
81 diag.openrouter_api_key = Some("***".into());
82 }
83
84 let mut user_notes = user_notes;
87 if let Some(n) = user_notes.as_mut() {
88 *n = crate::remote::redact_secret(&redact_path_str(n));
89 }
90
91 let mut redaction_notes = vec![
92 "API keys redacted".into(),
93 "source audio and transcripts never included".into(),
94 "absolute home/config/cache/pack paths tokenised where known".into(),
95 "no network requests performed while building this bundle".into(),
96 ];
97 if user_notes.is_some() {
98 redaction_notes.push(
99 "user_notes are untrusted free-form input: path/token redaction applied, but content is not privacy-guaranteed"
100 .into(),
101 );
102 }
103
104 SupportBundle {
105 schema_version: SUPPORT_BUNDLE_VERSION,
106 created_at_unix: SystemTime::now()
107 .duration_since(UNIX_EPOCH)
108 .map(|d| d.as_secs())
109 .unwrap_or(0),
110 aurum_version: env!("CARGO_PKG_VERSION").into(),
111 os: env::consts::OS.into(),
112 arch: env::consts::ARCH.into(),
113 family: env::consts::FAMILY.into(),
114 doctor,
115 metrics: process_metrics().snapshot(),
116 config: diag,
117 redaction_notes,
118 user_notes,
119 }
120}
121
122pub fn default_bundle_path() -> PathBuf {
124 let stamp = SystemTime::now()
125 .duration_since(UNIX_EPOCH)
126 .map(|d| d.as_secs())
127 .unwrap_or(0);
128 PathBuf::from(format!("aurum-support-{stamp}.json"))
129}
130
131fn redact_path_str(s: &str) -> String {
132 let mut out = s.to_string();
133 let mut roots: Vec<(String, &str)> = Vec::new();
135 if let Ok(home) = env::var("HOME") {
136 if !home.is_empty() {
137 roots.push((home, "$HOME"));
138 }
139 }
140 if let Ok(userprofile) = env::var("USERPROFILE") {
141 if !userprofile.is_empty() {
142 roots.push((userprofile, "$HOME"));
143 }
144 }
145 if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") {
146 if !xdg_config.is_empty() {
147 roots.push((xdg_config, "$XDG_CONFIG_HOME"));
148 }
149 }
150 if let Ok(xdg_cache) = env::var("XDG_CACHE_HOME") {
151 if !xdg_cache.is_empty() {
152 roots.push((xdg_cache, "$XDG_CACHE_HOME"));
153 }
154 }
155 roots.sort_by_key(|(p, _)| std::cmp::Reverse(p.len()));
156 for (root, token) in roots {
157 if out.contains(&root) {
158 out = out.replace(&root, token);
159 }
160 }
161 out
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use crate::secret::SecretString;
168 use std::fs;
169 use tempfile::tempdir;
170
171 #[test]
172 fn support_bundle_redacts_secrets_and_stays_offline() {
173 let dir = tempdir().unwrap();
174 let mut cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
175 cfg.cache_dir = dir.path().join("cache");
176 cfg.openrouter_api_key = Some(SecretString::new("sk-leaked-api-key-value"));
177 let bundle = build_support_bundle(&cfg, Some("notes only".into()));
178 let json = bundle.to_json_pretty().unwrap();
179 assert!(!json.contains("sk-leaked-api-key-value"));
180 assert!(json.contains("***") || bundle.config.openrouter_api_key.as_deref() == Some("***"));
181 assert!(bundle
182 .redaction_notes
183 .iter()
184 .any(|n| n.contains("no network")));
185 assert!(bundle.doctor.checks.iter().any(|c| c.id == "offline"));
186 }
187
188 #[test]
189 fn write_to_path_uses_secure_commit_and_noclobber() {
190 let dir = tempdir().unwrap();
191 let path = dir.path().join("bundle.json");
192 let cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
193 let bundle = build_support_bundle(&cfg, None);
194 bundle.write_to_path(&path).unwrap();
195 assert!(path.is_file());
196 let err = bundle.write_to_path(&path).unwrap_err();
198 let msg = err.to_string().to_ascii_lowercase();
199 assert!(
200 msg.contains("exists") || msg.contains("clobber") || msg.contains("already"),
201 "unexpected err: {msg}"
202 );
203 assert!(!dir.path().join("bundle.json.tmp").exists());
205 }
206
207 #[test]
208 fn user_notes_token_shaped_material_redacted() {
209 let dir = tempdir().unwrap();
210 let cfg = Config::load_from(&dir.path().join("nope.toml")).unwrap();
211 let notes = "repro with Bearer sk-or-v1-canarynotesecretvalue99";
212 let bundle = build_support_bundle(&cfg, Some(notes.into()));
213 let json = bundle.to_json_pretty().unwrap();
214 assert!(!json.contains("canarynotesecretvalue99"));
215 assert!(bundle
216 .redaction_notes
217 .iter()
218 .any(|n| n.contains("user_notes")));
219 }
220
221 #[test]
222 fn bundle_has_no_home_leak_when_redacted() {
223 let cfg = Config::load().unwrap();
224 let b = build_support_bundle(&cfg, Some("repro steps".into()));
225 let json = b.to_json_pretty().unwrap();
226 assert!(json.contains("schema_version"));
227 assert!(json.contains("redaction_notes"));
228 assert!(!json.contains("OPENROUTER_API_KEY="));
229 if let Ok(home) = env::var("HOME") {
231 if cfg.cache_dir.starts_with(&home) {
232 assert!(!json.contains(&home));
233 assert!(json.contains("$HOME") || !json.contains("cache_dir"));
234 }
235 }
236 }
237
238 #[test]
239 fn write_roundtrip() {
240 let dir = tempfile::tempdir().unwrap();
241 let path = dir.path().join("bundle.json");
242 let cfg = Config::load().unwrap();
243 let b = build_support_bundle(&cfg, None);
244 b.write_to_path(&path).unwrap();
245 let loaded: SupportBundle =
246 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
247 assert_eq!(loaded.schema_version, SUPPORT_BUNDLE_VERSION);
248 }
249}