From 30d6c82246e3f6db8d0fdaf015825d2701a570b8 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Fri, 11 Sep 2026 08:51:42 +0800 Subject: [PATCH 1/2] fix(shell): preserve PowerShell smart quotes in generated scripts Share safe PowerShell quoting across integration, completions, and configured picker paths to prevent smart quote literals from being altered or executed. Add round-trip coverage for smart quotes in generated sources, aliases, worktree completions, and picker configuration. Closes #62 close #62 --- src/completions.rs | 7 +- src/key_bindings.rs | 6 +- src/main.rs | 1 + src/powershell.rs | 17 +++++ src/shell.rs | 7 +- tests/key_binding_powershell.rs | 10 +-- tests/shell_generation.rs | 118 +++++++++++++++++++++++++++++++- 7 files changed, 148 insertions(+), 18 deletions(-) create mode 100644 src/powershell.rs diff --git a/src/completions.rs b/src/completions.rs index 3781281..7819cdf 100644 --- a/src/completions.rs +++ b/src/completions.rs @@ -2,6 +2,7 @@ use std::collections::BTreeSet; use crate::cli::Shell; use crate::config::Config; +use crate::powershell::quote as quote_ps; const FLAGS: &[&str] = &[ "-C", @@ -401,7 +402,7 @@ fn powershell(destinations: &[String]) -> String { }} $candidates | Where-Object {{ $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) }} | ForEach-Object {{ - $completionText = if ($_ -match '[^\p{{L}}\p{{N}}_./:\\-]') {{ "'" + $_.Replace("'", "''") + "'" }} else {{ $_ }} + $completionText = if ($_ -match '[^\p{{L}}\p{{N}}_./:\\-]') {{ "'" + [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($_) + "'" }} else {{ $_ }} [System.Management.Automation.CompletionResult]::new($completionText, $_, 'ParameterValue', $_) }} }}"# @@ -451,10 +452,6 @@ fn quote_nu(value: &str) -> String { unreachable!() } -fn quote_ps(value: &str) -> String { - format!("'{}'", value.replace('\'', "''")) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/key_bindings.rs b/src/key_bindings.rs index 0340786..c467725 100644 --- a/src/key_bindings.rs +++ b/src/key_bindings.rs @@ -1,6 +1,7 @@ //! Foreground line-editor widgets for ordered directory completion. use crate::cli::Shell; use crate::config::{Config, KeyBinding, KeyBindingBehavior}; +use crate::powershell::quote as quote_powershell; pub fn render(shell: Shell, chord: Option, config: &Config) -> String { let cleanup = cleanup(shell); @@ -197,9 +198,8 @@ fn render_posix(shell: Shell, chord: KeyBinding, config: &Config, history: bool) } fn render_powershell(chord: KeyBinding, config: &Config, history: bool) -> String { - let quote = |value: &str| format!("'{}'", value.replace('\'', "''")); - let zoxide = quote(&config.programs.zoxide.to_string_lossy()); - let fzf = quote(&config.programs.fzf.to_string_lossy()); + let zoxide = quote_powershell(&config.programs.zoxide.to_string_lossy()); + let fzf = quote_powershell(&config.programs.fzf.to_string_lossy()); let behaviors = config .key_binding_behaviors() .iter() diff --git a/src/main.rs b/src/main.rs index 7daf8e9..06a9c39 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod key_history; mod key_zoxide; mod mounts; mod path_bytes; +mod powershell; mod resolver; mod shell; #[cfg(windows)] diff --git a/src/powershell.rs b/src/powershell.rs new file mode 100644 index 0000000..7008f43 --- /dev/null +++ b/src/powershell.rs @@ -0,0 +1,17 @@ +//! String literals embedded in generated PowerShell source. + +/// Match PowerShell's `CodeGeneration.EscapeSingleQuotedStringContent`: +/// ASCII apostrophes and U+2018–U+201B all delimit single-quoted strings. +/// Duplicate the original character so the literal preserves its exact value. +pub fn quote(value: &str) -> String { + let mut quoted = String::with_capacity(value.len() + 2); + quoted.push('\''); + for character in value.chars() { + quoted.push(character); + if matches!(character, '\'' | '\u{2018}'..='\u{201b}') { + quoted.push(character); + } + } + quoted.push('\''); + quoted +} diff --git a/src/shell.rs b/src/shell.rs index 8004564..6b824fe 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -2,6 +2,7 @@ use std::path::Path; use crate::cli::Shell; use crate::config::{Config, KeyBinding}; +use crate::powershell::quote as quote_powershell; pub fn render( shell: Shell, @@ -488,7 +489,7 @@ function script:Add-CjHistory {{ function script:ConvertTo-CjCompletionText {{ param([string]$Value) - return "'" + $Value.Replace("'", "''") + "'" + return "'" + [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent($Value) + "'" }} function script:Complete-CjCdArgument {{ @@ -664,10 +665,6 @@ fn quote_nu(value: &str) -> String { unreachable!() } -fn quote_powershell(value: &str) -> String { - format!("'{}'", value.replace('\'', "''")) -} - #[cfg(test)] mod tests { use super::*; diff --git a/tests/key_binding_powershell.rs b/tests/key_binding_powershell.rs index d22a6ec..d318c48 100644 --- a/tests/key_binding_powershell.rs +++ b/tests/key_binding_powershell.rs @@ -25,8 +25,8 @@ fn real_psreadline_keypresses_preserve_buffers_history_and_cwd() { ("'zoxide', 'cj'", "0", "", 3), ] { let temp = TempDir::new("psreadline-雪"); - let zoxide = temp.path().join("custom zoxide's executable"); - let fzf = temp.path().join("custom fzf's executable"); + let zoxide = temp.path().join("custom zoxide's ‘left’ ‚low‛ executable"); + let fzf = temp.path().join("custom fzf's ‘left’ ‚low‛ executable"); write_executable( &zoxide, r#" @@ -47,7 +47,7 @@ if [ "$CJ_TEST_PICK_STATUS" = 7 ]; then echo 'picker exploded' >&2; fi exit "$CJ_TEST_PICK_STATUS" "#, ); - let config = temp.path().join("config.toml"); + let config = temp.path().join("config's ‘left’ ‚low‛.toml"); fs::write(&config, format!( "[programs]\nzoxide = {}\nfzf = {}\n[key-bindings]\nmacos = {{ key = 'ctrl-o', behaviors = [{behaviors}] }}\nlinux = {{ key = 'ctrl-o', behaviors = [{behaviors}] }}\n", serde_json::to_string(zoxide.to_str().unwrap()).unwrap(), @@ -100,7 +100,7 @@ exec "$CJ_TEST_PWSH" -NoLogo -NoProfile -NoExit -File "$CJ_TEST_SCRIPT" let results = temp.path().join("results.jsonl"); let older = temp.path().join("older 'quoted' 雪"); let newer = temp.path().join("-newer folder"); - let target = "/selected 'quote' 雪/‘’/$literal;$(no-execution)\nlast\n\n"; + let target = "/selected 'quote' 雪/‘’‚‛/$literal;$(no-execution)\nlast\n\n"; let binary = PathBuf::from(env!("CARGO_BIN_EXE_cj")); let mut paths = vec![binary.parent().unwrap().to_path_buf()]; paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); @@ -177,6 +177,8 @@ exit $timed_out path.replace('\'', "''") .replace('‘', "‘‘") .replace('’', "’’") + .replace('‚', "‚‚") + .replace('‛', "‛‛") ) }, ); diff --git a/tests/shell_generation.rs b/tests/shell_generation.rs index 845e276..88d599f 100644 --- a/tests/shell_generation.rs +++ b/tests/shell_generation.rs @@ -5,7 +5,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; -use support::{TempDir, assert_success, cj}; +use support::{GitFixture, TempDir, assert_success, cj}; #[test] fn generated_sources_parse_in_available_shells() { @@ -316,6 +316,122 @@ if (Get-Command Get-PSReadLineKeyHandler -ErrorAction SilentlyContinue) { ); } +#[test] +fn powershell_smart_quotes_round_trip_in_source_and_completions() { + if !available("pwsh") { + eprintln!("skipping PowerShell smart quote test: pwsh is unavailable"); + return; + } + let fixture = GitFixture::new("powershell-smart-quotes"); + let temp = &fixture.temp; + let worktree = temp.path().join("worktree's ‘left’ ‚low‛"); + assert_success( + &Command::new("git") + .current_dir(&fixture.main) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_COMMON_DIR") + .env_remove("GIT_INDEX_FILE") + .args(["worktree", "move"]) + .arg(&fixture.linked) + .arg(&worktree) + .output() + .unwrap(), + ); + let destination = temp.path().join("target's ‘left’ ‚low‛ 雪"); + let config = temp.path().join("config's ‘left’ ‚low‛.toml"); + fs::create_dir_all(&destination).unwrap(); + let mut aliases = vec!["it’s-here".to_string()]; + for (index, quote) in ['\'', '‘', '’', '‚', '‛'].into_iter().enumerate() { + aliases.push(format!( + "quote{index}{quote}; $global:CjInjected=1; #{quote}" + )); + } + let mut contents = String::from("[aliases]\n"); + for name in &aliases { + contents.push_str(&format!( + "{} = \"{}\"\n", + serde_json::to_string(name).unwrap(), + toml_string(&destination) + )); + } + fs::write(&config, contents).unwrap(); + let integration = temp.path().join("init.ps1"); + let completions = temp.path().join("completions.ps1"); + fs::write( + &integration, + generate(temp.path(), &config, "init", "powershell"), + ) + .unwrap(); + fs::write( + &completions, + generate(temp.path(), &config, "completions", "powershell"), + ) + .unwrap(); + + let script = r#"$ErrorActionPreference = 'Stop' +$global:CjInjected = 0 +. $env:CJ_INIT_SOURCE +. $env:CJ_COMPLETION_SOURCE +if ($script:__cj_config[1] -cne $env:CJ_TEST_CONFIG) { throw 'config path changed' } +$line = 'cd -jw' +$before = (Get-Location).ProviderPath +$result = TabExpansion2 $line $line.Length +$match = @($result.CompletionMatches | Where-Object ListItemText -CEQ $env:CJ_TEST_WORKTREE) +if ($match.Count -ne 1) { throw 'missing smart-quote worktree completion' } +if ((Get-Location).ProviderPath -cne $before) { throw 'Tab changed directory' } +$completed = $line.Remove($result.ReplacementIndex, $result.ReplacementLength).Insert($result.ReplacementIndex, $match[0].CompletionText) +& ([scriptblock]::Create($completed)) +if ((Get-Location).ProviderPath -cne $env:CJ_TEST_WORKTREE) { throw 'completed worktree path changed' } +$aliases = @($env:CJ_TEST_ALIASES | ConvertFrom-Json) +foreach ($command in @('cj', 'cd')) { + foreach ($expected in $aliases) { + $line = $command + ' ' + $expected.Substring(0, 2) + $candidates = if ($command -ceq 'cd') { + Complete-CjCdArgument $expected.Substring(0, 2) + } else { + [System.Management.Automation.CommandCompletion]::CompleteInput($line, $line.Length, $null).CompletionMatches + } + $matches = @($candidates | Where-Object ListItemText -CEQ $expected) + if ($matches.Count -ne 1) { throw "missing $command completion: $expected" } + $literal = $matches[0].CompletionText + $value = & ([scriptblock]::Create($literal)) + if ($value -isnot [string] -or $value -cne $expected) { throw "completion changed: $expected" } + if ($command -ceq 'cd') { + & ([scriptblock]::Create('cd ' + $literal)) + if ((Get-Location).ProviderPath -cne $env:CJ_TEST_DESTINATION) { throw 'completed alias failed to resolve' } + } + if ($global:CjInjected -ne 0) { throw 'completion executed embedded code' } + } +} +if ($global:CjInjected -ne 0) { throw 'generated source executed embedded code' } +"#; + let output = Command::new("pwsh") + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ]) + .current_dir(&fixture.main) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_COMMON_DIR") + .env_remove("GIT_INDEX_FILE") + .env("PATH", path_with_cj()) + .env("HOME", temp.path()) + .env("CJ_INIT_SOURCE", &integration) + .env("CJ_COMPLETION_SOURCE", &completions) + .env("CJ_TEST_CONFIG", &config) + .env("CJ_TEST_WORKTREE", &worktree) + .env("CJ_TEST_ALIASES", serde_json::to_string(&aliases).unwrap()) + .env("CJ_TEST_DESTINATION", &destination) + .output() + .unwrap(); + assert_success(&output); +} + #[cfg(unix)] #[test] fn bash_completion_handles_nested_commands_and_spaced_names() { From 9ce4e5a526126333584d04cdc5802ba0be3580a1 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Fri, 11 Sep 2026 18:17:29 +0800 Subject: [PATCH 2/2] test(shell): normalize Windows paths in smart-quote completion test Preserve exact literal and alias checks while comparing resolved directories with platform-aware path semantics. Refs #62 --- tests/shell_generation.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/shell_generation.rs b/tests/shell_generation.rs index 88d599f..509b23a 100644 --- a/tests/shell_generation.rs +++ b/tests/shell_generation.rs @@ -371,18 +371,25 @@ fn powershell_smart_quotes_round_trip_in_source_and_completions() { let script = r#"$ErrorActionPreference = 'Stop' $global:CjInjected = 0 +function Test-SameDirectory([string]$Actual, [string]$Expected) { + # Git uses '/', while Windows PathBuf and Get-Location use '\'. + $comparison = if ($IsWindows) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } + return [System.IO.Path]::GetFullPath($Actual).Equals([System.IO.Path]::GetFullPath($Expected), $comparison) +} . $env:CJ_INIT_SOURCE . $env:CJ_COMPLETION_SOURCE if ($script:__cj_config[1] -cne $env:CJ_TEST_CONFIG) { throw 'config path changed' } $line = 'cd -jw' $before = (Get-Location).ProviderPath $result = TabExpansion2 $line $line.Length -$match = @($result.CompletionMatches | Where-Object ListItemText -CEQ $env:CJ_TEST_WORKTREE) -if ($match.Count -ne 1) { throw 'missing smart-quote worktree completion' } +$match = @($result.CompletionMatches | Where-Object { Test-SameDirectory $_.ListItemText $env:CJ_TEST_WORKTREE }) +if ($match.Count -ne 1) { throw "missing smart-quote worktree completion: expected $env:CJ_TEST_WORKTREE; got $($result.CompletionMatches.ListItemText -join ', ')" } if ((Get-Location).ProviderPath -cne $before) { throw 'Tab changed directory' } +$literalPath = & ([scriptblock]::Create($match[0].CompletionText)) +if ($literalPath -isnot [string] -or $literalPath -cne $match[0].ListItemText) { throw 'completion changed literal worktree path' } $completed = $line.Remove($result.ReplacementIndex, $result.ReplacementLength).Insert($result.ReplacementIndex, $match[0].CompletionText) & ([scriptblock]::Create($completed)) -if ((Get-Location).ProviderPath -cne $env:CJ_TEST_WORKTREE) { throw 'completed worktree path changed' } +if (-not (Test-SameDirectory (Get-Location).ProviderPath $env:CJ_TEST_WORKTREE)) { throw 'completed worktree path changed' } $aliases = @($env:CJ_TEST_ALIASES | ConvertFrom-Json) foreach ($command in @('cj', 'cd')) { foreach ($expected in $aliases) { @@ -399,7 +406,7 @@ foreach ($command in @('cj', 'cd')) { if ($value -isnot [string] -or $value -cne $expected) { throw "completion changed: $expected" } if ($command -ceq 'cd') { & ([scriptblock]::Create('cd ' + $literal)) - if ((Get-Location).ProviderPath -cne $env:CJ_TEST_DESTINATION) { throw 'completed alias failed to resolve' } + if (-not (Test-SameDirectory (Get-Location).ProviderPath $env:CJ_TEST_DESTINATION)) { throw 'completed alias failed to resolve' } } if ($global:CjInjected -ne 0) { throw 'completion executed embedded code' } }