Skip to main content

nu_plugin_core/communication_mode/
mod.rs

1use std::ffi::OsStr;
2use std::io::{Stdin, Stdout};
3#[cfg(all(feature = "local-socket", unix))]
4use std::path::{Path, PathBuf};
5use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
6use std::sync::atomic::{AtomicBool, Ordering};
7
8use nu_protocol::ShellError;
9#[cfg(feature = "local-socket")] // unused without that feature
10use nu_protocol::shell_error::io::IoError;
11
12#[cfg(feature = "local-socket")]
13mod local_socket;
14
15#[cfg(feature = "local-socket")]
16use local_socket::*;
17
18/// Controls whether plugin stderr is forwarded to Nushell's stderr.
19///
20/// Plugin stderr is inherited by default, so anything written by a plugin to
21/// stderr is shown directly to the user. When this flag is set, that forwarding
22/// is suppressed for plugin processes started while the flag is active.
23pub static SUPPRESS_STDERR: AtomicBool = AtomicBool::new(false);
24
25/// The type of communication used between the plugin and the engine.
26///
27/// `Stdio` is required to be supported by all plugins, and is attempted initially. If the
28/// `local-socket` feature is enabled and the plugin supports it, `LocalSocket` may be attempted.
29///
30/// Local socket communication has the benefit of not tying up stdio, so it's more compatible with
31/// plugins that want to take user input from the terminal in some way.
32#[derive(Debug, Clone)]
33pub enum CommunicationMode {
34    /// Communicate using `stdin` and `stdout`.
35    Stdio,
36    /// Communicate using an operating system-specific local socket.
37    #[cfg(feature = "local-socket")]
38    LocalSocket(std::ffi::OsString),
39}
40
41impl CommunicationMode {
42    /// Generate a new local socket communication mode based on the given plugin exe path.
43    #[cfg(feature = "local-socket")]
44    pub fn local_socket(plugin_exe: &std::path::Path) -> CommunicationMode {
45        use std::hash::{Hash, Hasher};
46        use std::time::SystemTime;
47
48        // Generate the unique ID based on the plugin path and the current time. The actual
49        // algorithm here is not very important, we just want this to be relatively unique very
50        // briefly. Using the default hasher in the stdlib means zero extra dependencies.
51        let mut hasher = std::collections::hash_map::DefaultHasher::new();
52
53        plugin_exe.hash(&mut hasher);
54        SystemTime::now().hash(&mut hasher);
55
56        let unique_id = format!("{:016x}", hasher.finish());
57
58        CommunicationMode::LocalSocket(make_local_socket_name(&unique_id))
59    }
60
61    pub fn args(&self) -> Vec<&OsStr> {
62        match self {
63            CommunicationMode::Stdio => vec![OsStr::new("--stdio")],
64            #[cfg(feature = "local-socket")]
65            CommunicationMode::LocalSocket(path) => {
66                vec![OsStr::new("--local-socket"), path.as_os_str()]
67            }
68        }
69    }
70
71    pub fn setup_command_io(&self, command: &mut Command) {
72        match self {
73            CommunicationMode::Stdio => {
74                // Both stdout and stdin are piped so we can receive information from the plugin
75                command.stdin(Stdio::piped());
76                command.stdout(Stdio::piped());
77            }
78            #[cfg(feature = "local-socket")]
79            CommunicationMode::LocalSocket(_) => {
80                // Stdio can be used by the plugin to talk to the terminal in local socket mode,
81                // which is the big benefit
82                command.stdin(Stdio::inherit());
83                command.stdout(Stdio::inherit());
84            }
85        }
86
87        if SUPPRESS_STDERR.load(Ordering::Relaxed) {
88            command.stderr(Stdio::null());
89        }
90    }
91
92    pub fn serve(&self) -> Result<PreparedServerCommunication, ShellError> {
93        match self {
94            // Nothing to set up for stdio - we just take it from the child.
95            CommunicationMode::Stdio => Ok(PreparedServerCommunication::Stdio),
96            // For sockets: we need to create the server so that the child won't fail to connect.
97            #[cfg(feature = "local-socket")]
98            CommunicationMode::LocalSocket(name) => {
99                use interprocess::local_socket::ListenerOptions;
100
101                #[cfg(unix)]
102                let socket_dir = prepare_local_socket_path(name).map_err(|err| {
103                    IoError::new_internal(
104                        err,
105                        format!(
106                            "Could not prepare local socket path {:?}",
107                            name.to_string_lossy()
108                        ),
109                    )
110                })?;
111
112                let listener = match interpret_local_socket_name(name)
113                    .and_then(|name| ListenerOptions::new().name(name).create_sync())
114                {
115                    Ok(listener) => listener,
116                    Err(err) => {
117                        #[cfg(unix)]
118                        cleanup_local_socket_dir(socket_dir.as_ref());
119                        return Err(ShellError::Io(IoError::new_internal(
120                            err,
121                            format!(
122                                "Could not interpret local socket name {:?}",
123                                name.to_string_lossy()
124                            ),
125                        )));
126                    }
127                };
128                Ok(PreparedServerCommunication::LocalSocket {
129                    listener,
130                    #[cfg(unix)]
131                    socket_dir,
132                })
133            }
134        }
135    }
136
137    pub fn connect_as_client(&self) -> Result<ClientCommunicationIo, ShellError> {
138        match self {
139            CommunicationMode::Stdio => Ok(ClientCommunicationIo::Stdio(
140                std::io::stdin(),
141                std::io::stdout(),
142            )),
143            #[cfg(feature = "local-socket")]
144            CommunicationMode::LocalSocket(name) => {
145                // Connect to the specified socket.
146                let get_socket = || {
147                    use interprocess::local_socket as ls;
148                    use ls::traits::Stream;
149
150                    interpret_local_socket_name(name)
151                        .and_then(|name| ls::Stream::connect(name))
152                        .map_err(|err| {
153                            ShellError::Io(IoError::new_internal(
154                                err,
155                                format!(
156                                    "Could not interpret local socket name {:?}",
157                                    name.to_string_lossy()
158                                ),
159                            ))
160                        })
161                };
162                // Reverse order from the server: read in, write out
163                let read_in = get_socket()?;
164                let write_out = get_socket()?;
165                Ok(ClientCommunicationIo::LocalSocket { read_in, write_out })
166            }
167        }
168    }
169}
170
171/// The result of [`CommunicationMode::serve()`], which acts as an intermediate stage for
172/// communication modes that require some kind of socket binding to occur before the client process
173/// can be started. Call [`.connect()`](Self::connect) once the client process has been started.
174///
175/// The socket may be cleaned up on `Drop` if applicable.
176pub enum PreparedServerCommunication {
177    /// Will take stdin and stdout from the process on [`.connect()`](Self::connect).
178    Stdio,
179    /// Contains the listener to accept connections on. On Unix, the socket is unlinked on `Drop`.
180    #[cfg(feature = "local-socket")]
181    LocalSocket {
182        listener: interprocess::local_socket::Listener,
183        #[cfg(unix)]
184        socket_dir: Option<PathBuf>,
185    },
186}
187
188#[cfg(all(feature = "local-socket", unix))]
189fn prepare_local_socket_path(name: &OsStr) -> Result<Option<PathBuf>, std::io::Error> {
190    use std::os::unix::fs::DirBuilderExt;
191
192    let path = Path::new(name);
193    let Some(parent) = path.parent() else {
194        return Ok(None);
195    };
196
197    let mut builder = std::fs::DirBuilder::new();
198    builder.mode(0o700).create(parent)?;
199    Ok(Some(parent.to_path_buf()))
200}
201
202#[cfg(all(feature = "local-socket", unix))]
203fn cleanup_local_socket_dir(socket_dir: Option<&PathBuf>) {
204    if let Some(socket_dir) = socket_dir {
205        let _ = std::fs::remove_dir_all(socket_dir);
206    }
207}
208
209#[cfg(all(test, feature = "local-socket", unix))]
210mod tests {
211    use super::{CommunicationMode, make_local_socket_name};
212    use std::os::unix::fs::PermissionsExt;
213    use std::path::Path;
214    use std::process::Command;
215
216    const OWNER_ONLY_UMASK_CHILD: &str = "NU_PLUGIN_CORE_OWNER_ONLY_UMASK_CHILD";
217
218    #[test]
219    fn local_socket_dir_is_owner_only() {
220        if std::env::var_os(OWNER_ONLY_UMASK_CHILD).is_none() {
221            let test_binary =
222                std::env::current_exe().expect("test binary path should be available");
223            let status = Command::new("sh")
224                .args(["-c", "umask 000; exec \"$@\"", "sh"])
225                .arg(test_binary)
226                .args([
227                    "--exact",
228                    "communication_mode::tests::local_socket_dir_is_owner_only",
229                ])
230                .env(OWNER_ONLY_UMASK_CHILD, "1")
231                .status()
232                .expect("owner-only test should run in a child process");
233
234            assert!(status.success(), "owner-only child test failed");
235            return;
236        }
237
238        let name = make_local_socket_name("owner-only-test");
239        let socket_dir = Path::new(&name)
240            .parent()
241            .expect("socket should have a parent dir")
242            .to_owned();
243
244        let mode = CommunicationMode::LocalSocket(name)
245            .serve()
246            .expect("local socket should bind");
247
248        let permissions = std::fs::metadata(&socket_dir)
249            .expect("local socket dir should exist")
250            .permissions()
251            .mode();
252
253        drop(mode);
254
255        assert_eq!(0o700, permissions & 0o777);
256        assert!(!socket_dir.exists());
257    }
258
259    #[test]
260    fn pre_existing_local_socket_dir_is_rejected() {
261        let name = make_local_socket_name("pre-existing-dir-test");
262        let socket_path = Path::new(&name);
263        let socket_dir = socket_path
264            .parent()
265            .expect("socket should have a parent dir");
266
267        std::fs::create_dir(socket_dir).expect("pre-existing socket dir should be created");
268        std::fs::set_permissions(socket_dir, std::fs::Permissions::from_mode(0o777))
269            .expect("pre-existing socket dir should be world-accessible");
270        let permissions = std::fs::metadata(socket_dir)
271            .expect("pre-existing socket dir should exist")
272            .permissions()
273            .mode();
274        assert_eq!(0o777, permissions & 0o777);
275
276        let result = CommunicationMode::LocalSocket(name.clone()).serve();
277
278        assert!(
279            result.is_err(),
280            "a pre-existing socket dir must be rejected"
281        );
282        assert!(
283            socket_dir.exists(),
284            "the pre-existing dir must not be removed"
285        );
286        assert!(!socket_path.exists(), "the socket must not be bound");
287
288        std::fs::remove_dir(socket_dir).expect("pre-existing socket dir should be removed");
289    }
290}
291
292impl Drop for PreparedServerCommunication {
293    fn drop(&mut self) {
294        match self {
295            PreparedServerCommunication::Stdio => {}
296            #[cfg(feature = "local-socket")]
297            PreparedServerCommunication::LocalSocket {
298                #[cfg(unix)]
299                socket_dir,
300                ..
301            } => {
302                #[cfg(unix)]
303                cleanup_local_socket_dir(socket_dir.as_ref());
304            }
305        }
306    }
307}
308
309impl PreparedServerCommunication {
310    pub fn connect(&self, child: &mut Child) -> Result<ServerCommunicationIo, ShellError> {
311        match self {
312            PreparedServerCommunication::Stdio => {
313                let stdin = child
314                    .stdin
315                    .take()
316                    .ok_or_else(|| ShellError::PluginFailedToLoad {
317                        msg: "Plugin missing stdin writer".into(),
318                    })?;
319
320                let stdout = child
321                    .stdout
322                    .take()
323                    .ok_or_else(|| ShellError::PluginFailedToLoad {
324                        msg: "Plugin missing stdout writer".into(),
325                    })?;
326
327                Ok(ServerCommunicationIo::Stdio(stdin, stdout))
328            }
329            #[cfg(feature = "local-socket")]
330            PreparedServerCommunication::LocalSocket { listener, .. } => {
331                use interprocess::local_socket::ListenerNonblockingMode;
332                use interprocess::local_socket::traits::{Listener, Stream};
333                use nu_utils::time::Instant;
334                use std::time::Duration;
335
336                const RETRY_PERIOD: Duration = Duration::from_millis(1);
337                const TIMEOUT: Duration = Duration::from_secs(10);
338
339                let start = Instant::now();
340
341                // Use a loop to try to get two clients from the listener: one for read (the plugin
342                // output) and one for write (the plugin input)
343                //
344                // Be non-blocking on Accept only, so we can timeout.
345                listener
346                    .set_nonblocking(ListenerNonblockingMode::Accept)
347                    .map_err(|err| {
348                        IoError::new_internal(
349                            err,
350                            "Could not set non-blocking mode accept for listener",
351                        )
352                    })?;
353                let mut get_socket = || {
354                    let mut result = None;
355                    while let Ok(None) = child.try_wait() {
356                        match listener.accept() {
357                            Ok(stream) => {
358                                // Success! Ensure the stream is in nonblocking mode though, for
359                                // good measure. Had an issue without this on macOS.
360                                stream.set_nonblocking(false).map_err(|err| {
361                                    IoError::new_internal(
362                                        err,
363                                        "Could not disable non-blocking mode for listener",
364                                    )
365                                })?;
366                                result = Some(stream);
367                                break;
368                            }
369                            Err(err) => {
370                                if !is_would_block_err(&err) {
371                                    // `WouldBlock` is ok, just means it's not ready yet, but some other
372                                    // kind of error should be reported
373                                    return Err(ShellError::Io(IoError::new_internal(
374                                        err,
375                                        "Accepting new data from listener failed",
376                                    )));
377                                }
378                            }
379                        }
380                        if Instant::now().saturating_duration_since(start) > TIMEOUT {
381                            return Err(ShellError::PluginFailedToLoad {
382                                msg: "Plugin timed out while waiting to connect to socket".into(),
383                            });
384                        } else {
385                            std::thread::sleep(RETRY_PERIOD);
386                        }
387                    }
388                    if let Some(stream) = result {
389                        Ok(stream)
390                    } else {
391                        // The process may have exited
392                        Err(ShellError::PluginFailedToLoad {
393                            msg: "Plugin exited without connecting".into(),
394                        })
395                    }
396                };
397                // Input stream always comes before output
398                let write_in = get_socket()?;
399                let read_out = get_socket()?;
400                Ok(ServerCommunicationIo::LocalSocket { read_out, write_in })
401            }
402        }
403    }
404}
405
406/// The required streams for communication from the engine side, i.e. the server in socket terms.
407pub enum ServerCommunicationIo {
408    Stdio(ChildStdin, ChildStdout),
409    #[cfg(feature = "local-socket")]
410    LocalSocket {
411        read_out: interprocess::local_socket::Stream,
412        write_in: interprocess::local_socket::Stream,
413    },
414}
415
416/// The required streams for communication from the plugin side, i.e. the client in socket terms.
417pub enum ClientCommunicationIo {
418    Stdio(Stdin, Stdout),
419    #[cfg(feature = "local-socket")]
420    LocalSocket {
421        read_in: interprocess::local_socket::Stream,
422        write_out: interprocess::local_socket::Stream,
423    },
424}