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>
This commit is contained in:
Thomas Vincent authored and GitHub committed 2026-08-31 17:07:36 -04:00
1 parent 3045b4665e
commit c77906b144
8 files changed
+405 -129

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
name: MD5 Finder Coverage
on:
push:
branches: [develop]
paths:
- '.github/workflows/md5-finder-coverage.yml'
- '.github/actions/setup-php-composer/**'
- 'composer.json'
- 'composer.lock'
- 'lib/CactiMd5FileFinder.php'
- 'phpunit-md5-finder-coverage.xml'
- 'tests/Unit/CactiMd5FileFinderTest.php'
pull_request:
branches: [develop]
paths:
- '.github/workflows/md5-finder-coverage.yml'
- '.github/actions/setup-php-composer/**'
- 'composer.json'
- 'composer.lock'
- 'lib/CactiMd5FileFinder.php'
- 'phpunit-md5-finder-coverage.xml'
- 'tests/Unit/CactiMd5FileFinderTest.php'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: md5-finder-coverage-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
COMPOSER_ALLOW_SUPERUSER: 1
jobs:
coverage:
name: MD5 finder 100% coverage
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- name: Setup locked PHP 8.2 dependencies
uses: ./.github/actions/setup-php-composer
with:
php-version: '8.2'
coverage: xdebug
extensions: intl, mysql, gd, ldap, gmp, xml, curl, json, mbstring
dependency-mode: locked-md5-finder-coverage
install-args: --prefer-dist --no-progress --no-interaction
- name: Enforce line coverage
env:
XDEBUG_MODE: coverage
run: |
set -euo pipefail
include/vendor/bin/pest \
--configuration=phpunit-md5-finder-coverage.xml \
--coverage \
--coverage-text \
--min=100
+1
View File
@@ -139,6 +139,7 @@ Cacti CHANGELOG
-issue#7536: Consolidate the four hand-written poller_output_boost inserts into one shared helper
-issue#7535: Consolidate the poller_item delete call sites behind poller_item_delete_for_data_source() and poller_item_delete_for_host()
-issue#7576: Stop passing an unset path_php_binary into string parameters
-issue#7718: Discover files recursively for the MD5 listing with Symfony Finder
-issue#7673: Require a POST and a CSRF token for the delete actions that were reachable over GET
-issue#7674: Enforce tree access when creating, deleting and renaming tree nodes
-issue#7682: Revoke the remember-me token and terminate other sessions on logout
+20 -60
View File
@@ -23,8 +23,11 @@
+-------------------------------------------------------------------------+
*/
use Symfony\Component\Finder\Exception\DirectoryNotFoundException;
require(__DIR__ . '/../include/cli_check.php');
require_once(CACTI_PATH_LIBRARY . '/CactiFilesystem.php');
require_once(CACTI_PATH_LIBRARY . '/CactiMd5FileFinder.php');
// process calling arguments
$parms = $_SERVER['argv'];
@@ -151,7 +154,23 @@ foreach ($ignore_files as $ignore) {
}
$ignore_regex = "~($ignore_regex)~";
$file_array = dirToArray('', $base_dir, $ignore_regex);
$excluded_directories = ['.git', 'cache', 'log', 'plugins', 'rra'];
$file_finder = new CactiMd5FileFinder();
$debug_callback = null;
if (!$quiet && $debug) {
print PHP_EOL . "Searching '$base_dir' ..." . PHP_EOL;
$debug_callback = static function (string $message) : void {
print $message . PHP_EOL;
};
}
try {
$file_array = $file_finder->findHashes($base_dir, $ignore_regex, $excluded_directories, $debug_callback);
} catch (DirectoryNotFoundException | InvalidArgumentException $e) {
printf('ERROR: %s' . PHP_EOL, $e->getMessage());
exit(6);
}
if ($create) {
$output = '';
@@ -237,65 +256,6 @@ if ($create) {
}
}
function dirToArray(mixed $dir, string $base, string $ignore) : array {
global $debug, $quiet;
$result = [];
$fulldir = $base;
if ($dir != '') {
$fulldir .= DIRECTORY_SEPARATOR . $dir;
}
$fulldir = realpath($fulldir);
if (str_contains($fulldir, $base)) {
if (is_dir($fulldir)) {
$cdir = scandir($fulldir);
} else {
$cdir = [];
}
if (!$quiet && $debug) {
print PHP_EOL . "Searching '$fulldir' ..." . PHP_EOL;
}
$dir_list = [];
foreach ($cdir as $key => $value) {
$fullpath = $fulldir . DIRECTORY_SEPARATOR . $value;
$partpath = substr($fullpath,strlen($base));
if (preg_match($ignore,$partpath) == 0) {
if (is_dir($fullpath)) {
$dir_list[] = $partpath;
} else {
$md5_sum = @md5_file($fullpath);
if (!$quiet && $debug) {
print "[$md5_sum] $value" . PHP_EOL;
}
$result[substr($partpath,1)] = $md5_sum;
}
} else {
if (!$quiet && $debug) {
print "[ Ignored] $value" . PHP_EOL;
}
}
}
foreach ($dir_list as $partpath) {
$result = array_merge($result, dirToArray($partpath, $base, $ignore));
}
} elseif (!$quiet && ($debug || !strlen($dir))) {
$value = substr($dir,strlen(dirname($dir)) + 1);
print "[ Outside Base, Ignored] $value" . PHP_EOL;
}
return $result;
}
/**
* display_version - displays version information
*
+1
View File
@@ -57,6 +57,7 @@
"stevenmaguire/oauth2-keycloak": "^6.1.0",
"stevenmaguire/oauth2-microsoft": "^2.2",
"symfony/filesystem": "^7.4",
"symfony/finder": "^6.4 || ^7.4",
"symfony/http-foundation": "^7.4",
"symfony/mailer": "^7.4",
"symfony/notifier": "^7.4",
Generated
+69 -69
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "8b11d855049443d645aa8a3cdfb2ed37",
"content-hash": "ff3b05dc45eb4a6c840368e2384f1bea",
"packages": [
{
"name": "doctrine/lexer",
@@ -2281,6 +2281,74 @@
],
"time": "2026-08-21T12:09:28+00:00"
},
{
"name": "symfony/finder",
"version": "v6.4.44",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
"reference": "211b28d13d044dacdc00c1629a3bbcff27dc793a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/finder/zipball/211b28d13d044dacdc00c1629a3bbcff27dc793a",
"reference": "211b28d13d044dacdc00c1629a3bbcff27dc793a",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"require-dev": {
"symfony/filesystem": "^6.0|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Finder\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/finder/tree/v6.4.44"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-08-21T10:00:03+00:00"
},
{
"name": "symfony/http-foundation",
"version": "v7.4.17",
@@ -7644,74 +7712,6 @@
],
"time": "2026-08-21T12:09:28+00:00"
},
{
"name": "symfony/finder",
"version": "v6.4.44",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
"reference": "211b28d13d044dacdc00c1629a3bbcff27dc793a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/finder/zipball/211b28d13d044dacdc00c1629a3bbcff27dc793a",
"reference": "211b28d13d044dacdc00c1629a3bbcff27dc793a",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"require-dev": {
"symfony/filesystem": "^6.0|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Finder\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/finder/tree/v6.4.44"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-08-21T10:00:03+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v7.4.8",
+92
View File
@@ -0,0 +1,92 @@
<?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\Finder\Finder;
use Symfony\Component\Finder\Exception\DirectoryNotFoundException;
final class CactiMd5FileFinder {
public function findHashes(string $basePath, string $ignoreRegex, array $excludedDirectories = [], ?Closure $debug = null) : array {
if (!is_dir($basePath) || !is_readable($basePath)) {
throw new DirectoryNotFoundException("The directory '$basePath' does not exist or is not readable.");
}
/* preg_match() returns false on a bad pattern, and false is not 0, so an
* unchecked test would quietly ignore every file and write an empty manifest. */
// the handler swallows the compile warning; the return value is the verdict
set_error_handler(static fn () : bool => true);
$patternIsValid = preg_match($ignoreRegex, '') !== false;
restore_error_handler();
if (!$patternIsValid) {
throw new InvalidArgumentException("The ignore pattern '$ignoreRegex' is not a valid regular expression.");
}
$finder = (new Finder())
->files()
->ignoreDotFiles(false)
->ignoreVCS(false)
->ignoreUnreadableDirs()
->exclude($excludedDirectories)
->in($basePath);
$hashes = [];
foreach ($finder as $file) {
$relativePath = str_replace(DIRECTORY_SEPARATOR, '/', $file->getRelativePathname());
if (preg_match($ignoreRegex, '/' . $relativePath) === 1) {
if ($debug !== null) {
$debug('[ Ignored] ' . $file->getFilename());
}
continue;
}
$md5 = @md5_file($file->getPathname());
if ($debug !== null) {
$debug("[$md5] " . $file->getFilename());
}
$hashes[$relativePath] = $md5;
}
uksort($hashes, self::compareManifestPaths(...));
return $hashes;
}
private static function compareManifestPaths(string $left, string $right) : int {
$leftParts = explode('/', $left);
$rightParts = explode('/', $right);
$partCount = min(count($leftParts), count($rightParts));
for ($index = 0; $index < $partCount; $index++) {
$leftIsFile = $index === count($leftParts) - 1;
$rightIsFile = $index === count($rightParts) - 1;
if ($leftIsFile !== $rightIsFile) {
return $leftIsFile ? -1 : 1;
}
$comparison = strcmp($leftParts[$index], $rightParts[$index]);
if ($comparison !== 0) {
return $comparison;
}
}
return count($leftParts) <=> count($rightParts);
}
}
+16
View File
@@ -0,0 +1,16 @@
<phpunit bootstrap="tests/bootstrap-unit.php" beStrictAboutOutputDuringTests="false">
<php>
<ini name="error_reporting" value="-1"/>
<env name="CACTI_TEST_BOOTSTRAP" value="1"/>
</php>
<source>
<include>
<file>lib/CactiMd5FileFinder.php</file>
</include>
</source>
<testsuites>
<testsuite name="MD5 finder coverage">
<file>tests/Unit/CactiMd5FileFinderTest.php</file>
</testsuite>
</testsuites>
</phpunit>
+140
View File
@@ -0,0 +1,140 @@
<?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);
});