1<?php
2
3namespace Shellbox\Command;
4
5use Shellbox\Shellbox;
6
7/**
8 * The abstract base class for commands.
9 */
10abstract class Command {
11	/** @var string */
12	private $command = '';
13	/** @var int|float|null */
14	private $cpuTimeLimit;
15	/** @var int|float|null */
16	private $wallTimeLimit;
17	/** @var int|float|null */
18	private $memoryLimit;
19	/** @var int|float|null */
20	private $fileSizeLimit;
21	/** @var string[] */
22	private $environment = [];
23	/** @var string */
24	private $stdin = '';
25	/** @var bool */
26	private $passStdin;
27	/** @var bool */
28	private $includeStderr;
29	/** @var bool */
30	private $logStderr = false;
31	/** @var bool */
32	private $forwardStderr = false;
33	/** @var bool */
34	private $useLogPipe = false;
35	/** @var string|null */
36	private $workingDirectory;
37	/** @var array */
38	private $procOpenOptions = [];
39	/** @var bool */
40	private $disableNetwork = false;
41	/** @var string[] */
42	private $disabledSyscalls = [];
43	/** @var bool */
44	private $firejailDefaultSeccomp = false;
45	/** @var bool */
46	private $noNewPrivs = false;
47	/** @var bool */
48	private $privateUserNamespace = false;
49	/** @var bool */
50	private $privateDev = false;
51	/** @var string[] */
52	private $allowedPaths = [];
53	/** @var string[] */
54	private $disallowedPaths = [];
55	/** @var bool */
56	private $disableSandbox = false;
57
58	/**
59	 * Adds parameters to the command. All parameters are escaped via Shellbox::escape().
60	 * Null values are ignored.
61	 *
62	 * @param mixed|mixed[] ...$args
63	 * @return $this
64	 */
65	public function params( ...$args ) {
66		if ( count( $args ) === 1 && is_array( $args[0] ) ) {
67			$args = $args[0];
68		}
69		$command = Shellbox::escape( $args );
70		if ( $this->command === '' ) {
71			$this->command = $command;
72		} else {
73			$this->command .= ' ' . $command;
74		}
75		return $this;
76	}
77
78	/**
79	 * Adds unsafe parameters to the command. These parameters are NOT sanitized in any way.
80	 * Null values are ignored.
81	 *
82	 * @param string|string[]|null ...$args
83	 * @return $this
84	 */
85	public function unsafeParams( ...$args ) {
86		if ( count( $args ) === 1 && is_array( $args[0] ) ) {
87			$args = $args[0];
88		}
89		foreach ( $args as $arg ) {
90			if ( $arg !== null ) {
91				if ( $this->command !== '' ) {
92					$this->command .= ' ';
93				}
94				$this->command .= $arg;
95			}
96		}
97		return $this;
98	}
99
100	/**
101	 * Replace the whole command with the given set of arguments.
102	 *
103	 * @param string|string[] ...$args
104	 * @return $this
105	 */
106	public function replaceParams( ...$args ) {
107		$this->command = '';
108		$this->params( ...$args );
109		return $this;
110	}
111
112	/**
113	 * Replace the whole command string with something else. The command is not
114	 * escaped or sanitized.
115	 *
116	 * @param string $command
117	 * @return $this
118	 */
119	public function unsafeCommand( string $command ) {
120		$this->command = $command;
121		return $this;
122	}
123
124	/**
125	 * Set the CPU time limit, that is, the amount of time the process spends
126	 * in the running state.
127	 *
128	 * Whether this limit can be respected depends on the executor
129	 * configuration.
130	 *
131	 * @param int|float $limit The limit in seconds
132	 * @return $this
133	 */
134	public function cpuTimeLimit( $limit ) {
135		$this->cpuTimeLimit = $limit;
136		return $this;
137	}
138
139	/**
140	 * Set the wall clock time limit, that is, the amount of real time the
141	 * process may run for.
142	 *
143	 * Whether this limit can be respected depends on the executor
144	 * configuration.
145	 *
146	 * @param int|float $limit The limit in seconds
147	 * @return $this
148	 */
149	public function wallTimeLimit( $limit ) {
150		$this->wallTimeLimit = $limit;
151		return $this;
152	}
153
154	/**
155	 * Set the memory limit in bytes.
156	 *
157	 * Whether this limit can be respected depends on the executor
158	 * configuration.
159	 *
160	 * @param int|float $limit The limit in bytes
161	 * @return $this
162	 */
163	public function memoryLimit( $limit ) {
164		$this->memoryLimit = $limit;
165		return $this;
166	}
167
168	/**
169	 * Set the maximum file size that the command may create
170	 *
171	 * Whether this limit can be respected depends on the executor
172	 * configuration.
173	 *
174	 * @param int|float $limit The limit in bytes
175	 * @return $this
176	 */
177	public function fileSizeLimit( $limit ) {
178		$this->fileSizeLimit = $limit;
179		return $this;
180	}
181
182	/**
183	 * Sets environment variables which should be added to the executed command
184	 * environment. In CLI mode, the environment of the parent process will
185	 * also be inherited.
186	 *
187	 * @param string[] $environment array of variable name => value
188	 * @return $this
189	 */
190	public function environment( array $environment ) {
191		$this->environment = $environment;
192		return $this;
193	}
194
195	/**
196	 * Sends the provided input to the command. Defaults to an empty string.
197	 * If you want to pass stdin through to the command instead, use
198	 * passStdin().
199	 *
200	 * @param string $stdin
201	 * @return $this
202	 */
203	public function stdin( string $stdin ) {
204		$this->stdin = $stdin;
205		return $this;
206	}
207
208	/**
209	 * Controls whether stdin is passed through to the command, so that the
210	 * user can interact with the command when it is run in CLI mode. If this
211	 * is enabled:
212	 *   - The wall clock timeout will be disabled to avoid stopping the
213	 *     process with SIGTTIN/SIGTTOU (T206957).
214	 *   - The string specified with input() will be ignored.
215	 *
216	 * @param bool $yesno
217	 * @return $this
218	 */
219	public function passStdin( bool $yesno = true ) {
220		$this->passStdin = $yesno;
221		return $this;
222	}
223
224	/**
225	 * Controls whether stderr should be included in stdout, including errors
226	 * from wrappers. Default: don't include.
227	 *
228	 * @param bool $includeStderr
229	 * @return $this
230	 */
231	public function includeStderr( bool $includeStderr = true ) {
232		$this->includeStderr = $includeStderr;
233		return $this;
234	}
235
236	/**
237	 * If this is set to true, text written to stderr by the command will be
238	 * passed through to PHP's stderr. To avoid SIGTTIN/SIGTTOU, and to support
239	 * Result::getStderr(), the file descriptor is not passed through, we just
240	 * copy the data to stderr as we receive it.
241	 *
242	 * @param bool $yesno
243	 * @return $this
244	 */
245	public function forwardStderr( bool $yesno = true ) {
246		$this->forwardStderr = $yesno;
247		return $this;
248	}
249
250	/**
251	 * When enabled, text sent to stderr will be logged with a level of 'error'.
252	 *
253	 * @param bool $yesno
254	 * @return $this
255	 */
256	public function logStderr( bool $yesno = true ) {
257		$this->logStderr = $yesno;
258		return $this;
259	}
260
261	/**
262	 * Open FD 3 as a pipe and pass the write side to the command. Lines
263	 * written to this pipe will be logged. This is used by some wrappers to
264	 * provide log messages.
265	 *
266	 * @internal For Wrapper subclasses only
267	 * @param bool $yesno
268	 * @return $this
269	 */
270	public function useLogPipe( bool $yesno = true ) {
271		$this->useLogPipe = $yesno;
272		return $this;
273	}
274
275	/**
276	 * Set the working directory under which the command will be run.
277	 *
278	 * @param string $path
279	 * @return $this
280	 */
281	public function workingDirectory( string $path ) {
282		$this->workingDirectory = $path;
283		return $this;
284	}
285
286	/**
287	 * Set special options to proc_open().
288	 *
289	 * @internal For Wrapper subclasses only
290	 * @param array $options
291	 * @return $this
292	 */
293	public function procOpenOptions( array $options ) {
294		$this->procOpenOptions = $options;
295		return $this;
296	}
297
298	/**
299	 * Disable networking, if possible.
300	 *
301	 * @param bool $yesno
302	 * @return $this
303	 */
304	public function disableNetwork( bool $yesno = true ) {
305		$this->disableNetwork = $yesno;
306		return $this;
307	}
308
309	/**
310	 * Specify the set of disabled syscalls. If the sandbox configuration
311	 * permits, a seccomp filter will be set up to disallow them.
312	 *
313	 * @param string[] $syscalls
314	 * @return $this
315	 */
316	public function disabledSyscalls( array $syscalls ) {
317		$this->disabledSyscalls = $syscalls;
318		return $this;
319	}
320
321	/**
322	 * Enable/disable the default Firejail seccomp filter. This only works if
323	 * Firejail is enabled. Firejail will also enable no_new_privs when this is
324	 * enabled.
325	 *
326	 * @param bool $yesno
327	 * @return $this
328	 */
329	public function firejailDefaultSeccomp( bool $yesno = true ) {
330		$this->firejailDefaultSeccomp = $yesno;
331		return $this;
332	}
333
334	/**
335	 * Enable the no_new_privs attribute to prevent privilege escalation via
336	 * setuid executables and similar.
337	 *
338	 * @param bool $yesno
339	 * @return $this
340	 */
341	public function noNewPrivs( bool $yesno = true ) {
342		$this->noNewPrivs = $yesno;
343		return $this;
344	}
345
346	/**
347	 * Use a private user namespace.
348	 *
349	 * @param bool $yesno
350	 * @return $this
351	 */
352	public function privateUserNamespace( bool $yesno = true ) {
353		$this->privateUserNamespace = $yesno;
354		return $this;
355	}
356
357	/**
358	 * Create a private /dev mount
359	 *
360	 * @param bool $yesno
361	 * @return $this
362	 */
363	public function privateDev( bool $yesno = true ) {
364		$this->privateDev = $yesno;
365		return $this;
366	}
367
368	/**
369	 * If called, the files/directories that are allowed will certainly be
370	 * available to the shell command.
371	 *
372	 * Whether this can be respected depends on the configuration of the
373	 * executor.
374	 *
375	 * @param string ...$paths
376	 *
377	 * @return $this
378	 */
379	public function allowPath( ...$paths ) {
380		$this->allowedPaths = array_merge( $this->allowedPaths, $paths );
381		return $this;
382	}
383
384	/**
385	 * Replace the list of allowed paths.
386	 *
387	 * @param string[] $paths
388	 * @return $this
389	 */
390	public function allowedPaths( array $paths ) {
391		$this->allowedPaths = $paths;
392		return $this;
393	}
394
395	/**
396	 * Disallow the specified paths so that the command cannot access them.
397	 *
398	 * Whether this can be respected depends on the configuration of the
399	 * executor.
400	 *
401	 * @param string ...$paths
402	 * @return $this
403	 */
404	public function disallowPath( ...$paths ) {
405		$this->disallowedPaths = array_merge( $this->disallowedPaths, $paths );
406		return $this;
407	}
408
409	/**
410	 * Replace the list of disallowed paths
411	 *
412	 * @param string[] $paths
413	 * @return $this
414	 */
415	public function disallowedPaths( array $paths ) {
416		$this->disallowedPaths = $paths;
417		return $this;
418	}
419
420	/**
421	 * Disable firejail and similar sandboxes
422	 *
423	 * @param bool $yesno
424	 * @return $this
425	 */
426	public function disableSandbox( bool $yesno = true ) {
427		$this->disableSandbox = $yesno;
428		return $this;
429	}
430
431	/**
432	 * Get command parameters for JSON serialization by the client.
433	 *
434	 * @internal
435	 * @return array
436	 */
437	public function getClientData() {
438		return [
439			'command' => $this->command,
440			'cpuLimit' => $this->cpuTimeLimit,
441			'wallTimeLimit' => $this->wallTimeLimit,
442			'memoryLimit' => $this->memoryLimit,
443			'fileSizeLimit' => $this->fileSizeLimit,
444			'environment' => $this->environment,
445			'includeStderr' => $this->includeStderr,
446			'logStderr' => $this->logStderr
447		];
448	}
449
450	/**
451	 * Set command parameters using a data array created by getClientData()
452	 *
453	 * @internal
454	 * @param array $data
455	 */
456	public function setClientData( $data ) {
457		foreach ( $data as $name => $value ) {
458			switch ( $name ) {
459				case 'command':
460					$this->command = $value;
461					break;
462
463				case 'cpuLimit':
464					$this->cpuTimeLimit = $value;
465					break;
466
467				case 'wallTimeLimit':
468					$this->wallTimeLimit = $value;
469					break;
470
471				case 'memoryLimit':
472					$this->memoryLimit = $value;
473					break;
474
475				case 'fileSizeLimit':
476					$this->fileSizeLimit = $value;
477					break;
478
479				case 'environment':
480					$this->environment = $value;
481					break;
482
483				case 'includeStderr':
484					$this->includeStderr = $value;
485					break;
486
487				case 'logStderr':
488					$this->logStderr = $value;
489					break;
490			}
491		}
492	}
493
494	/**
495	 * Get the current command string
496	 *
497	 * @return string
498	 */
499	public function getCommandString() {
500		return $this->command;
501	}
502
503	/**
504	 * Get the CPU limit
505	 *
506	 * @return int|float|null
507	 */
508	public function getCpuTimeLimit() {
509		return $this->cpuTimeLimit;
510	}
511
512	/**
513	 * Get the wall clock time limit
514	 *
515	 * @return int|float|null
516	 */
517	public function getWallTimeLimit() {
518		return $this->wallTimeLimit;
519	}
520
521	/**
522	 * Get the memory limit
523	 *
524	 * @return int|float|null
525	 */
526	public function getMemoryLimit() {
527		return $this->memoryLimit;
528	}
529
530	/**
531	 * Get the file size limit
532	 *
533	 * @return int|float|null
534	 */
535	public function getFileSizeLimit() {
536		return $this->fileSizeLimit;
537	}
538
539	/**
540	 * Get the environment
541	 *
542	 * @return string[]
543	 */
544	public function getEnvironment() {
545		return $this->environment;
546	}
547
548	/**
549	 * Get the text to be passed to stdin
550	 *
551	 * @return string
552	 */
553	public function getStdin() {
554		return $this->stdin;
555	}
556
557	/**
558	 * Get whether to pass through stdin
559	 *
560	 * @return bool
561	 */
562	public function getPassStdin() {
563		return $this->passStdin;
564	}
565
566	/**
567	 * Get whether to duplicate stderr to stdout
568	 *
569	 * @return bool
570	 */
571	public function getIncludeStderr() {
572		return $this->includeStderr;
573	}
574
575	/**
576	 * Get whether to log text seen on stderr
577	 *
578	 * @return bool
579	 */
580	public function getLogStderr() {
581		return $this->logStderr;
582	}
583
584	/**
585	 * Get whether to forward the command's stderr to the parent's stderr
586	 *
587	 * @return bool
588	 */
589	public function getForwardStderr() {
590		return $this->forwardStderr;
591	}
592
593	/**
594	 * Get whether to enable the log pipe
595	 *
596	 * @return bool
597	 */
598	public function getUseLogPipe() {
599		return $this->useLogPipe;
600	}
601
602	/**
603	 * @return string|null
604	 */
605	public function getWorkingDirectory() {
606		return $this->workingDirectory;
607	}
608
609	/**
610	 * Get the additional proc_open() options
611	 *
612	 * @return array
613	 */
614	public function getProcOpenOptions() {
615		return $this->procOpenOptions;
616	}
617
618	/**
619	 * Get whether to disable external networking
620	 *
621	 * @return bool
622	 */
623	public function getDisableNetwork() {
624		return $this->disableNetwork;
625	}
626
627	/**
628	 * Get the list of disabled syscalls
629	 *
630	 * @return string[]
631	 */
632	public function getDisabledSyscalls() {
633		return $this->disabledSyscalls;
634	}
635
636	/**
637	 * Get whether to use firejail's default seccomp filter
638	 *
639	 * @return bool
640	 */
641	public function getFirejailDefaultSeccomp() {
642		return $this->firejailDefaultSeccomp;
643	}
644
645	/**
646	 * Get whether to enable the no_new_privs process attribute
647	 *
648	 * @return bool
649	 */
650	public function getNoNewPrivs() {
651		return $this->noNewPrivs;
652	}
653
654	/**
655	 * Get whether to use a private user namespace
656	 *
657	 * @return bool
658	 */
659	public function getPrivateUserNamespace() {
660		return $this->privateUserNamespace;
661	}
662
663	/**
664	 * Get whether to mount a private /dev filesystem
665	 *
666	 * @return bool
667	 */
668	public function getPrivateDev() {
669		return $this->privateDev;
670	}
671
672	/**
673	 * Get the allowed paths
674	 *
675	 * @return string[]
676	 */
677	public function getAllowedPaths() {
678		return $this->allowedPaths;
679	}
680
681	/**
682	 * Get the disallowed paths
683	 *
684	 * @return string[]
685	 */
686	public function getDisallowedPaths() {
687		return $this->disallowedPaths;
688	}
689
690	/**
691	 * Get whether to disable firejail and similar sandboxes
692	 *
693	 * @return bool
694	 */
695	public function getDisableSandbox() {
696		return $this->disableSandbox;
697	}
698}
699