Initial commit

This commit is contained in:
2026-08-20 11:41:17 +00:00
commit 6949695eac
2334 changed files with 646393 additions and 0 deletions
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Test;
class A0FileNoForceCurlTest extends TestUtil
{
/**
* Covers the early getUrlData() guard when allow_url_fopen is enabled and
* FORCE_CURL is not defined yet.
*
* @throws \Com\Tecnick\File\Exception
*/
public function testGetUrlDataReturnsFalseWhenAllowUrlFopenEnabledWithoutForceCurl(): void
{
if (\defined('FORCE_CURL')) {
$this->markTestSkipped('FORCE_CURL already defined by another test class');
}
if (\ini_get('allow_url_fopen') === false || \ini_get('allow_url_fopen') === '0') {
$this->markTestSkipped('allow_url_fopen is disabled in this environment');
}
$file = new \Com\Tecnick\File\File(['example.com']);
$this->assertFalse($file->getUrlData('http://example.com/path.txt'));
}
}
+435
View File
@@ -0,0 +1,435 @@
<?php
/**
* ByteTest.php
*
* @since 2015-07-28
* @category Library
* @package File
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2015-2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-file
*
* This file is part of tc-lib-file software library.
*/
namespace Test;
use PHPUnit\Framework\Attributes\DataProvider;
/**
* Byte Color class test
*
* @since 2015-07-28
* @category Library
* @package File
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2015-2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-file
*/
class ByteTest extends TestUtil
{
/**
* @throws \RangeException
*/
protected function getTestObject(): \Com\Tecnick\File\Byte
{
$str =
\chr(0)
. \chr(0)
. \chr(0)
. \chr(0)
. \chr(1)
. \chr(3)
. \chr(7)
. \chr(15)
. \chr(31)
. \chr(63)
. \chr(127)
. \chr(255)
. \chr(254)
. \chr(252)
. \chr(248)
. \chr(240)
. \chr(224)
. \chr(192)
. \chr(128)
. \chr(0)
. \chr(255)
. \chr(255)
. \chr(255)
. \chr(255);
return new \Com\Tecnick\File\Byte($str);
}
/**
* @throws \RangeException
*/
#[DataProvider('getByteDataProvider')]
public function testGetByte(int $offset, int $expected): void
{
$byte = $this->getTestObject();
$res = $byte->getByte($offset);
$this->assertEquals($expected, $res);
}
/**
* @return array<array{int, int}>
*/
public static function getByteDataProvider(): array
{
return [
[0, 0],
[1, 0],
[2, 0],
[3, 0],
[4, 1],
[5, 3],
[6, 7],
[7, 15],
[8, 31],
[9, 63],
[10, 127],
[11, 255],
[12, 254],
[13, 252],
[14, 248],
[15, 240],
[16, 224],
[17, 192],
[18, 128],
[19, 0],
[20, 255],
[21, 255],
[22, 255],
[23, 255],
];
}
/**
* @throws \RangeException
*/
#[DataProvider('getUShortDataProvider')]
public function testGetUShort(int $offset, int $expected): void
{
$byte = $this->getTestObject();
$res = $byte->getUShort($offset);
$this->assertEquals($expected, $res);
}
/**
* @throws \RangeException
*/
#[DataProvider('getUShortDataProvider')]
public function testGetUFWord(int $offset, int $expected): void
{
$byte = $this->getTestObject();
$res = $byte->getUFWord($offset);
$this->assertEquals($expected, $res);
}
/**
* @return array<array{int, int}>
*/
public static function getUShortDataProvider(): array
{
return [
[0, 0],
[1, 0],
[2, 0],
[3, 1],
[4, 259],
[5, 775],
[6, 1_807],
[7, 3_871],
[8, 7_999],
[9, 16_255],
[10, 32_767],
[11, 65_534],
[12, 65_276],
[13, 64_760],
[14, 63_728],
[15, 61_664],
[16, 57_536],
[17, 49_280],
[18, 32_768],
[19, 255],
[20, 65_535],
[21, 65_535],
[22, 65_535],
];
}
/**
* @throws \RangeException
*/
#[DataProvider('getShortDataProvider')]
public function testGetShort(int $offset, int $expected): void
{
$byte = $this->getTestObject();
$res = $byte->getShort($offset);
$this->assertEquals($expected, $res);
}
/**
* @throws \RangeException
*/
#[DataProvider('getShortDataProvider')]
public function testGetFWord(int $offset, int $expected): void
{
$byte = $this->getTestObject();
$res = $byte->getFWord($offset);
$this->assertEquals($expected, $res);
}
/**
* @return array<array{int, int}>
*/
public static function getShortDataProvider(): array
{
return [
[0, 0],
[1, 0],
[2, 0],
[3, 1],
[4, 259],
[5, 775],
[6, 1_807],
[7, 3_871],
[8, 7_999],
[9, 16_255],
[10, 32_767],
[11, -2],
[12, -260],
[13, -776],
[14, -1_808],
[15, -3_872],
[16, -8_000],
[17, -16_256],
[18, -32_768],
[19, 255],
[20, -1],
[21, -1],
[22, -1],
];
}
/**
* @throws \RangeException
*/
#[DataProvider('getULongDataProvider')]
public function testGetULong(int $offset, int $expected): void
{
$byte = $this->getTestObject();
$res = $byte->getULong($offset);
$this->assertEquals($expected, $res);
}
/**
* @return array<array{int, int}>
*/
public static function getULongDataProvider(): array
{
return [
[0, 0],
[1, 1],
[2, 259],
[3, 66_311],
[4, 16_975_631],
[5, 50_794_271],
[6, 118_431_551],
[7, 253_706_111],
[8, 524_255_231],
[9, 1_065_353_214],
[10, 2_147_483_388],
[11, 4_294_900_984],
[12, 4_277_991_664],
[13, 4_244_173_024],
[14, 4_176_535_744],
[15, 4_041_261_184],
[16, 3_770_712_064],
[17, 3_229_614_335],
[18, 2_147_549_183],
[19, 16_777_215],
[20, 4_294_967_295],
];
}
/**
* @throws \RangeException
*/
#[DataProvider('getLongDataProvider')]
public function testGetLong(int $offset, int $expected): void
{
$byte = $this->getTestObject();
$res = $byte->getLong($offset);
$this->assertEquals($expected, $res);
}
/**
* @return array<array{int, int}>
*/
public static function getLongDataProvider(): array
{
return [
[0, 0],
[1, 1],
[2, 259],
[3, 66_311],
[4, 16_975_631],
[5, 50_794_271],
[6, 118_431_551],
[7, 253_706_111],
[8, 524_255_231],
[9, 1_065_353_214],
[10, 2_147_483_388],
[11, -66_312],
[12, -16_975_632],
[13, -50_794_272],
[14, -118_431_552],
[15, -253_706_112],
[16, -524_255_232],
[17, -1_065_352_961],
[18, -2_147_418_113],
[19, 16_777_215],
[20, -1],
];
}
/**
* @throws \RangeException
*/
#[DataProvider('getFixedDataProvider')]
public function testGetFixed(int $offset, int|float $expected): void
{
$byte = $this->getTestObject();
$res = $byte->getFixed($offset);
// compare floats with a small tolerance to avoid precision issues
$this->assertEqualsWithDelta($expected, $res, 1e-12, "float mismatch at offset {$offset}");
}
/**
* @return array<array{int, float}>
*/
public static function getFixedDataProvider(): array
{
return [
// high and low parts from the test string; see getShort/getUShort providers
// offset 0: all zero bytes
[0, 0.0],
// offset 1: high=0, low=1 -> 1/65536
[1, 1.52587890625E-5],
// offset 2: high=0, low=259 -> 259/65536
[2, 0.0039520263671875],
// offset 3: high=1, low=775 -> 1 + 775/65536
[3, 1.0118255615234375],
// a negative result with fractional part
[11, -1.0118408203125],
// large positive value near next integer
[19, 255.99998474121094],
// negative value very close to zero (tiny fraction)
[20, -1.52587890625E-5],
];
}
// -------------------------------------------------------------------------
// Issue 7: out-of-bounds reads — always throw exceptions
// -------------------------------------------------------------------------
/**
* @throws \RangeException
*/
public function testGetByteOutOfBoundsThrows(): void
{
$byte = new \Com\Tecnick\File\Byte('AB'); // 2 bytes
$this->expectException(\RangeException::class);
$this->expectExceptionMessageMatches('/Out-of-bounds read/');
$byte->getByte(10);
}
/**
* @throws \RangeException
*/
public function testGetUShortOutOfBoundsThrows(): void
{
$byte = new \Com\Tecnick\File\Byte('A'); // 1 byte only
$this->expectException(\RangeException::class);
$byte->getUShort(0);
}
/**
* @throws \RangeException
*/
public function testGetULongOutOfBoundsThrows(): void
{
$byte = new \Com\Tecnick\File\Byte('ABC'); // 3 bytes
$this->expectException(\RangeException::class);
$byte->getULong(0);
}
/**
* @throws \RangeException
*/
public function testGetShortOutOfBoundsThrows(): void
{
$byte = new \Com\Tecnick\File\Byte('A');
$this->expectException(\RangeException::class);
$byte->getShort(0);
}
/**
* @throws \RangeException
*/
public function testGetFixedOutOfBoundsThrows(): void
{
$byte = new \Com\Tecnick\File\Byte('AB'); // needs 4 bytes
$this->expectException(\RangeException::class);
$byte->getFixed(0);
}
/**
* @throws \RangeException
*/
public function testGetUFWordOutOfBoundsThrows(): void
{
$byte = new \Com\Tecnick\File\Byte('A'); // 1 byte, needs 2
$this->expectException(\RangeException::class);
$this->expectExceptionMessageMatches('/Out-of-bounds read/');
$byte->getUFWord(0);
}
/**
* @throws \RangeException
*/
public function testGetFWordOutOfBoundsThrows(): void
{
$byte = new \Com\Tecnick\File\Byte('A'); // 1 byte, needs 2
$this->expectException(\RangeException::class);
$this->expectExceptionMessageMatches('/Out-of-bounds read/');
$byte->getFWord(0);
}
/**
* @throws \RangeException
*/
public function testGetLongOutOfBoundsThrows(): void
{
$byte = new \Com\Tecnick\File\Byte('ABC'); // 3 bytes, needs 4
$this->expectException(\RangeException::class);
$this->expectExceptionMessageMatches('/Out-of-bounds read/');
$byte->getLong(0);
}
/**
* @throws \RangeException
*/
public function testInBoundsReadNoWarning(): void
{
// A read exactly at the boundary must not warn.
$byte = new \Com\Tecnick\File\Byte("\x00\x01");
$this->assertSame(0, $byte->getByte(0));
$this->assertSame(1, $byte->getByte(1));
}
}
+399
View File
@@ -0,0 +1,399 @@
<?php
/**
* CacheTest.php
*
* @since 2011-05-23
* @category Library
* @package File
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2011-2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-pdf-filecache
*
* This file is part of tc-lib-pdf-filecache software library.
*/
namespace Test;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
/**
* Unit Test
*
* @since 2011-05-23
* @category Library
* @package File
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2011-2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-pdf-filecache
*/
class CacheTest extends TestUtil
{
protected function getTestObject(): \Com\Tecnick\File\Cache
{
return new \Com\Tecnick\File\Cache('1_2-a+B/c');
}
public function testAutoPrefix(): void
{
$cache = new \Com\Tecnick\File\Cache();
$this->assertNotEmpty($cache->getFilePrefix());
}
public function testGetCachePath(): void
{
$cache = $this->getTestObject();
$cachePath = $cache->getCachePath();
// The normalized cache path always ends with the platform separator.
$this->assertSame(\DIRECTORY_SEPARATOR, \substr($cachePath, -1));
$cache->setCachePath();
$this->assertEquals($cachePath, $cache->getCachePath());
// Use the real temp dir and compare realpath-to-realpath so the
// assertion holds on every platform (e.g. macOS /var -> /private/var
// symlink, Windows backslash separators).
$path = \sys_get_temp_dir();
$real = \realpath($path);
if ($real === false) {
self::fail('the system temp dir must resolve');
}
$cache->setCachePath($path);
$this->assertSame($real . \DIRECTORY_SEPARATOR, $cache->getCachePath());
}
public function testGetFilePrefix(): void
{
$cache = $this->getTestObject();
$filePrefix = $cache->getFilePrefix();
$this->assertEquals('_1_2-a-B_c_', $filePrefix);
}
/**
* @throws \Com\Tecnick\File\Exception
*/
public function testGetNewFileName(): void
{
$cache = $this->getTestObject();
$val = $cache->getNewFileName('tst', '0123');
$this->bcAssertMatchesRegularExpression('/_1_2-a-B_c_tst_0123_/', $val);
\unlink($val);
}
/**
* The full prefix, type and key must survive in the generated filename so
* the prefix-based glob in delete()/deleteOlderThan() matches on every
* platform. tempnam() alone truncates the prefix to 3 chars on Windows.
*
* @throws \Com\Tecnick\File\Exception
*/
public function testGetNewFileNamePreservesFullPrefix(): void
{
$cache = $this->getTestObject();
$fileA = $cache->getNewFileName('typ', 'k1');
$fileB = $cache->getNewFileName('typ', 'k2');
try {
$this->assertNotSame($fileA, $fileB, 'each call must return a distinct file');
$this->assertTrue(\file_exists($fileA));
$this->assertTrue(\file_exists($fileB));
$this->assertStringStartsWith($cache->getFilePrefix() . 'typ_k1_', \basename($fileA));
$this->assertStringStartsWith($cache->getFilePrefix() . 'typ_k2_', \basename($fileB));
} finally {
if (\is_file($fileA)) {
\unlink($fileA);
}
if (\is_file($fileB)) {
\unlink($fileB);
}
}
}
/**
* Characters that are invalid in Windows filenames (and glob metacharacters)
* are stripped from the generated name, keeping it valid and consistent with
* the patterns delete() builds.
*
* @throws \Com\Tecnick\File\Exception
*/
public function testGetNewFileNameSanitizesUnsafeTypeAndKey(): void
{
$cache = $this->getTestObject();
$file = $cache->getNewFileName('a/b*', 'c:d?');
try {
$this->assertStringStartsWith($cache->getFilePrefix() . 'ab_cd_', \basename($file));
$this->assertTrue(\file_exists($file));
} finally {
if (\is_file($file)) {
\unlink($file);
}
}
}
public function testNormalizePathInvalid(): void
{
$cache = $this->getTestObject();
// invoke protected normalizePath via reflection so we can test
// the branch where realpath() returns false
$ref = new \ReflectionMethod($cache, 'normalizePath');
$invalid = \sys_get_temp_dir() . '/nonexistent_' . \uniqid('', true);
$this->assertFalse(\file_exists($invalid), 'Sanity check: path should not exist');
$this->assertSame('', $ref->invoke($cache, $invalid));
}
/**
* @throws \Com\Tecnick\File\Exception
*/
public function testDelete(): void
{
$cache = $this->getTestObject();
$idk = 0;
/** @var array<int, string> $file */
$file = [];
for ($idx = 1; $idx <= 2; ++$idx) {
for ($idy = 1; $idy <= 2; ++$idy) {
$file[$idk] = $cache->getNewFileName((string) $idx, (string) $idy);
\file_put_contents($file[$idk], '');
$this->assertTrue(\file_exists($file[$idk]));
++$idk;
}
}
$f0 = $file[0] ?? '';
$f1 = $file[1] ?? '';
$f2 = $file[2] ?? '';
$f3 = $file[3] ?? '';
// delete a specific type/key pair
$cache->delete('2', '1');
$this->assertFalse(\file_exists($f2));
// delete all entries for type "1"
$cache->delete('1');
$this->assertFalse(\file_exists($f0));
$this->assertFalse(\file_exists($f1));
$this->assertTrue(\file_exists($f3));
// delete everything
$cache->delete();
$this->assertFalse(\file_exists($f3));
}
/**
* @throws \Com\Tecnick\File\Exception
*/
public function testKeyOnlyDeletesAll(): void
{
$cache = $this->getTestObject();
$file = $cache->getNewFileName('foo', 'bar');
\file_put_contents($file, '');
$this->assertTrue(\file_exists($file));
// key-only call should treat as delete all
$cache->delete(null, 'bar');
$this->assertFalse(\file_exists($file));
}
/**
* @throws \Com\Tecnick\File\Exception
*/
public function testDeleteNonExistingPatterns(): void
{
$cache = $this->getTestObject();
$file = $cache->getNewFileName('foo', 'bar');
\file_put_contents($file, '');
$this->assertTrue(\file_exists($file));
// deleting a type that does not exist should leave the file in place
$cache->delete('no-such-type');
$this->assertTrue(\file_exists($file));
// deleting a non-existent key under existing type
$cache->delete('foo', 'no-such-key');
$this->assertTrue(\file_exists($file));
\unlink($file);
}
public function testEachInstanceHasOwnPrefix(): void
{
// Each instance should have its own prefix
$cache1 = new \Com\Tecnick\File\Cache('pfx1');
$cache2 = new \Com\Tecnick\File\Cache('pfx2');
$prefix1 = $cache1->getFilePrefix();
$prefix2 = $cache2->getFilePrefix();
// Prefixes should be different since each instance is independent
$this->assertNotSame($prefix1, $prefix2);
$this->assertStringContainsString('pfx1', $prefix1);
$this->assertStringContainsString('pfx2', $prefix2);
}
public function testEachInstanceHasOwnCachePath(): void
{
$cache1 = new \Com\Tecnick\File\Cache();
$path1 = $cache1->getCachePath();
$tempdir = \sys_get_temp_dir() . '/cache_test_' . \uniqid();
\mkdir($tempdir);
$cache2 = new \Com\Tecnick\File\Cache();
$cache2->setCachePath($tempdir);
$path2 = $cache2->getCachePath();
// Paths should be different for each instance
$this->assertNotSame($path1, $path2);
$this->assertStringContainsString('cache_test_', $path2);
\rmdir($tempdir);
}
// -------------------------------------------------------------------------
// Issue 4: glob-injection sanitisation in delete()
// -------------------------------------------------------------------------
/**
* @throws \Com\Tecnick\File\Exception
*/
public function testDeleteGlobCharsInTypeSanitised(): void
{
$cache = new \Com\Tecnick\File\Cache('safepfx');
// Create a real file we do NOT want deleted.
$real = $cache->getNewFileName('safe', '1');
\file_put_contents($real, '');
$this->assertTrue(\file_exists($real));
// Call delete() with glob metacharacters in $type — must not expand.
$cache->delete('*', null);
// The real file must still exist because '*' was stripped to '' and
// the resulting pattern matched nothing (or only unrelated files).
// If glob injection were possible every file would be gone.
$this->assertTrue(\file_exists($real), 'Glob injection via $type must not delete unrelated files');
\unlink($real);
}
/**
* @throws \Com\Tecnick\File\Exception
*/
public function testDeleteGlobCharsInKeySanitised(): void
{
$cache = new \Com\Tecnick\File\Cache('safepfx2');
$real = $cache->getNewFileName('mytype', 'goodkey');
\file_put_contents($real, '');
$this->assertTrue(\file_exists($real));
// Inject glob metacharacter in $key — must be stripped.
$cache->delete('mytype', '?');
$this->assertTrue(\file_exists($real), 'Glob injection via $key must not delete unrelated files');
\unlink($real);
}
// -------------------------------------------------------------------------
// Issue 6: per-instance cache properties
// -------------------------------------------------------------------------
// Testing covered by testEachInstanceHasOwnPrefix() and testEachInstanceHasOwnCachePath()
// -------------------------------------------------------------------------
// Issue 9: createNewFileName()
// -------------------------------------------------------------------------
// Testing the exception path of getNewFileName() requires tempnam() to return false,
// which is not deterministic in this environment because tempnam() can fall back
// to the system temporary directory.
// -------------------------------------------------------------------------
// Issue 10: deleteOlderThan()
// -------------------------------------------------------------------------
public function testDeleteOlderThanNoFiles(): void
{
// Call deleteOlderThan() when no files exist for this cache prefix.
// glob() returns [] so the early-return on line 171 is exercised.
$cache = new \Com\Tecnick\File\Cache('emptyprefix_' . \uniqid('', true));
$cache->deleteOlderThan(3600);
// No exception thrown is the expected outcome.
$this->expectNotToPerformAssertions();
}
/**
* @throws \Com\Tecnick\File\Exception
*/
public function testDeleteOlderThan(): void
{
$cache = new \Com\Tecnick\File\Cache('ttl');
$old = $cache->getNewFileName('aged', '1');
$fresh = $cache->getNewFileName('aged', '2');
\file_put_contents($old, '');
\file_put_contents($fresh, '');
// Back-date the "old" file to 2 hours ago.
\touch($old, \time() - 7200);
// Delete files older than 1 hour.
$cache->deleteOlderThan(3600);
$this->assertFalse(\file_exists($old), 'Expired file must be deleted');
$this->assertTrue(\file_exists($fresh), 'Fresh file must be kept');
\unlink($fresh);
}
/**
* When the host application defines K_PATH_CACHE without a trailing separator,
* the fallback path must still be normalized so generated files land inside the
* cache directory instead of escaping into its parent (e.g. ".../cache" + name
* yielding ".../cache_<name>"). Runs in a separate process because K_PATH_CACHE
* is a constant that cannot be (re)defined once the cache class has set it.
*
* @throws \Com\Tecnick\File\Exception
*/
#[RunInSeparateProcess]
#[PreserveGlobalState(false)]
public function testGetNewFileNameRespectsCachePathWithoutTrailingSeparator(): void
{
$dir = \sys_get_temp_dir() . \DIRECTORY_SEPARATOR . 'tcfilecache_' . \uniqid('', true);
$this->assertTrue(\mkdir($dir, 0o700));
// Define K_PATH_CACHE without a trailing separator, before the cache class
// has a chance to define it from sys_get_temp_dir().
\define('K_PATH_CACHE', $dir);
try {
$cache = new \Com\Tecnick\File\Cache('trailsep');
// The reported cache path must end with the platform separator.
$cachePath = $cache->getCachePath();
$this->assertSame(\DIRECTORY_SEPARATOR, \substr($cachePath, -1));
$file = $cache->getNewFileName('tmp', '0');
try {
// The generated file must live inside the cache directory.
$this->assertTrue(\str_starts_with($file, $cachePath));
$this->assertSame(\realpath($dir), \realpath(\dirname($file)));
$this->assertTrue(\is_file($file));
} finally {
if (\is_file($file)) {
\unlink($file);
}
}
} finally {
\rmdir($dir);
}
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
/**
* DirTest.php
*
* @since 2015-07-28
* @category Library
* @package File
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2015-2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-file
*
* This file is part of tc-lib-file software library.
*/
namespace Test;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
/**
* File Color class test
*
* @since 2015-07-28
* @category Library
* @package File
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2015-2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-file
*/
class DirTest extends TestUtil
{
protected function getTestObject(): \Com\Tecnick\File\Dir
{
return new \Com\Tecnick\File\Dir();
}
#[DataProvider('getAltFilePathsDataProvider')]
public function testGetAltFilePaths(string $name, string $expected): void
{
$testObj = $this->getTestObject();
$dir = $testObj->findParentDir($name);
$this->bcAssertMatchesRegularExpression('#' . $expected . '#', $dir);
}
/**
* @return array<array{string, string}>
*/
public static function getAltFilePathsDataProvider(): array
{
return [['', '/src/'], ['missing', '/'], ['src', '/src/']];
}
/**
* The upward directory search must not raise open_basedir warnings when it walks
* above the allowed paths (see issue #238). Runs in a separate process because
* open_basedir cannot be relaxed once set.
*/
#[RunInSeparateProcess]
#[PreserveGlobalState(false)]
public function testFindParentDirUnderOpenBasedir(): void
{
$libRoot = \dirname(__DIR__);
// Restrict file access to the library tree (the temp dir is required by the
// test runner). The ancestors of $libRoot fall outside this restriction, so
// probing them would raise open_basedir warnings without the guard.
// @mago-expect lint:no-ini-set -- open_basedir can only be set at runtime in a test.
\ini_set('open_basedir', $libRoot . PATH_SEPARATOR . \sys_get_temp_dir());
$warnings = [];
\set_error_handler(static function (int $_errno, string $errstr) use (&$warnings): bool {
$warnings[] = $errstr;
return true;
});
try {
$dir = $this->getTestObject()->findParentDir('missing', $libRoot . '/src');
} finally {
\restore_error_handler();
}
$this->assertSame([], $warnings, 'open_basedir restriction must not raise warnings');
$this->bcAssertMatchesRegularExpression('#/$#', $dir);
}
}
@@ -0,0 +1,70 @@
<?php
/**
* EmptyReadStreamWrapper.php
*
* @since 2026-04-30
* @category Library
* @package File
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2015-2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-file
*
* This file is part of tc-lib-file software library.
*/
namespace Test;
/**
* A stream wrapper that returns initial data on the first read then returns
* empty strings on all subsequent reads while never signalling EOF via
* stream_eof(). This exercises the inner `break` in File::rfRead() that
* fires when fread() yields an empty string before the while-loop condition
* can detect EOF.
*/
class EmptyReadStreamWrapper
{
public mixed $context;
private string $initialData = 'ab';
private bool $initialRead = false;
// phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool
{
unset($path, $mode, $options, $opened_path);
return true;
}
// phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
public function stream_read(int $count): string
{
unset($count);
if (!$this->initialRead) {
$this->initialRead = true;
return $this->initialData;
}
// Return empty string while still claiming not at EOF so that the
// while-loop in rfRead() re-enters and hits the inner break.
return '';
}
// phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
public function stream_eof(): bool
{
// Never signal EOF — stream_read() will return '' instead.
return false;
}
/**
* @return array<string, mixed>
*/
// phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
public function stream_stat(): array
{
return [];
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
<?php
/**
* RecursiveReadFile.php
*
* @since 2015-07-28
* @category Library
* @package File
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2015-2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-file
*
* This file is part of tc-lib-file software library.
*/
namespace Test;
class RecursiveReadFile extends \Com\Tecnick\File\File
{
protected function hasUnreadBytes(mixed $resource): bool
{
return \is_resource($resource) && !\feof($resource);
}
}
@@ -0,0 +1,61 @@
<?php
/**
* RecursiveReadStreamWrapper.php
*
* @since 2015-07-28
* @category Library
* @package File
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2015-2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-file
*
* This file is part of tc-lib-file software library.
*/
namespace Test;
class RecursiveReadStreamWrapper
{
public mixed $context;
private string $data = 'abcde';
private int $position = 0;
private int $reads = 0;
// phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool
{
unset($path, $mode, $options, $opened_path);
return true;
}
// phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
public function stream_read(int $count): string
{
++$this->reads;
$length = $this->reads === 1 ? \min(2, $count) : $count;
$chunk = \substr($this->data, $this->position, $length);
$this->position += \strlen($chunk);
return $chunk;
}
// phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
public function stream_eof(): bool
{
return $this->position >= \strlen($this->data);
}
/**
* @return array<string, mixed>
*/
// phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
public function stream_stat(): array
{
return [];
}
}
@@ -0,0 +1,66 @@
<?php
/**
* SingleByteStreamWrapper.php
*
* @since 2026-04-30
* @category Library
* @package File
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2015-2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-file
*
* This file is part of tc-lib-file software library.
*/
namespace Test;
/**
* A stream wrapper that delivers exactly one byte per stream_read() call.
* Used to exercise the iterative while-loop in File::rfRead().
*/
class SingleByteStreamWrapper
{
public mixed $context;
private string $data = 'abcdefgh';
private int $position = 0;
// phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool
{
unset($path, $mode, $options, $opened_path);
return true;
}
// phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
public function stream_read(int $count): string
{
unset($count);
// Always return one byte at a time regardless of requested count.
if ($this->position >= \strlen($this->data)) {
return '';
}
$byte = $this->data[$this->position];
++$this->position;
return $byte;
}
// phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
public function stream_eof(): bool
{
return $this->position >= \strlen($this->data);
}
/**
* @return array<string, mixed>
*/
// phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
public function stream_stat(): array
{
return [];
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
/**
* TestUtil.php
*
* @since 2020-12-19
* @category Library
* @package file
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2015-2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-file
*
* This file is part of tc-lib-file software library.
*/
namespace Test;
use PHPUnit\Framework\TestCase;
/**
* Test Util
*
* @since 2020-12-19
* @category Library
* @package file
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2015-2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-file
*/
class TestUtil extends TestCase
{
public function bcAssertEqualsWithDelta(
mixed $expected,
mixed $actual,
float $delta = 0.01,
string $message = '',
): void {
parent::assertEqualsWithDelta($expected, $actual, $delta, $message);
}
/**
* @param class-string<\Throwable> $exception
*/
public function bcExpectException(string $exception): void
{
parent::expectException($exception);
}
public function bcAssertIsResource(mixed $res): void
{
parent::assertIsResource($res);
}
public function bcAssertMatchesRegularExpression(string $pattern, string $string, string $message = ''): void
{
parent::assertMatchesRegularExpression($pattern, $string, $message);
}
}
View File
+10
View File
@@ -0,0 +1,10 @@
<?php
return;
/**
* empty.php - Test HTTP endpoint that returns an empty response body.
*
* Used by FileTest to verify the `$ret === true` branch in getUrlData()
* when CURLOPT_RETURNTRANSFER is not set.
*/
+10
View File
@@ -0,0 +1,10 @@
<?php
/**
* large.php - Test HTTP endpoint that returns a large body.
*
* Used by FileTest to verify that the maxRemoteSize limit is enforced
* via the cURL progress callback.
*/
echo \str_repeat('X', 1000);
+4
View File
@@ -0,0 +1,4 @@
<?php
$target = isset($_GET['to']) && \is_string($_GET['to']) ? $_GET['to'] : '/empty.php';
\header('Location: ' . $target, true, 302);