diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php index a53889ffdf..ec79300e30 100644 --- a/bootstrap/helpers/sudo.php +++ b/bootstrap/helpers/sudo.php @@ -39,6 +39,41 @@ function isSingleSudoShellScript(string $line): bool return preg_match("/^\\s*sudo (?:ba)?sh -c '(?:[^']|'\\\\'')*'\\s*$/", $line) === 1; } +/** + * Puts sudo after each ` | ` that the shell sees as a pipe. Pipes inside single or double quotes + * (a Go template, an `sh -c "..."` script) are text and stay unchanged; a stage that already + * starts with sudo is not given a second one. + */ +function addSudoAfterUnquotedPipes(string $line): string +{ + $result = ''; + $quote = null; + $length = strlen($line); + + for ($i = 0; $i < $length; $i++) { + $char = $line[$i]; + + if ($quote === null && substr($line, $i, 3) === ' | ') { + $result .= str_starts_with(substr($line, $i + 3), 'sudo ') ? ' | ' : ' | sudo '; + $i += 2; + + continue; + } + + $result .= $char; + + if ($char === '\\' && $quote !== "'" && $i + 1 < $length) { + $result .= $line[++$i]; + } elseif ($quote === null && ($char === "'" || $char === '"')) { + $quote = $char; + } elseif ($char === $quote) { + $quote = null; + } + } + + return $result; +} + function parseCommandsByLineForSudo(Collection $commands, Server $server): array { $commands = $commands->map(function ($line) { @@ -148,8 +183,8 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array $line = $line->replace('&&', '&& sudo'); } // Don't insert sudo into pipes for complex commands - if (! $isComplexPipeCommand && str($line)->contains(' | ')) { - $line = $line->replace(' | ', ' | sudo '); + if (! $isComplexPipeCommand) { + $line = str(addSudoAfterUnquotedPipes($line->value())); } return $line->value(); @@ -182,7 +217,7 @@ function parseLineForSudo(string $command, Server $server): string $command = str($command)->replace('&&', '&& sudo ')->value(); } // Each pipe stage is a separate process; without sudo, `| tee file` writes as the SSH user. - $command = preg_replace('/ \| (?!sudo )/', ' | sudo ', $command); + $command = addSudoAfterUnquotedPipes($command); return $command; } diff --git a/tests/Unit/ParseCommandsByLineForSudoTest.php b/tests/Unit/ParseCommandsByLineForSudoTest.php index ec13295438..f9309ac5b9 100644 --- a/tests/Unit/ParseCommandsByLineForSudoTest.php +++ b/tests/Unit/ParseCommandsByLineForSudoTest.php @@ -798,3 +798,32 @@ test('parseLineForSudo adds sudo to each pipe stage without doubling it', functi ->and(parseLineForSudo('docker ps | sudo grep app', $this->server)) ->toBe('sudo docker ps | sudo grep app'); }); + +test('parseLineForSudo keeps pipes inside quoted strings unchanged', function (string $line, string $expected) { + expect(parseLineForSudo($line, $this->server))->toBe($expected); +})->with([ + 'single-quoted go template' => [ + "docker inspect --format '{{a | b}}' coolify-proxy | grep x", + "sudo docker inspect --format '{{a | b}}' coolify-proxy | sudo grep x", + ], + 'double-quoted sh -c script' => [ + 'sh -c "x | y"', + 'sudo sh -c "x | y"', + ], + 'escaped double quote inside double quotes' => [ + 'echo "a \" | b" | tee /tmp/coolify/file', + 'sudo echo "a \" | b" | sudo tee /tmp/coolify/file', + ], + 'single quote inside double quotes' => [ + "echo \"it's | here\" | tee /tmp/coolify/file", + "sudo echo \"it's | here\" | sudo tee /tmp/coolify/file", + ], +]); + +test('parseCommandsByLineForSudo keeps pipes inside quoted strings unchanged', function () { + $result = parseCommandsByLineForSudo(collect([ + "docker ps --format '{{.Names}} | {{.Image}}' | grep app", + ]), $this->server); + + expect($result[0])->toBe("sudo docker ps --format '{{.Names}} | {{.Image}}' | sudo grep app"); +});