diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index dcad03c..08ec774 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,15 +24,15 @@ When generating code for this repository: ## Project Structure ``` -quicktree/ # Repository root (install to plugins/quicktree/ in Cacti) -├── images/ # UI icons -├── locales/ # Internationalization files -├── quicktree.css # Client-side styling -├── quicktree.js # Client-side add/remove graph interactions -├── quicktree.php # Main playlist view / management page -├── INFO # Plugin metadata (name, version, compat) +quicktree/ # Repository root (install to plugins/quicktree/ in Cacti) +├── images/ # UI icons +├── locales/ # Internationalization files +├── quicktree.css # Client-side styling +├── quicktree.js # Client-side add/remove graph interactions +├── quicktree.php # Main playlist view / management page +├── INFO # Plugin metadata (name, version, compat) ├── README.md -└── setup.php # Plugin install/uninstall/upgrade hooks +└── setup.php # Plugin install/uninstall/upgrade hooks ``` ## Naming Conventions @@ -173,3 +173,7 @@ existing code or adding new code, not just in dedicated cleanup passes: line, `@param` lines, a blank comment line, then `@return`. Infer parameter/return types from actual usage; don't change the function's real type-hints in the same pass (let static analysis flag mismatches separately). Skip vendored third-party library files. + +## File manifest & upgrade pruning + +The plugin ships a root `manifest.json` with three arrays: `tombstones` (files/directories older versions shipped that have since moved or been removed), `expected` (the top-level files and directories that ship today, directories written with a trailing `/`), and `whitelist` (paths holding user data that must never be touched). Keep `expected` current: CI runs `tests/bin/validate-manifest.php`, which fails on any drift between `expected` and the real top-level tree (it ignores `tests/`, `phpunit.xml`, `.git*`, `.md*`, and whitelisted paths). Custom customer CSS/theme files belong in `expected`, and stylesheets live in `css/` (not `themes/`). On upgrade, `quicktree_prune_files()` deletes the tombstoned paths, the dev-only `tests/` tree, and the `phpunit.xml` test config, leaves `whitelist`, `.git*`, and `.md*` alone, and logs (without removing) any top-level entry the manifest does not account for. As a safety measure it refuses any tombstone that resolves outside the plugin directory (a tampered manifest.json) and logs a warning for any file or directory it cannot remove. When you move or delete a shipped file, add its old path to `tombstones` and update `expected` in the same change. diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index f2c2b45..7e8f379 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -88,6 +88,9 @@ jobs: - name: Check PHP version run: php -v + - name: Validate plugin manifest (expected-file drift) + run: php cacti/plugins/quicktree/tests/bin/validate-manifest.php + - name: Run apt-get update run: sudo apt-get update diff --git a/INFO b/INFO index 356013f..1eabb5c 100644 --- a/INFO +++ b/INFO @@ -20,7 +20,7 @@ ; +-------------------------------------------------------------------------+ [info] -compat = 1.2.17 +compat = 1.2.29 author=The Cacti Group, Howard Jones homepage=https://github.com/howardjones/cacti-quicktree longname=QuickTree for Cacti diff --git a/ui_helpers.php b/includes/helpers.php similarity index 100% rename from ui_helpers.php rename to includes/helpers.php diff --git a/quicktree_security.php b/includes/security.php similarity index 100% rename from quicktree_security.php rename to includes/security.php diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..1a31643 --- /dev/null +++ b/manifest.json @@ -0,0 +1,22 @@ +{ + "tombstones": [ + "quicktree_security.php", + "ui_helpers.php" + ], + "expected": [ + "CHANGELOG.md", + "INFO", + "LICENSE", + "README.md", + "css/", + "images/", + "includes/", + "index.php", + "js/", + "locales/", + "manifest.json", + "quicktree.php", + "setup.php" + ], + "whitelist": [] +} diff --git a/phpunit.xml b/phpunit.xml index 88931d7..0a313ec 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -25,10 +25,9 @@ --> - quicktree.php setup.php - quicktree_security.php - ui_helpers.php + includes/security.php + includes/helpers.php diff --git a/quicktree.php b/quicktree.php index 45e0d62..c56d3b6 100644 --- a/quicktree.php +++ b/quicktree.php @@ -27,8 +27,8 @@ chdir('../../'); include_once('include/auth.php'); -include_once('plugins/quicktree/ui_helpers.php'); -include_once('plugins/quicktree/quicktree_security.php'); +include_once('plugins/quicktree/includes/helpers.php'); +include_once('plugins/quicktree/includes/security.php'); /** @var array $config */ define('QUICKTREE_BASE_URI', $config['url_path'] . 'plugins/quicktree/'); diff --git a/setup.php b/setup.php index de6856b..fefe5d6 100644 --- a/setup.php +++ b/setup.php @@ -439,5 +439,177 @@ function quicktree_check_upgrade(): void { ] ); } + + quicktree_prune_files(); + } +} + +/** + * Removes files and directories that a previous version of this plugin + * shipped but that have since moved or been deleted, using the tombstone + * and whitelist lists in manifest.json. Whitelisted (user-data) paths and + * any VCS metadata (.git*) are never touched; the dev-only tests/ tree is + * removed. Any path that resolves outside the plugin directory (a tampered + * manifest.json) is refused, and any file/directory that cannot be removed + * (e.g. read-only) is reported to the Cacti log. Any top-level entry that is + * neither expected nor a tombstone nor whitelisted is logged to the Cacti + * log and left in place. Called on a plugin version change. + * + * @return void + * + * @global array $config Cacti global configuration array; used to resolve + * the plugin directory. + */ +function quicktree_prune_files(): void { + global $config; + + $plugin_dir = $config['base_path'] . '/plugins/quicktree'; + $manifest_path = $plugin_dir . '/manifest.json'; + + if (!is_readable($manifest_path)) { + return; + } + + $manifest = json_decode((string) file_get_contents($manifest_path), true); + + if (!is_array($manifest)) { + cacti_log('WARNING: quicktree manifest.json could not be parsed; skipping file prune', false, 'QUICKTREE'); + + return; + } + + $tombstones = isset($manifest['tombstones']) && is_array($manifest['tombstones']) ? $manifest['tombstones'] : []; + $expected = isset($manifest['expected']) && is_array($manifest['expected']) ? $manifest['expected'] : []; + $whitelist = isset($manifest['whitelist']) && is_array($manifest['whitelist']) ? $manifest['whitelist'] : []; + + $protected = function (string $rel) use ($whitelist): bool { + if (strncmp($rel, '.git', 4) === 0 || strncmp($rel, '.md', 3) === 0) { + return true; + } + + foreach ($whitelist as $entry) { + $entry = trim((string) $entry, '/'); + + if ($entry !== '' && ($rel === $entry + || strncmp($rel, $entry . '/', strlen($entry) + 1) === 0 + || strncmp($entry, $rel . '/', strlen($rel) + 1) === 0)) { + return true; + } + } + + return false; + }; + + // Security: resolve the plugin directory so a tampered manifest.json + // cannot steer the prune outside of it. + $plugin_real = realpath($plugin_dir); + + // Remove tombstoned (moved/deleted) paths plus the dev-only tests/ + // tree and the phpunit.xml test configuration. + $remove = $tombstones; + $remove[] = 'tests/'; + $remove[] = 'phpunit.xml'; + + foreach ($remove as $rel) { + $rel = trim((string) $rel, '/'); + + if ($rel === '' || $protected($rel)) { + continue; + } + + // A tombstone must never contain '.'/'..' segments; a tampered manifest + // could use them to escape the plugin directory or target its root. + $segments = explode('/', $rel); + + if (in_array('.', $segments, true) || in_array('..', $segments, true)) { + cacti_log(sprintf('WARNING: quicktree prune refused to remove %s: path contains a traversal segment (tampered manifest.json?)', $rel), false, 'QUICKTREE'); + + continue; + } + + $path = $plugin_dir . '/' . $rel; + + if (!is_link($path) && !file_exists($path)) { + continue; + } + + // Refuse any path that, after resolving symlinks and ../ segments, + // escapes the plugin directory (protects user data from a tampered + // manifest.json). + $anchor = is_link($path) ? dirname($path) : $path; + $real = realpath($anchor); + + if ($real === false || ($real !== $plugin_real && strncmp($real, $plugin_real . DIRECTORY_SEPARATOR, strlen((string) $plugin_real) + 1) !== 0)) { + cacti_log(sprintf('WARNING: quicktree prune refused to remove %s: path resolves outside the plugin directory (tampered manifest.json?)', $rel), false, 'QUICKTREE'); + + continue; + } + + if (is_dir($path) && !is_link($path)) { + $removed = quicktree_rmtree($path); + } else { + $removed = @unlink($path); + } + + if (!$removed) { + cacti_log(sprintf('WARNING: quicktree upgrade could not remove %s (check file/directory permissions)', $rel), false, 'QUICKTREE'); + } + } + + // Surface any top-level entry the manifest does not account for. + $known = []; + + foreach (array_merge($expected, $tombstones) as $entry) { + $top = explode('/', trim((string) $entry, '/'))[0]; + + if ($top !== '') { + $known[$top] = true; + } + } + + $entries = scandir($plugin_dir); + + foreach (($entries !== false ? $entries : []) as $entry) { + if ($entry === '.' || $entry === '..' || $entry === 'tests' || $entry === 'phpunit.xml' || $protected($entry) || isset($known[$entry])) { + continue; + } + + cacti_log(sprintf('WARNING: quicktree upgrade found a file/directory not described in manifest.json: %s (left in place)', $entry), false, 'QUICKTREE'); + } +} + +/** + * Recursively deletes a directory and its contents. Symlinks are removed + * without being followed. Helper for quicktree_prune_files(). + * + * @param string $dir Absolute path to the directory to remove. + * + * @return bool True if the directory and everything under it was removed; + * false if any entry could not be deleted. + */ +function quicktree_rmtree(string $dir): bool { + $entries = scandir($dir); + $ok = true; + + foreach (($entries !== false ? $entries : []) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $dir . '/' . $entry; + + if (is_dir($path) && !is_link($path)) { + if (!quicktree_rmtree($path)) { + $ok = false; + } + } elseif (!@unlink($path)) { + $ok = false; + } + } + + if (!@rmdir($dir)) { + $ok = false; } + + return $ok; } diff --git a/tests/Integration/QuicktreeRedirectSafetyTest.php b/tests/Integration/QuicktreeRedirectSafetyTest.php index df6bc1f..9802330 100644 --- a/tests/Integration/QuicktreeRedirectSafetyTest.php +++ b/tests/Integration/QuicktreeRedirectSafetyTest.php @@ -15,7 +15,7 @@ $contents = file_get_contents($path); expect($contents)->not->toBeFalse(); - expect($contents)->toContain("include_once('plugins/quicktree/quicktree_security.php');"); + expect($contents)->toContain("include_once('plugins/quicktree/includes/security.php');"); expect($contents)->toContain("\$location = quicktree_normalize_location(get_nfilter_request_var('location'));"); expect($contents)->toContain("header('Location: quicktree.php?location=' . \$location);"); expect($contents)->toContain("form_start('quicktree.php?location=' . \$location, 'quicktree_form');"); diff --git a/tests/Unit/FormWrapperTest.php b/tests/Unit/FormWrapperTest.php index 3ddd102..e333f80 100644 --- a/tests/Unit/FormWrapperTest.php +++ b/tests/Unit/FormWrapperTest.php @@ -14,7 +14,7 @@ * $GLOBALS['__test_request_vars'], which lets this file drive both branches * of quicktree_action_form_begin(). */ -require_once __DIR__ . '/../../ui_helpers.php'; +require_once __DIR__ . '/../../includes/helpers.php'; describe('shared quicktree action-form wrappers', function () { it('begin/end wrapper includes top header when header var is not set', function () { @@ -51,7 +51,7 @@ it('quicktree.php includes ui helper file', function () { $source = file_get_contents(realpath(__DIR__ . '/../../quicktree.php')); - expect(preg_match('/(?:include_once|require_once)\s*\(\s*[\'"]plugins\/quicktree\/ui_helpers\.php[\'"]\s*\)\s*;/', $source))->toBe(1); + expect(preg_match('/(?:include_once|require_once)\s*\(\s*[\'"]plugins\/quicktree\/includes\/helpers\.php[\'"]\s*\)\s*;/', $source))->toBe(1); }); it('quicktree.php uses begin wrapper in add-tree and add-branch', function () { diff --git a/tests/Unit/LocationNormalizationTest.php b/tests/Unit/LocationNormalizationTest.php index 7a0d175..0ac698d 100644 --- a/tests/Unit/LocationNormalizationTest.php +++ b/tests/Unit/LocationNormalizationTest.php @@ -7,7 +7,7 @@ +-------------------------------------------------------------------------+ */ -require_once __DIR__ . '/../../quicktree_security.php'; +require_once __DIR__ . '/../../includes/security.php'; describe('quicktree location normalization', function () { it('allows only the supported quicktree locations', function () { diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php new file mode 100644 index 0000000..c9d152e --- /dev/null +++ b/tests/Unit/PruneFilesTest.php @@ -0,0 +1,256 @@ + ['include/', 'oldfile.php', 'userdata/', 'gone.png'], + 'expected' => ['INFO', 'setup.php', 'includes/', 'manifest.json'], + 'whitelist' => ['userdata/'], + ]; + + $base = quicktree_prune_fixture($manifest); + $plugin = $base . '/plugins/quicktree'; + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + quicktree_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // Tombstone and the dev-only tests/ tree are gone. + expect(is_dir($plugin . '/include'))->toBeFalse(); + expect(is_dir($plugin . '/tests'))->toBeFalse(); + expect(is_file($plugin . '/oldfile.php'))->toBeFalse(); + expect(is_file($plugin . '/phpunit.xml'))->toBeFalse(); + + // Whitelisted user data, VCS metadata, and expected files are untouched. + // (userdata/ is even listed as a tombstone, but the whitelist wins.) + expect(is_file($plugin . '/userdata/keep.dat'))->toBeTrue(); + expect(is_dir($plugin . '/.git'))->toBeTrue(); + expect(is_file($plugin . '/INFO'))->toBeTrue(); + expect(is_dir($plugin . '/includes'))->toBeTrue(); + expect(is_file($plugin . '/.mdlrc'))->toBeTrue(); + expect(is_file($plugin . '/.md_style.rb'))->toBeTrue(); + + // An unexpected, non-whitelisted stray is left in place but logged. + expect(is_file($plugin . '/stray.php'))->toBeTrue(); + + $logged = implode("\n", $GLOBALS['__test_cacti_log']); + expect($logged)->toContain('stray.php'); + expect($logged)->not->toContain('userdata'); + expect($logged)->not->toContain('.git'); + expect($logged)->not->toContain('.mdlrc'); + expect($logged)->not->toContain('.md_style.rb'); +}); + +it('is a safe no-op when the manifest is missing', function () { + $base = sys_get_temp_dir() . '/quicktree-prune-missing-' . uniqid(); + mkdir($base . '/plugins/quicktree', 0777, true); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + quicktree_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + expect($GLOBALS['__test_cacti_log'])->toBe([]); +}); + +it('logs and skips pruning when the manifest is malformed', function () { + $base = sys_get_temp_dir() . '/quicktree-prune-bad-' . uniqid(); + $plugin = $base . '/plugins/quicktree'; + mkdir($plugin, 0777, true); + file_put_contents($plugin . '/manifest.json', 'not json'); + mkdir($plugin . '/tests', 0777, true); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + quicktree_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // A malformed manifest must not delete anything. + expect(is_dir($plugin . '/tests'))->toBeTrue(); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('could not be parsed'); +}); + +it('refuses to remove a tombstone that resolves outside the plugin directory', function () { + $manifest = [ + 'tombstones' => ['../escapee.txt'], + 'expected' => ['manifest.json'], + 'whitelist' => [], + ]; + + $base = quicktree_prune_fixture($manifest); + $plugin = $base . '/plugins/quicktree'; + $outside = $base . '/plugins/escapee.txt'; + file_put_contents($outside, 'precious user data'); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + quicktree_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // The out-of-tree file is untouched and the refusal is logged. + expect(is_file($outside))->toBeTrue(); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('a traversal segment'); +}); + +it('warns when a tombstoned path cannot be removed', function () { + $manifest = [ + 'tombstones' => ['locked/'], + 'expected' => ['manifest.json'], + 'whitelist' => [], + ]; + + $base = quicktree_prune_fixture($manifest); + $plugin = $base . '/plugins/quicktree'; + mkdir($plugin . '/locked/sub', 0777, true); + file_put_contents($plugin . '/locked/sub/data', 'x'); + chmod($plugin . '/locked/sub', 0500); // read-only dir: its child cannot be unlinked + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + set_error_handler(static fn () => true); // swallow the expected unlink warning + + try { + quicktree_prune_files(); + } finally { + restore_error_handler(); + $GLOBALS['config']['base_path'] = $restore; + @chmod($plugin . '/locked/sub', 0700); + } + + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('could not remove'); +})->skip(function () { + return function_exists('posix_getuid') && posix_getuid() === 0; +}, 'permission checks are bypassed for the root user'); + +it('refuses a tombstone that escapes through a symlinked directory', function () { + $manifest = [ + 'tombstones' => ['escdir/secret.txt'], + 'expected' => ['manifest.json'], + 'whitelist' => [], + ]; + + $base = quicktree_prune_fixture($manifest); + $plugin = $base . '/plugins/quicktree'; + $outside = $base . '/outside'; + mkdir($outside, 0777, true); + file_put_contents($outside . '/secret.txt', 'precious user data'); + @symlink($outside, $plugin . '/escdir'); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + quicktree_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // The out-of-tree file reached through the symlink is untouched and logged. + expect(is_file($outside . '/secret.txt'))->toBeTrue(); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('outside the plugin directory'); +})->skip(function () { + $probe = sys_get_temp_dir() . '/.prune-symlink-probe-' . uniqid(); + $ok = @symlink(__FILE__, $probe); + @unlink($probe); + + return $ok === false; +}, 'symlinks are not supported on this filesystem'); + +it('protects a whitelisted file from a tombstone on its parent directory', function () { + $manifest = [ + 'tombstones' => ['userdata/'], + 'expected' => ['manifest.json'], + 'whitelist' => ['userdata/keep.dat'], + ]; + + $base = quicktree_prune_fixture($manifest); + $plugin = $base . '/plugins/quicktree'; + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + quicktree_prune_files(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } + + // A whitelisted file shields its parent directory from a tombstone. + expect(is_file($plugin . '/userdata/keep.dat'))->toBeTrue(); +}); diff --git a/tests/Unit/QuicktreeCheckUpgradeTest.php b/tests/Unit/QuicktreeCheckUpgradeTest.php index 48dc143..e39321c 100644 --- a/tests/Unit/QuicktreeCheckUpgradeTest.php +++ b/tests/Unit/QuicktreeCheckUpgradeTest.php @@ -21,6 +21,22 @@ $GLOBALS['__test_registered_hooks'] = array(); $GLOBALS['__test_db_fetch_cell_prepared_return'] = ''; unset($_SERVER['PHP_SELF']); + + // Sandbox base_path so the version-drift branch runs + // quicktree_prune_files() against a throwaway tree with no + // manifest.json (prune no-ops), never the real checkout. The temp tree + // carries a copy of the real INFO so plugin_quicktree_version() still matches. + $GLOBALS['__quicktree_base_restore'] = $GLOBALS['config']['base_path']; + $base = sys_get_temp_dir() . '/quicktree-test-' . uniqid(); + mkdir($base . '/plugins/quicktree', 0777, true); + copy(__DIR__ . '/../../INFO', $base . '/plugins/quicktree/INFO'); + $GLOBALS['config']['base_path'] = $base; +}); + +afterEach(function () { + if (isset($GLOBALS['__quicktree_base_restore'])) { + $GLOBALS['config']['base_path'] = $GLOBALS['__quicktree_base_restore']; + } }); it('does nothing when the current page is not in the allowed list', function () { diff --git a/tests/Unit/QuicktreeConfigSettingsAndArraysTest.php b/tests/Unit/QuicktreeConfigSettingsAndArraysTest.php index b74bac1..93667c6 100644 --- a/tests/Unit/QuicktreeConfigSettingsAndArraysTest.php +++ b/tests/Unit/QuicktreeConfigSettingsAndArraysTest.php @@ -17,6 +17,21 @@ beforeEach(function () { $GLOBALS['__test_config_options'] = array(); unset($_SERVER['PHP_SELF']); + + // Sandbox base_path so quicktree_config_arrays()->quicktree_check_upgrade()'s + // version-drift prune runs against a throwaway tree with no manifest.json + // (prune no-ops), never the real checkout. + $GLOBALS['__quicktree_base_restore'] = $GLOBALS['config']['base_path']; + $base = sys_get_temp_dir() . '/quicktree-test-' . uniqid(); + mkdir($base . '/plugins/quicktree', 0777, true); + copy(__DIR__ . '/../../INFO', $base . '/plugins/quicktree/INFO'); + $GLOBALS['config']['base_path'] = $base; +}); + +afterEach(function () { + if (isset($GLOBALS['__quicktree_base_restore'])) { + $GLOBALS['config']['base_path'] = $GLOBALS['__quicktree_base_restore']; + } }); it('adds the misc tab and quicktree settings when no settings exist yet', function () { diff --git a/tests/bin/patch-coverage.php b/tests/bin/patch-coverage.php index e65b53d..00f67c5 100644 --- a/tests/bin/patch-coverage.php +++ b/tests/bin/patch-coverage.php @@ -160,6 +160,8 @@ function changed_lines($base_ref) { * Empty by default; add entries per repository as the need arises. */ $unmeasured_allowlist = [ + // web UI entry point (chdir + include auth.php); not loadable in the isolated unit process + 'quicktree.php', ]; $unmeasured = array_values(array_diff(array_keys($changed), array_keys($measured))); $unexpected_unmeasured = array_values(array_diff($unmeasured, $unmeasured_allowlist)); diff --git a/tests/bin/validate-manifest.php b/tests/bin/validate-manifest.php new file mode 100644 index 0000000..c54fab5 --- /dev/null +++ b/tests/bin/validate-manifest.php @@ -0,0 +1,73 @@ +