Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion legacy/phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -7149,7 +7149,7 @@ parameters:
-
message: '#^Parameter \#2 \$array of function implode expects array, mixed given\.$#'
identifier: argument.type
count: 5
count: 4
path: src/Service/SshConfig.php

-
Expand Down
29 changes: 26 additions & 3 deletions legacy/src/Service/SshConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Platformsh\Cli\SshCert\Certifier;
use Platformsh\Cli\Util\OsUtil;
use Platformsh\Cli\Util\Snippeter;
use Platformsh\Cli\Util\SshConfigInspector;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Filesystem\Exception\IOException;
Expand Down Expand Up @@ -284,13 +285,16 @@ public function addUserSshConfig(QuestionHelper $questionHelper): bool
$filename = $this->getUserSshConfigFilename();

$wildcards = $this->config->getWithDefault('ssh.domain_wildcards', []);
if (!$wildcards) {
if (!\is_array($wildcards) || !$wildcards) {
return false;
}
$wildcards = \array_values(\array_filter($wildcards, 'is_string'));

$includePaths = $this->formattedPaths($this->getCliSshDir() . DIRECTORY_SEPARATOR . '*.config');

$lines = [];
$lines[] = 'Host ' . \implode(' ', $wildcards);
foreach ($this->formattedPaths($this->getCliSshDir() . DIRECTORY_SEPARATOR . '*.config') as $path) {
foreach ($includePaths as $path) {
$lines[] = ' Include ' . $path;
}
$lines[] = 'Host *';
Expand All @@ -312,7 +316,7 @@ public function addUserSshConfig(QuestionHelper $questionHelper): bool
$this->stdErr->writeln('Failed to read file: <comment>' . $filename . '</comment>');
return false;
}
if (str_contains($currentContents, $suggestedConfig)) {
if (SshConfigInspector::includesPath($currentContents, $wildcards, $includePaths, $this->getSshHomeDirectory())) {
$this->stdErr->writeln('Validated SSH configuration file: <info>' . $filename . '</info>', OutputInterface::VERBOSITY_VERBOSE);
return true;
}
Expand Down Expand Up @@ -438,6 +442,25 @@ private function getUserSshConfigChanges(string $currentConfig, string $newConfi
*
* @return string
*/
/**
* Returns the home directory that the OpenSSH client uses to expand "~".
*
* On Unix, OpenSSH reads the home directory from the passwd database,
* not from HOME, which may differ (see quoteFilePath()).
*/
private function getSshHomeDirectory(): string
{
if (OsUtil::isWindows() || !\function_exists('posix_getpwuid') || !\function_exists('posix_geteuid')) {
return $this->config->getHomeDirectory();
}
$entry = \posix_getpwuid(\posix_geteuid());
if (!$entry || $entry['dir'] === '') {
return '';
}

return \realpath($entry['dir']) ?: $entry['dir'];
}

private function getUserSshConfigFilename(): string
{
return $this->config->getHomeDirectory() . DIRECTORY_SEPARATOR . '.ssh' . DIRECTORY_SEPARATOR . 'config';
Expand Down
179 changes: 179 additions & 0 deletions legacy/src/Util/SshConfigInspector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<?php

declare(strict_types=1);

namespace Platformsh\Cli\Util;

/**
* Inspects the contents of an OpenSSH client configuration file.
*
* This is a deliberately small parser: it only needs to answer whether an
* Include directive already applies to a set of host patterns. See the
* ssh_config(5) man page for the file format.
*/
class SshConfigInspector
{
/**
* Checks whether the config already includes all of the given paths for all of the given host patterns.
*
* The include is accepted if it appears:
* - at the top level (before any Host or Match block), or in a "Match all" block, or
* - in a Host block whose patterns contain every required pattern, or the catch-all "*".
*
* Host blocks containing negated patterns ("!example.com") are ignored, as
* they may exclude hosts that the CLI needs to configure.
*
* @param string $contents
* The contents of the SSH config file.
* @param string[] $hostPatterns
* The host patterns (wildcards) that the include must apply to, e.g. ['*.example.com'].
* @param string[] $includePaths
* Required Include paths. These may be quoted in the same way as an SSH
* config value. Each one must be included, unless a single "~" path
* matches: that resolves for every SSH client, via its own home directory.
* @param string $homeDir
* The user's home directory as SSH sees it, used to expand a leading "~"
* in Include paths. If empty, "~" paths are never matched.
*/
public static function includesPath(string $contents, array $hostPatterns, array $includePaths, string $homeDir = ''): bool
{
if ($hostPatterns === [] || $includePaths === []) {
return false;
}

$expected = [];
foreach ($includePaths as $path) {
foreach (self::splitArgs($path) as $arg) {
$expected[] = $arg;
}
}
$expected = \array_values(\array_unique($expected));
$canonicalExpected = \array_map([self::class, 'canonicalPath'], $expected);
$found = [];
$required = \array_map('strtolower', $hostPatterns);

// The scope is null at the top level, or otherwise the list of Host
// patterns (lowercase) for the current block. A Match block, other
// than "Match all", is represented as an empty list so that its
// Include directives are not counted.
$scope = null;

foreach (\preg_split('/\r\n|\r|\n/', $contents) ?: [] as $line) {
$line = \trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
if (!\preg_match('/^([A-Za-z]+)(?:\s*=\s*|\s+)(.*)$/', $line, $matches)) {
continue;
}
$keyword = \strtolower($matches[1]);
$args = self::splitArgs($matches[2]);
switch ($keyword) {
case 'host':
$scope = \array_map('strtolower', $args);
break;

case 'match':
$scope = \count($args) === 1 && \strtolower($args[0]) === 'all' ? null : [];
break;

case 'include':
if ($scope !== null && !self::patternsCover($scope, $required)) {
break;
}
foreach ($args as $arg) {
if ($arg === '~' || \str_starts_with($arg, '~/')) {
if ($homeDir !== '' && \in_array(self::canonicalPath($homeDir . \substr($arg, 1)), $canonicalExpected, true)) {
return true;
}
continue;
}
$index = \array_search($arg, $expected, true);
if ($index !== false) {
$found[$index] = true;
if (\count($found) === \count($expected)) {
return true;
}
}
}
break;
}
}

return false;
}

/**
* Checks whether a Host block's patterns cover all of the required patterns.
*
* @param string[] $patterns
* @param string[] $required
*/
private static function patternsCover(array $patterns, array $required): bool
{
foreach ($patterns as $pattern) {
if (\str_starts_with($pattern, '!')) {
return false;
}
}
if (\in_array('*', $patterns, true)) {
return true;
}
foreach ($required as $pattern) {
if (!\in_array($pattern, $patterns, true)) {
return false;
}
}
return true;
}

/**
* Splits a config value into arguments, honoring double quotes.
*
* @return string[]
*/
private static function splitArgs(string $value): array
{
$args = [];
$current = '';
$quoted = false;
$started = false;
$length = \strlen($value);
for ($i = 0; $i < $length; $i++) {
$char = $value[$i];
if ($char === '"') {
$quoted = !$quoted;
$started = true;
continue;
}
if (!$quoted && ($char === ' ' || $char === "\t")) {
if ($started) {
$args[] = $current;
$current = '';
$started = false;
}
continue;
}
$current .= $char;
$started = true;
}
if ($started) {
$args[] = $current;
}
return $args;
}

/**
* Converts a path to a canonical form, for comparing Windows paths in their different formats.
*
* For example "C:\Users\me/.ssh" and "/c/Users/me/.ssh" are both converted to "/c/Users/me/.ssh".
*/
private static function canonicalPath(string $path): string
{
$path = \str_replace('\\', '/', $path);
if (\preg_match('#^([A-Za-z]):/#', $path, $matches)) {
$path = '/' . \strtolower($matches[1]) . \substr($path, 2);
}
return (string) \preg_replace('#/+#', '/', $path);
}
}
Loading
Loading