fix(cli): sanitize errors and diagnostics at their choke points

From the narrow Codex confirmation: provider, KSEI, and manifest text
still reached the terminal through error messages (a Yahoo error
description, a manifest's `kind`), idx-core diagnostics, the ownership
heading's ticker code and Bing date, the technical heading and
comparison symbol, and printed paths. Sanitize where every error
(emit_error) and every diagnostic (runtime) is printed instead of at each
caller, plus the remaining direct prints. terminal_safe moves to
idx_core::text so the runtime can use it; the CLI re-exports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
hermes 2026-10-17 05:44:37 +00:00
commit 927d4224e9
8 changed files with 67 additions and 49 deletions

View file

@ -34,7 +34,10 @@ pub fn handle(cmd: &CacheCmd, format: &OutputFormat) -> Result<(), IdxError> {
"newest": info.newest,
}));
}
println!("path: {}", info.path.display());
println!(
"path: {}",
crate::output::text::terminal_safe(&info.path.display().to_string())
);
println!("files: {}", info.files);
println!("size_bytes: {}", info.total_size);
println!(

View file

@ -29,7 +29,7 @@ pub fn handle(cmd: &ConfigCmd, format: &OutputFormat) -> Result<(), IdxError> {
if format.is_json() {
json::print_json(&document)
} else {
println!("{text}");
println!("{}", crate::output::text::terminal_safe(&text));
Ok(())
}
};

View file

@ -228,7 +228,7 @@ fn handle_sync(investor: &Investor, args: &SyncArgs, config: &IdxConfig) -> Resu
result.latest_as_of_date.format("%Y-%m-%d"),
result.release_count,
result.ticker_count,
result.db_path
terminal_safe(&result.db_path)
);
}
snapshot::OwnershipSyncAction::Updated => {
@ -237,14 +237,14 @@ fn handle_sync(investor: &Investor, args: &SyncArgs, config: &IdxConfig) -> Resu
result.latest_as_of_date.format("%Y-%m-%d"),
result.release_count,
result.ticker_count,
result.db_path
terminal_safe(&result.db_path)
);
}
snapshot::OwnershipSyncAction::Refreshed => {
println!(
"Refreshed ownership snapshot {} in {}.",
result.latest_as_of_date.format("%Y-%m-%d"),
result.db_path
terminal_safe(&result.db_path)
);
}
snapshot::OwnershipSyncAction::NoChange
@ -318,11 +318,11 @@ fn handle_ticker(
println!(
"{} {} KSEI={} Bing={}",
"Ownership:".bold(),
data.ticker.code.bold(),
terminal_safe(&data.ticker.code).bold(),
data.ksei_as_of
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "-".to_string()),
data.bing_as_of.unwrap_or_else(|| "-".to_string())
terminal_safe(&data.bing_as_of.unwrap_or_else(|| "-".to_string()))
);
let mut table = Table::new();

View file

@ -211,7 +211,8 @@ pub fn render_screener(
pub fn emit_error(err: &IdxError, format: &OutputFormat) {
if !format.is_json() {
eprintln!("Error: {err}");
// Errors can carry provider, KSEI, or manifest text.
eprintln!("Error: {}", text::terminal_safe(&err.to_string()));
return;
}
let payload = serde_json::json!({

View file

@ -135,7 +135,8 @@ pub fn print_technical(report: &TechnicalReport, no_color: bool) -> Result<(), I
"{}",
format!(
"Technical Analysis for {} ({})",
report.symbol, report.as_of
terminal_safe(&report.symbol),
report.as_of
)
.bold()
);
@ -363,7 +364,7 @@ pub fn print_compare(reports: &[FundamentalReport], no_color: bool) -> Result<()
"Symbol",
reports
.iter()
.map(|report| report.symbol.clone())
.map(|report| terminal_safe(&report.symbol))
.collect::<Vec<_>>(),
);
add_compare_row(

View file

@ -1,24 +1,8 @@
//! Text from providers and KSEI files is untrusted: before it reaches a
//! terminal, control characters (which can start escape sequences that clear
//! the screen, rewrite output, or set the window title) and bidirectional
//! overrides (which can reorder what is displayed) are replaced.
//! Terminal-safe table cells; see [`idx_core::text`].
use comfy_table::Cell;
/// `text` with control characters other than newline, and bidirectional
/// override characters, replaced by U+FFFD.
pub fn terminal_safe(text: &str) -> String {
text.chars()
.map(|ch| {
let bidi = matches!(ch, '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}');
if (ch.is_control() && ch != '\n') || bidi {
'\u{FFFD}'
} else {
ch
}
})
.collect()
}
pub use idx_core::text::terminal_safe;
/// A table cell whose content is made [`terminal_safe`].
pub fn cell(value: impl ToString) -> Cell {
@ -42,26 +26,7 @@ pub fn truncate_chars(text: &str, max: usize) -> String {
#[cfg(test)]
mod tests {
use super::{painted, terminal_safe, truncate_chars};
#[test]
fn escape_sequences_and_bidi_overrides_are_neutralized() {
assert_eq!(
terminal_safe("\u{1b}[2J\u{1b}[Hfake"),
"\u{FFFD}[2J\u{FFFD}[Hfake"
);
assert_eq!(
terminal_safe("\u{1b}]0;title\u{7}x"),
"\u{FFFD}]0;title\u{FFFD}x"
);
assert_eq!(terminal_safe("a\u{9b}31mb"), "a\u{FFFD}31mb");
assert_eq!(terminal_safe("ab\u{202E}cd"), "ab\u{FFFD}cd");
assert_eq!(terminal_safe("line one\nline two"), "line one\nline two");
assert_eq!(
terminal_safe("Saham Grup Djarum’s 14,81%"),
"Saham Grup Djarum’s 14,81%"
);
}
use super::{painted, truncate_chars};
#[test]
fn painted_cells_keep_idx_color_codes() {

View file

@ -15,6 +15,7 @@ pub mod investor;
pub mod market;
#[cfg(feature = "ownership")]
pub mod ownership;
pub mod text;
pub use client::{CachePolicy, Fetched, Idx};
@ -47,7 +48,10 @@ pub mod runtime {
let line = serde_json::json!({ "level": level, "message": message.to_string() });
eprintln!("{line}");
} else {
eprintln!("{level}: {message}");
eprintln!(
"{level}: {}",
crate::text::terminal_safe(&message.to_string())
);
}
}

View file

@ -0,0 +1,44 @@
//! Text from providers, KSEI files, and snapshot manifests is untrusted.
//! Before it reaches a terminal, control characters (which can start escape
//! sequences that clear the screen, rewrite output, or set the window title)
//! and bidirectional overrides (which can reorder what is displayed) are
//! replaced.
/// `text` with control characters other than newline, and bidirectional
/// override characters, replaced by U+FFFD.
pub fn terminal_safe(text: &str) -> String {
text.chars()
.map(|ch| {
let bidi = matches!(ch, '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}');
if (ch.is_control() && ch != '\n') || bidi {
'\u{FFFD}'
} else {
ch
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::terminal_safe;
#[test]
fn escape_sequences_and_bidi_overrides_are_neutralized() {
assert_eq!(
terminal_safe("\u{1b}[2J\u{1b}[Hfake"),
"\u{FFFD}[2J\u{FFFD}[Hfake"
);
assert_eq!(
terminal_safe("\u{1b}]0;title\u{7}x"),
"\u{FFFD}]0;title\u{FFFD}x"
);
assert_eq!(terminal_safe("a\u{9b}31mb"), "a\u{FFFD}31mb");
assert_eq!(terminal_safe("ab\u{202E}cd"), "ab\u{FFFD}cd");
assert_eq!(terminal_safe("line one\nline two"), "line one\nline two");
assert_eq!(
terminal_safe("Saham Grup Djarum’s 14,81%"),
"Saham Grup Djarum’s 14,81%"
);
}
}