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'], [])); + } +}