diff --git a/legacy/phpstan-baseline.neon b/legacy/phpstan-baseline.neon
index 128f1d365..02fac6e02 100644
--- a/legacy/phpstan-baseline.neon
+++ b/legacy/phpstan-baseline.neon
@@ -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
-
diff --git a/legacy/src/Service/SshConfig.php b/legacy/src/Service/SshConfig.php
index 819074a47..898974c2b 100644
--- a/legacy/src/Service/SshConfig.php
+++ b/legacy/src/Service/SshConfig.php
@@ -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;
@@ -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 *';
@@ -312,7 +316,7 @@ public function addUserSshConfig(QuestionHelper $questionHelper): bool
$this->stdErr->writeln('Failed to read file: ' . $filename . '');
return false;
}
- if (str_contains($currentContents, $suggestedConfig)) {
+ if (SshConfigInspector::includesPath($currentContents, $wildcards, $includePaths, $this->getSshHomeDirectory())) {
$this->stdErr->writeln('Validated SSH configuration file: ' . $filename . '', OutputInterface::VERBOSITY_VERBOSE);
return true;
}
@@ -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';
diff --git a/legacy/src/Util/SshConfigInspector.php b/legacy/src/Util/SshConfigInspector.php
new file mode 100644
index 000000000..8afd2a6b3
--- /dev/null
+++ b/legacy/src/Util/SshConfigInspector.php
@@ -0,0 +1,179 @@
+ [
+ "Host *.platform.sh *.upsun.com\n Include $include\nHost *\n",
+ true,
+ ],
+ 'extra options in the block' => [
+ "# BEGIN: Upsun certificate configuration\n"
+ . "Host *.platform.sh *.upsun.com\n"
+ . " StrictHostKeyChecking no\n"
+ . " UserKnownHostsFile /dev/null\n"
+ . " LogLevel ERROR\n"
+ . " Include $include\n"
+ . "# END: Upsun certificate configuration\n",
+ true,
+ ],
+ 'different indentation, case, and pattern order' => [
+ "host *.UPSUN.com *.platform.sh\n\tinclude $include\n",
+ true,
+ ],
+ 'keyword with equals sign' => [
+ "Host=*.platform.sh *.upsun.com\nInclude=$include\n",
+ true,
+ ],
+ 'include among several paths' => [
+ "Host *.platform.sh *.upsun.com\n Include ~/.ssh/other.config $include\n",
+ true,
+ ],
+ 'tilde expansion' => [
+ "Host *.platform.sh *.upsun.com\n Include ~/.upsun-cli/ssh/*.config\n",
+ true,
+ ],
+ 'quoted path' => [
+ "Host *.platform.sh *.upsun.com\n Include \"$include\"\n",
+ true,
+ ],
+ 'top-level include' => [
+ "Include $include\n\nHost github.com\n User git\n",
+ true,
+ ],
+ 'match all block' => [
+ "Match all\n Include $include\n",
+ true,
+ ],
+ 'catch-all host block' => [
+ "Host *\n Include $include\n",
+ true,
+ ],
+ 'Windows line endings' => [
+ "Host *.platform.sh *.upsun.com\r\n Include $include\r\n",
+ true,
+ ],
+ 'empty file' => [
+ '',
+ false,
+ ],
+ 'commented out' => [
+ "# Host *.platform.sh *.upsun.com\n# Include $include\n",
+ false,
+ ],
+ 'missing one wildcard' => [
+ "Host *.platform.sh\n Include $include\n",
+ false,
+ ],
+ 'different include path' => [
+ "Host *.platform.sh *.upsun.com\n Include /home/user/.platformsh/ssh/*.config\n",
+ false,
+ ],
+ 'include in an unrelated host block' => [
+ "Host *.platform.sh *.upsun.com\n User foo\nHost github.com\n Include $include\n",
+ false,
+ ],
+ 'include in a match block' => [
+ "Match host *.platform.sh\n Include $include\n",
+ false,
+ ],
+ 'negated pattern' => [
+ "Host *.platform.sh *.upsun.com !foo.upsun.com\n Include $include\n",
+ false,
+ ],
+ 'include path as a prefix only' => [
+ "Host *.platform.sh *.upsun.com\n Include {$include}.bak\n",
+ false,
+ ],
+ ];
+ foreach ($cases as $name => [$contents, $expected]) {
+ $this->assertSame($expected, SshConfigInspector::includesPath($contents, $wildcards, [$include], '/home/user'), $name);
+ }
+ }
+
+ public function testQuotedIncludePathWithSpaces(): void
+ {
+ $include = '"/home/some user/.upsun-cli/ssh/*.config"';
+ $contents = "Host *.example.com\n Include \"/home/some user/.upsun-cli/ssh/*.config\"\n";
+ $this->assertTrue(SshConfigInspector::includesPath($contents, ['*.example.com'], [$include]));
+ $this->assertFalse(SshConfigInspector::includesPath($contents, ['*.example.com'], ['/home/other/.upsun-cli/ssh/*.config']));
+ }
+
+ public function testTildeWithDifferentHomeDirectory(): void
+ {
+ $contents = "Host *.example.com\n Include ~/.upsun-cli/ssh/*.config\n";
+ $include = '/home/user/.upsun-cli/ssh/*.config';
+ $this->assertTrue(SshConfigInspector::includesPath($contents, ['*.example.com'], [$include], '/home/user/'));
+ $this->assertFalse(SshConfigInspector::includesPath($contents, ['*.example.com'], [$include], '/root'));
+ $this->assertFalse(SshConfigInspector::includesPath($contents, ['*.example.com'], [$include], ''));
+ }
+
+ public function testWindowsPaths(): void
+ {
+ $wildcards = ['*.example.com'];
+ // The converted and raw formats, as produced by SshConfig::formattedPaths() with ssh.windows_paths: both.
+ $includes = ['/c/Users/me/.upsun-cli/ssh/*.config', 'C:\Users\me\.upsun-cli\ssh\*.config'];
+ $homeDir = 'C:\Users\me';
+ $cases = [
+ 'both formats' => [
+ "Host *.example.com\n Include /c/Users/me/.upsun-cli/ssh/*.config\n Include C:\\Users\\me\\.upsun-cli\\ssh\\*.config\n",
+ true,
+ ],
+ 'both formats on one line' => [
+ "Host *.example.com\n Include /c/Users/me/.upsun-cli/ssh/*.config C:\\Users\\me\\.upsun-cli\\ssh\\*.config\n",
+ true,
+ ],
+ 'converted format only' => [
+ "Host *.example.com\n Include /c/Users/me/.upsun-cli/ssh/*.config\n",
+ false,
+ ],
+ 'raw format only' => [
+ "Host *.example.com\n Include C:\\Users\\me\\.upsun-cli\\ssh\\*.config\n",
+ false,
+ ],
+ 'tilde' => [
+ "Host *.example.com\n Include ~/.upsun-cli/ssh/*.config\n",
+ true,
+ ],
+ 'tilde with a different path' => [
+ "Host *.example.com\n Include ~/.platformsh/ssh/*.config\n",
+ false,
+ ],
+ ];
+ foreach ($cases as $name => [$contents, $expected]) {
+ $this->assertSame($expected, SshConfigInspector::includesPath($contents, $wildcards, $includes, $homeDir), $name);
+ }
+ }
+
+ public function testEmptyInputs(): void
+ {
+ $this->assertFalse(SshConfigInspector::includesPath("Include /foo\n", [], ['/foo']));
+ $this->assertFalse(SshConfigInspector::includesPath("Include /foo\n", ['*.example.com'], []));
+ }
+}