diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 9736814..0033eb3 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -4,7 +4,7 @@
When generating code for this repository:
-1. **Version Compatibility**: This is a Cacti plugin (`slowlog`, version 2.1) targeting Cacti 1.2.32+
+1. **Version Compatibility**: This is a Cacti plugin (`slowlog`, version 2.1) targeting Cacti 1.2.29+
2. **Context Files**: Prioritize patterns and standards defined in this file (`.github/copilot-instructions.md`)
3. **Codebase Patterns**: When context files don't provide specific guidance, scan the codebase for established patterns
4. **Architectural Consistency**: Maintain plugin-based architecture extending Cacti core
@@ -20,26 +20,26 @@ When generating code for this repository:
### Key Dependencies
- Cacti core framework (`api_plugin_*`, `db_*`)
- `js/` chart rendering for slow-query analysis views
-- `keywords.txt` reserved-word reference used by the log parser
+- `docs/keywords.txt` reserved-word reference used by the log parser
## Project Structure
```
-slowlog/ # Repository root (install to plugins/slowlog/ in Cacti)
-├── images/ # UI icons
-├── includes/ # Library/helper files, require_once'd from the entry points
-│ ├── database.php # Schema management: table defs + create/upgrade/drop helpers
-│ └── slowlog_functions.php # Log parsing, import, and charting logic
-├── js/ # Chart rendering client-side code
-├── locales/ # Internationalization files
-├── tests/ # Test suite
-├── themes/ # CSS theme overlays
-├── import_log.php # CLI slow-query-log importer
-├── keywords.txt # SQL reserved-word list used by the parser
-├── slowlog.php # Main viewer/administration UI
-├── INFO # Plugin metadata (name, version, compat)
+slowlog/ # Repository root (install to plugins/slowlog/ in Cacti)
+├── images/ # UI icons
+├── includes/ # Library/helper files, require_once'd from the entry points
+│ ├── database.php # Schema management: table defs + create/upgrade/drop helpers
+│ └── slowlog_functions.php # Log parsing, import, and charting logic
+├── js/ # Chart rendering client-side code
+├── locales/ # Internationalization files
+├── tests/ # Test suite
+├── docs/ # keywords.txt (SQL reserved-word list used by the parser)
+├── css/ # CSS theme overlays
+├── import_log.php # CLI slow-query-log importer
+├── slowlog.php # Main viewer/administration UI
+├── INFO # Plugin metadata (name, version, compat)
├── README.md
-└── setup.php # Plugin install/uninstall/upgrade hooks
+└── setup.php # Plugin install/uninstall/upgrade hooks
```
## Naming Conventions
@@ -83,7 +83,7 @@ db_execute("DELETE FROM plugin_slowlog WHERE logid = $logid");
```
### Log Import Handling
-`import_logfile()`/`slowlog_import()` parse arbitrary uploaded/imported slow-query-log text. Treat log contents as untrusted: never `eval()` or directly execute parsed queries, and use `keywords.txt`-driven tokenizing (`is_reserved_word()`) rather than ad hoc regex that could mis-parse crafted input.
+`import_logfile()`/`slowlog_import()` parse arbitrary uploaded/imported slow-query-log text. Treat log contents as untrusted: never `eval()` or directly execute parsed queries, and use `docs/keywords.txt`-driven tokenizing (`is_reserved_word()`) rather than ad hoc regex that could mis-parse crafted input.
### Input Validation
Use `get_filter_request_var()` / `get_nfilter_request_var()` for request input; never read `$_GET`/`$_POST` directly.
@@ -223,3 +223,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, `slowlog_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 17aa76b..3f95d94 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/slowlog/tests/bin/validate-manifest.php
+
- name: Run apt-get update
run: sudo apt-get update
diff --git a/INFO b/INFO
index d394f46..8579854 100644
--- a/INFO
+++ b/INFO
@@ -5,5 +5,5 @@ longname = MySQL/MariaDB Slow Log Viewer
author = The Cacti Group
email =
homepage = http://www.cacti.net
-compat = 1.2.32
+compat = 1.2.29
capabilities = online_view:1, online_mgmt:1, offline_view:0, offline_mgmt:0, remote_collect:0
diff --git a/themes/modern/apexcharts.css b/css/modern_apexcharts.css
similarity index 100%
rename from themes/modern/apexcharts.css
rename to css/modern_apexcharts.css
diff --git a/keywords.txt b/docs/keywords.txt
similarity index 100%
rename from keywords.txt
rename to docs/keywords.txt
diff --git a/includes/database.php b/includes/database.php
index d2651ce..63bf23a 100644
--- a/includes/database.php
+++ b/includes/database.php
@@ -251,8 +251,8 @@ function slowlog_setup_table_new(): void {
// The (id, word) primary key doesn't prevent duplicate words on a re-run, since id is
// auto-incrementing - only load once, when the table is still empty.
- if (file_exists(__DIR__ . '/../keywords.txt') && !db_fetch_cell_prepared('SELECT COUNT(*) FROM plugin_slowlog_reserved_words')) {
- $words = file(__DIR__ . '/../keywords.txt') ?: [];
+ if (file_exists(__DIR__ . '/../docs/keywords.txt') && !db_fetch_cell_prepared('SELECT COUNT(*) FROM plugin_slowlog_reserved_words')) {
+ $words = file(__DIR__ . '/../docs/keywords.txt') ?: [];
if (cacti_sizeof($words)) {
foreach ($words as $word) {
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..462bd23
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,23 @@
+{
+ "tombstones": [
+ "keywords.txt",
+ "themes/"
+ ],
+ "expected": [
+ "CHANGELOG.md",
+ "INFO",
+ "LICENSE",
+ "README.md",
+ "css/",
+ "docs/",
+ "images/",
+ "import_log.php",
+ "includes/",
+ "js/",
+ "locales/",
+ "manifest.json",
+ "setup.php",
+ "slowlog.php"
+ ],
+ "whitelist": []
+}
diff --git a/setup.php b/setup.php
index 5754194..1b55ab0 100644
--- a/setup.php
+++ b/setup.php
@@ -209,6 +209,8 @@ function slowlog_check_upgrade(): void {
// The schema create/refresh lives in includes/database.php (the thold model).
slowlog_upgrade_tables();
+
+ slowlog_prune_files();
}
}
@@ -335,3 +337,173 @@ function slowlog_show_tab(): void {
}
}
}
+
+/**
+ * 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 slowlog_prune_files(): void {
+ global $config;
+
+ $plugin_dir = $config['base_path'] . '/plugins/slowlog';
+ $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: slowlog manifest.json could not be parsed; skipping file prune', false, 'SLOWLOG');
+
+ 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: slowlog prune refused to remove %s: path contains a traversal segment (tampered manifest.json?)', $rel), false, 'SLOWLOG');
+
+ 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: slowlog prune refused to remove %s: path resolves outside the plugin directory (tampered manifest.json?)', $rel), false, 'SLOWLOG');
+
+ continue;
+ }
+
+ if (is_dir($path) && !is_link($path)) {
+ $removed = slowlog_rmtree($path);
+ } else {
+ $removed = @unlink($path);
+ }
+
+ if (!$removed) {
+ cacti_log(sprintf('WARNING: slowlog upgrade could not remove %s (check file/directory permissions)', $rel), false, 'SLOWLOG');
+ }
+ }
+
+ // 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: slowlog upgrade found a file/directory not described in manifest.json: %s (left in place)', $entry), false, 'SLOWLOG');
+ }
+}
+
+/**
+ * Recursively deletes a directory and its contents. Symlinks are removed
+ * without being followed. Helper for slowlog_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 slowlog_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 (!slowlog_rmtree($path)) {
+ $ok = false;
+ }
+ } elseif (!@unlink($path)) {
+ $ok = false;
+ }
+ }
+
+ if (!@rmdir($dir)) {
+ $ok = false;
+ }
+
+ return $ok;
+}
diff --git a/slowlog.php b/slowlog.php
index 8041aa9..1b8a9f4 100644
--- a/slowlog.php
+++ b/slowlog.php
@@ -343,8 +343,8 @@ function slowlog_import(): void {
print get_md5_include_js('plugins/slowlog/js/apexcharts.js');
- if (file_exists($config['base_path'] . "/plugins/slowlog/themes/$selected_theme/apexcharts.css")) {
- print get_md5_include_css("plugins/slowlog/themes/$selected_theme/apexcharts.css");
+ if (file_exists($config['base_path'] . "/plugins/slowlog/css/{$selected_theme}_apexcharts.css")) {
+ print get_md5_include_css("plugins/slowlog/css/{$selected_theme}_apexcharts.css");
} else {
print '';
}
@@ -1036,8 +1036,8 @@ function slowlog_view_charts(string $method): void {
print get_md5_include_js('plugins/slowlog/js/apexcharts.js');
- if (file_exists($config['base_path'] . "/plugins/slowlog/themes/$selected_theme/apexcharts.css")) {
- print get_md5_include_css("plugins/slowlog/themes/$selected_theme/apexcharts.css");
+ if (file_exists($config['base_path'] . "/plugins/slowlog/css/{$selected_theme}_apexcharts.css")) {
+ print get_md5_include_css("plugins/slowlog/css/{$selected_theme}_apexcharts.css");
} else {
print '';
}
diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php
new file mode 100644
index 0000000..f2eb1ae
--- /dev/null
+++ b/tests/Unit/PruneFilesTest.php
@@ -0,0 +1,255 @@
+ ['include/', 'oldfile.php', 'userdata/', 'gone.png'],
+ 'expected' => ['INFO', 'setup.php', 'includes/', 'manifest.json'],
+ 'whitelist' => ['userdata/'],
+ ];
+
+ $base = slowlog_prune_fixture($manifest);
+ $plugin = $base . '/plugins/slowlog';
+ $restore = $GLOBALS['config']['base_path'];
+
+ $GLOBALS['config']['base_path'] = $base;
+
+ try {
+ slowlog_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() . '/slowlog-prune-missing-' . uniqid();
+ mkdir($base . '/plugins/slowlog', 0777, true);
+ $restore = $GLOBALS['config']['base_path'];
+
+ $GLOBALS['config']['base_path'] = $base;
+
+ try {
+ slowlog_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() . '/slowlog-prune-bad-' . uniqid();
+ $plugin = $base . '/plugins/slowlog';
+ 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 {
+ slowlog_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 = slowlog_prune_fixture($manifest);
+ $plugin = $base . '/plugins/slowlog';
+ $outside = $base . '/plugins/escapee.txt';
+ file_put_contents($outside, 'precious user data');
+ $restore = $GLOBALS['config']['base_path'];
+
+ $GLOBALS['config']['base_path'] = $base;
+
+ try {
+ slowlog_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 = slowlog_prune_fixture($manifest);
+ $plugin = $base . '/plugins/slowlog';
+ 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 {
+ slowlog_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 = slowlog_prune_fixture($manifest);
+ $plugin = $base . '/plugins/slowlog';
+ $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 {
+ slowlog_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 = slowlog_prune_fixture($manifest);
+ $plugin = $base . '/plugins/slowlog';
+ $restore = $GLOBALS['config']['base_path'];
+
+ $GLOBALS['config']['base_path'] = $base;
+
+ try {
+ slowlog_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/SlowlogCheckUpgradeTest.php b/tests/Unit/SlowlogCheckUpgradeTest.php
index fb6062e..0f82468 100644
--- a/tests/Unit/SlowlogCheckUpgradeTest.php
+++ b/tests/Unit/SlowlogCheckUpgradeTest.php
@@ -20,6 +20,9 @@
beforeAll(function () {
require_once __DIR__ . '/../../setup.php';
+ // Define slowlog_upgrade_tables() from the real checkout so
+ // slowlog_check_upgrade() runs while base_path is sandboxed below.
+ require_once __DIR__ . '/../../includes/database.php';
$stubLibraryPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'slowlog-test-lib-stub';
@@ -36,6 +39,24 @@
beforeEach(function () {
slowlog_test_reset_db_mocks();
unset($_SERVER['PHP_SELF']);
+
+ // Sandbox base_path so the version-drift branch runs
+ // slowlog_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 slowlog_version() still matches)
+ // and an empty includes/database.php the top-level require_once can load.
+ $GLOBALS['__slowlog_base_restore'] = $GLOBALS['config']['base_path'];
+ $base = sys_get_temp_dir() . '/slowlog-test-' . uniqid();
+ mkdir($base . '/plugins/slowlog/includes', 0777, true);
+ copy(__DIR__ . '/../../INFO', $base . '/plugins/slowlog/INFO');
+ file_put_contents($base . '/plugins/slowlog/includes/database.php', "