ensureDirectory(dirname($file)); $filesystem->writeFile($file, 'alpha'); $filesystem->appendFile($file, "\nbeta"); $filesystem->copyFile($file, $copy); expect($filesystem->has([$file, $copy]))->toBeTrue(); expect($filesystem->read($file))->toBe("alpha\nbeta"); expect(CactiFilesystem::canonicalize(CactiFilesystem::join($dir, 'nested', '..', 'nested', 'settings.txt')))->toBe($file); } finally { $filesystem->delete($dir); } }); test('CactiFilesystem read throws when the file is missing', function () { $filesystem = new CactiFilesystem(); $missing = sys_get_temp_dir() . '/cacti-filesystem-missing-' . bin2hex(random_bytes(4)); expect(fn () => $filesystem->read($missing)) ->toThrow(RuntimeException::class); }); test('CactiFilesystem read rejects a path containing a null byte', function () { $filesystem = new CactiFilesystem(); expect(fn () => $filesystem->read("/tmp/foo\0bar")) ->toThrow(RuntimeException::class, 'null byte'); }); test('CactiFilesystem rejects a null byte in has, ensureDirectory and delete', function () { $filesystem = new CactiFilesystem(); $tainted = "/tmp/foo\0bar"; expect(fn () => $filesystem->has($tainted))->toThrow(RuntimeException::class, 'null byte') ->and(fn () => $filesystem->has([$tainted]))->toThrow(RuntimeException::class, 'null byte') ->and(fn () => $filesystem->ensureDirectory($tainted))->toThrow(RuntimeException::class, 'null byte') ->and(fn () => $filesystem->ensureDirectory([$tainted]))->toThrow(RuntimeException::class, 'null byte') ->and(fn () => $filesystem->delete($tainted))->toThrow(RuntimeException::class, 'null byte') ->and(fn () => $filesystem->delete([$tainted]))->toThrow(RuntimeException::class, 'null byte'); }); test('CactiFilesystem rejects null bytes across write, copy, move and temporary paths', function () { $filesystem = new CactiFilesystem(); $clean = sys_get_temp_dir() . '/clean'; $tainted = "/tmp/foo\0bar"; expect(fn () => $filesystem->writeFile($tainted, 'data'))->toThrow(RuntimeException::class, 'null byte') ->and(fn () => $filesystem->appendFile($tainted, 'data'))->toThrow(RuntimeException::class, 'null byte') ->and(fn () => $filesystem->copyFile($tainted, $clean))->toThrow(RuntimeException::class, 'null byte') ->and(fn () => $filesystem->copyFile($clean, $tainted))->toThrow(RuntimeException::class, 'null byte') ->and(fn () => $filesystem->move($tainted, $clean))->toThrow(RuntimeException::class, 'null byte') ->and(fn () => $filesystem->move($clean, $tainted))->toThrow(RuntimeException::class, 'null byte') ->and(fn () => $filesystem->temporaryName($tainted, 'tmp'))->toThrow(RuntimeException::class, 'null byte') ->and(fn () => $filesystem->temporaryName($clean, "tmp\0"))->toThrow(RuntimeException::class, 'null byte') ->and(fn () => $filesystem->temporaryName($clean, 'tmp', ".tmp\0"))->toThrow(RuntimeException::class, 'null byte') ->and(fn () => $filesystem->isAbsolute($tainted))->toThrow(RuntimeException::class, 'null byte'); }); test('CactiFilesystem accepts an injected Symfony filesystem', function () { $injected = new Symfony\Component\Filesystem\Filesystem(); $filesystem = new CactiFilesystem($injected); $dir = sys_get_temp_dir() . '/cacti-fs-inject-' . bin2hex(random_bytes(4)); try { $filesystem->ensureDirectory($dir); expect($filesystem->has($dir))->toBeTrue(); } finally { $filesystem->delete($dir); } }); test('CactiFilesystem instance moves, renames and reports absolute paths', function () { $filesystem = new CactiFilesystem(); $dir = sys_get_temp_dir() . '/cacti-fs-move-' . bin2hex(random_bytes(4)); $origin = CactiFilesystem::join($dir, 'origin.txt'); $target = CactiFilesystem::join($dir, 'target.txt'); try { $filesystem->ensureDirectory($dir); $filesystem->writeFile($origin, 'payload'); $filesystem->move($origin, $target); expect($filesystem->has($origin))->toBeFalse() ->and($filesystem->read($target))->toBe('payload'); $temp = $filesystem->temporaryName($dir, 'tmp'); expect($filesystem->has($temp))->toBeTrue(); expect($filesystem->isAbsolute($target))->toBeTrue() ->and($filesystem->isAbsolute('relative/path'))->toBeFalse(); } finally { $filesystem->delete($dir); } }); test('CactiFilesystem static facade renames, creates temp files with a suffix, and reports absolute paths', function () { $dir = sys_get_temp_dir() . '/cacti-fs-static-' . bin2hex(random_bytes(4)); $origin = CactiFilesystem::join($dir, 'origin.txt'); $target = CactiFilesystem::join($dir, 'target.txt'); try { CactiFilesystem::mkdir($dir); CactiFilesystem::dumpFile($origin, 'payload'); CactiFilesystem::rename($origin, $target); expect(CactiFilesystem::exists($origin))->toBeFalse() ->and(CactiFilesystem::exists($target))->toBeTrue(); // The suffix must land at the end of the generated filename: this is // the Symfony Filesystem::tempnam() argument position, not a file mode. $temp = CactiFilesystem::tempnam($dir, 'tmp', '.suffix'); expect(CactiFilesystem::exists($temp))->toBeTrue() ->and($temp)->toEndWith('.suffix'); expect(CactiFilesystem::isAbsolutePath($target))->toBeTrue() ->and(CactiFilesystem::isAbsolutePath('relative/path'))->toBeFalse(); } finally { CactiFilesystem::remove($dir); } }); test('CactiFilesystem is a readonly value wrapper', function () { $property = (new ReflectionClass(CactiFilesystem::class))->getProperty('filesystem'); expect($property->isReadOnly())->toBeTrue(); });