Skip to main content

runex_core/
init.rs

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
7/// Quote a filesystem path for embedding in a Nu shell string literal.
8///
9/// Uses Nu double-quoted string syntax (no `^` prefix — this is a string value,
10/// not a command invocation).  Escapes `\`, `"`, and `$` (to suppress variable
11/// interpolation of `$env.FOO` etc.).  NUL, DEL, ASCII control characters, Unicode
12/// line/paragraph separators, and visual-deception characters are all dropped.
13fn 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
29/// Marker comment written into rc files to enable idempotent init.
30pub const RUNEX_INIT_MARKER: &str = "# runex-init";
31
32/// Minimal config template written by `runex init`.
33pub 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
43/// The single line appended to the shell rc file.
44///
45/// ## Drift resistance
46///
47/// For bash/zsh/pwsh/nu the line either *re-evaluates* the export at
48/// every shell start (`eval "$(runex export ...)"`,
49/// `Invoke-Expression (& runex export pwsh ...)`) or re-writes the
50/// shell-side script every start (nu's `save --force`). That makes the
51/// integration **drift-proof**: upgrading runex automatically picks up
52/// the latest template the next time the shell starts.
53///
54/// **clink is the exception.** The lua file lives outside any rcfile
55/// reload pathway, so users have to re-run `runex init clink` after a
56/// `runex` upgrade. `runex doctor` flags this drift via the
57/// `integration:clink` check (see
58/// [`crate::integration_check::check_clink_lua_freshness`]).
59pub 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
84/// The rc file path for a given shell (best-effort; may not exist yet).
85///
86/// For PowerShell, `$PROFILE` is a runtime variable and cannot be resolved statically,
87/// so the conventional filesystem path is used instead.
88pub 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    /// `bin = "run'ex"` must not break out of the eval context.
137    #[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    /// PowerShell doubles single quotes inside single-quoted strings.
152    #[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    /// `bin = "app; echo PWNED"` — semicolon must be enclosed in single quotes,
160    /// neutralising `;` to prevent command injection.
161    #[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    /// `bin = "app; Write-Host PWNED"` — semicolon must be enclosed in single quotes.
171    #[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    /// Nu: quoting without `^` makes a string, not a command — must use `^"runex"`.
181    #[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    /// `nu_quote_path` must wrap the path in double quotes so Nu doesn't tokenize on spaces.
197    #[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    /// Windows paths: backslashes must be escaped inside Nu double-quoted strings.
205    #[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    /// The generated Nu integration line must quote the runex.nu path.
212    /// Both `save` and `source` must use quoted paths (starting with `"`).
213    #[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    /// A single quote in bin must be passed through `lua_quote_string`.
225    /// `lua_quote_string("run'ex")` = `"run'ex"` (single quotes need no escaping in Lua double-quoted strings).
226    #[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    /// `lua_quote_string` escapes `\n` to `\\n`, preventing the Lua comment from being broken.
236    #[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 integration_line
250
251    /// Control-character and NUL handling in `nu_quote_path`.
252    ///
253    /// `nu_quote_path` embeds paths into Nu double-quoted string literals.
254    /// Newlines, carriage returns, tabs, NUL, DEL, and Unicode line separators
255    /// must be escaped or dropped so they cannot break out of the string context.
256    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    /// If XDG_CONFIG_HOME contains a newline, it must not inject Nu statements into env.nu.
274    #[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    /// C0 control chars other than `\n`, `\r`, `\t`, `\0`, `\x7f` must be dropped.
317    #[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_escaping
338
339    /// `nu_quote_path` embeds the XDG_CONFIG_HOME path into the `source "..."` line
340    /// written to env.nu. If XDG_CONFIG_HOME contains Unicode visual-deception
341    /// characters (RLO, BOM, ZWSP, etc.), the displayed `source` path would appear
342    /// different from its actual content, potentially deceiving the user into
343    /// thinking a safe path is being sourced. These characters must be dropped.
344    mod nu_quote_path_deceptive {
345        use super::*;
346
347    /// U+202E (Right-to-Left Override) reverses display order in the terminal.
348    #[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    /// U+FEFF (BOM / zero-width no-break space) is invisible.
358    #[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    /// U+200B (Zero-Width Space) is invisible.
368    #[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    /// Non-deceptive Unicode (e.g. Japanese path components) must pass through.
378    #[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    /// A `$` in XDG_CONFIG_HOME would allow Nu variable interpolation inside
388    /// the double-quoted `source "..."` path. Must be escaped as `\$`.
389    #[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    } // mod nu_quote_path_deceptive
412
413}