Skip to main content

standard_plugin/testing/
machine.rs

1//! The daemon world of the in-process host: children a plugin spawns
2//! (recorded with their argv, environment and directory, answered from a
3//! script) and file watches (checked with the host's own path rules).
4
5use alloc::collections::VecDeque;
6use alloc::string::{String, ToString};
7use alloc::vec::Vec;
8use std::path::Path;
9
10use standard_plugin_manifest::paths::{Excludes, PathGrants, real};
11
12use super::MockHost;
13use crate::daemon::process::{Command, Stdio};
14use crate::error::{Error, Result};
15use crate::ui_runtime::Event;
16
17/// What a scripted program does when the plugin spawns it: the output
18/// it writes (delivered as `Event::ProcessOutput` for a piped stream) and
19/// its exit status (`Event::ProcessExited`, and what `wait` answers).
20#[derive(Clone, Debug, Default, PartialEq, Eq)]
21pub struct ProcessScript {
22    pub stdout: Vec<u8>,
23    pub stderr: Vec<u8>,
24    pub status: i32,
25}
26
27impl ProcessScript {
28    /// Exits with `status` and writes nothing.
29    pub fn exit(status: i32) -> Self {
30        Self {
31            status,
32            ..Self::default()
33        }
34    }
35
36    pub fn stdout(mut self, bytes: impl AsRef<[u8]>) -> Self {
37        self.stdout = bytes.as_ref().to_vec();
38        self
39    }
40
41    pub fn stderr(mut self, bytes: impl AsRef<[u8]>) -> Self {
42        self.stderr = bytes.as_ref().to_vec();
43        self
44    }
45}
46
47/// One child the plugin spawned, as the host saw it.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct MockProcess {
50    /// Distinct per child, from 1000.
51    pub pid: u32,
52    pub program: String,
53    pub args: Vec<String>,
54    /// The environment the child got: the plugin's
55    /// ([`MockHost::env`]), less `env_remove`, plus `env` (or only `env`
56    /// with `clear_env`), sorted.
57    pub env: Vec<(String, String)>,
58    /// The directory it ran in: the command's, else the plugin's `HOME`.
59    pub cwd: Option<String>,
60    pub stdin: Vec<u8>,
61    pub stdin_closed: bool,
62    /// Its exit status once it exited (a script, [`DaemonHarness::exit`],
63    /// or a kill: `-9`).
64    ///
65    /// [`DaemonHarness::exit`]: super::DaemonHarness::exit
66    pub status: Option<i32>,
67    pub killed: bool,
68    stdout: Stdio,
69    stderr: Stdio,
70}
71
72impl MockProcess {
73    /// One variable of the child's environment.
74    pub fn var(&self, name: &str) -> Option<&str> {
75        self.env
76            .iter()
77            .find(|(key, _)| key == name)
78            .map(|(_, value)| value.as_str())
79    }
80}
81
82/// One watch the plugin holds.
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct MockWatch {
85    pub handle: u32,
86    pub path: String,
87    pub recursive: bool,
88    pub exclude: Vec<String>,
89}
90
91/// One pane the plugin opened (`daemon::panes::create`), as the host saw
92/// it.
93#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct MockPaneLaunch {
95    /// The pane's id, `pane-1` onwards; its generation is 1.
96    pub id: String,
97    /// The project whose root holds `cwd`.
98    pub project: String,
99    pub cwd: String,
100    pub command: Option<String>,
101    pub env: Vec<(String, String)>,
102    /// The title it asked for (`NewPane::title`); the plugin's id shows when
103    /// none.
104    pub title: Option<String>,
105}
106
107/// The daemon-world state of a [`MockHost`].
108#[derive(Clone, Debug, Default)]
109pub(crate) struct Machine {
110    pub processes: Vec<MockProcess>,
111    pub pane_launches: Vec<MockPaneLaunch>,
112    pub scripts: Vec<(String, Option<Vec<String>>, ProcessScript)>,
113    pub watches: Vec<MockWatch>,
114    /// WebSockets the plugin opened (`daemon-net`), by handle.
115    pub websockets: Vec<MockWebSocket>,
116    next_pid: u32,
117    next_watch: u32,
118    /// Machine events for the plugin, delivered before the next drive.
119    pub pending: VecDeque<Event>,
120}
121
122/// A WebSocket the plugin opened on the test host.
123#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct MockWebSocket {
125    pub socket: u32,
126    pub url: String,
127    pub headers: Vec<(String, String)>,
128    /// The text frames it sent.
129    pub sent: Vec<String>,
130    pub closed: bool,
131}
132
133/// Watches one plugin may hold, as the host allows.
134const MAX_WATCHES: usize = 256;
135
136impl MockHost {
137    /// The WebSockets the plugin opened, in order.
138    pub fn websockets(&self) -> &[MockWebSocket] {
139        &self.machine.websockets
140    }
141
142    /// Delivers a socket's news to the plugin as the host would
143    /// (`system.net.websocket`): `kind` `open`, `message` (with `text`) or
144    /// `closed` (with `text` as the reason).
145    pub fn websocket_event(&mut self, socket: u32, kind: &str, text: &str) {
146        let payload = match kind {
147            "message" => serde_json::json!({"socket": socket, "kind": kind, "text": text}),
148            "closed" => serde_json::json!({"socket": socket, "kind": kind, "reason": text}),
149            _ => serde_json::json!({"socket": socket, "kind": kind}),
150        };
151        self.machine.pending.push_back(Event::Plugin {
152            name: String::from(crate::daemon::net::WEBSOCKET_EVENT),
153            payload: crate::Json(payload.to_string()),
154        });
155    }
156
157    pub(crate) fn websocket_open(
158        &mut self,
159        url: &str,
160        headers: &[(String, String)],
161    ) -> Result<u32> {
162        let target = url
163            .strip_prefix("wss://")
164            .map(|rest| rest.split('/').next().unwrap_or_default())
165            .ok_or_else(|| Error::Invalid(alloc::format!("{url:?} is not a wss:// URL")))?;
166        let (host, port) = match target.rsplit_once(':') {
167            Some((host, port)) => (host, port.parse::<u16>().unwrap_or(0)),
168            None => (target, 443),
169        };
170        if !self.grants.socket_ports(host).contains(&port) {
171            return Err(self.deny(standard_plugin_manifest::GrantDenied {
172                grant: alloc::format!("socket.connect:{host}:{port}"),
173            }));
174        }
175        let socket = self.machine.websockets.len() as u32 + 1;
176        self.machine.websockets.push(MockWebSocket {
177            socket,
178            url: url.to_string(),
179            headers: headers.to_vec(),
180            sent: Vec::new(),
181            closed: false,
182        });
183        Ok(socket)
184    }
185
186    pub(crate) fn websocket_send(&mut self, socket: u32, text: &str) -> Result<()> {
187        let open = self
188            .machine
189            .websockets
190            .iter_mut()
191            .find(|open| open.socket == socket && !open.closed)
192            .ok_or_else(|| Error::Invalid(alloc::format!("socket {socket} is not open")))?;
193        open.sent.push(text.to_string());
194        Ok(())
195    }
196
197    pub(crate) fn websocket_close(&mut self, socket: u32) {
198        if let Some(open) = self
199            .machine
200            .websockets
201            .iter_mut()
202            .find(|open| open.socket == socket)
203        {
204            open.closed = true;
205        }
206    }
207
208    /// Answers every spawn of `program` (as spawned, any arguments) with
209    /// `script`. A later script for the same program wins.
210    pub fn script(mut self, program: &str, script: ProcessScript) -> Self {
211        self.machine
212            .scripts
213            .insert(0, (program.to_string(), None, script));
214        self
215    }
216
217    /// Answers spawns of `program` whose arguments start with `args`.
218    pub fn script_args(mut self, program: &str, args: &[&str], script: ProcessScript) -> Self {
219        let args = args.iter().map(|arg| arg.to_string()).collect();
220        self.machine
221            .scripts
222            .insert(0, (program.to_string(), Some(args), script));
223        self
224    }
225
226    /// Every child the plugin spawned, in order.
227    pub fn processes(&self) -> &[MockProcess] {
228        &self.machine.processes
229    }
230
231    /// The watches the plugin holds.
232    /// The panes the plugin opened, in order.
233    pub fn pane_launches(&self) -> &[MockPaneLaunch] {
234        &self.machine.pane_launches
235    }
236
237    /// Opens a pane as `standardd` does: in a directory inside a project
238    /// root of this machine (`account.projects`, by whole components after
239    /// symlinks; any machine's while [`MockHost::machine`] is unset), with
240    /// the command and variables recorded, listed in `panes` at
241    /// generation 1.
242    pub(crate) fn create_pane(
243        &mut self,
244        cwd: &str,
245        command: Option<&str>,
246        env: &[(String, String)],
247        title: Option<&str>,
248    ) -> Result<(String, u64)> {
249        if title.is_some_and(|title| {
250            title.trim().is_empty()
251                || title.chars().count() > 128
252                || title.chars().any(char::is_control)
253        }) {
254            return Err(Error::Invalid(
255                "a pane title is 1 to 128 characters with no control characters".to_string(),
256            ));
257        }
258        if env.len() > 64
259            || env.iter().any(|(name, value)| {
260                name.is_empty() || name.contains(['=', '\0']) || value.contains('\0')
261            })
262        {
263            return Err(Error::Invalid(
264                "variables a pane cannot be given".to_string(),
265            ));
266        }
267        let path = Path::new(cwd);
268        if !path.is_absolute() {
269            return Err(Error::Invalid(alloc::format!(
270                "{cwd} is not an absolute path"
271            )));
272        }
273        let resolved = real(path);
274        let machine = self.machine_id.clone();
275        let project = self
276            .account
277            .projects
278            .iter()
279            .filter(|project| machine.as_ref().is_none_or(|id| &project.machine == id))
280            .filter(|project| resolved.starts_with(real(Path::new(&project.path))))
281            .max_by_key(|project| real(Path::new(&project.path)).components().count())
282            .ok_or_else(|| {
283                Error::Invalid(alloc::format!(
284                    "{cwd} is not inside a project root on this machine"
285                ))
286            })?
287            .id
288            .clone();
289        let id = alloc::format!("pane-{}", self.machine.pane_launches.len() + 1);
290        self.panes.push(crate::api::account::Pane {
291            id: id.clone(),
292            generation: 1,
293            machine: machine.unwrap_or_default(),
294            project: Some(project.clone()),
295            title: title.map_or_else(|| self.plugin_id.clone(), str::to_string),
296            cwd: cwd.to_string(),
297            cols: 80,
298            rows: 24,
299            ..Default::default()
300        });
301        self.machine.pane_launches.push(MockPaneLaunch {
302            id: id.clone(),
303            project,
304            cwd: cwd.to_string(),
305            command: command.map(str::to_string),
306            env: env.to_vec(),
307            title: title.map(str::to_string),
308        });
309        Ok((id, 1))
310    }
311
312    pub fn watches(&self) -> &[MockWatch] {
313        &self.machine.watches
314    }
315
316    pub(crate) fn spawn(&mut self, command: &Command) -> Result<u32> {
317        if command.program.is_empty() {
318            return Err(Error::Invalid("no program".into()));
319        }
320        if !self.grants.allows("machine.full") {
321            self.check(&alloc::format!("process.exec:{}", command.program))?;
322        }
323        let mut env: Vec<(String, String)> = if command.clear_env {
324            Vec::new()
325        } else {
326            self.environment
327                .iter()
328                .filter(|(name, _)| !command.env_remove.contains(name))
329                .cloned()
330                .collect()
331        };
332        for (name, value) in &command.env {
333            env.retain(|(key, _)| key != name);
334            env.push((name.clone(), value.clone()));
335        }
336        env.sort();
337        let cwd = command.cwd.clone().or_else(|| {
338            self.environment
339                .iter()
340                .find(|(name, _)| name == "HOME")
341                .map(|(_, home)| home.clone())
342        });
343        let pid = 1000 + self.machine.next_pid;
344        self.machine.next_pid += 1;
345        let script = self
346            .machine
347            .scripts
348            .iter()
349            .find(|(program, args, _)| {
350                *program == command.program
351                    && args
352                        .as_ref()
353                        .is_none_or(|args| command.args.starts_with(args))
354            })
355            .map(|(_, _, script)| script.clone());
356        self.machine.processes.push(MockProcess {
357            pid,
358            program: command.program.clone(),
359            args: command.args.clone(),
360            env,
361            cwd,
362            stdin: Vec::new(),
363            stdin_closed: false,
364            status: None,
365            killed: false,
366            stdout: command.stdout,
367            stderr: command.stderr,
368        });
369        if let Some(script) = script {
370            self.output(pid, false, &script.stdout);
371            self.output(pid, true, &script.stderr);
372            self.exited(pid, script.status);
373        }
374        Ok(pid)
375    }
376
377    fn process(&mut self, pid: u32) -> Result<&mut MockProcess> {
378        self.machine
379            .processes
380            .iter_mut()
381            .find(|process| process.pid == pid)
382            .ok_or_else(|| Error::Invalid(alloc::format!("no child {pid} of this plugin")))
383    }
384
385    /// Queues output of `pid` for the plugin, when that stream is piped.
386    pub(crate) fn output(&mut self, pid: u32, stderr: bool, bytes: &[u8]) {
387        let Ok(process) = self.process(pid) else {
388            return;
389        };
390        let piped = if stderr {
391            process.stderr == Stdio::Piped
392        } else {
393            process.stdout == Stdio::Piped
394        };
395        if piped && !bytes.is_empty() {
396            self.machine.pending.push_back(Event::ProcessOutput {
397                pid,
398                stderr,
399                bytes: bytes.to_vec(),
400            });
401        }
402    }
403
404    /// `pid` exits with `status`: the plugin hears it after its output.
405    pub(crate) fn exited(&mut self, pid: u32, status: i32) {
406        let Ok(process) = self.process(pid) else {
407            return;
408        };
409        if process.status.is_some() {
410            return;
411        }
412        process.status = Some(status);
413        self.machine
414            .pending
415            .push_back(Event::ProcessExited { pid, status });
416    }
417
418    pub(crate) fn wait(&mut self, pid: u32) -> Result<i32> {
419        let process = self.process(pid)?;
420        process.status.ok_or_else(|| {
421            Error::Unavailable(alloc::format!(
422                "child {pid} ({}) has no script and would block forever: script it \
423                 (MockHost::script) or end it (DaemonHarness::exit) first",
424                process.program
425            ))
426        })
427    }
428
429    pub(crate) fn try_wait(&mut self, pid: u32) -> Result<Option<i32>> {
430        Ok(self.process(pid)?.status)
431    }
432
433    pub(crate) fn write(&mut self, pid: u32, bytes: &[u8]) -> Result<()> {
434        let process = self.process(pid)?;
435        if process.stdin_closed {
436            return Err(Error::Invalid(alloc::format!(
437                "child {pid}'s stdin is closed"
438            )));
439        }
440        process.stdin.extend_from_slice(bytes);
441        Ok(())
442    }
443
444    pub(crate) fn close_stdin(&mut self, pid: u32) -> Result<()> {
445        self.process(pid)?.stdin_closed = true;
446        Ok(())
447    }
448
449    pub(crate) fn kill(&mut self, pid: u32) -> Result<()> {
450        let process = self.process(pid)?;
451        if process.status.is_none() {
452            process.killed = true;
453            self.exited(pid, -9);
454        }
455        Ok(())
456    }
457
458    /// `daemon-watch.watch` with the host's own rules: an absolute path a
459    /// read grant covers after resolving symlinks (or `machine.full`), and
460    /// exclude globs the host accepts.
461    pub(crate) fn watch(&mut self, path: &str, recursive: bool, exclude: &[String]) -> Result<u32> {
462        let named = Path::new(path);
463        if !named.is_absolute() {
464            return Err(Error::Invalid(alloc::format!(
465                "{path} is not an absolute path"
466            )));
467        }
468        if !PathGrants::from_grants(&self.grants).allows(&real(named), false) {
469            return Err(self.deny(standard_plugin_manifest::GrantDenied {
470                grant: alloc::format!("fs.read:{path}"),
471            }));
472        }
473        Excludes::new(exclude).map_err(|error| Error::Invalid(error.to_string()))?;
474        if self.machine.watches.len() >= MAX_WATCHES {
475            return Err(Error::RateLimited);
476        }
477        self.machine.next_watch += 1;
478        let handle = self.machine.next_watch;
479        self.machine.watches.push(MockWatch {
480            handle,
481            path: path.to_string(),
482            recursive,
483            exclude: exclude.to_vec(),
484        });
485        Ok(handle)
486    }
487
488    pub(crate) fn unwatch(&mut self, handle: u32) -> Result<()> {
489        let before = self.machine.watches.len();
490        self.machine.watches.retain(|watch| watch.handle != handle);
491        if self.machine.watches.len() == before {
492            return Err(Error::Invalid(alloc::format!("no watch {handle}")));
493        }
494        Ok(())
495    }
496
497    /// Queues `Event::FileChanged` for every watch that hears a change at
498    /// `path`, as the host decides it (depth and exclude globs). Returns
499    /// how many watches hear it.
500    pub(crate) fn changed(&mut self, path: &str) -> usize {
501        let path = Path::new(path);
502        let mut heard = 0;
503        for watch in &self.machine.watches {
504            let Ok(rest) = path.strip_prefix(&watch.path) else {
505                continue;
506            };
507            if !watch.recursive && rest.components().count() > 1 {
508                continue;
509            }
510            let excluded = Excludes::new(&watch.exclude).is_ok_and(|globs| globs.excludes(rest));
511            if excluded {
512                continue;
513            }
514            heard += 1;
515            self.machine.pending.push_back(Event::FileChanged {
516                watch: watch.handle,
517                paths: alloc::vec![path.to_string_lossy().into_owned()],
518            });
519        }
520        heard
521    }
522}