diff --git a/src-tauri/src/core/ssh/osc.rs b/src-tauri/src/core/ssh/osc.rs index 90e17404..b1df942d 100644 --- a/src-tauri/src/core/ssh/osc.rs +++ b/src-tauri/src/core/ssh/osc.rs @@ -42,6 +42,7 @@ const READY_FAILED_MARKER_PREFIX: &str = "7777;NyaTermReadyFailed:"; const COMMAND_MARKER_PREFIX: &str = "7777;NyaTermCommand:"; const LEGACY_READY_MARKER_PREFIX: &str = "7777;DflyReady:"; const LEGACY_COMMAND_MARKER_PREFIX: &str = "7777;DflyCommand:"; +const BASH_HISTORY_PRUNE_MAX_ENTRIES: usize = 256; /// Build a session-unique ready marker: `\x1b]7777;NyaTermReady:\x07`. pub fn build_ready_marker(session_id: &str) -> String { @@ -86,18 +87,27 @@ pub fn injection_script(shell: ShellKind, ready_marker: &str) -> Option .replace('\x1b', "\\033") .replace('\x07', "\\007"); let command_marker = command_marker_for_ready(ready_marker); + let bash_history_token = format!( + "NyaTermHistory:{}", + BASE64_STANDARD.encode(marker_inner(ready_marker)) + ); match shell { ShellKind::Bash => Some(format!( concat!( - // Keep this as one compound history entry while bounding every - // physical line below remote PTY canonical-input limits. - " {{\n", + // Use one compound entry when cmdhist is enabled while keeping + // every physical line below remote PTY canonical-input limits. + // Explicit boundaries cover shells with `shopt -u cmdhist`. + " : '{history_token}:begin'; {{\n", " NYATERM_PRUNE_HISTORY=1;\n", " NYATERM_READY_PENDING=1;\n", + " NYATERM_SKIP_COMMAND_ONCE=1;\n", + " NYATERM_HISTORY_TOKEN='{history_token}';\n", + " NYATERM_HISTORY_BEGIN=\"${{NYATERM_HISTORY_TOKEN}}:begin\";\n", + " NYATERM_HISTORY_END=\"${{NYATERM_HISTORY_TOKEN}}:end\";\n", " export NYATERM_INJ=1;\n", - " NYATERM_COMMAND_MARKER=\"{}\";\n", - " NYATERM_READY_FAILED_MARKER=\"$(printf '{}')\";\n", + " NYATERM_COMMAND_MARKER=\"{command_marker}\";\n", + " NYATERM_READY_FAILED_MARKER=\"$(printf '{ready_failed_osc}')\";\n", " NYATERM_LAST_HISTCMD=\"${{HISTCMD-}}\";\n", " __nyaterm_host(){{ hostname 2>/dev/null || printf localhost; }};\n", " __nyaterm_ready_failed(){{\n", @@ -108,16 +118,23 @@ pub fn injection_script(shell: ShellKind, ready_marker: &str) -> Option " __nyaterm_prune_history(){{\n", " [ -n \"${{NYATERM_PRUNE_HISTORY:-}}\" ] || return 0;\n", " unset NYATERM_PRUNE_HISTORY;\n", - " local hline history_number;\n", + " local hline history_number found_end= history_window remaining={history_prune_limit};\n", + " history_window=\"$(HISTTIMEFORMAT= history \"$remaining\" 2>/dev/null || true)\";\n", + " case \"$history_window\" in *\"$NYATERM_HISTORY_BEGIN\"*\"$NYATERM_HISTORY_END\"*) ;; *) NYATERM_LAST_HISTCMD=\"${{HISTCMD-}}\"; return 0;; esac;\n", + " while [ \"$remaining\" -gt 0 ]; do\n", " hline=\"$(HISTTIMEFORMAT= history 1 2>/dev/null || true)\";\n", - " case \"$hline\" in *NYATERM_PRUNE_HISTORY*|*NYATERM_INJ*|*__nyaterm_prompt*|*NyaTermReady*)\n", + " if [ -z \"$found_end\" ]; then case \"$hline\" in *\"$NYATERM_HISTORY_END\"*) found_end=1;; *) break;; esac; fi;\n", " history_number=${{hline#\"${{hline%%[![:space:]]*}}\"}}; history_number=${{history_number%%[!0-9]*}};\n", - " [ -z \"$history_number\" ] || history -d \"$history_number\" 2>/dev/null || true;;\n", - " esac;\n", + " [ -n \"$history_number\" ] || break;\n", + " history -d \"$history_number\" 2>/dev/null || break;\n", + " case \"$hline\" in *\"$NYATERM_HISTORY_BEGIN\"*) break;; esac;\n", + " remaining=$((remaining - 1));\n", + " done;\n", " NYATERM_LAST_HISTCMD=\"${{HISTCMD-}}\";\n", " }};\n", " __nyaterm_emit_command(){{\n", " local histcmd=\"${{HISTCMD-}}\";\n", + " if [ -n \"${{NYATERM_SKIP_COMMAND_ONCE:-}}\" ]; then unset NYATERM_SKIP_COMMAND_ONCE; NYATERM_LAST_HISTCMD=\"$histcmd\"; return 0; fi;\n", " if [ -n \"$histcmd\" ] && [ \"${{NYATERM_LAST_HISTCMD-}}\" != \"$histcmd\" ]; then\n", " NYATERM_LAST_HISTCMD=\"$histcmd\";\n", " local cmd; cmd=\"$(fc -ln -1 2>/dev/null)\";\n", @@ -189,10 +206,14 @@ pub fn injection_script(shell: ShellKind, ready_marker: &str) -> Option " }};\n", " __nyaterm_repair_prompt(){{ local status=$?; __nyaterm_repair_prompt_container || __nyaterm_ready_failed; return \"$status\"; }};\n", " __nyaterm_install_prompt(){{ __nyaterm_repair_prompt_container; }};\n", - " if __nyaterm_install_prompt; then if [ -n \"${{NYATERM_READY_PENDING:-}}\" ]; then unset NYATERM_READY_PENDING; printf '{}'; fi; else unset NYATERM_READY_PENDING; __nyaterm_ready_failed; fi;\n", - " }}\n", + " if __nyaterm_install_prompt; then __nyaterm_install_ok=1; else __nyaterm_install_ok=0; fi;\n", + " }}; : '{history_token}:end'; __nyaterm_prune_history; if [ \"$__nyaterm_install_ok\" = 1 ]; then if [ -n \"${{NYATERM_READY_PENDING:-}}\" ]; then unset NYATERM_READY_PENDING; printf '{ready_osc}'; fi; else unset NYATERM_READY_PENDING; __nyaterm_ready_failed; fi; unset __nyaterm_install_ok\n", ), - command_marker, ready_failed_osc, ready_osc, + history_token = bash_history_token, + command_marker = command_marker, + ready_failed_osc = ready_failed_osc, + ready_osc = ready_osc, + history_prune_limit = BASH_HISTORY_PRUNE_MAX_ENTRIES, )), ShellKind::Zsh => Some(format!( @@ -926,21 +947,41 @@ fn parse_command_marker(inner: &str, expected_marker: Option<&str>) -> Option/dev/null || true;")); + assert!(script.contains("history -d \"$history_number\" 2>/dev/null || break;")); + assert!(script.contains("NYATERM_SKIP_COMMAND_ONCE=1;")); + assert!(script.contains("unset NYATERM_SKIP_COMMAND_ONCE")); + assert!(script.lines().count() < BASH_HISTORY_PRUNE_MAX_ENTRIES); assert!(!script.contains("BASH_REMATCH")); assert!(script.contains("if declare -F __nyaterm_prompt")); assert!(script.contains("__nyaterm_exported_prompt_fallback")); @@ -973,8 +1014,66 @@ mod tests { } let bash = injection_script(ShellKind::Bash, &ready).expect("Bash injection script"); - assert!(bash.starts_with(" {\n")); - assert!(bash.ends_with(" }\n")); + assert!(bash.starts_with(" : 'NyaTermHistory:")); + assert!(bash.contains(":begin'; {\n")); + assert!(bash.contains("}; : 'NyaTermHistory:")); + assert!(bash.contains(":end'; __nyaterm_prune_history;")); + } + + #[cfg(unix)] + #[test] + fn native_bash_injection_prunes_history_with_cmdhist_enabled_or_disabled() { + use std::io::Write; + use std::process::{Command, Stdio}; + + for cmdhist in ["shopt -s cmdhist", "shopt -u cmdhist"] { + let integration = injection_script(ShellKind::Bash, &build_ready_marker("session-1")) + .expect("Bash injection script"); + let input = format!( + concat!( + "HISTFILE=/dev/null\n", + "HISTCONTROL=\n", + "history -c\n", + "{cmdhist}\n", + "PS1=\n", + "PS2=\n", + "{integration}", + "history\n", + "exit\n", + ), + cmdhist = cmdhist, + integration = integration, + ); + let mut child = Command::new("/bin/bash") + .args(["--noprofile", "--norc", "-i"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn interactive Bash"); + child + .stdin + .take() + .expect("Bash stdin") + .write_all(input.as_bytes()) + .expect("write Bash injection"); + let output = child.wait_with_output().expect("wait for Bash"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(output.status.success(), "{cmdhist}\n{stdout}\n{stderr}"); + assert!( + stdout.contains("NyaTermReady:session-1"), + "{cmdhist}\n{stdout}" + ); + assert!(stdout.contains(cmdhist), "{cmdhist}\n{stdout}"); + assert!( + !stdout.contains("NYATERM_PRUNE_HISTORY=1;") + && !stdout.contains("__nyaterm_host(){") + && !stdout.contains("NyaTermHistory:"), + "injection leaked into Bash history with {cmdhist}\n{stdout}" + ); + } } #[test]