Skip to main content

pi/core/platform/
debug_dump.rs

1//! `/debug` support dump: rendered TUI lines plus session messages, written to
2//! `{agent}/pi-debug.log`.
3//!
4//! Ports the observable shape of `handleDebugCommand` in the TypeScript
5//! `modes/interactive/interactive-mode.ts`: an ISO timestamp, terminal size,
6//! the fully rendered lines with their visible widths, and the agent messages
7//! as JSONL. Two additions over the reference, both required by the rewrite:
8//!
9//! - **Redaction.** The dump is written to the user's disk and frequently
10//!   shared for support, so credential-shaped substrings (bearer tokens,
11//!   `Authorization` headers, `sk-` style keys, JSON `api_key` fields, and
12//!   `x-api-key` headers) are masked before the file is written. Image data
13//!   and ordinary text are preserved.
14//! - **Atomic write.** The file is written to a sibling temporary path and
15//!   renamed into place, so a crash mid-write never leaves a half dump.
16
17use std::fmt::Write as _;
18use std::io;
19use std::path::{Path, PathBuf};
20
21use regex::Regex;
22
23use crate::core::config::{get_debug_log_path, get_debug_log_path_with};
24
25/// Input captured for a debug dump.
26#[derive(Debug)]
27pub struct DebugDumpInput<'a> {
28    /// ISO-8601 timestamp (UTC).
29    pub timestamp: &'a str,
30    /// Terminal width in columns.
31    pub width: u16,
32    /// Terminal height in rows.
33    pub height: u16,
34    /// Fully rendered lines paired with their visible (display) width.
35    pub rendered_lines: &'a [(String, u16)],
36    /// Agent messages serialized as JSONL, one entry per line.
37    pub messages: &'a [String],
38}
39
40/// Render a debug dump to its full text form, with secrets redacted.
41///
42/// The section order and headers match the TypeScript reference so support
43/// tooling can parse either implementation's output identically.
44#[must_use]
45pub fn render_debug_dump(input: &DebugDumpInput<'_>) -> String {
46    let mut out = String::new();
47    out.push_str(input.timestamp);
48    out.push('\n');
49    let _ = writeln!(out, "Terminal: {}x{}", input.width, input.height);
50    let _ = writeln!(out, "Total lines: {}", input.rendered_lines.len());
51    out.push_str("\n=== All rendered lines with visible widths ===\n");
52    for (idx, (line, width)) in input.rendered_lines.iter().enumerate() {
53        let redacted = redact_secrets(line);
54        let _ = writeln!(out, "[{idx}] (w={width}) {redacted}");
55    }
56    out.push_str("\n=== Agent messages (JSONL) ===\n");
57    for message in input.messages {
58        let redacted = redact_secrets(message);
59        out.push_str(&redacted);
60        out.push('\n');
61    }
62    out
63}
64
65/// Mask credential-shaped substrings in `text`.
66///
67/// Recognizes:
68/// - `Authorization: <scheme> <token>` and bare `Bearer <token>`
69/// - `x-api-key: <value>`
70/// - OpenAI-style `sk-…` keys (20+ word characters after the prefix)
71/// - JSON fields named `apiKey` / `api_key` / `apikey` with a string value
72///
73/// Returns the text with each matched secret replaced by `[REDACTED]`, leaving
74/// the surrounding key or scheme intact so the structure stays readable.
75#[must_use]
76pub fn redact_secrets(text: &str) -> String {
77    let Ok(patterns) = redaction_patterns() else {
78        return "[REDACTED]".to_owned();
79    };
80    let mut value = text.to_owned();
81    for (regex, template) in patterns {
82        value = regex.replace_all(&value, template).into_owned();
83    }
84    value
85}
86
87/// Write `content` to `path` atomically (temp file + rename).
88///
89/// Creates parent directories. The temporary file is a sibling of the target
90/// so the rename stays on the same filesystem.
91///
92/// # Errors
93///
94/// Returns directory creation, write, or rename failures.
95pub fn write_debug_dump_atomically(path: &Path, content: &str) -> io::Result<()> {
96    if let Some(parent) = path.parent()
97        && !parent.as_os_str().is_empty()
98    {
99        std::fs::create_dir_all(parent)?;
100    }
101    let temp_path = sibling_temp_path(path);
102    std::fs::write(&temp_path, content)?;
103    std::fs::rename(&temp_path, path).or_else(|_| {
104        // Rename can fail across some filesystems; fall back to a plain write
105        // so the dump still lands. Best-effort cleanup of the temp file.
106        let _ = std::fs::remove_file(&temp_path);
107        std::fs::write(path, content)
108    })
109}
110
111/// Render and atomically write a dump to the host debug log path.
112///
113/// Returns the path written.
114///
115/// # Errors
116///
117/// Propagates [`write_debug_dump_atomically`] failures.
118pub fn write_debug_dump(input: &DebugDumpInput<'_>) -> io::Result<PathBuf> {
119    let path = get_debug_log_path();
120    let rendered = render_debug_dump(input);
121    write_debug_dump_atomically(&path, &rendered)?;
122    Ok(path)
123}
124
125/// Render and atomically write a dump to the path resolved from `agent_dir`.
126///
127/// # Errors
128///
129/// Propagates [`write_debug_dump_atomically`] failures.
130pub fn write_debug_dump_with(input: &DebugDumpInput<'_>, agent_dir: &Path) -> io::Result<PathBuf> {
131    let path = get_debug_log_path_with(agent_dir);
132    let rendered = render_debug_dump(input);
133    write_debug_dump_atomically(&path, &rendered)?;
134    Ok(path)
135}
136
137/// Build a sibling temporary path that does not collide with the target.
138fn sibling_temp_path(path: &Path) -> PathBuf {
139    let pid = std::process::id();
140    let mut name = path.file_name().map_or_else(
141        || "pi-debug.log".to_owned(),
142        |n| n.to_string_lossy().into_owned(),
143    );
144    let _ = write!(name, ".tmp.{pid}");
145    path.with_file_name(name)
146}
147
148/// Compiled redaction patterns paired with their replacement templates.
149///
150/// Each `$1` captures the key/scheme/header name so only the secret value is
151/// stripped. Built per call because `/debug` is invoked manually and rarely.
152fn redaction_patterns() -> Result<Vec<(Regex, String)>, regex::Error> {
153    [
154        (
155            r"(?i)(authorization\s*:\s*)(?:bearer|basic|token|digest)?\s*\S+",
156            "$1[REDACTED]",
157        ),
158        (r"(?i)(bearer\s+)\S+", "$1[REDACTED]"),
159        (r"(?i)(x-api-key\s*:\s*)\S+", "$1[REDACTED]"),
160        (r"(sk-)[A-Za-z0-9_-]{20,}", "$1[REDACTED]"),
161        (r#"(?i)("(?:api[_-]?key)"\s*:\s*")[^"]*"#, "$1[REDACTED]\""),
162    ]
163    .into_iter()
164    .map(|(pattern, replacement)| Regex::new(pattern).map(|regex| (regex, replacement.to_owned())))
165    .collect()
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    type TestResult = Result<(), Box<dyn std::error::Error>>;
172
173    fn sample_input<'a>(lines: &'a [(String, u16)], messages: &'a [String]) -> DebugDumpInput<'a> {
174        DebugDumpInput {
175            timestamp: "2026-07-18T00:00:00Z",
176            width: 80,
177            height: 24,
178            rendered_lines: lines,
179            messages,
180        }
181    }
182
183    #[test]
184    fn render_includes_sections_and_counts() {
185        let lines = vec![("hello".to_owned(), 5u16), ("world".to_owned(), 5)];
186        let messages = vec!["{\"role\":\"user\"}".to_owned()];
187        let out = render_debug_dump(&sample_input(&lines, &messages));
188        assert!(out.starts_with("2026-07-18T00:00:00Z\n"));
189        assert!(out.contains("Terminal: 80x24"));
190        assert!(out.contains("Total lines: 2"));
191        assert!(out.contains("=== All rendered lines with visible widths ==="));
192        assert!(out.contains("[0] (w=5) hello"));
193        assert!(out.contains("=== Agent messages (JSONL) ==="));
194        assert!(out.contains("{\"role\":\"user\"}"));
195    }
196
197    #[test]
198    fn redact_masks_authorization_and_bearer() {
199        // Construct the secret programmatically so no credential literal is
200        // ever committed to source.
201        let secret = format!("tok{}", "a".repeat(30));
202        let header = format!("Authorization: Bearer {secret}");
203        let redacted = redact_secrets(&header);
204        assert!(!redacted.contains(&secret), "got: {redacted}");
205        assert!(redacted.contains("Authorization:"));
206        assert!(redacted.contains("[REDACTED]"));
207        let bare = format!("token bearer {secret} end");
208        let redacted = redact_secrets(&bare);
209        assert!(!redacted.contains(&secret), "got: {redacted}");
210    }
211
212    #[test]
213    fn redact_masks_sk_prefix_key() {
214        let body = format!("key sk-{}", "b".repeat(40));
215        let redacted = redact_secrets(&body);
216        assert!(redacted.contains("sk-"));
217        assert!(redacted.contains("[REDACTED]"));
218        assert!(!redacted.contains(&"b".repeat(40)));
219    }
220
221    #[test]
222    fn redact_masks_json_api_key_field() {
223        let value = format!("value{}", "c".repeat(20));
224        let json = format!("{{\"apiKey\":\"{value}\",\"n\":1}}");
225        let redacted = redact_secrets(&json);
226        assert!(redacted.contains("\"apiKey\":\"[REDACTED]\""));
227        assert!(!redacted.contains(&value));
228        // Non-secret fields are preserved.
229        assert!(redacted.contains("\"n\":1"));
230    }
231
232    #[test]
233    fn redact_leaves_plain_text_untouched() {
234        let text = "The quick brown fox jumps over the lazy dog.";
235        assert_eq!(redact_secrets(text), text);
236    }
237
238    #[test]
239    fn atomic_write_creates_file_and_parents() -> TestResult {
240        let dir = tempfile::tempdir()?;
241        let target = dir.path().join("nested").join("pi-debug.log");
242        write_debug_dump_atomically(&target, "body")?;
243        assert_eq!(std::fs::read_to_string(&target)?, "body");
244        // No leftover temp files.
245        let parent = target
246            .parent()
247            .ok_or_else(|| std::io::Error::other("debug dump target has no parent"))?;
248        let entries = std::fs::read_dir(parent)?.count();
249        assert_eq!(entries, 1);
250        Ok(())
251    }
252
253    #[test]
254    fn atomic_write_overwrites_existing() -> TestResult {
255        let dir = tempfile::tempdir()?;
256        let target = dir.path().join("pi-debug.log");
257        std::fs::write(&target, "old")?;
258        write_debug_dump_atomically(&target, "new")?;
259        assert_eq!(std::fs::read_to_string(&target)?, "new");
260        Ok(())
261    }
262
263    #[test]
264    fn write_to_agent_dir_uses_config_path() -> TestResult {
265        let dir = tempfile::tempdir()?;
266        let lines = vec![("hi".to_owned(), 2u16)];
267        let input = sample_input(&lines, &[]);
268        let path = write_debug_dump_with(&input, dir.path())?;
269        assert_eq!(path, dir.path().join("pi-debug.log"));
270        let content = std::fs::read_to_string(&path)?;
271        assert!(content.contains("Terminal: 80x24"));
272        Ok(())
273    }
274}