diff --git a/crates/core/src/io/progressive_reader.rs b/crates/core/src/io/progressive_reader.rs index e0ba5ed..4a1f70a 100644 --- a/crates/core/src/io/progressive_reader.rs +++ b/crates/core/src/io/progressive_reader.rs @@ -223,6 +223,9 @@ pub fn compute_line_visual_height( } if json_format { let formatted = format_json_line(line_text); + if formatted.len() > MAX_WRAP_INPUT_LEN { + return 1; + } compute_text_visual_height(&formatted, terminal_width) } else { compute_text_visual_height(line_text, terminal_width) diff --git a/crates/core/src/io/wrap.rs b/crates/core/src/io/wrap.rs index 94e269c..f42ef7c 100644 --- a/crates/core/src/io/wrap.rs +++ b/crates/core/src/io/wrap.rs @@ -1,5 +1,6 @@ /// Maximum input length for wrap/format operations (10 MB). -/// Lines exceeding this are returned as-is to avoid pathological cases. +/// Callers should check against this constant before invoking `wrap_line_chars` +/// to avoid pathological cases on oversized lines. pub const MAX_WRAP_INPUT_LEN: usize = 10 * 1024 * 1024; /// Split a line into chunks of exactly `width` display columns. diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 7570ebe..68cf869 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -6,7 +6,7 @@ use log_viewer_core::io::progressive_reader::{ IndexerMessage, ProgressiveFileReader, VisualHeightIndex, compute_line_visual_height, spawn_indexer, }; -use log_viewer_core::io::wrap::{format_json_line, wrap_line_chars}; +use log_viewer_core::io::wrap::{format_json_line, wrap_line_chars, MAX_WRAP_INPUT_LEN}; use log_viewer_core::types::LogLevel; use log_viewer_core::watcher::file_watcher::{FileEvent, FileWatcher}; @@ -201,12 +201,33 @@ impl App { /// Compute a single line's viewport entry (wrapped rows + level + height). fn compute_line_entry(&self, line: usize, width: usize) -> ViewportEntry { let raw = self.get_line(line).unwrap_or_default(); + + // Guard 1: oversized raw input — skip detect_level and JSON formatting + // to avoid O(n) parsing overhead on huge lines. + if raw.len() > MAX_WRAP_INPUT_LEN { + return ViewportEntry { + wrapped_rows: vec![truncate_to_columns(&raw, width)], + level: None, + visual_height: 1, + }; + } + let level = log_viewer_core::parser::level::detect_level(&raw); let display_text = if self.json_format { format_json_line(&raw) } else { raw }; + + // Guard 2: JSON pretty-printing may expand a line beyond the limit. + if display_text.len() > MAX_WRAP_INPUT_LEN { + return ViewportEntry { + wrapped_rows: vec![truncate_to_columns(&display_text, width)], + level, + visual_height: 1, + }; + } + let mut wrapped = Vec::new(); for sub_line in display_text.split('\n') { wrapped.extend(wrap_line_chars(sub_line, width)); @@ -222,11 +243,23 @@ impl App { /// Compute visual height for a single line without storing it. fn compute_visual_height(&self, line: usize, width: usize) -> usize { let raw = self.get_line(line).unwrap_or_default(); + + // Guard 1: oversized raw input. + if raw.len() > MAX_WRAP_INPUT_LEN { + return 1; + } + let display_text = if self.json_format { format_json_line(&raw) } else { raw }; + + // Guard 2: post-format expansion. + if display_text.len() > MAX_WRAP_INPUT_LEN { + return 1; + } + let mut height = 0; for sub_line in display_text.split('\n') { height += wrap_line_chars(sub_line, width).len(); @@ -1154,6 +1187,13 @@ impl App { } } +fn truncate_to_columns(s: &str, max_cols: usize) -> String { + if max_cols == 0 || s.is_empty() { + return String::new(); + } + wrap_line_chars(s, max_cols).into_iter().next().unwrap_or_default() +} + #[cfg(test)] mod tests { use super::*; @@ -3419,4 +3459,93 @@ plain text line }); assert!(result.is_ok()); } + + #[test] + fn test_compute_line_entry_oversized_raw_guard() { + let long_line = "x".repeat(MAX_WRAP_INPUT_LEN + 1); + let path = make_temp_file(&format!("short\n{}\n", long_line)); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + load_file_ready(&mut app, &path); + assert!(app.is_loaded()); + + let entry = app.compute_line_entry(1, 80); + assert_eq!(entry.visual_height, 1, "oversized line should have height 1"); + assert!(entry.level.is_none(), "oversized raw line should skip detect_level"); + assert_eq!(entry.wrapped_rows.len(), 1); + assert!( + entry.wrapped_rows[0].len() <= 80, + "preview should be bounded to width" + ); + + let short_entry = app.compute_line_entry(0, 80); + assert_eq!(short_entry.visual_height, 1); + assert!(short_entry.level.is_none(), "'short' has no log level"); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_compute_visual_height_oversized_raw_guard() { + let long_line = "a".repeat(MAX_WRAP_INPUT_LEN + 100); + let path = make_temp_file(&format!("hi\n{}\n", long_line)); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + load_file_ready(&mut app, &path); + assert!(app.is_loaded()); + + assert_eq!(app.compute_visual_height(0, 80), 1, "normal line height=1"); + assert_eq!(app.compute_visual_height(1, 80), 1, "oversized line height=1"); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_compute_line_entry_post_format_guard() { + // Build a JSON object whose raw form is just under MAX_WRAP_INPUT_LEN + // but whose pretty-printed form (with added spaces around ':') exceeds it. + let inner = "x".repeat(MAX_WRAP_INPUT_LEN - 11); + let json_line = format!(r#"{{"msg":"{}"}}"#, inner); + assert!( + json_line.len() < MAX_WRAP_INPUT_LEN, + "raw JSON should be under limit: got {}", + json_line.len() + ); + + let path = make_temp_file(&format!("{}\n", json_line)); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + load_file_ready(&mut app, &path); + app.json_format = true; + + let entry = app.compute_line_entry(0, 80); + assert_eq!(entry.visual_height, 1, "post-format oversized should have height 1"); + assert_eq!(entry.wrapped_rows.len(), 1); + }); + cleanup(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_truncate_to_columns_basic() { + assert_eq!(truncate_to_columns("hello world", 5), "hello"); + assert_eq!(truncate_to_columns("hi", 80), "hi"); + assert_eq!(truncate_to_columns("", 80), ""); + assert_eq!(truncate_to_columns("abc", 0), ""); + } + + #[test] + fn test_truncate_to_columns_cjk() { + // Each CJK char is 2 columns wide + assert_eq!(truncate_to_columns("你好世界", 3), "你"); + assert_eq!(truncate_to_columns("你好世界", 4), "你好"); + } + + #[test] + fn test_truncate_to_columns_tab() { + // Tab expands to 4 spaces + assert_eq!(truncate_to_columns("a\tb", 3), "a"); + } } \ No newline at end of file