fix: never use a shell

This commit is contained in:
2025-08-17 18:45:40 +02:00
parent 25e4c6a151
commit ad740afb5f
4 changed files with 61 additions and 45 deletions

View File

@@ -10,23 +10,16 @@ use Nih\CommandBuilder\Stdio;
require_once __DIR__ . '/vendor/autoload.php'; require_once __DIR__ . '/vendor/autoload.php';
$child = (new Command('/usr/bin/cat')) $child = (new Command('cat'))
->stdin(Stdio::piped()) ->stdin(Stdio::piped())
->spawn(shell: false); ->stdout(Stdio::piped())
->spawn();
$child->stdin?->write('Hello, this is pretty cool.'); $child->stdin?->write('Hello, this is pretty cool.');
$child->stdin?->close();
$output = $child->output(); $output = $child->output();
var_dump($output) echo $output->stdout;
// object(Nih\CommandBuilder\Output)#4 (3) { // Hello, this is pretty cool.
// ["stdout"]=>
// string(27) "Hello, this is pretty cool."
// ["stderr"]=>
// string(0) ""
// ["code"]=>
// int(0)
// }
``` ```

View File

@@ -8,17 +8,6 @@ $output = (new Command('echo'))
->arg('Hello, World!') ->arg('Hello, World!')
->output(); ->output();
var_dump($output); echo $output->stdout;
// object(Nih\CommandBuilder\Output)#9 (3) { // Hello, World!
// ["stdout"]=>
// string(14) "Hello, World!
// "
// ["stderr"]=>
// string(0) ""
// ["code"]=>
// object(Nih\CommandBuilder\ExitStatus)#8 (1) {
// ["code"]=>
// int(0)
// }
// }

View File

@@ -10,8 +10,8 @@ $echo = (new Command('echo'))
->stdout(Stdio::piped()) ->stdout(Stdio::piped())
->spawn(); ->spawn();
$cat = (new Command('cat')) (new Command('cat'))
->stdin(Stdio::stream($echo->stdout)) ->stdin(Stdio::stream($echo->stdout))
->status(); ->status();
// Prints "Hello, World!\n" // Hello, World!

View File

@@ -15,7 +15,7 @@ final class Command implements Stringable
{ {
public readonly string $program; public readonly string $program;
private array $args; private array $args = [];
private ?array $environment = null; private ?array $environment = null;
private bool $environmentInherit = true; private bool $environmentInherit = true;
private ?string $cwd = null; private ?string $cwd = null;
@@ -36,6 +36,8 @@ final class Command implements Stringable
* *
* Builder methods are provided to change these defaults and otherwise * Builder methods are provided to change these defaults and otherwise
* configure the process. * configure the process.
*
* If program is not an absolute path, the `PATH` will be searched.
*/ */
public function __construct(string $program) public function __construct(string $program)
{ {
@@ -44,7 +46,6 @@ final class Command implements Stringable
} }
$this->program = $program; $this->program = $program;
$this->args = [$this->program];
} }
/** /**
@@ -65,6 +66,11 @@ final class Command implements Stringable
* ``` * ```
* *
* To pass multiple arguments see {@see Command::args()}. * To pass multiple arguments see {@see Command::args()}.
*
* Note that the arguments are not passed through a shell, but given
* literally to the program. This means that shell syntax like quotes,
* escaped characters, word splitting, glob patterns, variable substitution,
* etc. have no effect.
*/ */
public function arg(string|Stringable $arg): static public function arg(string|Stringable $arg): static
{ {
@@ -77,6 +83,11 @@ final class Command implements Stringable
* *
* To pass a single argument see {@see Command::arg()}. * To pass a single argument see {@see Command::arg()}.
* *
* Note that the arguments are not passed through a shell, but given
* literally to the program. This means that shell syntax like quotes,
* escaped characters, word splitting, glob patterns, variable substitution,
* etc. have no effect.
*
* @param iterable<string|Stringable> $args * @param iterable<string|Stringable> $args
*/ */
public function args(iterable $args): static public function args(iterable $args): static
@@ -259,9 +270,9 @@ final class Command implements Stringable
* *
* @param bool $shell Run the command with or without a shell * @param bool $shell Run the command with or without a shell
*/ */
public function spawn(bool $shell = true): Child public function spawn(): Child
{ {
return $this->spawnWithDescriptorSpec($shell, [ return $this->spawnWithDescriptorSpec([
$this->stdin instanceof Stdio $this->stdin instanceof Stdio
? $this->stdin->descriptorSpec ? $this->stdin->descriptorSpec
: STDIN, : STDIN,
@@ -282,9 +293,9 @@ final class Command implements Stringable
* *
* @param bool $shell Run the command with or without a shell * @param bool $shell Run the command with or without a shell
*/ */
public function status(bool $shell = true): ExitStatus public function status(): ExitStatus
{ {
return $this->spawn($shell)->wait(); return $this->spawn()->wait();
} }
/** /**
@@ -296,9 +307,9 @@ final class Command implements Stringable
* *
* @param bool $shell Run the command with or without a shell * @param bool $shell Run the command with or without a shell
*/ */
public function output(bool $shell = true): Output public function output(): Output
{ {
return $this->spawnWithDescriptorSpec($shell, [ return $this->spawnWithDescriptorSpec([
$this->stdin instanceof Stdio $this->stdin instanceof Stdio
? $this->stdin->descriptorSpec ? $this->stdin->descriptorSpec
: ['pipe', 'r'], : ['pipe', 'r'],
@@ -326,7 +337,7 @@ final class Command implements Stringable
*/ */
public function getArgs(): array public function getArgs(): array
{ {
return array_slice($this->args, 1); return $this->args;
} }
/** /**
@@ -356,11 +367,15 @@ final class Command implements Stringable
public function __toString(): string public function __toString(): string
{ {
return implode(' ', array_map(escapeshellarg(...), $this->args)); return implode(' ', [
escapeshellarg($this->program),
...array_map(escapeshellarg(...), $this->args)
]);
} }
private function spawnWithDescriptorSpec(bool $shell, array $descriptorSpec): Child private function spawnWithDescriptorSpec(array $descriptorSpec): Child
{ {
// Validate stream resources in descriptor spec.
foreach ($descriptorSpec as $descriptor => $spec) { foreach ($descriptorSpec as $descriptor => $spec) {
if (!is_array($spec) && !is_resource($spec)) { if (!is_array($spec) && !is_resource($spec)) {
throw new CommandException(sprintf( throw new CommandException(sprintf(
@@ -371,17 +386,29 @@ final class Command implements Stringable
} }
} }
if ($shell) { // Find executable if path is not absolute.
$command = (string) $this; $program = $this->program;
} else if (is_executable($this->program)) { if ($program[0] !== DIRECTORY_SEPARATOR) {
$command = $this->args; $path = getenv('PATH');
} else { if (is_string($path)) {
foreach (explode(':', $path) as $path) {
$path = $path . '/' . $program;
if (is_executable($path)) {
$program = $path;
break;
}
}
}
}
if (!is_executable($program)) {
throw new CommandException(sprintf( throw new CommandException(sprintf(
'Program "%s" is not executable', 'Program "%s" is not executable',
$this->program, $program,
)); ));
} }
// Handle environment inheritance.
$environment = $this->environment; $environment = $this->environment;
if (is_array($environment) && $this->environmentInherit) { if (is_array($environment) && $this->environmentInherit) {
foreach (getenv() as $key => $val) { foreach (getenv() as $key => $val) {
@@ -391,7 +418,14 @@ final class Command implements Stringable
} }
} }
$proc = proc_open($command, $descriptorSpec, $pipes, $this->cwd, $environment); $proc = proc_open(
[$program, ...$this->args],
$descriptorSpec,
$pipes,
$this->cwd,
$environment,
);
if ($proc === false) { if ($proc === false) {
throw new CommandException(sprintf( throw new CommandException(sprintf(
'Program "%s" failed to start', 'Program "%s" failed to start',