From 76fc0250fdcd8619892445e99b24cbdefb7df1a4 Mon Sep 17 00:00:00 2001 From: kuddev Date: Thu, 16 Jul 2026 16:25:44 +0800 Subject: [PATCH] fix: harden terminal output handling --- nebula_app/src/display/mod.rs | 55 +++++++++++++++++++++++--- nebula_app/src/event.rs | 5 +++ nebula_terminal/src/tty/windows/mod.rs | 8 +++- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index a627cc0..c144f98 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -576,6 +576,31 @@ pub(crate) fn extract_program(line: &str) -> Option { (!name.is_empty()).then_some(name) } +/// Log replay commands can contain terminal query sequences captured from a +/// different process. Replying writes those answers into the shell's stdin, +/// where they become the next command after the replay process exits. +pub(crate) fn replays_untrusted_terminal_output(line: &str) -> bool { + let words: Vec = line + .split_whitespace() + .take(4) + .map(|word| word.trim_matches(['"', '\'']).to_ascii_lowercase()) + .collect(); + matches!( + words.as_slice(), + [docker, logs, ..] if docker == "docker" && logs == "logs" + ) || matches!( + words.as_slice(), + [docker, compose, logs, ..] + if docker == "docker" && compose == "compose" && logs == "logs" + ) || matches!( + words.as_slice(), + [podman, logs, ..] if podman == "podman" && logs == "logs" + ) || matches!( + words.as_slice(), + [kubectl, logs, ..] if kubectl == "kubectl" && logs == "logs" + ) || matches!(words.as_slice(), [journalctl, ..] if journalctl == "journalctl") +} + /// Sidebar icon for a running program — Nerd Font glyphs (the chrome text /// layer renders with Maple NF). AI CLIs get distinct marks; everything else /// shares a generic "running" play sign. @@ -1646,14 +1671,13 @@ impl Display { let (cell_width, cell_height) = compute_cell_size(config, &metrics); // Resize the window to the user-configured size, or a Windows - // Terminal-like default when unset. 120 columns keeps the default - // window compact (user flagged 140 as too wide); 20 lines so the - // welcome intro (17-row galaxy + prompt) still fits without - // scrolling. + // Terminal-like default when unset. A slightly narrower 116-column + // canvas with 50 rows leaves room for the sidebar without producing + // the previous wide, shallow first-launch window. let dimensions = config .window .dimensions() - .unwrap_or(crate::config::window::Dimensions { columns: 120, lines: 20 }); + .unwrap_or(crate::config::window::Dimensions { columns: 116, lines: 50 }); let size = window_size(config, dimensions, cell_width, cell_height, scale_factor); window.request_inner_size(size); @@ -6585,12 +6609,31 @@ fn window_size( mod nebula_ux_tests { use nebula_terminal::grid::Dimensions; - use super::{NebulaConfirm, SizeInfo, remove_ssh_host_from_lists, restore_ssh_host_to_lists}; + use super::{ + NebulaConfirm, SizeInfo, remove_ssh_host_from_lists, replays_untrusted_terminal_output, + restore_ssh_host_to_lists, + }; fn strings(values: &[&str]) -> Vec { values.iter().map(|value| (*value).to_owned()).collect() } + #[test] + fn log_replay_commands_do_not_receive_terminal_query_answers() { + for command in [ + "docker logs app", + "docker compose logs -f api", + "podman logs app", + "kubectl logs pod/api", + "journalctl -f -u nebula", + ] { + assert!(replays_untrusted_terminal_output(command), "{command}"); + } + for command in ["docker run app", "kubectl exec pod -- sh", "cargo test", "nvim"] { + assert!(!replays_untrusted_terminal_output(command), "{command}"); + } + } + #[test] fn ssh_delete_undo_restores_saved_and_pinned_order() { let mut saved = strings(&["alpha", "target", "omega"]); diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index 530ee6d..e6c1e92 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -2756,6 +2756,11 @@ impl input::Processor> { } }, TerminalEvent::ColorRequest(index, format) => { + if crate::display::replays_untrusted_terminal_output( + &self.ctx.nebula_state.last_committed, + ) { + return; + } let color = match self.ctx.terminal().colors()[index] { Some(color) => Rgb(color), // Ignore cursor color requests unless it was changed. diff --git a/nebula_terminal/src/tty/windows/mod.rs b/nebula_terminal/src/tty/windows/mod.rs index bb8ce56..1dbcc12 100644 --- a/nebula_terminal/src/tty/windows/mod.rs +++ b/nebula_terminal/src/tty/windows/mod.rs @@ -293,6 +293,7 @@ fn nebula_find_bash() -> Option { /// terminal itself, so colors work regardless of the PowerShell version. const NEBULA_PROMPT_PS1: &str = r#" $global:NebE = [char]27 +$global:PSDefaultParameterValues['Get-Content:Encoding'] = 'utf8' $global:NebArrow = [char]0xE0B0 $global:NebLeftRound = [char]0xE0B6 $global:NebRightRound = [char]0xE0B4 @@ -803,9 +804,14 @@ pub fn win32_string + ?Sized>(value: &S) -> Vec { #[cfg(test)] mod test { - use crate::tty::windows::{cmdline, push_escaped_arg}; + use crate::tty::windows::{NEBULA_PROMPT_PS1, cmdline, push_escaped_arg}; use crate::tty::{Options, Shell}; + #[test] + fn powershell_cat_defaults_to_utf8() { + assert!(NEBULA_PROMPT_PS1.contains("PSDefaultParameterValues['Get-Content:Encoding']")); + } + #[test] fn test_escape() { let test_set = vec![