Skip to main content

hjkl_engine/
policy.rs

1//! Process-global execution policy for non-TUI / RPC modes.
2//!
3//! Interactive TUI keeps full vim parity (shell-out, unrestricted paths). The
4//! non-TUI entry points (`--embed`, `--nvim-api`, `--headless`) may take
5//! commands from a remote or automated caller that is not the local user, so
6//! they can tighten this policy at startup. Mirrors the one-shot global pattern
7//! used by the clipboard-disable path (`host::disable_clipboard_for_rpc`).
8//!
9//! Flags are set once, before any editor is built, and only ever flip from the
10//! permissive default to the restrictive state — never back — so a plain
11//! `Relaxed` atomic is sufficient.
12
13use std::sync::atomic::{AtomicBool, Ordering};
14
15/// When `true`, shell-out commands (`:!cmd`, `:[range]!cmd`, `:r !cmd`, and the
16/// engine range filter) are refused. Default `false` (allowed, as in vim).
17static SHELL_DISABLED: AtomicBool = AtomicBool::new(false);
18
19/// Refuse shell-out for the rest of the process. Call once at RPC/headless
20/// startup, before building any editor.
21pub fn disable_shell() {
22    SHELL_DISABLED.store(true, Ordering::Relaxed);
23}
24
25/// True if shell-out has been disabled for this process.
26pub fn shell_disabled() -> bool {
27    SHELL_DISABLED.load(Ordering::Relaxed)
28}
29
30/// Build the platform shell invocation for a user-typed shell-out command —
31/// the one builder every shell-out site uses, so they all run the same shell.
32/// Callers still check [`shell_disabled`] first.
33///
34/// Unix runs `sh -c <command>`. Windows runs `%COMSPEC% /S /C "<command>"`
35/// (`cmd.exe` when `COMSPEC` is unset), vim's `shell` / `shellcmdflag` /
36/// `shellxquote` defaults there: there is no `sh` on a stock Windows `PATH`.
37/// The command goes to cmd.exe as one raw, quote-wrapped argument, and `/S`
38/// makes cmd.exe strip exactly those outer quotes and run the rest verbatim.
39/// std's default argument escaping would backslash-escape inner quotes, which
40/// cmd.exe does not understand — `echo "a b"` printed `\"a b\"` and a quoted
41/// program path failed to run.
42pub fn shell_command(command: &str) -> std::process::Command {
43    #[cfg(windows)]
44    {
45        use std::os::windows::process::CommandExt;
46        let shell = std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into());
47        let mut cmd = std::process::Command::new(shell);
48        cmd.arg("/S").arg("/C").raw_arg(format!("\"{command}\""));
49        cmd
50    }
51    #[cfg(not(windows))]
52    {
53        let mut cmd = std::process::Command::new("sh");
54        cmd.arg("-c").arg(command);
55        cmd
56    }
57}
58
59/// Split a shell filter's `stdout` into the rows that replace the filtered
60/// range, where `input` is the range's rows joined by `\n` as fed to the
61/// command. Every range filter uses it, so they all agree on line endings.
62///
63/// A trailing newline ends the last row rather than starting an empty one, so
64/// empty output replaces the range with no rows (vim: `:%!true` deletes them).
65///
66/// Line endings follow the input. When no input row ended in `\r`, each output
67/// row's trailing `\r` is dropped: tools run through cmd.exe emit CRLF, which
68/// would otherwise turn an LF buffer's rows into CRLF ones. When the input had
69/// CRLF rows, the output rows are kept byte-for-byte, so a CRLF buffer stays
70/// CRLF through a filter.
71pub fn filter_output_rows(input: &str, stdout: &str) -> Vec<String> {
72    if stdout.is_empty() {
73        return Vec::new();
74    }
75    let input_has_cr = input.split('\n').any(|row| row.ends_with('\r'));
76    let body = stdout.strip_suffix('\n').unwrap_or(stdout);
77    body.split('\n')
78        .map(|row| {
79            if input_has_cr {
80                row
81            } else {
82                row.strip_suffix('\r').unwrap_or(row)
83            }
84        })
85        .map(String::from)
86        .collect()
87}
88
89/// When `true`, file I/O paths are confined to the current working directory
90/// subtree: absolute paths and paths containing a `..` component are refused.
91/// Default `false` (unrestricted, as in vim). The RPC entry points enable this
92/// so a remote/automated caller cannot read or write arbitrary filesystem
93/// locations via `:w`/`:e`/`:r`.
94static FS_RESTRICTED: AtomicBool = AtomicBool::new(false);
95
96/// Confine file I/O to the working-directory subtree for the rest of the
97/// process. Call once at RPC startup, before building any editor.
98pub fn restrict_fs() {
99    FS_RESTRICTED.store(true, Ordering::Relaxed);
100}
101
102/// True if filesystem access has been confined for this process.
103pub fn fs_restricted() -> bool {
104    FS_RESTRICTED.load(Ordering::Relaxed)
105}
106
107/// True if `path` would escape a confined working directory: it is absolute, or
108/// contains a parent-dir (`..`), root, or prefix component.
109pub fn path_escapes(path: &std::path::Path) -> bool {
110    use std::path::Component;
111    path.components().any(|c| {
112        matches!(
113            c,
114            Component::ParentDir | Component::RootDir | Component::Prefix(_)
115        )
116    })
117}
118
119/// `Err` with a uniform message when `path` is refused under a confined
120/// filesystem policy; `Ok(())` when access is allowed (policy off, or the path
121/// stays within the working directory).
122pub fn check_fs_path(path: &std::path::Path) -> Result<(), String> {
123    if fs_restricted() && path_escapes(path) {
124        return Err(format!(
125            "path {} is outside the working directory (blocked in RPC mode)",
126            path.display()
127        ));
128    }
129    Ok(())
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    fn run(command: &str) -> String {
137        let out = shell_command(command)
138            .output()
139            .expect("the platform shell must spawn");
140        assert!(out.status.success(), "`{command}` failed: {out:?}");
141        String::from_utf8(out.stdout)
142            .unwrap()
143            .trim_end()
144            .to_string()
145    }
146
147    #[test]
148    fn shell_command_runs_a_command() {
149        assert_eq!(run("echo hjkl"), "hjkl");
150    }
151
152    /// Inner quotes reach the shell exactly as typed: cmd.exe's `echo` prints
153    /// them, sh's `echo` consumes them. Either way the doubled space survives.
154    #[test]
155    fn shell_command_passes_inner_quotes_verbatim() {
156        let expected = if cfg!(windows) { "\"a  b\"" } else { "a  b" };
157        assert_eq!(run(r#"echo "a  b""#), expected);
158    }
159
160    fn rows(v: &[&str]) -> Vec<String> {
161        v.iter().map(|s| s.to_string()).collect()
162    }
163
164    #[test]
165    fn filter_output_rows_strips_cr_when_input_is_lf() {
166        assert_eq!(filter_output_rows("b\na", "a\r\nb\r\n"), rows(&["a", "b"]));
167    }
168
169    #[test]
170    fn filter_output_rows_keeps_cr_when_input_is_crlf() {
171        assert_eq!(
172            filter_output_rows("b\r\na\r", "a\r\nb\r\n"),
173            rows(&["a\r", "b\r"])
174        );
175    }
176
177    #[test]
178    fn filter_output_rows_trailing_newline_ends_the_last_row() {
179        assert_eq!(filter_output_rows("x", "a\n"), rows(&["a"]));
180        assert_eq!(filter_output_rows("x", "a"), rows(&["a"]));
181        assert_eq!(filter_output_rows("x", "a\n\n"), rows(&["a", ""]));
182        assert_eq!(filter_output_rows("x", "\n"), rows(&[""]));
183    }
184
185    #[test]
186    fn filter_output_rows_empty_output_is_no_rows() {
187        assert!(filter_output_rows("x", "").is_empty());
188    }
189
190    #[test]
191    fn shell_command_runs_pipelines() {
192        let command = if cfg!(windows) {
193            "echo ab| findstr b"
194        } else {
195            "echo ab | grep b"
196        };
197        assert_eq!(run(command), "ab");
198    }
199}