1use 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#[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 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#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct MockProcess {
50 pub pid: u32,
52 pub program: String,
53 pub args: Vec<String>,
54 pub env: Vec<(String, String)>,
58 pub cwd: Option<String>,
60 pub stdin: Vec<u8>,
61 pub stdin_closed: bool,
62 pub status: Option<i32>,
67 pub killed: bool,
68 stdout: Stdio,
69 stderr: Stdio,
70}
71
72impl MockProcess {
73 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#[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#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct MockPaneLaunch {
95 pub id: String,
97 pub project: String,
99 pub cwd: String,
100 pub command: Option<String>,
101 pub env: Vec<(String, String)>,
102 pub title: Option<String>,
105}
106
107#[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 pub websockets: Vec<MockWebSocket>,
116 next_pid: u32,
117 next_watch: u32,
118 pub pending: VecDeque<Event>,
120}
121
122#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct MockWebSocket {
125 pub socket: u32,
126 pub url: String,
127 pub headers: Vec<(String, String)>,
128 pub sent: Vec<String>,
130 pub closed: bool,
131}
132
133const MAX_WATCHES: usize = 256;
135
136impl MockHost {
137 pub fn websockets(&self) -> &[MockWebSocket] {
139 &self.machine.websockets
140 }
141
142 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 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 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 pub fn processes(&self) -> &[MockProcess] {
228 &self.machine.processes
229 }
230
231 pub fn pane_launches(&self) -> &[MockPaneLaunch] {
234 &self.machine.pane_launches
235 }
236
237 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 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 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 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 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}