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:
7 files changed
+155
-8
No files matched your search
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}")),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user