Files
cacti/tests/Unit/CactiMd5FileFinderTest.php
Thomas Vincent c77906b144 Use a maintained finder for recursive MD5 discovery (#7718)
* Use Symfony Finder for MD5 traversal

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* chore: add the CHANGELOG entry

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(md5sum): reject a bad ignore pattern instead of ignoring every file

preg_match() returns false on a bad pattern and false is not 0, so the old
test skipped the whole tree and wrote an empty manifest.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* style: use a line comment to satisfy php-cs-fixer

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: resolve includes through Cacti path constants

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test(md5): enforce full finder coverage

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* Move Finder import before CLI bootstrap

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* build: lock Symfony Finder runtime dependency

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* build: leave Composer autoload metadata generated

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: run Finder coverage on PHP 8.2

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix: widen the symfony/finder constraint for PHP 8.5

Pinning ^6.4 left the dev dependency graph unsatisfiable on PHP 8.5 in the native-dependencies job.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
2026-08-31 17:07:36 -04:00

141 lines
5.4 KiB
PHP

<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Exception\DirectoryNotFoundException;
require_once(CACTI_PATH_LIBRARY . '/CactiMd5FileFinder.php');
beforeEach(function () {
$this->filesystem = new Filesystem();
$this->directory = sys_get_temp_dir() . '/cacti-finder-' . bin2hex(random_bytes(8));
$this->outside = $this->directory . '-sibling';
$this->filesystem->mkdir([
$this->directory . '/nested',
$this->directory . '/nested/deeper',
$this->directory . '/cache',
$this->directory . '/nested/plugins',
$this->outside,
]);
$this->filesystem->dumpFile($this->directory . '/visible.php', 'visible');
$this->filesystem->dumpFile($this->directory . '/.env', 'hidden');
$this->filesystem->dumpFile($this->directory . '/ignored.log', 'log');
$this->filesystem->dumpFile($this->directory . '/nested/file.php', 'nested');
$this->filesystem->dumpFile($this->directory . '/nested/deeper/file.php', 'deeper');
$this->filesystem->dumpFile($this->directory . '/cache/cache.php', 'cache');
$this->filesystem->dumpFile($this->directory . '/nested/plugins/plugin.php', 'plugin');
$this->filesystem->dumpFile($this->outside . '/outside.php', 'outside');
});
afterEach(function () {
$this->filesystem->remove([$this->directory, $this->outside]);
});
function cactiFinderIgnoreRegex() : string {
return '~((\.log$)|(/\.htaccess$))~';
}
test('findHashes returns stable relative paths and hashes', function () {
$finder = new CactiMd5FileFinder();
$hashes = $finder->findHashes($this->directory, cactiFinderIgnoreRegex(), ['cache', 'plugins']);
expect(array_keys($hashes))->toBe(['.env', 'visible.php', 'nested/file.php', 'nested/deeper/file.php'])
->and($hashes['visible.php'])->toBe(md5('visible'))
->and($hashes['nested/file.php'])->toBe(md5('nested'))
->and($hashes['nested/deeper/file.php'])->toBe(md5('deeper'));
});
test('findHashes preserves hidden files when they are not ignored', function () {
$finder = new CactiMd5FileFinder();
$hashes = $finder->findHashes($this->directory, cactiFinderIgnoreRegex(), ['cache', 'plugins']);
expect($hashes)->toHaveKey('.env');
});
test('findHashes reports filtered files through the debug callback', function () {
$messages = [];
$finder = new CactiMd5FileFinder();
$finder->findHashes(
$this->directory,
cactiFinderIgnoreRegex(),
['cache', 'plugins'],
static function (string $message) use (&$messages) : void {
$messages[] = $message;
}
);
expect($messages)->toContain('[ Ignored] ignored.log')
->and(implode("\n", $messages))->toContain(md5('visible'));
});
test('findHashes does not follow a sibling-prefix symlink target', function () {
if (DIRECTORY_SEPARATOR === '\\') {
$this->markTestSkipped('Symlink creation is not consistently available on Windows.');
}
if (!@symlink($this->outside, $this->directory . '/outside-link')) {
$this->markTestSkipped('Symlink creation is not permitted in this environment.');
}
$finder = new CactiMd5FileFinder();
$hashes = $finder->findHashes($this->directory, cactiFinderIgnoreRegex(), ['cache', 'plugins']);
expect($hashes)->not->toHaveKey('outside-link/outside.php');
});
test('findHashes rejects a missing root directory', function () {
$finder = new CactiMd5FileFinder();
expect(fn () => $finder->findHashes($this->directory . '/missing', cactiFinderIgnoreRegex()))
->toThrow(DirectoryNotFoundException::class);
});
test('findHashes rejects an unreadable root directory', function () {
if (DIRECTORY_SEPARATOR === '\\') {
$this->markTestSkipped('POSIX directory permissions are not available on Windows.');
}
chmod($this->directory, 0000);
clearstatcache(true, $this->directory);
if (is_readable($this->directory)) {
chmod($this->directory, 0700);
$this->markTestSkipped('The test process can bypass directory permissions.');
}
try {
$finder = new CactiMd5FileFinder();
expect(fn () => $finder->findHashes($this->directory, cactiFinderIgnoreRegex()))
->toThrow(DirectoryNotFoundException::class);
} finally {
chmod($this->directory, 0700);
}
});
test('findHashes rejects an ignore pattern that is not a valid regex', function () {
$finder = new CactiMd5FileFinder();
// an unchecked preg_match() would return false here and ignore every file
expect(fn () => $finder->findHashes($this->directory, '/unterminated'))
->toThrow(InvalidArgumentException::class);
});
test('the manifest comparator treats identical paths as equal', function () {
$comparator = new ReflectionMethod(CactiMd5FileFinder::class, 'compareManifestPaths');
$comparator->setAccessible(true);
expect($comparator->invoke(null, 'nested/file.php', 'nested/file.php'))->toBe(0);
});