Merge pull request #175 from Kuddev/fix/shell-script-crlf-20260916

fix(shell): prevent CRLF syntax errors in WSL prompt integration
This commit is contained in:
Kuddev authored and GitHub committed 2026-09-17 10:15:45 +08:00
commit c48dffd213
7 files changed
+155 -8

No files matched your search

+3
View File
@@ -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
+2 -2
View File
@@ -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,
+74 -2
View File
@@ -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]
+1 -1
View File
@@ -698,7 +698,7 @@ pub fn run(args: Vec<String>) -> 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}")),
+34 -2
View File
@@ -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<Cow<'static, str>> =
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");
}
}
+1 -1
View File
@@ -1017,7 +1017,7 @@ fi
fn nebula_bash_rc_path() -> Option<std::path::PathBuf> {
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)
}
+40
View File
@@ -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)