diff --git a/src/bridge/phpunit/telemetry/src/Flow/Bridge/PHPUnit/Telemetry/TelemetryFactory.php b/src/bridge/phpunit/telemetry/src/Flow/Bridge/PHPUnit/Telemetry/TelemetryFactory.php index b4803a19c..c6d63a464 100644 --- a/src/bridge/phpunit/telemetry/src/Flow/Bridge/PHPUnit/Telemetry/TelemetryFactory.php +++ b/src/bridge/phpunit/telemetry/src/Flow/Bridge/PHPUnit/Telemetry/TelemetryFactory.php @@ -25,10 +25,15 @@ use function Flow\Telemetry\DSL\batching_log_processor; use function Flow\Telemetry\DSL\batching_metric_processor; use function Flow\Telemetry\DSL\batching_span_processor; +use function Flow\Telemetry\DSL\composer_detector; +use function Flow\Telemetry\DSL\environment_detector; use function Flow\Telemetry\DSL\git_detector; +use function Flow\Telemetry\DSL\host_detector; use function Flow\Telemetry\DSL\logger_provider; use function Flow\Telemetry\DSL\memory_context_storage; use function Flow\Telemetry\DSL\meter_provider; +use function Flow\Telemetry\DSL\os_detector; +use function Flow\Telemetry\DSL\process_detector; use function Flow\Telemetry\DSL\resource; use function Flow\Telemetry\DSL\resource_detector; use function Flow\Telemetry\DSL\telemetry; @@ -39,9 +44,15 @@ final class TelemetryFactory { public static function create(Configuration $config): Telemetry { - $telemetryResource = resource_detector() + $telemetryResource = resource_detector([ + os_detector(), + host_detector(), + process_detector(), + composer_detector(), + environment_detector(), + git_detector(), + ]) ->detect() - ->merge(git_detector()->detect()) ->merge(resource([ 'service.name' => $config->serviceName, 'telemetry.sdk.name' => 'flow-php-phpunit-telemetry', diff --git a/src/lib/telemetry/src/Flow/Telemetry/Resource/Detector/GitDetector.php b/src/lib/telemetry/src/Flow/Telemetry/Resource/Detector/GitDetector.php index a1df30fca..f39c5f838 100644 --- a/src/lib/telemetry/src/Flow/Telemetry/Resource/Detector/GitDetector.php +++ b/src/lib/telemetry/src/Flow/Telemetry/Resource/Detector/GitDetector.php @@ -9,11 +9,17 @@ use Flow\Telemetry\Resource\ResourceDetector; use function fclose; +use function feof; +use function fread; +use function getenv; use function is_resource; -use function is_string; +use function microtime; use function proc_close; use function proc_open; -use function stream_get_contents; +use function proc_terminate; +use function stream_select; +use function stream_set_blocking; +use function strlen; use function trim; /** @@ -39,6 +45,10 @@ */ final readonly class GitDetector implements ResourceDetector { + private const MAX_OUTPUT_BYTES = 1_048_576; + + private const TIMEOUT_SECONDS = 10.0; + private RemoteUrlSanitizer $remoteUrlSanitizer; public function __construct( @@ -108,8 +118,12 @@ private function runGit(array $arguments): ?string 2 => ['pipe', 'w'], ]; + $environment = getenv(); + // Never let git block waiting for an interactive credential prompt. + $environment['GIT_TERMINAL_PROMPT'] = '0'; + $pipes = []; - $process = @proc_open($command, $descriptors, $pipes); + $process = @proc_open($command, $descriptors, $pipes, null, $environment); if (!is_resource($process)) { return null; @@ -123,21 +137,83 @@ private function runGit(array $arguments): ?string fclose($stdin); } - $output = is_resource($stdout) ? stream_get_contents($stdout) : null; + foreach ([$stdout, $stderr] as $pipe) { + if (is_resource($pipe)) { + stream_set_blocking($pipe, false); + } + } + + $output = ''; + $deadline = microtime(true) + self::TIMEOUT_SECONDS; + $timedOut = false; + + while (is_resource($stdout) || is_resource($stderr)) { + $remaining = $deadline - microtime(true); + + if ($remaining <= 0) { + $timedOut = true; + + break; + } + + $read = []; - if (is_resource($stdout)) { - fclose($stdout); + if (is_resource($stdout)) { + $read[] = $stdout; + } + + if (is_resource($stderr)) { + $read[] = $stderr; + } + + $write = []; + $except = []; + $seconds = (int) $remaining; + + if ( + @stream_select($read, $write, $except, $seconds, (int) (($remaining - $seconds) * 1_000_000)) === false + ) { + break; + } + + foreach ($read as $pipe) { + $chunk = fread($pipe, 8192); + + // Drain stderr without retaining it; cap stdout so a hostile config cannot exhaust memory. + if ( + $chunk !== false + && $chunk !== '' + && $pipe === $stdout + && strlen($output) < self::MAX_OUTPUT_BYTES + ) { + $output .= $chunk; + } + + if ($chunk === false || feof($pipe)) { + fclose($pipe); + + if ($pipe === $stdout) { + $stdout = null; + } elseif ($pipe === $stderr) { + $stderr = null; + } + } + } } - // Drain and discard stderr so the child never blocks on a full pipe. - if (is_resource($stderr)) { - stream_get_contents($stderr); - fclose($stderr); + if ($timedOut) { + proc_terminate($process, 9); + } + + foreach ([$stdout, $stderr] as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } } proc_close($process); - if (!is_string($output)) { + if ($timedOut) { return null; } diff --git a/src/lib/telemetry/src/Flow/Telemetry/Resource/Detector/RemoteUrlSanitizer.php b/src/lib/telemetry/src/Flow/Telemetry/Resource/Detector/RemoteUrlSanitizer.php index 823efa664..5a46ecb75 100644 --- a/src/lib/telemetry/src/Flow/Telemetry/Resource/Detector/RemoteUrlSanitizer.php +++ b/src/lib/telemetry/src/Flow/Telemetry/Resource/Detector/RemoteUrlSanitizer.php @@ -8,8 +8,10 @@ use function parse_url; /** - * Removes any embedded credentials (user:password@) from a remote URL so they - * are never reported as a resource attribute. + * Reduces a remote URL to its scheme, host, optional port and path so that no + * secrets are ever reported as a resource attribute. Embedded credentials + * (user:password@) are removed, and any query string or fragment is dropped as + * either can carry an access token. * * SCP-like SSH remotes (e.g. "git@github.com:org/repo.git") carry no secret and * are returned untouched, as are URLs that cannot be parsed. @@ -24,26 +26,12 @@ public function sanitize(string $url): string return $url; } - if (!isset($parts['user']) && !isset($parts['pass'])) { - return $url; - } - $sanitized = $parts['scheme'] . '://' . $parts['host']; if (isset($parts['port'])) { $sanitized .= ':' . $parts['port']; } - $sanitized .= $parts['path'] ?? ''; - - if (isset($parts['query'])) { - $sanitized .= '?' . $parts['query']; - } - - if (isset($parts['fragment'])) { - $sanitized .= '#' . $parts['fragment']; - } - - return $sanitized; + return $sanitized . ($parts['path'] ?? ''); } } diff --git a/src/lib/telemetry/tests/Flow/Telemetry/Tests/Integration/Resource/Detector/GitRepositoryHelper.php b/src/lib/telemetry/tests/Flow/Telemetry/Tests/Context/GitContext.php similarity index 81% rename from src/lib/telemetry/tests/Flow/Telemetry/Tests/Integration/Resource/Detector/GitRepositoryHelper.php rename to src/lib/telemetry/tests/Flow/Telemetry/Tests/Context/GitContext.php index c58e11bbe..8a4f0027c 100644 --- a/src/lib/telemetry/tests/Flow/Telemetry/Tests/Integration/Resource/Detector/GitRepositoryHelper.php +++ b/src/lib/telemetry/tests/Flow/Telemetry/Tests/Context/GitContext.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Flow\Telemetry\Tests\Integration\Resource\Detector; +namespace Flow\Telemetry\Tests\Context; use FilesystemIterator; +use PHPUnit\Framework\TestCase; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; -use RuntimeException; use SplFileInfo; use function explode; @@ -21,6 +21,7 @@ use function proc_open; use function rmdir; use function stream_get_contents; +use function sys_get_temp_dir; use function uniqid; use function unlink; @@ -31,11 +32,14 @@ * Provides Git working copies for tests by shallow-cloning a small, public * fixture repository. */ -final class GitRepositoryHelper +final class GitContext { - public const REPOSITORY_URL = 'https://github.com/flow-php/phpstan-types-bridge.git'; public const BRANCH = '1.x'; + + public const REPOSITORY_URL = 'https://github.com/flow-php/phpstan-types-bridge.git'; + public const TAG = '0.39.0'; + public const TAG_REVISION = '4d135d4eff0d7895ad2d07737484474936fb5200'; private static bool $repositoryUnavailable = false; @@ -45,51 +49,22 @@ final class GitRepositoryHelper */ private array $directories = []; - public function gitBinaryExists(): bool - { - return $this->runGit(['--version']); - } - - public function resolveGitBinaryPath(): ?string + public function cleanup(): void { - $path = getenv('PATH'); - - if ($path === false) { - return null; - } - - foreach (explode(PATH_SEPARATOR, $path) as $directory) { - if ($directory === '') { - continue; - } - - $candidate = $directory . DIRECTORY_SEPARATOR . 'git'; - - if (is_file($candidate) && is_executable($candidate)) { - return $candidate; - } + foreach ($this->directories as $directory) { + $this->removeDirectory($directory); } - return null; + $this->directories = []; } - /** - * Shallow-clones the fixture repository at the given branch or tag. Cloning - * a tag leaves HEAD detached. - * - * `--no-tags` is required: the fixture repository points several release tags - * at the same commit, so auto-following tags would let `git describe - * --exact-match` resolve a different tag than the one requested. - * - * @throws RuntimeException when the repository cannot be cloned (e.g. no network access) - */ public function cloneRepository(string $ref): string { if (self::$repositoryUnavailable) { - throw new RuntimeException('Fixture repository ' . self::REPOSITORY_URL . ' is unavailable'); + TestCase::markTestSkipped('Unable to clone the fixture repository ' . self::REPOSITORY_URL); } - $directory = __DIR__ . '/var/flow_telemetry_git_' . uniqid(); + $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'flow_telemetry_git_' . uniqid(); $this->directories[] = $directory; if (!$this->runGit([ @@ -105,15 +80,12 @@ public function cloneRepository(string $ref): string ])) { self::$repositoryUnavailable = true; - throw new RuntimeException('Unable to clone fixture repository ' . self::REPOSITORY_URL); + TestCase::markTestSkipped('Unable to clone the fixture repository ' . self::REPOSITORY_URL); } return $directory; } - /** - * @throws RuntimeException when the repository cannot be cloned (e.g. no network access) - */ public function cloneRepositoryWithRemote(string $remoteUrl): string { $directory = $this->cloneRepository(self::BRANCH); @@ -123,13 +95,58 @@ public function cloneRepositoryWithRemote(string $remoteUrl): string return $directory; } - public function cleanup(): void + public function gitBinaryExists(): bool { - foreach ($this->directories as $directory) { - $this->removeDirectory($directory); + return $this->runGit(['--version']); + } + + public function resolveGitBinaryPath(): ?string + { + $path = getenv('PATH'); + + if ($path === false) { + return null; } - $this->directories = []; + foreach (explode(PATH_SEPARATOR, $path) as $directory) { + if ($directory === '') { + continue; + } + + $candidate = $directory . DIRECTORY_SEPARATOR . 'git'; + + if (is_file($candidate) && is_executable($candidate)) { + return $candidate; + } + } + + return null; + } + + private function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $items = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($items as $item) { + if (!$item instanceof SplFileInfo) { + continue; + } + + if ($item->isDir()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + + rmdir($directory); } /** @@ -177,30 +194,4 @@ private function runGit(array $arguments): bool return proc_close($process) === 0; } - - private function removeDirectory(string $directory): void - { - if (!is_dir($directory)) { - return; - } - - $items = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::CHILD_FIRST, - ); - - foreach ($items as $item) { - if (!$item instanceof SplFileInfo) { - continue; - } - - if ($item->isDir()) { - rmdir($item->getPathname()); - } else { - unlink($item->getPathname()); - } - } - - rmdir($directory); - } } diff --git a/src/lib/telemetry/tests/Flow/Telemetry/Tests/Integration/GitTestCase.php b/src/lib/telemetry/tests/Flow/Telemetry/Tests/Integration/GitTestCase.php new file mode 100644 index 000000000..3dc294aab --- /dev/null +++ b/src/lib/telemetry/tests/Flow/Telemetry/Tests/Integration/GitTestCase.php @@ -0,0 +1,27 @@ +gitContext = new GitContext(); + + if (!$this->gitContext->gitBinaryExists()) { + static::markTestSkipped('Git binary is unavailable'); + } + } + + protected function tearDown(): void + { + $this->gitContext->cleanup(); + } +} diff --git a/src/lib/telemetry/tests/Flow/Telemetry/Tests/Integration/Resource/Detector/GitDetectorTest.php b/src/lib/telemetry/tests/Flow/Telemetry/Tests/Integration/Resource/Detector/GitDetectorTest.php index 598400c7b..a6e82f9fc 100644 --- a/src/lib/telemetry/tests/Flow/Telemetry/Tests/Integration/Resource/Detector/GitDetectorTest.php +++ b/src/lib/telemetry/tests/Flow/Telemetry/Tests/Integration/Resource/Detector/GitDetectorTest.php @@ -6,32 +6,41 @@ use Flow\Telemetry\Resource\Attribute\VcsAttribute; use Flow\Telemetry\Resource\Detector\GitDetector; -use PHPUnit\Framework\TestCase; -use RuntimeException; +use Flow\Telemetry\Tests\Context\GitContext; +use Flow\Telemetry\Tests\Integration\GitTestCase; +use function sys_get_temp_dir; use function uniqid; -final class GitDetectorTest extends TestCase -{ - private GitRepositoryHelper $gitRepositoryHelper; +use const DIRECTORY_SEPARATOR; - protected function setUp(): void +final class GitDetectorTest extends GitTestCase +{ + public function test_detect_keeps_scp_like_ssh_remote_untouched(): void { - $this->gitRepositoryHelper = new GitRepositoryHelper(); + $directory = $this->gitContext->cloneRepositoryWithRemote('git@github.com:flow-php/phpstan-types-bridge.git'); - if (!$this->gitRepositoryHelper->gitBinaryExists()) { - static::markTestSkipped('Git binary is unavailable'); - } + $resource = (new GitDetector($directory))->detect(); + + static::assertSame( + 'git@github.com:flow-php/phpstan-types-bridge.git', + $resource->get(VcsAttribute::REPOSITORY_URL->value), + ); } - protected function tearDown(): void + public function test_detect_returns_branch_name_and_type_when_not_detached(): void { - $this->gitRepositoryHelper->cleanup(); + $directory = $this->gitContext->cloneRepository(GitContext::BRANCH); + + $resource = (new GitDetector($directory))->detect(); + + static::assertSame(GitContext::BRANCH, $resource->get(VcsAttribute::REF_HEAD_NAME->value)); + static::assertSame('branch', $resource->get(VcsAttribute::REF_HEAD_TYPE->value)); } public function test_detect_returns_empty_resource_outside_a_work_tree(): void { - $directory = __DIR__ . '/var/non_existing_' . uniqid(); + $directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'flow_telemetry_git_non_existing_' . uniqid(); $resource = (new GitDetector($directory))->detect(); @@ -40,7 +49,7 @@ public function test_detect_returns_empty_resource_outside_a_work_tree(): void public function test_detect_returns_head_revision(): void { - $directory = $this->cloneRepository(GitRepositoryHelper::BRANCH); + $directory = $this->gitContext->cloneRepository(GitContext::BRANCH); $resource = (new GitDetector($directory))->detect(); @@ -50,39 +59,29 @@ public function test_detect_returns_head_revision(): void static::assertMatchesRegularExpression('/^[0-9a-f]{40}$/', $revision); } - public function test_detect_returns_branch_name_and_type_when_not_detached(): void + public function test_detect_returns_repository_url(): void { - $directory = $this->cloneRepository(GitRepositoryHelper::BRANCH); + $directory = $this->gitContext->cloneRepository(GitContext::BRANCH); $resource = (new GitDetector($directory))->detect(); - static::assertSame(GitRepositoryHelper::BRANCH, $resource->get(VcsAttribute::REF_HEAD_NAME->value)); - static::assertSame('branch', $resource->get(VcsAttribute::REF_HEAD_TYPE->value)); + static::assertSame(GitContext::REPOSITORY_URL, $resource->get(VcsAttribute::REPOSITORY_URL->value)); } public function test_detect_returns_tag_name_and_type_in_detached_head(): void { - $directory = $this->cloneRepository(GitRepositoryHelper::TAG); + $directory = $this->gitContext->cloneRepository(GitContext::TAG); $resource = (new GitDetector($directory))->detect(); - static::assertSame(GitRepositoryHelper::TAG, $resource->get(VcsAttribute::REF_HEAD_NAME->value)); + static::assertSame(GitContext::TAG, $resource->get(VcsAttribute::REF_HEAD_NAME->value)); static::assertSame('tag', $resource->get(VcsAttribute::REF_HEAD_TYPE->value)); - static::assertSame(GitRepositoryHelper::TAG_REVISION, $resource->get(VcsAttribute::REF_HEAD_REVISION->value)); - } - - public function test_detect_returns_repository_url(): void - { - $directory = $this->cloneRepository(GitRepositoryHelper::BRANCH); - - $resource = (new GitDetector($directory))->detect(); - - static::assertSame(GitRepositoryHelper::REPOSITORY_URL, $resource->get(VcsAttribute::REPOSITORY_URL->value)); + static::assertSame(GitContext::TAG_REVISION, $resource->get(VcsAttribute::REF_HEAD_REVISION->value)); } public function test_detect_strips_credentials_from_repository_url(): void { - $directory = $this->cloneRepositoryWithRemote( + $directory = $this->gitContext->cloneRepositoryWithRemote( 'https://user:secret@github.com/flow-php/phpstan-types-bridge.git', ); @@ -94,48 +93,18 @@ public function test_detect_strips_credentials_from_repository_url(): void ); } - public function test_detect_keeps_scp_like_ssh_remote_untouched(): void - { - $directory = $this->cloneRepositoryWithRemote('git@github.com:flow-php/phpstan-types-bridge.git'); - - $resource = (new GitDetector($directory))->detect(); - - static::assertSame( - 'git@github.com:flow-php/phpstan-types-bridge.git', - $resource->get(VcsAttribute::REPOSITORY_URL->value), - ); - } - public function test_detect_uses_explicit_git_binary_path(): void { - $gitBinary = $this->gitRepositoryHelper->resolveGitBinaryPath(); + $gitBinary = $this->gitContext->resolveGitBinaryPath(); if ($gitBinary === null) { static::markTestSkipped('Unable to resolve an absolute git binary path'); } - $directory = $this->cloneRepository(GitRepositoryHelper::BRANCH); + $directory = $this->gitContext->cloneRepository(GitContext::BRANCH); $resource = (new GitDetector($directory, $gitBinary))->detect(); - static::assertSame(GitRepositoryHelper::BRANCH, $resource->get(VcsAttribute::REF_HEAD_NAME->value)); - } - - private function cloneRepository(string $ref): string - { - try { - return $this->gitRepositoryHelper->cloneRepository($ref); - } catch (RuntimeException) { - static::markTestSkipped('Unable to clone the fixture repository ' . GitRepositoryHelper::REPOSITORY_URL); - } - } - - private function cloneRepositoryWithRemote(string $remoteUrl): string - { - try { - return $this->gitRepositoryHelper->cloneRepositoryWithRemote($remoteUrl); - } catch (RuntimeException) { - static::markTestSkipped('Unable to clone the fixture repository ' . GitRepositoryHelper::REPOSITORY_URL); - } + static::assertSame(GitContext::BRANCH, $resource->get(VcsAttribute::REF_HEAD_NAME->value)); } } diff --git a/src/lib/telemetry/tests/Flow/Telemetry/Tests/Unit/Resource/Detector/RemoteUrlSanitizerTest.php b/src/lib/telemetry/tests/Flow/Telemetry/Tests/Unit/Resource/Detector/RemoteUrlSanitizerTest.php index b2cf3ed69..3543ec37b 100644 --- a/src/lib/telemetry/tests/Flow/Telemetry/Tests/Unit/Resource/Detector/RemoteUrlSanitizerTest.php +++ b/src/lib/telemetry/tests/Flow/Telemetry/Tests/Unit/Resource/Detector/RemoteUrlSanitizerTest.php @@ -23,9 +23,14 @@ public static function provideUrls(): Generator 'https://github.com/flow-php/repo.git', ]; - yield 'preserves port, path, query and fragment while stripping credentials' => [ + yield 'preserves port and path while stripping credentials, query and fragment' => [ 'https://user:secret@github.com:8443/flow-php/repo.git?ref=main#readme', - 'https://github.com:8443/flow-php/repo.git?ref=main#readme', + 'https://github.com:8443/flow-php/repo.git', + ]; + + yield 'strips query and fragment from a credential-free url' => [ + 'https://github.com/flow-php/repo.git?token=secret#section', + 'https://github.com/flow-php/repo.git', ]; yield 'leaves scp-like ssh remote untouched' => [