Skip to main content

aurum_core/
support.rs

1//! Privacy-safe support bundles for early adopters (JOE-1728).
2//!
3//! Bundles never include API keys, audio, transcripts, or private absolute
4//! home paths by default. Paths are redacted to `$HOME` / `$CACHE` tokens.
5
6use 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
15/// Support bundle schema version.
16pub const SUPPORT_BUNDLE_VERSION: u32 = 1;
17
18/// Offline, allowlist-based diagnostic package.
19#[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    /// Explicit statements about what was excluded.
31    pub redaction_notes: Vec<String>,
32    /// Optional free-form notes from the user (already treated as public).
33    #[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        // JOE-1916 / F-003: use the shared secure output transaction (exclusive
49        // temp, no-follow symlink policy, durable commit) instead of predictable
50        // `*.json.tmp` + fs::write/rename.
51        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
57/// Build a support bundle from the effective config (no network, no downloads).
58pub fn build_support_bundle(cfg: &Config, user_notes: Option<String>) -> SupportBundle {
59    let mut doctor = run_doctor(cfg);
60    // Redact path-like strings inside doctor check details/summaries.
61    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    // Extra redaction pass on path-like fields.
72    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    // Never emit raw key material (Config already maps to "***" or None).
80    if diag.openrouter_api_key.is_some() {
81        diag.openrouter_api_key = Some("***".into());
82    }
83
84    // User notes are untrusted/user-supplied: path-tokenise and scrub token-shaped
85    // material. They are NOT covered by the same privacy guarantee as machine fields.
86    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
122/// Default path suggestion for a new support bundle file.
123pub 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    // Longer roots first when both apply.
134    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        // NoClobber: second write to same path must fail.
197        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        // No predictable leftover .json.tmp
204        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 HOME is set and appeared in cache_dir raw form, redaction should tokenise.
230        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}