From 451acdc5ca51f387cc23febb3d5f8f3775189f49 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 2 Jan 2022 10:41:36 +0100 Subject: [PATCH 1/7] Bump license year --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 9ff2d0d63..88bf75bb4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2004-2021 Fabien Potencier +Copyright (c) 2004-2022 Fabien Potencier Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 5edd78267c8fcce044de89fc73d81acdbccceac0 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Wed, 5 Jan 2022 11:46:56 +0100 Subject: [PATCH 2/7] Allow OutputFormatter::escape() to be used for escaping URLs used in - escape() now escapes `>` as well as `<` - URLs containing escaped `<` and `>` are rendered correctly as is - user-provided URLs should now be safe to use (as in they cannot break the formatting) as long as they're piped through `escape()` --- Formatter/OutputFormatter.php | 18 ++++++++---------- Tests/Fixtures/command_2.txt | 6 +++--- Tests/Fixtures/command_mbstring.txt | 6 +++--- Tests/Fixtures/input_argument_with_style.txt | 2 +- Tests/Fixtures/input_option_with_style.txt | 2 +- .../Fixtures/input_option_with_style_array.txt | 2 +- Tests/Formatter/OutputFormatterTest.php | 6 +++++- Tests/Helper/FormatterHelperTest.php | 6 +++--- 8 files changed, 25 insertions(+), 23 deletions(-) diff --git a/Formatter/OutputFormatter.php b/Formatter/OutputFormatter.php index 0f969c7ad..e8c10e700 100644 --- a/Formatter/OutputFormatter.php +++ b/Formatter/OutputFormatter.php @@ -34,7 +34,7 @@ public function __clone() } /** - * Escapes "<" special char in given text. + * Escapes "<" and ">" special chars in given text. * * @param string $text Text to escape * @@ -42,7 +42,7 @@ public function __clone() */ public static function escape($text) { - $text = preg_replace('/([^\\\\]?)])/', '$1\\\\$2', $text); return self::escapeTrailingBackslash($text); } @@ -144,9 +144,10 @@ public function formatAndWrap(string $message, int $width) { $offset = 0; $output = ''; - $tagRegex = '[a-z][^<>]*+'; + $openTagRegex = '[a-z](?:[^\\\\<>]*+ | \\\\.)*'; + $closeTagRegex = '[a-z][^<>]*+'; $currentLineLength = 0; - preg_match_all("#<(($tagRegex) | /($tagRegex)?)>#ix", $message, $matches, \PREG_OFFSET_CAPTURE); + preg_match_all("#<(($openTagRegex) | /($closeTagRegex)?)>#ix", $message, $matches, \PREG_OFFSET_CAPTURE); foreach ($matches[0] as $i => $match) { $pos = $match[1]; $text = $match[0]; @@ -180,11 +181,7 @@ public function formatAndWrap(string $message, int $width) $output .= $this->applyCurrentStyle(substr($message, $offset), $output, $width, $currentLineLength); - if (str_contains($output, "\0")) { - return strtr($output, ["\0" => '\\', '\\<' => '<']); - } - - return str_replace('\\<', '<', $output); + return strtr($output, ["\0" => '\\', '\\<' => '<', '\\>' => '>']); } /** @@ -218,7 +215,8 @@ private function createStyleFromString(string $string): ?OutputFormatterStyleInt } elseif ('bg' == $match[0]) { $style->setBackground(strtolower($match[1])); } elseif ('href' === $match[0]) { - $style->setHref($match[1]); + $url = preg_replace('{\\\\([<>])}', '$1', $match[1]); + $style->setHref($url); } elseif ('options' === $match[0]) { preg_match_all('([^,;]+)', strtolower($match[1]), $options); $options = array_shift($options); diff --git a/Tests/Fixtures/command_2.txt b/Tests/Fixtures/command_2.txt index 45e7bec4d..fcab77a29 100644 --- a/Tests/Fixtures/command_2.txt +++ b/Tests/Fixtures/command_2.txt @@ -2,9 +2,9 @@ command 2 description Usage: - descriptor:command2 [options] [--] \ - descriptor:command2 -o|--option_name \ - descriptor:command2 \ + descriptor:command2 [options] [--] \ + descriptor:command2 -o|--option_name \ + descriptor:command2 \ Arguments: argument_name diff --git a/Tests/Fixtures/command_mbstring.txt b/Tests/Fixtures/command_mbstring.txt index 2fd51d057..1fa4e3135 100644 --- a/Tests/Fixtures/command_mbstring.txt +++ b/Tests/Fixtures/command_mbstring.txt @@ -2,9 +2,9 @@ command åèä description Usage: - descriptor:åèä [options] [--] \ - descriptor:åèä -o|--option_name \ - descriptor:åèä \ + descriptor:åèä [options] [--] \ + descriptor:åèä -o|--option_name \ + descriptor:åèä \ Arguments: argument_åèä diff --git a/Tests/Fixtures/input_argument_with_style.txt b/Tests/Fixtures/input_argument_with_style.txt index 35384a6be..79149ca69 100644 --- a/Tests/Fixtures/input_argument_with_style.txt +++ b/Tests/Fixtures/input_argument_with_style.txt @@ -1 +1 @@ - argument_name argument description [default: "\style\"] + argument_name argument description [default: "\style\"] diff --git a/Tests/Fixtures/input_option_with_style.txt b/Tests/Fixtures/input_option_with_style.txt index 880a53518..4bd30a662 100644 --- a/Tests/Fixtures/input_option_with_style.txt +++ b/Tests/Fixtures/input_option_with_style.txt @@ -1 +1 @@ - -o, --option_name=OPTION_NAME option description [default: "\style\"] + -o, --option_name=OPTION_NAME option description [default: "\style\"] diff --git a/Tests/Fixtures/input_option_with_style_array.txt b/Tests/Fixtures/input_option_with_style_array.txt index 265c18c5a..1fbb05b8a 100644 --- a/Tests/Fixtures/input_option_with_style_array.txt +++ b/Tests/Fixtures/input_option_with_style_array.txt @@ -1 +1 @@ - -o, --option_name=OPTION_NAME option description [default: ["\Hello\","\world\"]] (multiple values allowed) + -o, --option_name=OPTION_NAME option description [default: ["\Hello\","\world\"]] (multiple values allowed) diff --git a/Tests/Formatter/OutputFormatterTest.php b/Tests/Formatter/OutputFormatterTest.php index 1bd2b5d57..f418f446f 100644 --- a/Tests/Formatter/OutputFormatterTest.php +++ b/Tests/Formatter/OutputFormatterTest.php @@ -32,7 +32,10 @@ public function testLGCharEscaping() $this->assertEquals('foo << bar \\', $formatter->format('foo << bar \\')); $this->assertEquals("foo << \033[32mbar \\ baz\033[39m \\", $formatter->format('foo << bar \\ baz \\')); $this->assertEquals('some info', $formatter->format('\\some info\\')); - $this->assertEquals('\\some info\\', OutputFormatter::escape('some info')); + $this->assertEquals('\\some info\\', OutputFormatter::escape('some info')); + // every < and > gets escaped if not already escaped, but already escaped ones do not get escaped again + // and escaped backslashes remain as such, same with backslashes escaping non-special characters + $this->assertEquals('foo \\< bar \\< baz \\\\< foo \\> bar \\> baz \\\\> \\x', OutputFormatter::escape('foo < bar \\< baz \\\\< foo > bar \\> baz \\\\> \\x')); $this->assertEquals( "\033[33mSymfony\\Component\\Console does work very well!\033[39m", @@ -259,6 +262,7 @@ public function provideDecoratedAndNonDecoratedOutput() ['some question', 'some question', "\033[30;46msome question\033[39;49m"], ['some text with inline style', 'some text with inline style', "\033[31msome text with inline style\033[39m"], ['some URL', 'some URL', "\033]8;;idea://open/?file=/path/SomeFile.php&line=12\033\\some URL\033]8;;\033\\"], + ['>some URL with \', 'some URL with ', "\033]8;;https://example.com/\033\\some URL with \033]8;;\033\\"], ['some URL', 'some URL', 'some URL', 'JetBrains-JediTerm'], ]; } diff --git a/Tests/Helper/FormatterHelperTest.php b/Tests/Helper/FormatterHelperTest.php index 934e11ac1..c9a3c5e00 100644 --- a/Tests/Helper/FormatterHelperTest.php +++ b/Tests/Helper/FormatterHelperTest.php @@ -83,9 +83,9 @@ public function testFormatBlockLGEscaping() $formatter = new FormatterHelper(); $this->assertEquals( - ' '."\n". - ' \some info\ '."\n". - ' ', + ' '."\n". + ' \some info\ '."\n". + ' ', $formatter->formatBlock('some info', 'error', true), '::formatBlock() escapes \'<\' chars' ); From 358d679d5744bc731861976aa060f2a97b9406f7 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 18 Jan 2022 10:12:43 +0100 Subject: [PATCH 3/7] [Console] use STDOUT/ERR in ConsoleOutput to save opening too many file descriptors --- Output/ConsoleOutput.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Output/ConsoleOutput.php b/Output/ConsoleOutput.php index 966fca099..484fcbdea 100644 --- a/Output/ConsoleOutput.php +++ b/Output/ConsoleOutput.php @@ -153,7 +153,8 @@ private function openOutputStream() return fopen('php://output', 'w'); } - return @fopen('php://stdout', 'w') ?: fopen('php://output', 'w'); + // Use STDOUT when possible to prevent from opening too many file descriptors + return \defined('STDOUT') ? \STDOUT : (@fopen('php://stdout', 'w') ?: fopen('php://output', 'w')); } /** @@ -161,6 +162,11 @@ private function openOutputStream() */ private function openErrorStream() { - return fopen($this->hasStderrSupport() ? 'php://stderr' : 'php://output', 'w'); + if (!$this->hasStderrSupport()) { + return fopen('php://output', 'w'); + } + + // Use STDERR when possible to prevent from opening too many file descriptors + return \defined('STDERR') ? \STDERR : (@fopen('php://stderr', 'w') ?: fopen('php://output', 'w')); } } From aa58a696655593b0a012166cfbcf34fafac6e945 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 20 Jan 2022 11:57:37 +0100 Subject: [PATCH 4/7] [Console] fix parsing escaped chars in StringInput --- Input/StringInput.php | 23 +++++++++++++++++++---- Tests/Input/StringInputTest.php | 1 + 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Input/StringInput.php b/Input/StringInput.php index eb5c07fdd..76f1d5030 100644 --- a/Input/StringInput.php +++ b/Input/StringInput.php @@ -24,7 +24,7 @@ */ class StringInput extends ArgvInput { - public const REGEX_STRING = '([^\s]+?)(?:\s|(?tokenize() parses long options with a value'], ["--long-option='foo bar'\"another\"", ['--long-option=foo baranother'], '->tokenize() parses long options with a value'], ['foo -a -ffoo --long bar', ['foo', '-a', '-ffoo', '--long', 'bar'], '->tokenize() parses when several arguments and options'], + ["--arg=\\\"'Jenny'\''s'\\\"", ["--arg=\"Jenny's\""], '->tokenize() parses quoted quotes'], ]; } From a89045900ae02d0f1b67ce9e627c3454fc25f0ff Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 21 Jan 2022 20:04:48 +0100 Subject: [PATCH 5/7] [Console] fix restoring stty mode on CTRL+C --- Application.php | 10 +++++++ Helper/QuestionHelper.php | 13 ++++++-- Tests/ApplicationTest.php | 35 ++++++++++++++++++++++ Tests/Fixtures/application_signalable.php | 36 +++++++++++++++++++++++ 4 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 Tests/Fixtures/application_signalable.php diff --git a/Application.php b/Application.php index 9f9f8394e..c99a3f7ef 100644 --- a/Application.php +++ b/Application.php @@ -952,6 +952,16 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI throw new RuntimeException('Unable to subscribe to signal events. Make sure that the `pcntl` extension is installed and that "pcntl_*" functions are not disabled by your php.ini\'s "disable_functions" directive.'); } + if (Terminal::hasSttyAvailable()) { + $sttyMode = shell_exec('stty -g'); + + foreach ([\SIGINT, \SIGTERM] as $signal) { + $this->signalRegistry->register($signal, static function () use ($sttyMode) { + shell_exec('stty '.$sttyMode); + }); + } + } + if ($this->dispatcher) { foreach ($this->signalsToDispatchEvent as $signal) { $event = new ConsoleSignalEvent($command, $input, $output, $signal); diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index 28931358d..20ac09a1e 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -248,6 +248,9 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu $numMatches = \count($matches); $sttyMode = shell_exec('stty -g'); + $isStdin = 'php://stdin' === (stream_get_meta_data($inputStream)['uri'] ?? null); + $r = [$inputStream]; + $w = []; // Disable icanon (so we can fread each keypress) and echo (we'll do echoing here instead) shell_exec('stty -icanon -echo'); @@ -257,11 +260,15 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu // Read a keypress while (!feof($inputStream)) { + while ($isStdin && 0 === @stream_select($r, $w, $w, 0, 100)) { + // Give signal handlers a chance to run + $r = [$inputStream]; + } $c = fread($inputStream, 1); // as opposed to fgets(), fread() returns an empty string when the stream content is empty, not false. if (false === $c || ('' === $ret && '' === $c && null === $question->getDefault())) { - shell_exec(sprintf('stty %s', $sttyMode)); + shell_exec('stty '.$sttyMode); throw new MissingInputException('Aborted.'); } elseif ("\177" === $c) { // Backspace Character if (0 === $numMatches && 0 !== $i) { @@ -366,7 +373,7 @@ function ($match) use ($ret) { } // Reset stty so it behaves normally again - shell_exec(sprintf('stty %s', $sttyMode)); + shell_exec('stty '.$sttyMode); return $fullChoice; } @@ -427,7 +434,7 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $ $value = fgets($inputStream, 4096); if (self::$stty && Terminal::hasSttyAvailable()) { - shell_exec(sprintf('stty %s', $sttyMode)); + shell_exec('stty '.$sttyMode); } if (false === $value) { diff --git a/Tests/ApplicationTest.php b/Tests/ApplicationTest.php index 542130569..15319c3d4 100644 --- a/Tests/ApplicationTest.php +++ b/Tests/ApplicationTest.php @@ -38,9 +38,11 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\StreamOutput; use Symfony\Component\Console\SignalRegistry\SignalRegistry; +use Symfony\Component\Console\Terminal; use Symfony\Component\Console\Tester\ApplicationTester; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Process\Process; class ApplicationTest extends TestCase { @@ -1882,6 +1884,39 @@ public function testSignalableCommandInterfaceWithoutSignals() $application->add($command); $this->assertSame(0, $application->run(new ArrayInput(['signal']))); } + + /** + * @group tty + */ + public function testSignalableRestoresStty() + { + if (!Terminal::hasSttyAvailable()) { + $this->markTestSkipped('stty not available'); + } + + if (!SignalRegistry::isSupported()) { + $this->markTestSkipped('pcntl signals not available'); + } + + $previousSttyMode = shell_exec('stty -g'); + + $p = new Process(['php', __DIR__.'/Fixtures/application_signalable.php']); + $p->setTty(true); + $p->start(); + + for ($i = 0; $i < 10 && shell_exec('stty -g') === $previousSttyMode; ++$i) { + usleep(100000); + } + + $this->assertNotSame($previousSttyMode, shell_exec('stty -g')); + $p->signal(\SIGINT); + $p->wait(); + + $sttyMode = shell_exec('stty -g'); + shell_exec('stty '.$previousSttyMode); + + $this->assertSame($previousSttyMode, $sttyMode); + } } class CustomApplication extends Application diff --git a/Tests/Fixtures/application_signalable.php b/Tests/Fixtures/application_signalable.php new file mode 100644 index 000000000..0194703b2 --- /dev/null +++ b/Tests/Fixtures/application_signalable.php @@ -0,0 +1,36 @@ +setCode(function(InputInterface $input, OutputInterface $output) { + $this->getHelper('question') + ->ask($input, $output, new ChoiceQuestion('😊', ['y'])); + + return 0; + }) + ->run() + +; From 32ba2ac82767a50959f826e3d64c27923070a85b Mon Sep 17 00:00:00 2001 From: BrokenSourceCode Date: Tue, 25 Jan 2022 23:56:21 +0100 Subject: [PATCH 6/7] [Console] Fix PHP 8.1 deprecation in ChoiceQuestion --- Question/ChoiceQuestion.php | 6 +++--- Tests/Question/ChoiceQuestionTest.php | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Question/ChoiceQuestion.php b/Question/ChoiceQuestion.php index 72703fb16..6247ca716 100644 --- a/Question/ChoiceQuestion.php +++ b/Question/ChoiceQuestion.php @@ -131,18 +131,18 @@ private function getDefaultValidator(): callable return function ($selected) use ($choices, $errorMessage, $multiselect, $isAssoc) { if ($multiselect) { // Check for a separated comma values - if (!preg_match('/^[^,]+(?:,[^,]+)*$/', $selected, $matches)) { + if (!preg_match('/^[^,]+(?:,[^,]+)*$/', (string) $selected, $matches)) { throw new InvalidArgumentException(sprintf($errorMessage, $selected)); } - $selectedChoices = explode(',', $selected); + $selectedChoices = explode(',', (string) $selected); } else { $selectedChoices = [$selected]; } if ($this->isTrimmable()) { foreach ($selectedChoices as $k => $v) { - $selectedChoices[$k] = trim($v); + $selectedChoices[$k] = trim((string) $v); } } diff --git a/Tests/Question/ChoiceQuestionTest.php b/Tests/Question/ChoiceQuestionTest.php index 9db12f852..327f69ad7 100644 --- a/Tests/Question/ChoiceQuestionTest.php +++ b/Tests/Question/ChoiceQuestionTest.php @@ -19,14 +19,15 @@ class ChoiceQuestionTest extends TestCase /** * @dataProvider selectUseCases */ - public function testSelectUseCases($multiSelect, $answers, $expected, $message) + public function testSelectUseCases($multiSelect, $answers, $expected, $message, $default = null) { $question = new ChoiceQuestion('A question', [ 'First response', 'Second response', 'Third response', 'Fourth response', - ]); + null, + ], $default); $question->setMultiselect($multiSelect); @@ -59,6 +60,19 @@ public function selectUseCases() ['First response', 'Second response'], 'When passed multiple answers on MultiSelect, the defaultValidator must return these answers as an array', ], + [ + false, + [null], + null, + 'When used null as default single answer on singleSelect, the defaultValidator must return this answer as null', + ], + [ + false, + ['First response'], + 'First response', + 'When used a string as default single answer on singleSelect, the defaultValidator must return this answer as a string', + 'First response', + ], ]; } From 0259f01dbf9d77badddbbf4c2abb681f24c9cac6 Mon Sep 17 00:00:00 2001 From: James Gilliland Date: Thu, 23 Sep 2021 10:00:15 -0500 Subject: [PATCH 7/7] Silence isatty warnings during tty detection --- Helper/QuestionHelper.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index 0516545bc..a4754b824 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -485,11 +485,11 @@ private function isInteractiveInput($inputStream): bool } if (\function_exists('stream_isatty')) { - return self::$stdinIsInteractive = stream_isatty(fopen('php://stdin', 'r')); + return self::$stdinIsInteractive = @stream_isatty(fopen('php://stdin', 'r')); } if (\function_exists('posix_isatty')) { - return self::$stdinIsInteractive = posix_isatty(fopen('php://stdin', 'r')); + return self::$stdinIsInteractive = @posix_isatty(fopen('php://stdin', 'r')); } if (!\function_exists('exec')) {