diff --git a/src/Container/GenericContainer.php b/src/Container/GenericContainer.php index ea85e7d..8afa2b1 100644 --- a/src/Container/GenericContainer.php +++ b/src/Container/GenericContainer.php @@ -16,10 +16,12 @@ use Docker\API\Model\PortBinding; use Docker\Docker; use Docker\Stream\CreateImageStream; use InvalidArgumentException; +use RuntimeException; use Testcontainers\ContainerClient\DockerContainerClient; use Testcontainers\Utils\PortGenerator\PortGenerator; use Testcontainers\Utils\PortGenerator\RandomUniquePortGenerator; use Testcontainers\Utils\PortNormalizer; +use Testcontainers\Utils\TarBuilder; use Testcontainers\Wait\WaitForContainer; use Testcontainers\Wait\WaitStrategy; @@ -58,8 +60,28 @@ class GenericContainer implements TestContainer protected PortGenerator $portGenerator; protected bool $isPrivileged = false; + protected ?string $networkName = null; + protected ?string $user = null; + + protected ?string $workingDir = null; + + /** + * @var array + */ + protected array $filesToCopy = []; + + /** + * @var array + */ + protected array $directoriesToCopy = []; + + /** + * @var array + */ + protected array $contentsToCopy = []; + protected int $startAttempts = 0; protected const MAX_START_ATTEMPTS = 2; @@ -94,6 +116,39 @@ class GenericContainer implements TestContainer return $this; } + /** + * @param array $files + */ + public function withCopyFilesToContainer(array $files): static + { + foreach ($files as $file) { + $this->filesToCopy[] = $file; + } + return $this; + } + + /** + * @param array $directories + */ + public function withCopyDirectoriesToContainer(array $directories): static + { + foreach ($directories as $directory) { + $this->directoriesToCopy[] = $directory; + } + return $this; + } + + /** + * @param array $contents + */ + public function withCopyContentToContainer(array $contents): static + { + foreach ($contents as $content) { + $this->contentsToCopy[] = $content; + } + return $this; + } + public function withEntryPoint(string $entryPoint): static { $this->entryPoint = $entryPoint; @@ -230,6 +285,20 @@ class GenericContainer implements TestContainer return $this; } + public function withUser(string $user): static + { + $this->user = $user; + + return $this; + } + + public function withWorkingDir(string $workingDir): static + { + $this->workingDir = $workingDir; + + return $this; + } + public function start(): StartedGenericContainer { $this->startAttempts++; @@ -244,7 +313,7 @@ class GenericContainer implements TestContainer $this->id = $containerCreateResponse?->getId() ?? ''; } catch (ContainerCreateNotFoundException) { if ($this->startAttempts >= self::MAX_START_ATTEMPTS) { - throw new \RuntimeException("Failed to start container after pulling image."); + throw new RuntimeException("Failed to start container after pulling image."); } // If the image is not found, pull it and try again // TODO: add withPullPolicy support @@ -254,12 +323,84 @@ class GenericContainer implements TestContainer $this->dockerClient->containerStart($this->id); + if ($this->filesToCopy !== [] || $this->directoriesToCopy !== [] || $this->contentsToCopy !== []) { + $this->copyToContainer(); + } + $startedContainer = new StartedGenericContainer($this->id); $this->waitStrategy->wait($startedContainer); return $startedContainer; } + /** + * Uploads a tar archive containing files/directories/content to the container, + * extracting it into a chosen directory (`$containerPath`). Allows setting + * Docker's `noOverwriteDirNonDir` and `copyUIDGID` query parameters. + * + * @param string $containerPath Path within the container to extract the tar contents. Must be a directory in the container. + * @param bool $noOverwriteDirNonDir If true, Docker will error if it would replace an existing directory with a non-directory and vice versa. + * @param bool $copyUIDGID If true, Docker will attempt to preserve UID/GID from the tar entries. + * @throws RuntimeException|InvalidArgumentException + */ + protected function copyToContainer( + string $containerPath = '/', + bool $noOverwriteDirNonDir = false, + bool $copyUIDGID = false + ): void { + $tarBuilder = new TarBuilder(); + foreach ($this->filesToCopy as $file) { + $tarBuilder->addFile($file['source'], $file['target'], $file['mode'] ?? null); + } + + foreach ($this->directoriesToCopy as $directory) { + $tarBuilder->addDirectory($directory['source'], $directory['target'], $directory['mode'] ?? null); + } + + foreach ($this->contentsToCopy as $content) { + $tarBuilder->addContent($content['content'], $content['target'], $content['mode'] ?? null); + } + + $tarFilePath = $tarBuilder->buildTarArchive(); + + if (!is_file($tarFilePath)) { + throw new RuntimeException("Tar file does not exist at: $tarFilePath"); + } + + $handle = fopen($tarFilePath, 'rb'); + + if ($handle === false) { + throw new RuntimeException("Cannot open temporary tar archive at: $tarFilePath"); + } + + $queryParams = [ + 'path' => $containerPath, + ]; + + if ($noOverwriteDirNonDir) { + $queryParams['noOverwriteDirNonDir'] = 'true'; + } + + if ($copyUIDGID) { + $queryParams['copyUIDGID'] = 'true'; + } + + /** + * TODO: should be improved. Currently without using dummy $result or FETCH_RESPONSE, the request is failing. + * Probably an issue with the beluga-php/docker-php client library. + * */ + $result = $this->dockerClient->putContainerArchive( + $this->id, + $handle, + $queryParams, + $this->dockerClient::FETCH_RESPONSE + ); + + fclose($handle); + unlink($tarFilePath); + } + + protected function createContainerConfig(): ContainersCreatePostBody { $containerCreatePostBody = new ContainersCreatePostBody(); @@ -267,6 +408,8 @@ class GenericContainer implements TestContainer $containerCreatePostBody->setCmd($this->command); $containerCreatePostBody->setLabels($this->labels); $containerCreatePostBody->setHostname($this->hostname); + $containerCreatePostBody->setWorkingDir($this->workingDir); + $containerCreatePostBody->setUser($this->user); $envs = array_map(static fn ($key, $value) => "$key=$value", array_keys($this->env), $this->env); $containerCreatePostBody->setEnv($envs); diff --git a/src/Utils/TarBuilder.php b/src/Utils/TarBuilder.php new file mode 100644 index 0000000..ba615e7 --- /dev/null +++ b/src/Utils/TarBuilder.php @@ -0,0 +1,293 @@ + + */ + private array $files = []; + + /** + * @var array + */ + private array $directories = []; + + /** + * @var array + */ + private array $contents = []; + + /** + * Add a single file from the local filesystem. + */ + public function addFile(string $source, string $target, ?int $mode = null): self + { + if (!is_file($source)) { + throw new InvalidArgumentException("Invalid file path: {$source}"); + } + if (empty($target)) { + throw new InvalidArgumentException("Target path cannot be empty."); + } + if ($mode !== null && ($mode < 0 || $mode > 0o777)) { + throw new InvalidArgumentException("Invalid mode for file: {$mode}"); + } + $this->files[] = [ + 'source' => $source, + 'target' => $target, + 'mode' => $mode, + ]; + return $this; + } + + /** + * Add a directory (recursively) from the local filesystem. + */ + public function addDirectory(string $source, string $target, ?int $mode = null): self + { + $this->directories[] = [ + 'source' => $source, + 'target' => $target, + 'mode' => $mode, + ]; + return $this; + } + + /** + * Add inline string content that should become a file in the tar. + */ + public function addContent(string $content, string $target, ?int $mode = null): self + { + $this->contents[] = [ + 'content' => $content, + 'target' => $target, + 'mode' => $mode, + ]; + return $this; + } + + /** + * Builds the .tar archive from everything that was added (files, directories, contents). + * + * Returns the full path to the created .tar file. + */ + public function buildTarArchive(): string + { + $tempDir = $this->createTempDir(); + + $this->copyFilesToLocalDir($tempDir, $this->files); + $this->copyDirectoriesToLocalDir($tempDir, $this->directories); + $this->createFilesFromContent($tempDir, $this->contents); + + $tarFilePath = $this->createTempTarPath(); + $this->runTarCommand($tarFilePath, $tempDir); + $this->removeDirectoryRecursively($tempDir); + + return $tarFilePath; + } + + public function clear(): void + { + $this->files = []; + $this->directories = []; + $this->contents = []; + } + + private function createTempDir(): string + { + $tmpDirName = tempnam(sys_get_temp_dir(), 'tc_files_'); + if ($tmpDirName === false) { + throw new RuntimeException("Failed to create a temp file for tar data"); + } + // tempnam() creates a file; remove it and create directory instead + unlink($tmpDirName); + + if (!mkdir($tmpDirName) && !is_dir($tmpDirName)) { + throw new RuntimeException("Failed to create temp directory: {$tmpDirName}"); + } + + return $tmpDirName; + } + + private function createTempTarPath(): string + { + $tmpFile = tempnam(sys_get_temp_dir(), 'tc_tar_'); + + if ($tmpFile === false) { + throw new RuntimeException("Failed to create temp file for tar archive"); + } + + $tarFilePath = $tmpFile . '.tar'; + + if (!rename($tmpFile, $tarFilePath)) { + throw new RuntimeException("Failed renaming temp file to .tar"); + } + return $tarFilePath; + } + + private function runTarCommand(string $tarFilePath, string $sourceDir): void + { + // without --disable-copyfile and --no-xattrs combination, tar will fail on macOS + $cmd = sprintf( + 'tar --no-xattrs --disable-copyfile -cf %s -C %s . 2>&1', + escapeshellarg($tarFilePath), + escapeshellarg($sourceDir) + ); + + exec($cmd, $output, $exitCode); + + if ($exitCode !== 0) { + $errorText = implode("\n", $output); + throw new RuntimeException("Failed to create tar archive:\n{$errorText}"); + } + } + + private function removeDirectoryRecursively(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) { + if (!$item instanceof SplFileInfo) { + continue; + } + $path = $item->getRealPath(); + if ($item->isDir()) { + rmdir($path); + } else { + unlink($path); + } + } + rmdir($dir); + } + + /** + * @param array $files + */ + private function copyFilesToLocalDir(string $tempDir, array $files): void + { + foreach ($files as $file) { + $source = $file['source']; + $target = $file['target']; + $mode = $file['mode'] ?? null; + + if (!is_file($source)) { + throw new InvalidArgumentException("File not found: $source"); + } + $destPath = $this->makeDestPath($tempDir, $target); + $this->ensureParentDir($destPath); + + if (!copy($source, $destPath)) { + throw new RuntimeException("Failed to copy file $source to $destPath"); + } + if ($mode !== null) { + chmod($destPath, $mode); + } + } + } + + /** + * @param array $directories + */ + private function copyDirectoriesToLocalDir(string $tempDir, array $directories): void + { + foreach ($directories as $dir) { + $source = $dir['source']; + $target = $dir['target']; + $mode = $dir['mode'] ?? null; + + if (!is_dir($source)) { + throw new InvalidArgumentException("Directory not found: $source"); + } + $destPath = $this->makeDestPath($tempDir, $target); + $this->copyDirectoryRecursively($source, $destPath); + + if ($mode !== null) { + chmod($destPath, $mode); + } + } + } + + /** + * @param array $contents + */ + private function createFilesFromContent(string $tempDir, array $contents): void + { + foreach ($contents as $content) { + $data = $content['content']; + $target = $content['target']; + $mode = $content['mode'] ?? null; + + $destPath = $this->makeDestPath($tempDir, $target); + $this->ensureParentDir($destPath); + + file_put_contents($destPath, $data); + if ($mode !== null) { + chmod($destPath, $mode); + } + } + } + + private function copyDirectoryRecursively(string $sourceDir, string $destDir): void + { + $this->ensureParentDir($destDir); + + $innerIterator = new RecursiveDirectoryIterator($sourceDir, \FilesystemIterator::SKIP_DOTS); + + /** @var RecursiveIteratorIterator $iterator */ + $iterator = new RecursiveIteratorIterator( + $innerIterator, + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $item) { + if (!$item instanceof SplFileInfo) { + continue; + } + + /** @var RecursiveDirectoryIterator $innerIterator */ + $innerIterator = $iterator->getInnerIterator(); + $subPathName = $innerIterator->getSubPathName(); + $targetPath = $destDir . '/' . $subPathName; + + // Ensure the parent directory for the target path exists + $this->ensureParentDir($targetPath); + + if ($item->isDir()) { + if (!mkdir($targetPath, 0o777, true) && !is_dir($targetPath)) { + throw new RuntimeException(sprintf('Directory "%s" was not created', $targetPath)); + } + } else { + copy($item->getPathname(), $targetPath); + } + } + } + + private function makeDestPath(string $tempDir, string $target): string + { + return rtrim($tempDir, '/') . '/' . ltrim($target, '/'); + } + + private function ensureParentDir(string $path): void + { + $parent = dirname($path); + if (!is_dir($parent) && !mkdir($parent, 0o777, true) && !is_dir($parent)) { + throw new RuntimeException("Failed to create parent directory: $parent"); + } + } +} diff --git a/tests/Integration/GenericContainerTest.php b/tests/Integration/GenericContainerTest.php index 7590db3..2a7c9e9 100644 --- a/tests/Integration/GenericContainerTest.php +++ b/tests/Integration/GenericContainerTest.php @@ -6,6 +6,7 @@ namespace Testcontainers\Tests\Integration; use Docker\API\Model\ContainersIdJsonGetResponse200; use PHPUnit\Framework\TestCase; +use RuntimeException; use Testcontainers\Container\GenericContainer; use Testcontainers\Utils\PortGenerator\FixedPortGenerator; use Testcontainers\Wait\WaitForHostPort; @@ -24,6 +25,94 @@ class GenericContainerTest extends TestCase $container->stop(); } + public function testShouldCopyContentToContainer(): void + { + $inlineContent = 'hello world'; + + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->withCopyContentToContainer([[ + 'content' => $inlineContent, + 'target' => '/tmp/inline.txt', + ]]) + ->start(); + + $output = $container->exec(['cat', '/tmp/inline.txt']); + + self::assertSame($inlineContent, $output); + + $container->stop(); + } + + public function testShouldCopyDirectoryToContainer(): void + { + $testDir = sys_get_temp_dir() . '/copy-dir-test'; + if (!is_dir($testDir)) { + mkdir($testDir); + } + file_put_contents($testDir . '/file1.txt', 'file1 contents'); + file_put_contents($testDir . '/file2.txt', 'file2 contents'); + + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->withCopyDirectoriesToContainer([[ + 'source' => $testDir, + 'target' => '/test-dir', + ]]) + ->start(); + + $output1 = $container->exec(['cat', '/test-dir/file1.txt']); + $output2 = $container->exec(['cat', '/test-dir/file2.txt']); + + self::assertSame('file1 contents', $output1); + self::assertSame('file2 contents', $output2); + + $container->stop(); + } + + public function testShouldCopyFileToContainer(): void + { + $localFilePath = sys_get_temp_dir() . '/copy-file-test.txt'; + file_put_contents($localFilePath, 'hello from file'); + + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->withCopyFilesToContainer([[ + 'source' => $localFilePath, + 'target' => '/tmp/test-file.txt', + ]]) + ->start(); + + $output = $container->exec(['cat', '/tmp/test-file.txt']); + + self::assertSame('hello from file', $output); + + $container->stop(); + } + + public function testShouldCopyFileWithPermissions(): void + { + $localFilePath = sys_get_temp_dir() . '/copy-perms-test.txt'; + file_put_contents($localFilePath, 'check perms'); + + $mode = 0o777; + + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->withCopyFilesToContainer([[ + 'source' => $localFilePath, + 'target' => '/tmp/perm-file.txt', + 'mode' => $mode, + ]]) + ->start(); + + $output = $container->exec(['stat', '-c', '%a', '/tmp/perm-file.txt']); + + self::assertSame('777', trim($output)); + + $container->stop(); + } + /** * @throws \JsonException */ @@ -41,6 +130,68 @@ class GenericContainerTest extends TestCase $container->stop(); } + public function testShouldSetLabels(): void + { + $labels = [ + 'label-1' => 'value-1', + 'label-2' => 'value-2', + ]; + $container = (new GenericContainer('alpine')) + ->withLabels($labels) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + /** @var ContainersIdJsonGetResponse200|null $inspectResult */ + $inspectResult = $container->getClient()->containerInspect($container->getId()); + $this->assertArrayHasKey('label-1', (array)$inspectResult?->getConfig()?->getLabels()); + $this->assertSame('value-1', ((array)$inspectResult?->getConfig()?->getLabels())['label-1']); + $this->assertArrayHasKey('label-2', (array)$inspectResult?->getConfig()?->getLabels()); + $this->assertSame('value-2', ((array)$inspectResult?->getConfig()?->getLabels())['label-2']); + + $container->stop(); + } + + public function testShouldSetName(): void + { + $name = 'test-container-name'; + $container = (new GenericContainer('alpine')) + ->withName($name) + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + /** @var ContainersIdJsonGetResponse200|null $inspectResult */ + $inspectResult = $container->getClient()->containerInspect($container->getId()); + $this->assertSame('/'.$name, $inspectResult?->getName()); + + $container->stop(); + } + + public function testShouldSetUser(): void + { + $container = (new GenericContainer('alpine')) + ->withUser('nobody') + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $output = $container->exec(['whoami']); + $this->assertStringContainsString('nobody', $output); + + $container->stop(); + } + + public function testShouldSetWorkingDir(): void + { + $container = (new GenericContainer('alpine')) + ->withWorkingDir('/tmp') + ->withCommand(['tail', '-f', '/dev/null']) + ->start(); + + $output = $container->exec(['pwd']); + $this->assertStringContainsString('/tmp', $output); + + $container->stop(); + } + public function testShouldCaptureStderrWhenCommandFails(): void { $container = (new GenericContainer('alpine')) @@ -66,6 +217,26 @@ class GenericContainerTest extends TestCase $container->stop(); } + public function testShouldSetHealthCheckCommand(): void + { + $container = (new GenericContainer('alpine')) + ->withCommand(['tail', '-f', '/dev/null']) + ->withHealthCheckCommand('echo "healthy" || exit 1') + ->start(); + + /** @var ContainersIdJsonGetResponse200|null $inspectResult */ + $inspectResult = $container->getClient()->containerInspect($container->getId()); + $healthConfig = $inspectResult?->getConfig()?->getHealthcheck(); + + $this->assertNotNull($healthConfig); + $this->assertEquals(['CMD-SHELL', 'echo "healthy" || exit 1'], $healthConfig->getTest()); + $this->assertSame(1000000000, $healthConfig->getInterval()); + $this->assertSame(3000000000, $healthConfig->getTimeout()); + $this->assertSame(3, $healthConfig->getRetries()); + + $container->stop(); + } + public function testShouldSetEntrypoint(): void { $container = (new GenericContainer('cristianrgreco/testcontainer:1.1.14')) @@ -95,6 +266,8 @@ class GenericContainerTest extends TestCase $result = $container->exec(["cat", $containerPath.'/test.txt']); self::assertSame('hello world', $result); + + $container->stop(); } public function testShouldSetPrivilegedMode(): void @@ -109,5 +282,7 @@ class GenericContainerTest extends TestCase $privileged = $inspectResult?->getHostConfig()?->getPrivileged(); self::assertTrue($privileged); + + $container->stop(); } } diff --git a/tests/Unit/Utils/TarBuilderTest.php b/tests/Unit/Utils/TarBuilderTest.php new file mode 100644 index 0000000..b2f326f --- /dev/null +++ b/tests/Unit/Utils/TarBuilderTest.php @@ -0,0 +1,218 @@ +tempDir = sys_get_temp_dir() . '/tarbuilder_test_' . uniqid('', true); + mkdir($this->tempDir); + } + + protected function tearDown(): void + { + $this->removeDirectoryRecursively($this->tempDir); + parent::tearDown(); + } + + public function testShouldAddSingleFile(): void + { + $sourceFile = $this->tempDir . '/file.txt'; + file_put_contents($sourceFile, self::TEST_CONTENT); + + $tarBuilder = new TarBuilder(); + $tarBuilder->addFile($sourceFile, 'mydir/file_in_tar.txt', 0o644); + + $tarPath = $tarBuilder->buildTarArchive(); + + $this->assertFileExists($tarPath, 'Tar file was not created'); + + $extractDir = $this->tempDir . '/extract'; + mkdir($extractDir); + $this->extractTar($tarPath, $extractDir); + + $extractedFile = $extractDir . '/mydir/file_in_tar.txt'; + $this->assertFileExists($extractedFile); + $this->assertSame(self::TEST_CONTENT, file_get_contents($extractedFile)); + + $perms = substr(sprintf('%o', fileperms($extractedFile)), -3); + $this->assertSame('644', $perms, 'Expected file mode 0644'); + } + + public function testShouldAddDirectoryRecursively(): void + { + $localDir = $this->tempDir . '/localdir'; + mkdir($localDir); + file_put_contents($localDir . '/one.txt', 'file1'); + file_put_contents($localDir . '/two.txt', 'file2'); + + $tarBuilder = new TarBuilder(); + $tarBuilder->addDirectory($localDir, 'mydir', 0o755); + $tarPath = $tarBuilder->buildTarArchive(); + + $this->assertFileExists($tarPath); + + $extractDir = $this->tempDir . '/extractdir'; + mkdir($extractDir); + $this->extractTar($tarPath, $extractDir); + + $oneExtracted = $extractDir . '/mydir/one.txt'; + $twoExtracted = $extractDir . '/mydir/two.txt'; + $this->assertFileExists($oneExtracted); + $this->assertFileExists($twoExtracted); + + $this->assertSame('file1', file_get_contents($oneExtracted)); + $this->assertSame('file2', file_get_contents($twoExtracted)); + + $dirPerms = substr(sprintf('%o', fileperms($extractDir . '/mydir')), -3); + $this->assertSame('755', $dirPerms, 'Expected directory mode 0755'); + } + + public function testShouldAddInlineContent(): void + { + $content = "Inline content test\nLine2"; + + $tarBuilder = new TarBuilder(); + $tarBuilder->addContent($content, 'some/path/inline.txt', 0o777); + $tarPath = $tarBuilder->buildTarArchive(); + + $this->assertFileExists($tarPath); + + $extractDir = $this->tempDir . '/extractContent'; + mkdir($extractDir); + $this->extractTar($tarPath, $extractDir); + + $inlineExtracted = $extractDir . '/some/path/inline.txt'; + $this->assertFileExists($inlineExtracted); + $this->assertSame($content, file_get_contents($inlineExtracted)); + + $perms = substr(sprintf('%o', fileperms($inlineExtracted)), -3); + $this->assertSame('777', $perms, 'Expected file mode 0777'); + } + + public function testShouldFailOnInvalidFilePath(): void + { + $tarBuilder = new TarBuilder(); + $this->expectException(InvalidArgumentException::class); + $tarBuilder->addFile('/some/nonexistent/file', 'target.txt'); + } + + public function testShouldFailOnEmptyTarget(): void + { + $localFile = $this->tempDir . '/somefile.txt'; + file_put_contents($localFile, 'abc'); + + $tarBuilder = new TarBuilder(); + $this->expectException(InvalidArgumentException::class); + $tarBuilder->addFile($localFile, ''); + } + + public function testShouldFailOnInvalidMode(): void + { + $localFile = $this->tempDir . '/somefile.txt'; + file_put_contents($localFile, 'abc'); + + $tarBuilder = new TarBuilder(); + $this->expectException(InvalidArgumentException::class); + $tarBuilder->addFile($localFile, 'target.txt', 9999); + } + + public function testShouldCreateEmptyTarIfNoItemsAdded(): void + { + $tarBuilder = new TarBuilder(); + + $tarPath = $tarBuilder->buildTarArchive(); + $this->assertFileExists($tarPath); + + $extractDir = $this->tempDir . '/extractEmpty'; + mkdir($extractDir); + $this->extractTar($tarPath, $extractDir); + + $scanned = array_diff(scandir($extractDir), ['.', '..']); + $this->assertCount(0, $scanned, 'Expected empty directory'); + } + + public function testShouldClearItems(): void + { + $tarBuilder = new TarBuilder(); + $localFile = $this->tempDir . '/somefile.txt'; + file_put_contents($localFile, 'abc'); + $tarBuilder->addFile($localFile, 'test.txt'); + + $tarBuilder->clear(); + + $tarPath = $tarBuilder->buildTarArchive(); + $this->assertFileExists($tarPath); + + $extractDir = $this->tempDir . '/extractCleared'; + mkdir($extractDir); + $this->extractTar($tarPath, $extractDir); + + $scanned = array_diff(scandir($extractDir), ['.', '..']); + $this->assertCount(0, $scanned, 'Expected no files after clear()'); + } + + /** + * Helper function to extract a .tar for verification. + */ + private function extractTar(string $tarPath, string $destination): void + { + $cmd = sprintf( + 'tar -xpf %s -C %s 2>&1', + escapeshellarg($tarPath), + escapeshellarg($destination) + ); + + exec($cmd, $output, $exitCode); + if ($exitCode !== 0) { + $errorText = implode("\n", $output); + throw new RuntimeException("Failed to extract tar:\n{$errorText}"); + } + } + + /** + * Recursively remove directory. + */ + private function removeDirectoryRecursively(string $path): void + { + if (!is_dir($path)) { + return; + } + + /** @var RecursiveIteratorIterator $items */ + $items = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($items as $item) { + if (!$item instanceof SplFileInfo) { + continue; + } + if ($item->isDir()) { + rmdir($item->getRealPath()); + } else { + unlink($item->getRealPath()); + } + } + rmdir($path); + } +}