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}