diff --git a/.gitattributes b/.gitattributes index af10760..be3f4e6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,6 @@ nebula_terminal/tests/ref/**/*.recording binary extra/completions/** text eol=lf +nebula_terminal/src/tty/connection.sh text eol=lf +nebula_app/res/shell/* text eol=lf +nebula_app/src/platform/pi_session.sh text eol=lf scripts/tests/fixtures/ai-hooks-v1.5.0/* -text diff --git a/nebula_app/src/platform/shell_integration.rs b/nebula_app/src/platform/shell_integration.rs index 30e59d6..0b1d75f 100644 --- a/nebula_app/src/platform/shell_integration.rs +++ b/nebula_app/src/platform/shell_integration.rs @@ -36,7 +36,7 @@ fn prepare_unix(options: &mut tty::Options) -> std::io::Result<()> { (".zshrc", include_str!("../../res/shell/zshrc")), ] { let content = if name == ".zshrc" { - format!("{content}\n{}", tty::CONNECTION_SHELL) + format!("{content}\n{}", tty::connection_shell()) } else { content.to_owned() }; @@ -59,7 +59,7 @@ fn prepare_unix(options: &mut tty::Options) -> std::io::Result<()> { std::fs::create_dir_all(&root)?; let init = root.join("bashrc"); let content = - format!("{}\n{}", include_str!("../../res/shell/bashrc"), tty::CONNECTION_SHELL); + format!("{}\n{}", include_str!("../../res/shell/bashrc"), tty::connection_shell()); crate::atomic_file::write(&init, content.as_bytes())?; options.shell = Some(tty::Shell::new( program, diff --git a/nebula_app/src/shell_detect.rs b/nebula_app/src/shell_detect.rs index 8fca37c..d3a6e52 100644 --- a/nebula_app/src/shell_detect.rs +++ b/nebula_app/src/shell_detect.rs @@ -680,7 +680,7 @@ fi; printf '\033]1337;SetUserVar=pebrel_shell=%s\007\033]133;D;%s\007\033]7;file wslenv.push_str("PROMPT_COMMAND"); } let report = - REPORT.replace("__PEBREL_CONNECTION_HOOK__", nebula_terminal::tty::CONNECTION_SHELL); + REPORT.replace("__PEBREL_CONNECTION_HOOK__", nebula_terminal::tty::connection_shell()); vec![("PROMPT_COMMAND".to_owned(), report), ("WSLENV".to_owned(), wslenv)] } @@ -997,7 +997,6 @@ mod tests { assert_eq!(launched.args(), &["-d".to_owned(), "Ubuntu".to_owned()]); } - #[cfg(windows)] #[test] fn wsl_cwd_report_preserves_the_refreshed_wslenv() { let additions = wsl_cwd_report_env( @@ -1015,6 +1014,79 @@ mod tests { assert!(prompt_command.contains("]133;D;%s")); assert!(prompt_command.contains("]7;file://%s%s")); assert!(prompt_command.contains("]133;A")); + assert!(!prompt_command.contains('\r')); + } + + #[test] + fn wsl_prompt_command_executes_repeatedly_without_checkout_carriage_returns() { + use std::io::Write; + use std::process::{Command, Stdio}; + + let environment = wsl_cwd_report_env("wsl.exe", &[], Some("PROMPT_COMMAND:KEEP/u")); + assert_eq!(environment[1].1, "PROMPT_COMMAND:KEEP/u"); + let prompt = &environment[0].1; + let bash = std::env::var_os("NEBULA_BASH").unwrap_or_else(|| { + [r"C:\Program Files\Git\bin\bash.exe", r"C:\Program Files (x86)\Git\bin\bash.exe"] + .into_iter() + .find(|path| std::path::Path::new(path).is_file()) + .unwrap_or("bash") + .into() + }); + let run = |prompt: &str, user_function: &str| { + let user_check = + if user_function.is_empty() { "" } else { "ssh; test $? = 42 || exit 97" }; + let mut child = Command::new(&bash) + .args(["--noprofile", "--norc", "-s"]) + .env("BASH_ENV", "/dev/null") + .env("PROMPT_COMMAND", prompt) + .env_remove("__pebrel_shell_token") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Bash is required for the shell injection regression"); + let script = format!( + r#"{user_function} +(exit 7); eval "$PROMPT_COMMAND" || exit 91 +typeset -f __pebrel_connection >/dev/null || exit 92 +typeset -f ssh >/dev/null || exit 93 +token=$__pebrel_shell_token +test -n "$token" || exit 94 +(exit 23); eval "$PROMPT_COMMAND" || exit 95 +test "$token" = "$__pebrel_shell_token" || exit 96 +{user_check} +"# + ); + child.stdin.take().unwrap().write_all(script.as_bytes()).unwrap(); + child.wait_with_output().unwrap() + }; + // Git Bash accepts CRLF in eval; Unix Bash (including WSL) rejects it. + // Run the failing control on Unix and the fixed payload on every host. + #[cfg(unix)] + { + let broken = prompt.replace( + nebula_terminal::tty::connection_shell(), + &nebula_terminal::tty::connection_shell().replace('\n', "\r\n"), + ); + let output = run(&broken, ""); + assert!(!output.status.success(), "CRLF must reproduce the original parse failure"); + assert!(String::from_utf8_lossy(&output.stderr).contains("syntax error")); + } + + for user_function in ["", "ssh() { return 42; }"] { + let output = run(prompt, user_function); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr)); + let stdout = String::from_utf8(output.stdout).unwrap(); + for marker in + ["\x1b]133;D;7\x07", "\x1b]133;D;23\x07", "\x1b]7;file://", "\x1b]133;A\x07"] + { + assert_eq!( + stdout.matches(marker).count(), + if marker.contains("D;") { 1 } else { 2 } + ); + } + } } #[test] diff --git a/nebula_app/src/ssh.rs b/nebula_app/src/ssh.rs index c8efb39..2a12ba8 100644 --- a/nebula_app/src/ssh.rs +++ b/nebula_app/src/ssh.rs @@ -698,7 +698,7 @@ pub fn run(args: Vec) -> i32 { }, SshPlan::Inject => { let b64 = base64::engine::general_purpose::STANDARD; - let shell = nebula_terminal::tty::CONNECTION_SHELL; + let shell = nebula_terminal::tty::connection_shell(); let bootstrap = build_bootstrap( &b64.encode(format!("{REMOTE_BASH}\n{shell}")), &b64.encode(format!("{REMOTE_ZSH}\n{shell}")), diff --git a/nebula_terminal/src/tty/mod.rs b/nebula_terminal/src/tty/mod.rs index 7d6a4e4..0b89194 100644 --- a/nebula_terminal/src/tty/mod.rs +++ b/nebula_terminal/src/tty/mod.rs @@ -1,9 +1,10 @@ //! TTY related functionality. +use std::borrow::Cow; use std::collections::HashMap; use std::path::PathBuf; use std::process::ExitStatus; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::{env, io}; use polling::{Event, PollMode, Poller}; @@ -19,7 +20,21 @@ pub mod windows; pub use self::windows::*; /// Shared SSH/WSL execution reports for local and bootstrapped bash/zsh shells. -pub const CONNECTION_SHELL: &str = include_str!("connection.sh"); +pub fn connection_shell() -> &'static str { + static SCRIPT: LazyLock> = + LazyLock::new(|| shell_line_endings(include_str!("connection.sh"))); + &SCRIPT +} + +fn shell_line_endings(script: &str) -> Cow<'_, str> { + // include_str! preserves checkout bytes. POSIX shells treat CR in a CRLF + // checkout as syntax, including when this script travels via PROMPT_COMMAND. + if script.contains("\r\n") { + Cow::Owned(script.replace("\r\n", "\n")) + } else { + Cow::Borrowed(script) + } +} /// Configuration for the `Pty` interface. #[derive(Clone, Debug, PartialEq, Eq, Default)] @@ -177,3 +192,20 @@ fn terminfo_exists(terminfo: &str) -> bool { // No valid terminfo path has been found. false } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn connection_script_survives_crlf_and_mixed_checkouts() { + let source = connection_shell(); + assert!(!source.contains('\r')); + assert!(matches!(shell_line_endings(source), Cow::Borrowed(_))); + for input in [source.replace('\n', "\r\n"), source.replacen('\n', "\r\n", 4)] { + assert_eq!(shell_line_endings(&input), source); + } + // Only line endings change; embedded/escaped carriage returns are data. + assert_eq!(shell_line_endings("printf '\\r\\n'\r\n# a\rb\n"), "printf '\\r\\n'\n# a\rb\n"); + } +} diff --git a/nebula_terminal/src/tty/windows/mod.rs b/nebula_terminal/src/tty/windows/mod.rs index b64d25a..d39a81b 100644 --- a/nebula_terminal/src/tty/windows/mod.rs +++ b/nebula_terminal/src/tty/windows/mod.rs @@ -1017,7 +1017,7 @@ fi fn nebula_bash_rc_path() -> Option { let path = std::env::temp_dir().join("pebrel_bashrc"); - let script = format!("{NEBULA_BASH_RC}\n{}", super::CONNECTION_SHELL); + let script = format!("{NEBULA_BASH_RC}\n{}", super::connection_shell()); write_if_changed(&path, script.as_bytes()).then_some(path) } diff --git a/scripts/tests/test_git_attributes.py b/scripts/tests/test_git_attributes.py index 2ce73be..dbf7379 100644 --- a/scripts/tests/test_git_attributes.py +++ b/scripts/tests/test_git_attributes.py @@ -9,6 +9,46 @@ FIXTURES = ROOT / "nebula_terminal/tests/ref" class GitAttributeTests(unittest.TestCase): + def test_embedded_shell_scripts_survive_autocrlf_checkout(self): + paths = [ + "nebula_terminal/src/tty/connection.sh", + "nebula_app/src/platform/pi_session.sh", + *( + path.relative_to(ROOT).as_posix() + for path in sorted((ROOT / "nebula_app/res/shell").iterdir()) + if path.is_file() + ), + ] + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) / "repository" + checkout = Path(temporary) / "checkout" + repository.mkdir() + git = ["git", "-C", str(repository)] + subprocess.run([*git, "init", "--quiet"], check=True) + subprocess.run([*git, "config", "core.autocrlf", "true"], check=True) + (repository / ".gitattributes").write_bytes((ROOT / ".gitattributes").read_bytes()) + originals = {} + for path in paths: + original = (ROOT / path).read_bytes().replace(b"\r\n", b"\n") + self.assertIn(b"\n", original) + originals[path] = original + target = repository / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(original) + # Prove autocrlf actually rewrites an unprotected shell script. + (repository / "control.sh").write_bytes(b"function example() {\n :\n}\n") + subprocess.run([*git, "add", "--", ".gitattributes", "control.sh", *paths], check=True) + subprocess.run( + [*git, "checkout-index", "--all", f"--prefix={checkout.as_posix()}/"], + check=True, + ) + self.assertIn(b"\r\n", (checkout / "control.sh").read_bytes()) + for path, original in originals.items(): + with self.subTest(path=path): + actual = (checkout / path).read_bytes() + self.assertEqual(actual, original) + self.assertNotIn(b"\r\n", actual) + def test_git_preserves_raw_terminal_streams(self): paths = sorted(str(path.relative_to(ROOT)).replace("\\", "/") for path in FIXTURES.rglob("*.recording")) self.assertTrue(paths)