1use std::path::PathBuf;
2
3use crate::config::xdg_config_home;
4use crate::sanitize::{double_quote_escape, is_nu_drop_char};
5use crate::shell::{bash_quote_string, lua_quote_string, nu_quote_string, pwsh_quote_string, Shell};
6
7fn nu_quote_path(path: &str) -> String {
14 let mut out = String::from("\"");
15 for ch in path.chars() {
16 if let Some(esc) = double_quote_escape(ch) {
17 out.push_str(esc);
18 } else if ch == '$' {
19 out.push_str("\\$");
20 } else if is_nu_drop_char(ch) {
21 } else {
22 out.push(ch);
23 }
24 }
25 out.push('"');
26 out
27}
28
29pub const RUNEX_INIT_MARKER: &str = "# runex-init";
31
32pub fn default_config_content() -> &'static str {
34 r#"version = 1
35
36# Add your abbreviations below.
37# [[abbr]]
38# key = "gcm"
39# expand = "git commit -m"
40"#
41}
42
43pub fn integration_line(shell: Shell, bin: &str) -> String {
60 match shell {
61 Shell::Bash => format!("eval \"$({} export bash)\"", bash_quote_string(bin)),
62 Shell::Zsh => format!("eval \"$({} export zsh)\"", bash_quote_string(bin)),
63 Shell::Pwsh => format!(
64 "Invoke-Expression (& {} export pwsh | Out-String)",
65 pwsh_quote_string(bin)
66 ),
67 Shell::Nu => {
68 let cfg_dir = xdg_config_home()
69 .map(|p| p.display().to_string())
70 .unwrap_or_else(|| "~/.config".to_string());
71 let nu_bin = nu_quote_string(bin);
72 let nu_path = nu_quote_path(&format!("{cfg_dir}/runex/runex.nu"));
73 format!(
74 "{nu_bin} export nu | save --force {nu_path}\nsource {nu_path}"
75 )
76 }
77 Shell::Clink => format!(
78 "-- add {} export clink output to your clink scripts directory",
79 lua_quote_string(bin)
80 ),
81 }
82}
83
84pub fn rc_file_for(shell: Shell) -> Option<PathBuf> {
89 let home = dirs::home_dir()?;
90 match shell {
91 Shell::Bash => Some(home.join(".bashrc")),
92 Shell::Zsh => Some(home.join(".zshrc")),
93 Shell::Pwsh => {
94 let base = if cfg!(windows) {
95 home.join("Documents").join("PowerShell")
96 } else {
97 home.join(".config").join("powershell")
98 };
99 Some(base.join("Microsoft.PowerShell_profile.ps1"))
100 }
101 Shell::Nu => {
102 let cfg = xdg_config_home().unwrap_or_else(|| home.join(".config"));
103 Some(cfg.join("nushell").join("env.nu"))
104 }
105 Shell::Clink => None,
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 mod integration_line {
114 use super::*;
115
116 #[test]
117 fn default_config_content_has_version() {
118 assert!(default_config_content().contains("version = 1"));
119 }
120
121 #[test]
122 fn integration_line_bash() {
123 assert_eq!(
124 integration_line(Shell::Bash, "runex"),
125 r#"eval "$('runex' export bash)""#
126 );
127 }
128
129 #[test]
130 fn integration_line_pwsh() {
131 let line = integration_line(Shell::Pwsh, "runex");
132 assert!(line.contains("Invoke-Expression"));
133 assert!(line.contains("'runex' export pwsh"));
134 }
135
136 #[test]
138 fn integration_line_bash_escapes_single_quote_in_bin() {
139 let line = integration_line(Shell::Bash, "run'ex");
140 assert!(!line.contains("run'ex"), "unescaped quote in bash line: {line}");
141 assert!(line.contains(r"run'\''ex"), "expected bash-escaped form: {line}");
142 }
143
144 #[test]
145 fn integration_line_zsh_escapes_single_quote_in_bin() {
146 let line = integration_line(Shell::Zsh, "run'ex");
147 assert!(!line.contains("run'ex"), "unescaped quote in zsh line: {line}");
148 assert!(line.contains(r"run'\''ex"), "expected zsh-escaped form: {line}");
149 }
150
151 #[test]
153 fn integration_line_pwsh_escapes_single_quote_in_bin() {
154 let line = integration_line(Shell::Pwsh, "run'ex");
155 assert!(!line.contains("run'ex"), "unescaped quote in pwsh line: {line}");
156 assert!(line.contains("run''ex"), "expected pwsh-escaped form: {line}");
157 }
158
159 #[test]
162 fn integration_line_bash_semicolon_does_not_inject() {
163 let line = integration_line(Shell::Bash, "app; echo PWNED");
164 assert!(
165 line.contains("'app; echo PWNED'"),
166 "bin must be single-quoted in bash line: {line}"
167 );
168 }
169
170 #[test]
172 fn integration_line_pwsh_semicolon_does_not_inject() {
173 let line = integration_line(Shell::Pwsh, "app; Write-Host PWNED");
174 assert!(
175 line.contains("'app; Write-Host PWNED'"),
176 "bin must be single-quoted in pwsh line: {line}"
177 );
178 }
179
180 #[test]
182 fn integration_line_nu_uses_caret_external_command_syntax() {
183 let line = integration_line(Shell::Nu, "runex");
184 assert!(
185 line.contains("^\"runex\""),
186 "nu integration line must use ^\"...\" syntax: {line}"
187 );
188 }
189
190 #[test]
191 fn integration_line_nu_escapes_special_chars_in_bin() {
192 let line = integration_line(Shell::Nu, "my\"app");
193 assert!(line.contains("^\"my\\\"app\""), "nu: special chars must be escaped: {line}");
194 }
195
196 #[test]
198 fn integration_line_nu_quotes_cfg_dir_with_spaces() {
199 let quoted = nu_quote_path("/home/my user/.config");
200 assert_eq!(quoted, "\"/home/my user/.config\"");
201 assert!(!quoted.starts_with('/'), "path must be quoted, not raw");
202 }
203
204 #[test]
206 fn integration_line_nu_quotes_cfg_dir_with_backslash() {
207 let quoted = nu_quote_path(r"C:\Users\my user\AppData");
208 assert_eq!(quoted, r#""C:\\Users\\my user\\AppData""#);
209 }
210
211 #[test]
214 fn integration_line_nu_save_path_is_quoted() {
215 let line = integration_line(Shell::Nu, "runex");
216 for fragment in ["save --force \"", "source \""] {
217 assert!(
218 line.contains(fragment),
219 "nu line must contain `{fragment}`: {line}"
220 );
221 }
222 }
223
224 #[test]
227 fn integration_line_clink_single_quote_in_bin_is_lua_quoted() {
228 let line = integration_line(Shell::Clink, "run'ex");
229 assert!(
230 line.contains("\"run'ex\""),
231 "bin must be lua-quoted in clink line: {line}"
232 );
233 }
234
235 #[test]
237 fn integration_line_clink_newline_in_bin_does_not_inject() {
238 let line = integration_line(Shell::Clink, "runex\nos.execute('evil')");
239 assert!(
240 !line.contains('\n'),
241 "literal newline must be escaped in clink line: {line:?}"
242 );
243 assert!(
244 line.contains("\\n"),
245 "expected \\n escape sequence in clink line: {line:?}"
246 );
247 }
248
249 } mod nu_quote_path_escaping {
257 use super::*;
258
259 #[test]
260 fn nu_quote_path_escapes_newline() {
261 let quoted = nu_quote_path("/home/user/.config\nevil");
262 assert!(!quoted.contains('\n'), "nu_quote_path must escape newline: {quoted}");
263 assert!(quoted.contains("\\n"), "expected \\n escape: {quoted}");
264 }
265
266 #[test]
267 fn nu_quote_path_escapes_carriage_return() {
268 let quoted = nu_quote_path("/path\r/evil");
269 assert!(!quoted.contains('\r'), "nu_quote_path must escape CR: {quoted}");
270 assert!(quoted.contains("\\r"), "expected \\r escape: {quoted}");
271 }
272
273 #[test]
275 fn integration_line_nu_newline_in_xdg_does_not_inject() {
276 let quoted = nu_quote_path("/home/user/.config\nsource /tmp/evil.nu\n#");
277 assert!(!quoted.contains('\n'), "newline injection must be escaped in nu path: {quoted}");
278 }
279
280 #[test]
281 fn nu_quote_path_escapes_nul() {
282 let quoted = nu_quote_path("path\x00evil");
283 assert!(!quoted.contains('\0'), "nu_quote_path must not produce literal NUL: {quoted:?}");
284 assert!(quoted.contains("path"), "path prefix must be preserved: {quoted:?}");
285 }
286
287 #[test]
288 fn nu_quote_path_escapes_tab() {
289 let quoted = nu_quote_path("path\t/evil");
290 assert!(!quoted.contains('\t'), "nu_quote_path must escape tab: {quoted:?}");
291 assert!(quoted.contains("\\t"), "expected \\t escape: {quoted:?}");
292 }
293
294 #[test]
295 fn nu_quote_path_drops_del() {
296 let quoted = nu_quote_path("path\x7fend");
297 assert!(!quoted.contains('\x7f'), "nu_quote_path must drop DEL: {quoted:?}");
298 }
299
300 #[test]
301 fn nu_quote_path_drops_unicode_line_separators() {
302 for ch in ['\u{0085}', '\u{2028}', '\u{2029}'] {
303 let input = format!("path{ch}end");
304 let quoted = nu_quote_path(&input);
305 assert!(!quoted.contains(ch), "nu_quote_path must drop U+{:04X}: {quoted:?}", ch as u32);
306 }
307 }
308
309 #[test]
310 fn rc_file_for_bash_ends_with_bashrc() {
311 if let Some(path) = rc_file_for(Shell::Bash) {
312 assert!(path.to_str().unwrap().ends_with(".bashrc"));
313 }
314 }
315
316 #[test]
318 fn nu_quote_path_drops_remaining_c0_control_chars() {
319 let dangerous_c0: &[char] = &[
320 '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07',
321 '\x08', '\x0b', '\x0c', '\x0e', '\x0f',
322 '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17',
323 '\x18', '\x19', '\x1a', '\x1b',
324 '\x1c', '\x1d', '\x1e', '\x1f',
325 ];
326 for &ch in dangerous_c0 {
327 let input = format!("path{}end", ch);
328 let quoted = nu_quote_path(&input);
329 assert!(
330 !quoted.contains(ch),
331 "nu_quote_path must drop C0 control U+{:04X}: {quoted:?}",
332 ch as u32
333 );
334 }
335 }
336
337 } mod nu_quote_path_deceptive {
345 use super::*;
346
347 #[test]
349 fn nu_quote_path_drops_rlo() {
350 let quoted = nu_quote_path("/home/user\u{202E}/.config");
351 assert!(
352 !quoted.contains('\u{202E}'),
353 "nu_quote_path must drop U+202E (RLO): {quoted:?}"
354 );
355 }
356
357 #[test]
359 fn nu_quote_path_drops_bom() {
360 let quoted = nu_quote_path("/home/user\u{FEFF}/.config");
361 assert!(
362 !quoted.contains('\u{FEFF}'),
363 "nu_quote_path must drop U+FEFF (BOM): {quoted:?}"
364 );
365 }
366
367 #[test]
369 fn nu_quote_path_drops_zwsp() {
370 let quoted = nu_quote_path("/home/user\u{200B}/.config");
371 assert!(
372 !quoted.contains('\u{200B}'),
373 "nu_quote_path must drop U+200B (ZWSP): {quoted:?}"
374 );
375 }
376
377 #[test]
379 fn nu_quote_path_preserves_non_deceptive_unicode() {
380 let quoted = nu_quote_path("/home/ユーザー/.config");
381 assert!(
382 quoted.contains("ユーザー"),
383 "nu_quote_path must preserve non-deceptive Unicode: {quoted:?}"
384 );
385 }
386
387 #[test]
390 fn nu_quote_path_escapes_dollar_sign() {
391 let quoted = nu_quote_path("/home/$USER/.config");
392 let bytes = quoted.as_bytes();
393 for i in 0..bytes.len() {
394 if bytes[i] == b'$' {
395 let mut preceding = 0usize;
396 let mut j = i;
397 while j > 0 && bytes[j - 1] == b'\\' {
398 preceding += 1;
399 j -= 1;
400 }
401 assert!(
402 preceding % 2 == 1,
403 "nu_quote_path: '$' at byte {i} has {preceding} preceding backslashes \
404 (even = Nu interpolation not suppressed). Full output: {quoted:?}"
405 );
406 }
407 }
408 assert!(quoted.contains("\\$"), "expected \\$ in: {quoted:?}");
409 }
410
411 } }