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 @@
+