From 23da94e3914f3041908c58a092307da16cafdcbf Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Sun, 25 May 2025 11:56:09 +0200 Subject: [PATCH 1/3] feat: add Docker registry authentication support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DockerAuthConfig class to handle authentication configuration - Support DOCKER_AUTH_CONFIG environment variable - Support reading from ~/.docker/config.json - Support credential stores (osxkeychain, wincred, etc.) - Automatically detect registry from image name - Update pullImage() to use authentication when available - Add comprehensive unit tests This allows pulling private images from registries like ghcr.io, Docker Hub, and custom registries by respecting the same authentication mechanisms as the Docker CLI. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/Container/GenericContainer.php | 31 ++- src/Utils/DockerAuthConfig.php | 257 ++++++++++++++++++++++ tests/Unit/Utils/DockerAuthConfigTest.php | 207 +++++++++++++++++ 3 files changed, 491 insertions(+), 4 deletions(-) create mode 100644 src/Utils/DockerAuthConfig.php create mode 100644 tests/Unit/Utils/DockerAuthConfigTest.php diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index 8d6a0dc..4ab93d3 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -24,6 +24,7 @@ use Testcontainers\Utils\TarBuilder; use Testcontainers\Wait\WaitForContainer; use Testcontainers\Wait\WaitStrategy; +use Testcontainers\Utils\DockerAuthConfig; class GenericContainer implements TestContainer { @@ -473,11 +474,33 @@ protected function createPortBindings(): array protected function pullImage(): void { [$fromImage, $tag] = explode(':', $this->image) + [1 => 'latest']; + + // Build headers for the request + $headers = []; + + // Try to get authentication for the registry + $authConfig = new DockerAuthConfig(); + $registry = DockerAuthConfig::getRegistryFromImage($fromImage); + $credentials = $authConfig->getAuthForRegistry($registry); + + if ($credentials !== null) { + // Docker expects the X-Registry-Auth header to be a base64-encoded JSON + $authData = [ + 'username' => $credentials['username'], + 'password' => $credentials['password'], + ]; + $headers['X-Registry-Auth'] = base64_encode(json_encode($authData, JSON_THROW_ON_ERROR)); + } + /** @var CreateImageStream $imageCreateResponse */ - $imageCreateResponse = $this->dockerClient->imageCreate(null, [ - 'fromImage' => $fromImage, - 'tag' => $tag, - ]); + $imageCreateResponse = $this->dockerClient->imageCreate( + null, + [ + 'fromImage' => $fromImage, + 'tag' => $tag, + ], + $headers + ); $imageCreateResponse->wait(); } } diff --git a/src/Utils/DockerAuthConfig.php b/src/Utils/DockerAuthConfig.php new file mode 100644 index 0000000..e817776 --- /dev/null +++ b/src/Utils/DockerAuthConfig.php @@ -0,0 +1,257 @@ + + */ + private array $auths = []; + + private ?string $credsStore = null; + + /** + * @var array + */ + private array $credHelpers = []; + + public function __construct() + { + $this->loadConfig(); + } + + /** + * Get authentication for a specific registry + * + * @return array{username: string, password: string}|null + */ + public function getAuthForRegistry(string $registry): ?array + { + // Normalize registry URL + $registry = $this->normalizeRegistry($registry); + + // Check if we have auth directly in config + if (isset($this->auths[$registry])) { + $auth = $this->auths[$registry]; + + // If auth string is present, decode it + if (isset($auth['auth'])) { + $decoded = base64_decode($auth['auth'], true); + if ($decoded === false) { + throw new RuntimeException('Invalid base64 auth string'); + } + + if (!str_contains($decoded, ':')) { + throw new RuntimeException('Invalid auth format'); + } + [$username, $password] = explode(':', $decoded, 2); + return ['username' => $username, 'password' => $password]; + } + + // If username/password are present directly + if (isset($auth['username']) && isset($auth['password'])) { + return ['username' => $auth['username'], 'password' => $auth['password']]; + } + } + + // Check credential helpers + if (isset($this->credHelpers[$registry])) { + return $this->getCredentialsFromHelper($this->credHelpers[$registry], $registry); + } + + // Check default credential store + if ($this->credsStore !== null) { + return $this->getCredentialsFromHelper($this->credsStore, $registry); + } + + return null; + } + + /** + * Load Docker configuration from environment or default paths + */ + private function loadConfig(): void + { + $configData = null; + + // First check DOCKER_AUTH_CONFIG environment variable + $envConfig = getenv('DOCKER_AUTH_CONFIG'); + if ($envConfig !== false && $envConfig !== '') { + $configData = json_decode($envConfig, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException('Invalid JSON in DOCKER_AUTH_CONFIG: ' . json_last_error_msg()); + } + } else { + // Try to load from default config files + foreach (self::DEFAULT_CONFIG_PATHS as $path) { + $expandedPath = str_replace('~', getenv('HOME') ?: '', $path); + if (file_exists($expandedPath)) { + $content = file_get_contents($expandedPath); + if ($content === false) { + continue; + } + + $configData = json_decode($content, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException("Invalid JSON in $expandedPath: " . json_last_error_msg()); + } + break; + } + } + } + + if (!is_array($configData)) { + return; + } + + // Parse the configuration + if (isset($configData['auths']) && is_array($configData['auths'])) { + /** @var array $auths */ + $auths = $configData['auths']; + $this->auths = $auths; + } + + if (isset($configData['credsStore']) && is_string($configData['credsStore'])) { + $this->credsStore = $configData['credsStore']; + } + + if (isset($configData['credHelpers']) && is_array($configData['credHelpers'])) { + /** @var array $credHelpers */ + $credHelpers = $configData['credHelpers']; + $this->credHelpers = $credHelpers; + } + } + + /** + * Get credentials from a credential helper + * + * @return array{username: string, password: string}|null + */ + private function getCredentialsFromHelper(string $helper, string $registry): ?array + { + $helperCommand = 'docker-credential-' . $helper; + + // Check if helper exists + $checkCommand = sprintf('command -v %s 2>/dev/null', escapeshellarg($helperCommand)); + $helperPath = trim(shell_exec($checkCommand) ?: ''); + + if (empty($helperPath)) { + return null; + } + + // Execute the credential helper + $descriptors = [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ]; + + $process = proc_open([$helperCommand, 'get'], $descriptors, $pipes); + + if (!is_resource($process)) { + throw new RuntimeException("Failed to execute credential helper: $helperCommand"); + } + + // Write the registry to stdin + fwrite($pipes[0], $registry); + fclose($pipes[0]); + + // Read the response + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + if ($exitCode !== 0) { + // Credentials not found is not an error + if ($stderr !== false && strpos($stderr, 'credentials not found') !== false) { + return null; + } + throw new RuntimeException("Credential helper failed: $stderr"); + } + + if ($stdout === false) { + return null; + } + + $credentials = json_decode($stdout, true); + if (!is_array($credentials) || json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException('Invalid JSON from credential helper: ' . json_last_error_msg()); + } + + if (!isset($credentials['Username']) || !isset($credentials['Secret']) || + !is_string($credentials['Username']) || !is_string($credentials['Secret'])) { + return null; + } + + return [ + 'username' => $credentials['Username'], + 'password' => $credentials['Secret'], + ]; + } + + /** + * Normalize registry URL to match Docker config format + */ + private function normalizeRegistry(string $registry): string + { + // Remove protocol if present + $normalized = preg_replace('#^https?://#', '', $registry); + + if ($normalized === null) { + $normalized = $registry; + } + + // Remove trailing slashes + $normalized = rtrim($normalized, '/'); + + // Docker Hub special case - normalize to the standard format + if ($normalized === 'docker.io' || $normalized === 'index.docker.io' || $normalized === 'registry-1.docker.io') { + return 'https://index.docker.io/v1/'; + } + + return $normalized; + } + + /** + * Get the registry from an image name + */ + public static function getRegistryFromImage(string $image): string + { + // First, check if image has a registry prefix by looking for slashes + $slashPos = strpos($image, '/'); + + if ($slashPos === false) { + // No slash means it's a Docker Hub official image + return 'docker.io'; + } + + // Extract the potential registry part (everything before the first slash) + $potentialRegistry = substr($image, 0, $slashPos); + + // Check if this looks like a registry: + // - Contains a dot (domain name) + // - Contains a colon (port specification) + // - Is 'localhost' (special case) + if (str_contains($potentialRegistry, '.') || + str_contains($potentialRegistry, ':') || + $potentialRegistry === 'localhost') { + return $potentialRegistry; + } + + // Otherwise, it's a Docker Hub image with a namespace + return 'docker.io'; + } +} diff --git a/tests/Unit/Utils/DockerAuthConfigTest.php b/tests/Unit/Utils/DockerAuthConfigTest.php new file mode 100644 index 0000000..bc8bb73 --- /dev/null +++ b/tests/Unit/Utils/DockerAuthConfigTest.php @@ -0,0 +1,207 @@ +originalHome = getenv('HOME') ?: ''; + $this->originalAuthConfig = getenv('DOCKER_AUTH_CONFIG') ?: null; + + // Clear the environment variable + putenv('DOCKER_AUTH_CONFIG'); + } + + protected function tearDown(): void + { + // Restore original values + putenv('HOME=' . $this->originalHome); + if ($this->originalAuthConfig !== null) { + putenv('DOCKER_AUTH_CONFIG=' . $this->originalAuthConfig); + } else { + putenv('DOCKER_AUTH_CONFIG'); + } + parent::tearDown(); + } + + public function testLoadConfigFromEnvironmentVariable(): void + { + $config = [ + 'auths' => [ + 'https://index.docker.io/v1/' => [ + 'auth' => base64_encode('user:pass'), + ], + ], + ]; + + putenv('DOCKER_AUTH_CONFIG=' . json_encode($config)); + + $authConfig = new DockerAuthConfig(); + $creds = $authConfig->getAuthForRegistry('docker.io'); + + $this->assertNotNull($creds); + $this->assertEquals('user', $creds['username']); + $this->assertEquals('pass', $creds['password']); + } + + public function testLoadConfigWithUsernamePassword(): void + { + $config = [ + 'auths' => [ + 'myregistry.com' => [ + 'username' => 'myuser', + 'password' => 'mypass', + ], + ], + ]; + + putenv('DOCKER_AUTH_CONFIG=' . json_encode($config)); + + $authConfig = new DockerAuthConfig(); + $creds = $authConfig->getAuthForRegistry('myregistry.com'); + + $this->assertNotNull($creds); + $this->assertEquals('myuser', $creds['username']); + $this->assertEquals('mypass', $creds['password']); + } + + public function testGetAuthForUnknownRegistry(): void + { + $config = [ + 'auths' => [ + 'docker.io' => [ + 'auth' => base64_encode('user:pass'), + ], + ], + ]; + + putenv('DOCKER_AUTH_CONFIG=' . json_encode($config)); + + $authConfig = new DockerAuthConfig(); + $creds = $authConfig->getAuthForRegistry('unknown.registry.com'); + + $this->assertNull($creds); + } + + public function testInvalidJsonInEnvironmentVariable(): void + { + putenv('DOCKER_AUTH_CONFIG=invalid json'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid JSON in DOCKER_AUTH_CONFIG'); + + new DockerAuthConfig(); + } + + public function testInvalidBase64Auth(): void + { + // Use a string that decodes to valid base64 but doesn't contain a colon + $config = [ + 'auths' => [ + 'https://index.docker.io/v1/' => [ + 'auth' => base64_encode('noColonInThis'), + ], + ], + ]; + + putenv('DOCKER_AUTH_CONFIG=' . json_encode($config)); + + $authConfig = new DockerAuthConfig(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid auth format'); + + $authConfig->getAuthForRegistry('docker.io'); + } + + public function testGetRegistryFromImage(): void + { + // Test various image formats + $this->assertEquals('docker.io', DockerAuthConfig::getRegistryFromImage('ubuntu')); + $this->assertEquals('docker.io', DockerAuthConfig::getRegistryFromImage('library/ubuntu')); + $this->assertEquals('docker.io', DockerAuthConfig::getRegistryFromImage('ubuntu:20.04')); + $this->assertEquals('ghcr.io', DockerAuthConfig::getRegistryFromImage('ghcr.io/owner/repo')); + $this->assertEquals('ghcr.io', DockerAuthConfig::getRegistryFromImage('ghcr.io/owner/repo:tag')); + $this->assertEquals('myregistry.com', DockerAuthConfig::getRegistryFromImage('myregistry.com/image')); + $this->assertEquals('localhost:5000', DockerAuthConfig::getRegistryFromImage('localhost:5000/image')); + } + + public function testNormalizeRegistry(): void + { + $config = [ + 'auths' => [ + 'https://index.docker.io/v1/' => [ + 'auth' => base64_encode('user:pass'), + ], + ], + ]; + + putenv('DOCKER_AUTH_CONFIG=' . json_encode($config)); + + $authConfig = new DockerAuthConfig(); + + // All these should resolve to the same Docker Hub entry + $registries = ['docker.io', 'index.docker.io', 'registry-1.docker.io']; + + foreach ($registries as $registry) { + $creds = $authConfig->getAuthForRegistry($registry); + $this->assertNotNull($creds, "Failed to get auth for $registry"); + $this->assertEquals('user', $creds['username']); + $this->assertEquals('pass', $creds['password']); + } + } + + public function testEmptyConfig(): void + { + putenv('DOCKER_AUTH_CONFIG={}'); + + $authConfig = new DockerAuthConfig(); + $creds = $authConfig->getAuthForRegistry('docker.io'); + + $this->assertNull($creds); + } + + public function testLoadConfigFromFile(): void + { + // Create a temporary directory for testing + $tempDir = sys_get_temp_dir() . '/testcontainers_test_' . uniqid(); + mkdir($tempDir); + mkdir($tempDir . '/.docker'); + + $config = [ + 'auths' => [ + 'fileregistry.com' => [ + 'auth' => base64_encode('fileuser:filepass'), + ], + ], + ]; + + file_put_contents($tempDir . '/.docker/config.json', json_encode($config)); + + // Set HOME to our temp directory + putenv('HOME=' . $tempDir); + + $authConfig = new DockerAuthConfig(); + $creds = $authConfig->getAuthForRegistry('fileregistry.com'); + + $this->assertNotNull($creds); + $this->assertEquals('fileuser', $creds['username']); + $this->assertEquals('filepass', $creds['password']); + + // Cleanup + unlink($tempDir . '/.docker/config.json'); + rmdir($tempDir . '/.docker'); + rmdir($tempDir); + } +} From 35ab91dfb1435ba7d8bbc4d80ac380177f6a5276 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Sun, 25 May 2025 12:04:58 +0200 Subject: [PATCH 2/3] refactor: use JSON_THROW_ON_ERROR for all JSON operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace manual json_last_error() checks with JSON_THROW_ON_ERROR - Wrap json_decode calls in try-catch blocks - Provide better error context with JsonException - Maintain backward compatibility with error messages 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/Utils/DockerAuthConfig.php | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/Utils/DockerAuthConfig.php b/src/Utils/DockerAuthConfig.php index e817776..6ba9522 100644 --- a/src/Utils/DockerAuthConfig.php +++ b/src/Utils/DockerAuthConfig.php @@ -5,6 +5,7 @@ namespace Testcontainers\Utils; use RuntimeException; +use JsonException; class DockerAuthConfig { @@ -87,9 +88,10 @@ private function loadConfig(): void // First check DOCKER_AUTH_CONFIG environment variable $envConfig = getenv('DOCKER_AUTH_CONFIG'); if ($envConfig !== false && $envConfig !== '') { - $configData = json_decode($envConfig, true); - if (json_last_error() !== JSON_ERROR_NONE) { - throw new RuntimeException('Invalid JSON in DOCKER_AUTH_CONFIG: ' . json_last_error_msg()); + try { + $configData = json_decode($envConfig, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new RuntimeException('Invalid JSON in DOCKER_AUTH_CONFIG: ' . $e->getMessage(), 0, $e); } } else { // Try to load from default config files @@ -101,9 +103,10 @@ private function loadConfig(): void continue; } - $configData = json_decode($content, true); - if (json_last_error() !== JSON_ERROR_NONE) { - throw new RuntimeException("Invalid JSON in $expandedPath: " . json_last_error_msg()); + try { + $configData = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new RuntimeException("Invalid JSON in $expandedPath: " . $e->getMessage(), 0, $e); } break; } @@ -186,9 +189,14 @@ private function getCredentialsFromHelper(string $helper, string $registry): ?ar return null; } - $credentials = json_decode($stdout, true); - if (!is_array($credentials) || json_last_error() !== JSON_ERROR_NONE) { - throw new RuntimeException('Invalid JSON from credential helper: ' . json_last_error_msg()); + try { + $credentials = json_decode($stdout, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new RuntimeException('Invalid JSON from credential helper: ' . $e->getMessage(), 0, $e); + } + + if (!is_array($credentials)) { + throw new RuntimeException('Credential helper returned invalid response'); } if (!isset($credentials['Username']) || !isset($credentials['Secret']) || From 9749a202e9f9d817dd2496c098755bc064d18998 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Sat, 27 Dec 2025 08:55:38 +0100 Subject: [PATCH 3/3] fix: cleanup code --- .php-cs-fixer.dist.php | 5 -- composer.json | 2 +- src/Container/GenericContainer.php | 3 +- src/Container/StartedGenericContainer.php | 2 +- src/Utils/DockerAuthConfig.php | 59 +++++++++++------------ tests/Unit/Utils/DockerAuthConfigTest.php | 3 ++ 6 files changed, 33 insertions(+), 41 deletions(-) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index ee6b594..49f210a 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,9 +1,4 @@ setRules([ diff --git a/composer.json b/composer.json index 01d13a1..f9b0bc5 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "ext-pdo_pgsql": "*", "phpunit/phpunit": "^9.5", "brianium/paratest": "^6.11", - "friendsofphp/php-cs-fixer": "^3.12", + "friendsofphp/php-cs-fixer": "^3.92", "phpstan/phpstan": "^1.8", "phpstan/phpstan-phpunit": "^1.1", "phpstan/extension-installer": "^1.2", diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index 4ab93d3..da3be02 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -479,9 +479,8 @@ protected function pullImage(): void $headers = []; // Try to get authentication for the registry - $authConfig = new DockerAuthConfig(); $registry = DockerAuthConfig::getRegistryFromImage($fromImage); - $credentials = $authConfig->getAuthForRegistry($registry); + $credentials = DockerAuthConfig::getInstance()->getAuthForRegistry($registry); if ($credentials !== null) { // Docker expects the X-Registry-Auth header to be a base64-encoded JSON diff --git a/src/Container/StartedGenericContainer.php b/src/Container/StartedGenericContainer.php index 13493dd..5ae3f82 100644 --- a/src/Container/StartedGenericContainer.php +++ b/src/Container/StartedGenericContainer.php @@ -99,7 +99,7 @@ public function logs(): string ->getContents() ?? ''; $converted = mb_convert_encoding($output, 'UTF-8', 'UTF-8'); - return $this->sanitizeOutput($converted === false ? $output : $converted); + return $this->sanitizeOutput($converted == false ? $output : $converted); } public function getHost(): string diff --git a/src/Utils/DockerAuthConfig.php b/src/Utils/DockerAuthConfig.php index 6ba9522..1f4a342 100644 --- a/src/Utils/DockerAuthConfig.php +++ b/src/Utils/DockerAuthConfig.php @@ -14,6 +14,8 @@ class DockerAuthConfig '/etc/docker/config.json', ]; + private static ?self $instance = null; + /** * @var array */ @@ -31,6 +33,27 @@ public function __construct() $this->loadConfig(); } + /** + * Get the singleton instance of DockerAuthConfig. + * This avoids re-reading config files/environment on every image pull. + */ + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Reset the singleton instance (useful for testing). + */ + public static function resetInstance(): void + { + self::$instance = null; + } + /** * Get authentication for a specific registry * @@ -38,14 +61,11 @@ public function __construct() */ public function getAuthForRegistry(string $registry): ?array { - // Normalize registry URL $registry = $this->normalizeRegistry($registry); - // Check if we have auth directly in config if (isset($this->auths[$registry])) { $auth = $this->auths[$registry]; - // If auth string is present, decode it if (isset($auth['auth'])) { $decoded = base64_decode($auth['auth'], true); if ($decoded === false) { @@ -59,18 +79,15 @@ public function getAuthForRegistry(string $registry): ?array return ['username' => $username, 'password' => $password]; } - // If username/password are present directly if (isset($auth['username']) && isset($auth['password'])) { return ['username' => $auth['username'], 'password' => $auth['password']]; } } - // Check credential helpers if (isset($this->credHelpers[$registry])) { return $this->getCredentialsFromHelper($this->credHelpers[$registry], $registry); } - // Check default credential store if ($this->credsStore !== null) { return $this->getCredentialsFromHelper($this->credsStore, $registry); } @@ -85,7 +102,6 @@ private function loadConfig(): void { $configData = null; - // First check DOCKER_AUTH_CONFIG environment variable $envConfig = getenv('DOCKER_AUTH_CONFIG'); if ($envConfig !== false && $envConfig !== '') { try { @@ -94,7 +110,6 @@ private function loadConfig(): void throw new RuntimeException('Invalid JSON in DOCKER_AUTH_CONFIG: ' . $e->getMessage(), 0, $e); } } else { - // Try to load from default config files foreach (self::DEFAULT_CONFIG_PATHS as $path) { $expandedPath = str_replace('~', getenv('HOME') ?: '', $path); if (file_exists($expandedPath)) { @@ -117,7 +132,6 @@ private function loadConfig(): void return; } - // Parse the configuration if (isset($configData['auths']) && is_array($configData['auths'])) { /** @var array $auths */ $auths = $configData['auths']; @@ -144,7 +158,6 @@ private function getCredentialsFromHelper(string $helper, string $registry): ?ar { $helperCommand = 'docker-credential-' . $helper; - // Check if helper exists $checkCommand = sprintf('command -v %s 2>/dev/null', escapeshellarg($helperCommand)); $helperPath = trim(shell_exec($checkCommand) ?: ''); @@ -152,11 +165,10 @@ private function getCredentialsFromHelper(string $helper, string $registry): ?ar return null; } - // Execute the credential helper $descriptors = [ - 0 => ['pipe', 'r'], // stdin - 1 => ['pipe', 'w'], // stdout - 2 => ['pipe', 'w'], // stderr + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], ]; $process = proc_open([$helperCommand, 'get'], $descriptors, $pipes); @@ -165,11 +177,9 @@ private function getCredentialsFromHelper(string $helper, string $registry): ?ar throw new RuntimeException("Failed to execute credential helper: $helperCommand"); } - // Write the registry to stdin fwrite($pipes[0], $registry); fclose($pipes[0]); - // Read the response $stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]); fclose($pipes[1]); @@ -178,7 +188,6 @@ private function getCredentialsFromHelper(string $helper, string $registry): ?ar $exitCode = proc_close($process); if ($exitCode !== 0) { - // Credentials not found is not an error if ($stderr !== false && strpos($stderr, 'credentials not found') !== false) { return null; } @@ -194,7 +203,7 @@ private function getCredentialsFromHelper(string $helper, string $registry): ?ar } catch (JsonException $e) { throw new RuntimeException('Invalid JSON from credential helper: ' . $e->getMessage(), 0, $e); } - + if (!is_array($credentials)) { throw new RuntimeException('Credential helper returned invalid response'); } @@ -215,17 +224,14 @@ private function getCredentialsFromHelper(string $helper, string $registry): ?ar */ private function normalizeRegistry(string $registry): string { - // Remove protocol if present $normalized = preg_replace('#^https?://#', '', $registry); if ($normalized === null) { $normalized = $registry; } - // Remove trailing slashes $normalized = rtrim($normalized, '/'); - // Docker Hub special case - normalize to the standard format if ($normalized === 'docker.io' || $normalized === 'index.docker.io' || $normalized === 'registry-1.docker.io') { return 'https://index.docker.io/v1/'; } @@ -233,33 +239,22 @@ private function normalizeRegistry(string $registry): string return $normalized; } - /** - * Get the registry from an image name - */ public static function getRegistryFromImage(string $image): string { - // First, check if image has a registry prefix by looking for slashes $slashPos = strpos($image, '/'); if ($slashPos === false) { - // No slash means it's a Docker Hub official image return 'docker.io'; } - // Extract the potential registry part (everything before the first slash) $potentialRegistry = substr($image, 0, $slashPos); - // Check if this looks like a registry: - // - Contains a dot (domain name) - // - Contains a colon (port specification) - // - Is 'localhost' (special case) if (str_contains($potentialRegistry, '.') || str_contains($potentialRegistry, ':') || $potentialRegistry === 'localhost') { return $potentialRegistry; } - // Otherwise, it's a Docker Hub image with a namespace return 'docker.io'; } } diff --git a/tests/Unit/Utils/DockerAuthConfigTest.php b/tests/Unit/Utils/DockerAuthConfigTest.php index bc8bb73..5bc8aa2 100644 --- a/tests/Unit/Utils/DockerAuthConfigTest.php +++ b/tests/Unit/Utils/DockerAuthConfigTest.php @@ -25,6 +25,9 @@ protected function setUp(): void protected function tearDown(): void { + // Reset singleton instance to avoid test interference + DockerAuthConfig::resetInstance(); + // Restore original values putenv('HOME=' . $this->originalHome); if ($this->originalAuthConfig !== null) {