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::fs;
13use std::path::{Path, PathBuf};
14use std::time::{SystemTime, UNIX_EPOCH};
15
16/// Support bundle schema version.
17pub const SUPPORT_BUNDLE_VERSION: u32 = 1;
18
19/// Offline, allowlist-based diagnostic package.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SupportBundle {
22    pub schema_version: u32,
23    pub created_at_unix: u64,
24    pub aurum_version: String,
25    pub os: String,
26    pub arch: String,
27    pub family: String,
28    pub doctor: DoctorReport,
29    pub metrics: MetricsSnapshot,
30    pub config: EffectiveConfigDiagnostic,
31    /// Explicit statements about what was excluded.
32    pub redaction_notes: Vec<String>,
33    /// Optional free-form notes from the user (already treated as public).
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub user_notes: Option<String>,
36}
37
38impl SupportBundle {
39    pub fn to_json_pretty(&self) -> Result<String> {
40        serde_json::to_string_pretty(self).map_err(|e| {
41            UserError::Other {
42                message: format!("support bundle json: {e}"),
43            }
44            .into()
45        })
46    }
47
48    pub fn write_to_path(&self, path: &Path) -> Result<()> {
49        if let Some(parent) = path.parent() {
50            fs::create_dir_all(parent).map_err(|e| UserError::Other {
51                message: format!("create support bundle dir: {e}"),
52            })?;
53        }
54        let json = self.to_json_pretty()?;
55        let tmp = path.with_extension("json.tmp");
56        fs::write(&tmp, json.as_bytes()).map_err(|e| UserError::Other {
57            message: format!("write support bundle temp: {e}"),
58        })?;
59        fs::rename(&tmp, path).map_err(|e| UserError::Other {
60            message: format!("publish support bundle: {e}"),
61        })?;
62        Ok(())
63    }
64}
65
66/// Build a support bundle from the effective config (no network, no downloads).
67pub fn build_support_bundle(cfg: &Config, user_notes: Option<String>) -> SupportBundle {
68    let mut doctor = run_doctor(cfg);
69    // Redact path-like strings inside doctor check details/summaries.
70    for c in &mut doctor.checks {
71        c.summary = redact_path_str(&c.summary);
72        if let Some(d) = c.detail.as_mut() {
73            *d = redact_path_str(d);
74        }
75        if let Some(h) = c.hint.as_mut() {
76            *h = redact_path_str(h);
77        }
78    }
79    let mut diag = cfg.effective_diagnostic();
80    // Extra redaction pass on path-like fields.
81    diag.cache_dir = redact_path_str(&diag.cache_dir);
82    if let Some(p) = diag.config_path.as_mut() {
83        *p = redact_path_str(p);
84    }
85    if let Some(p) = diag.tts_pack_dir.as_mut() {
86        *p = redact_path_str(p);
87    }
88    // Never emit raw key material (Config already maps to "***" or None).
89    if diag.openrouter_api_key.is_some() {
90        diag.openrouter_api_key = Some("***".into());
91    }
92
93    let mut user_notes = user_notes;
94    if let Some(n) = user_notes.as_mut() {
95        *n = redact_path_str(n);
96    }
97
98    SupportBundle {
99        schema_version: SUPPORT_BUNDLE_VERSION,
100        created_at_unix: SystemTime::now()
101            .duration_since(UNIX_EPOCH)
102            .map(|d| d.as_secs())
103            .unwrap_or(0),
104        aurum_version: env!("CARGO_PKG_VERSION").into(),
105        os: env::consts::OS.into(),
106        arch: env::consts::ARCH.into(),
107        family: env::consts::FAMILY.into(),
108        doctor,
109        metrics: process_metrics().snapshot(),
110        config: diag,
111        redaction_notes: vec![
112            "API keys redacted".into(),
113            "source audio and transcripts never included".into(),
114            "absolute home/cache paths tokenised".into(),
115            "no network requests performed while building this bundle".into(),
116        ],
117        user_notes,
118    }
119}
120
121/// Default path suggestion for a new support bundle file.
122pub fn default_bundle_path() -> PathBuf {
123    let stamp = SystemTime::now()
124        .duration_since(UNIX_EPOCH)
125        .map(|d| d.as_secs())
126        .unwrap_or(0);
127    PathBuf::from(format!("aurum-support-{stamp}.json"))
128}
129
130fn redact_path_str(s: &str) -> String {
131    let mut out = s.to_string();
132    if let Ok(home) = env::var("HOME") {
133        if !home.is_empty() {
134            out = out.replace(&home, "$HOME");
135        }
136    }
137    if let Ok(userprofile) = env::var("USERPROFILE") {
138        if !userprofile.is_empty() {
139            out = out.replace(&userprofile, "$HOME");
140        }
141    }
142    out
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn bundle_has_no_home_leak_when_redacted() {
151        let cfg = Config::load().unwrap();
152        let b = build_support_bundle(&cfg, Some("repro steps".into()));
153        let json = b.to_json_pretty().unwrap();
154        assert!(json.contains("schema_version"));
155        assert!(json.contains("redaction_notes"));
156        assert!(!json.contains("OPENROUTER_API_KEY="));
157        // If HOME is set and appeared in cache_dir raw form, redaction should tokenise.
158        if let Ok(home) = env::var("HOME") {
159            if cfg.cache_dir.starts_with(&home) {
160                assert!(!json.contains(&home));
161                assert!(json.contains("$HOME") || !json.contains("cache_dir"));
162            }
163        }
164    }
165
166    #[test]
167    fn write_roundtrip() {
168        let dir = tempfile::tempdir().unwrap();
169        let path = dir.path().join("bundle.json");
170        let cfg = Config::load().unwrap();
171        let b = build_support_bundle(&cfg, None);
172        b.write_to_path(&path).unwrap();
173        let loaded: SupportBundle =
174            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
175        assert_eq!(loaded.schema_version, SUPPORT_BUNDLE_VERSION);
176    }
177}