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, "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!("files: {}", info.files);
println!("size_bytes: {}", info.total_size); println!("size_bytes: {}", info.total_size);
println!( println!(

View file

@ -29,7 +29,7 @@ pub fn handle(cmd: &ConfigCmd, format: &OutputFormat) -> Result<(), IdxError> {
if format.is_json() { if format.is_json() {
json::print_json(&document) json::print_json(&document)
} else { } else {
println!("{text}"); println!("{}", crate::output::text::terminal_safe(&text));
Ok(()) 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.latest_as_of_date.format("%Y-%m-%d"),
result.release_count, result.release_count,
result.ticker_count, result.ticker_count,
result.db_path terminal_safe(&result.db_path)
); );
} }
snapshot::OwnershipSyncAction::Updated => { 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.latest_as_of_date.format("%Y-%m-%d"),
result.release_count, result.release_count,
result.ticker_count, result.ticker_count,
result.db_path terminal_safe(&result.db_path)
); );
} }
snapshot::OwnershipSyncAction::Refreshed => { snapshot::OwnershipSyncAction::Refreshed => {
println!( println!(
"Refreshed ownership snapshot {} in {}.", "Refreshed ownership snapshot {} in {}.",
result.latest_as_of_date.format("%Y-%m-%d"), result.latest_as_of_date.format("%Y-%m-%d"),
result.db_path terminal_safe(&result.db_path)
); );
} }
snapshot::OwnershipSyncAction::NoChange snapshot::OwnershipSyncAction::NoChange
@ -318,11 +318,11 @@ fn handle_ticker(
println!( println!(
"{} {} KSEI={} Bing={}", "{} {} KSEI={} Bing={}",
"Ownership:".bold(), "Ownership:".bold(),
data.ticker.code.bold(), terminal_safe(&data.ticker.code).bold(),
data.ksei_as_of data.ksei_as_of
.map(|d| d.format("%Y-%m-%d").to_string()) .map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "-".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(); let mut table = Table::new();

View file

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

View file

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

View file

@ -1,24 +1,8 @@
//! Text from providers and KSEI files is untrusted: before it reaches a //! Terminal-safe table cells; see [`idx_core::text`].
//! 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.
use comfy_table::Cell; use comfy_table::Cell;
/// `text` with control characters other than newline, and bidirectional pub use idx_core::text::terminal_safe;
/// 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()
}
/// A table cell whose content is made [`terminal_safe`]. /// A table cell whose content is made [`terminal_safe`].
pub fn cell(value: impl ToString) -> Cell { pub fn cell(value: impl ToString) -> Cell {
@ -42,26 +26,7 @@ pub fn truncate_chars(text: &str, max: usize) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{painted, terminal_safe, truncate_chars}; use super::{painted, 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%"
);
}
#[test] #[test]
fn painted_cells_keep_idx_color_codes() { fn painted_cells_keep_idx_color_codes() {

View file

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