From 2e21cc8d27ceeb6232db4f38b3fb8c1577ab8be2 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 07:00:04 -0400 Subject: [PATCH 01/12] Relocate schema into includes/database.php and standardize includes - Move plugin_wmi_setup_tables() verbatim into includes/database.php, loaded via require_once from plugin_wmi_install(). - Rename functions.php -> includes/functions.php and update its callers. - Convert include/include_once to require/require_once across production entry points. - Update tests, phpunit source, patch-coverage allowlist, and the .pot to track the relocated files. --- includes/database.php | 168 ++++++++++++++++++ functions.php => includes/functions.php | 4 +- locales/po/cacti.pot | 5 +- phpunit.xml | 2 +- poller_wmi.php | 10 +- script/wmi-script.php | 4 +- setup.php | 156 +--------------- .../PreparedStatementConsistencyTest.php | 4 +- tests/Unit/WmiLifecycleTest.php | 1 + tests/bin/patch-coverage.php | 4 + wmi_accounts.php | 6 +- wmi_queries.php | 6 +- wmi_script.php | 8 +- wmi_tools.php | 8 +- 14 files changed, 205 insertions(+), 181 deletions(-) create mode 100644 includes/database.php rename functions.php => includes/functions.php (99%) diff --git a/includes/database.php b/includes/database.php new file mode 100644 index 0000000..4598216 --- /dev/null +++ b/includes/database.php @@ -0,0 +1,168 @@ + 'wmi_account', + 'type' => 'int(10)', + 'unsigned' => true, + 'NULL' => false, + 'default' => '0', + 'after' => 'disabled' + ] + ); + + db_execute("CREATE TABLE IF NOT EXISTS `wmi_user_accounts` ( + `id` int(11) UNSIGNED NOT NULL auto_increment, + `name` varchar(64) NOT NULL, + `username` varchar(64) NOT NULL, + `password` varchar(256) NOT NULL, + PRIMARY KEY (`id`)) + ENGINE=InnoDB + COMMENT='Holds Account Information for WMI Queries'"); + + db_execute("CREATE TABLE IF NOT EXISTS `wmi_wql_queries` ( + `id` int(11) UNSIGNED NOT NULL auto_increment, + `hash` varchar(32) NOT NULL default '', + `name` varchar(64) NOT NULL, + `frequency` mediumint(8) unsigned NOT NULL DEFAULT '86400', + `enabled` char(2) DEFAULT 'on', + `namespace` varchar(64) NOT NULL, + `query` varchar(1024) NOT NULL, + `primary_key` varchar(128) NOT NULL DEFAULT 'None', + PRIMARY KEY (`id`)) + ENGINE=InnoDB + COMMENT='Holds WMI Queries for Devices'"); + + db_execute("CREATE TABLE IF NOT EXISTS `host_wmi_query` ( + `host_id` mediumint(8) unsigned NOT NULL DEFAULT '0', + `wmi_query_id` mediumint(8) unsigned NOT NULL DEFAULT '0', + `sort_field` varchar(50) NOT NULL DEFAULT '', + `title_format` varchar(50) NOT NULL DEFAULT '', + `last_started` timestamp NOT NULL DEFAULT '0000-00-00', + `last_runtime` double NOT NULL DEFAULT '0.00', + `last_failed` timestamp NOT NULL DEFAULT '0000-00-00', + PRIMARY KEY (`host_id`,`wmi_query_id`)) + ENGINE=InnoDB + COMMENT='Holds WMI Data Queries'"); + + db_execute("CREATE TABLE IF NOT EXISTS `host_template_wmi_query` ( + `host_template_id` mediumint(8) unsigned NOT NULL DEFAULT '0', + `wmi_query_id` mediumint(8) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`host_template_id`,`wmi_query_id`)) + ENGINE=InnoDB + COMMENT='Holds Device Template WMI Queries'"); + + db_execute("CREATE TABLE IF NOT EXISTS `host_wmi_accounts` ( + `id` int(11) UNSIGNED NOT NULL auto_increment, + `host_id` mediumint(8) unsigned NOT NULL DEFAULT '0', + `account_id` mediumint(8) unsigned NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `host_id` (`host_id`)) + ENGINE=InnoDB + COMMENT='Holds Device WMI Accounts'"); + + db_execute("CREATE TABLE IF NOT EXISTS `host_wmi_cache` ( + `host_id` mediumint(8) unsigned NOT NULL DEFAULT '0', + `wmi_query_id` mediumint(8) unsigned NOT NULL DEFAULT '0', + `field_name` varchar(50) NOT NULL DEFAULT '', + `field_value` varchar(4096) DEFAULT NULL, + `wmi_index` varchar(255) NOT NULL DEFAULT '', + `present` tinyint(4) NOT NULL DEFAULT '1', + `last_updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`host_id`,`wmi_query_id`,`field_name`,`wmi_index`), + KEY `host_id` (`host_id`,`field_name`), + KEY `wmi_index` (`wmi_index`), + KEY `field_name` (`field_name`), + KEY `field_value` (`field_value`), + KEY `wim_query_id` (`wmi_query_id`), + KEY `present` (`present`), + KEY `last_updated` (`last_updated`)) + ENGINE=InnoDB + COMMENT='Holds Device WMI Information'"); + + db_execute("CREATE TABLE IF NOT EXISTS `wmi_processes` ( + `pid` int(10) unsigned NOT NULL, + `taskid` int(10) unsigned NOT NULL, + `started` timestamp NOT NULL default CURRENT_TIMESTAMP, + PRIMARY KEY (`pid`)) + ENGINE=MEMORY + COMMENT='Running wmi collector processes';"); + + $exists = db_fetch_cell('SELECT id FROM data_input WHERE hash="4af550dfe8b451579054d038ad62ba3e"'); + + if (!$exists) { + $save = []; + $save['hash'] = '4af550dfe8b451579054d038ad62ba3e'; + $save['name'] = 'Get WMI Data'; + $save['input_string'] = ''; + $save['type_id'] = 7; + $id = sql_save($save, 'data_input'); + + if ($id) { + db_execute("INSERT INTO `data_input_fields` + (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) + VALUES ('e45cfa73589b88887725350a728d2ee9',$id,'The WMI Class Name','class','in','',0,'','','')"); + + db_execute("INSERT INTO `data_input_fields` + (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) + VALUES ('c5f782d783edec607f64bea9cccd533c',$id,'The WMI Column Name','column','in','',0,'','','')"); + } + } + + $exists = db_fetch_cell('SELECT id + FROM data_input + WHERE hash="42e584b81075f6ad6556e62afc509179"'); + + if (!$exists) { + $save = []; + $save['hash'] = '42e584b81075f6ad6556e62afc509179'; + $save['name'] = 'Get WMI Data (Indexed)'; + $save['input_string'] = ''; + $save['type_id'] = 8; + $id = sql_save($save, 'data_input'); + + if ($id) { + db_execute("INSERT INTO `data_input_fields` + (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) + VALUES ('fb6317f2c49e494007e968283576d5a8',$id,'The WMI Class Name','class','in','',0,'','','')"); + + db_execute("INSERT INTO `data_input_fields` + (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) + VALUES ('cfebf9aa08f98bc1bfda7de2ebe12d94',$id,'The WMI Column Name','column','in','',0,'','','')"); + + db_execute("INSERT INTO `data_input_fields` + (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) + VALUES ('41798400f48141c25bc2407b5f5b1573',$id,'Output Type ID','output_type','in','',0,'output_type','','')"); + + db_execute("INSERT INTO `data_input_fields` + (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) + VALUES ('02cd18a75a17e0a7d4ca28bc224630e0',$id,'Output Value','output','out','on',0,'','','')"); + } + } +} diff --git a/functions.php b/includes/functions.php similarity index 99% rename from functions.php rename to includes/functions.php index cfc8add..6dd8b46 100644 --- a/functions.php +++ b/includes/functions.php @@ -112,7 +112,7 @@ function plugin_wmi_query_exists($query) { function plugin_wmi_create_dataquery_xml($id) { global $config; - include_once($config['base_path'] . '/lib/export.php'); + require_once($config['base_path'] . '/lib/export.php'); $wmic = db_fetch_row_prepared('SELECT * FROM wmi_wql_queries WHERE id = ?', [$id]); $data = ''; @@ -445,7 +445,7 @@ function run_store_wmi_query($host_id, $wmi_query_id) { // Cacti server and the wmic shell transport otherwise (see // Linux_WMI::default_transport()), so the same client is used here // regardless of $config['cacti_server_os']. - include_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); + require_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); $wmi = new Linux_WMI(); $wmi->hostname = $host; diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 869e001..c26374d 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,6 @@ msgid "" msgstr "" "Project-Id-Version: Cacti \n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-09-28 16:09-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers >\n" "Language-Team: Cacti Developers \n" @@ -17,11 +16,11 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: functions.php wmi_queries.php wmi_tools.php +#: includes/functions.php wmi_queries.php wmi_tools.php msgid "Queries" msgstr "" -#: functions.php +#: includes/functions.php msgid "Authentication" msgstr "" diff --git a/phpunit.xml b/phpunit.xml index b3b4c5f..bf9b642 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -31,7 +31,7 @@ setup.php - functions.php + includes/functions.php linux_wmi.php poller_wmi.php wmi_accounts.php diff --git a/poller_wmi.php b/poller_wmi.php index b0de110..98706d0 100644 --- a/poller_wmi.php +++ b/poller_wmi.php @@ -26,10 +26,10 @@ chdir(__DIR__); chdir('../..'); -include('./include/cli_check.php'); -include_once('./lib/poller.php'); -include_once('./lib/ping.php'); -include_once('./plugins/wmi/functions.php'); +require('./include/cli_check.php'); +require_once('./lib/poller.php'); +require_once('./lib/ping.php'); +require_once('./plugins/wmi/includes/functions.php'); // process calling arguments $parms = $_SERVER['argv']; @@ -469,7 +469,7 @@ function display_version() { global $config; if (!function_exists('plugin_wmi_version')) { - include_once($config['base_path'] . '/plugins/wmi/setup.php'); + require_once($config['base_path'] . '/plugins/wmi/setup.php'); } $info = plugin_wmi_version(); diff --git a/script/wmi-script.php b/script/wmi-script.php index ad227ba..e63926c 100644 --- a/script/wmi-script.php +++ b/script/wmi-script.php @@ -23,7 +23,7 @@ */ if (!isset($called_by_script_server)) { - include_once(__DIR__ . '/../include/cli_check.php'); + require_once(__DIR__ . '/../include/cli_check.php'); array_shift($_SERVER['argv']); if (isset($_SERVER['argv'][0]) && $_SERVER['argv'][0] == 'wmi_script') { @@ -66,7 +66,7 @@ function wmi_script($hostname, $host_id, $wmiquery, $cmd = '', $arg1 = '', $arg2 = '') { global $config; - include_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); + require_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); $wmi = new Linux_WMI($host_id); $wmi->hostname = $hostname; diff --git a/setup.php b/setup.php index c4fe229..4084f01 100644 --- a/setup.php +++ b/setup.php @@ -51,6 +51,10 @@ function plugin_wmi_csp_nonce(): string { * @return void */ function plugin_wmi_install() { + global $config; + + require_once($config['base_path'] . '/plugins/wmi/includes/database.php'); + api_plugin_register_hook('wmi', 'config_arrays', 'wmi_config_arrays', 'setup.php'); api_plugin_register_hook('wmi', 'config_form', 'wmi_config_form', 'setup.php'); api_plugin_register_hook('wmi', 'config_settings', 'wmi_config_settings', 'setup.php'); @@ -161,158 +165,6 @@ function plugin_wmi_upgrade() { return true; } -/** - * Adds the 'wmi_account' column to Cacti's host table, creates this - * plugin's database tables (wmi_user_accounts, wmi_wql_queries, - * host_wmi_query, host_template_wmi_query, host_wmi_accounts, - * host_wmi_cache, wmi_processes), and registers this plugin's two Data - * Input Methods ('Get WMI Data' and 'Get WMI Data (Indexed)') along with - * their input/output fields, if not already present. Called from - * plugin_wmi_install() during plugin installation. - * - * @return void - */ -function plugin_wmi_setup_tables() { - api_plugin_db_add_column('wmi', 'host', - [ - 'name' => 'wmi_account', - 'type' => 'int(10)', - 'unsigned' => true, - 'NULL' => false, - 'default' => '0', - 'after' => 'disabled' - ] - ); - - db_execute("CREATE TABLE IF NOT EXISTS `wmi_user_accounts` ( - `id` int(11) UNSIGNED NOT NULL auto_increment, - `name` varchar(64) NOT NULL, - `username` varchar(64) NOT NULL, - `password` varchar(256) NOT NULL, - PRIMARY KEY (`id`)) - ENGINE=InnoDB - COMMENT='Holds Account Information for WMI Queries'"); - - db_execute("CREATE TABLE IF NOT EXISTS `wmi_wql_queries` ( - `id` int(11) UNSIGNED NOT NULL auto_increment, - `hash` varchar(32) NOT NULL default '', - `name` varchar(64) NOT NULL, - `frequency` mediumint(8) unsigned NOT NULL DEFAULT '86400', - `enabled` char(2) DEFAULT 'on', - `namespace` varchar(64) NOT NULL, - `query` varchar(1024) NOT NULL, - `primary_key` varchar(128) NOT NULL DEFAULT 'None', - PRIMARY KEY (`id`)) - ENGINE=InnoDB - COMMENT='Holds WMI Queries for Devices'"); - - db_execute("CREATE TABLE IF NOT EXISTS `host_wmi_query` ( - `host_id` mediumint(8) unsigned NOT NULL DEFAULT '0', - `wmi_query_id` mediumint(8) unsigned NOT NULL DEFAULT '0', - `sort_field` varchar(50) NOT NULL DEFAULT '', - `title_format` varchar(50) NOT NULL DEFAULT '', - `last_started` timestamp NOT NULL DEFAULT '0000-00-00', - `last_runtime` double NOT NULL DEFAULT '0.00', - `last_failed` timestamp NOT NULL DEFAULT '0000-00-00', - PRIMARY KEY (`host_id`,`wmi_query_id`)) - ENGINE=InnoDB - COMMENT='Holds WMI Data Queries'"); - - db_execute("CREATE TABLE IF NOT EXISTS `host_template_wmi_query` ( - `host_template_id` mediumint(8) unsigned NOT NULL DEFAULT '0', - `wmi_query_id` mediumint(8) unsigned NOT NULL DEFAULT '0', - PRIMARY KEY (`host_template_id`,`wmi_query_id`)) - ENGINE=InnoDB - COMMENT='Holds Device Template WMI Queries'"); - - db_execute("CREATE TABLE IF NOT EXISTS `host_wmi_accounts` ( - `id` int(11) UNSIGNED NOT NULL auto_increment, - `host_id` mediumint(8) unsigned NOT NULL DEFAULT '0', - `account_id` mediumint(8) unsigned NOT NULL DEFAULT '0', - PRIMARY KEY (`id`), - KEY `host_id` (`host_id`)) - ENGINE=InnoDB - COMMENT='Holds Device WMI Accounts'"); - - db_execute("CREATE TABLE IF NOT EXISTS `host_wmi_cache` ( - `host_id` mediumint(8) unsigned NOT NULL DEFAULT '0', - `wmi_query_id` mediumint(8) unsigned NOT NULL DEFAULT '0', - `field_name` varchar(50) NOT NULL DEFAULT '', - `field_value` varchar(4096) DEFAULT NULL, - `wmi_index` varchar(255) NOT NULL DEFAULT '', - `present` tinyint(4) NOT NULL DEFAULT '1', - `last_updated` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`host_id`,`wmi_query_id`,`field_name`,`wmi_index`), - KEY `host_id` (`host_id`,`field_name`), - KEY `wmi_index` (`wmi_index`), - KEY `field_name` (`field_name`), - KEY `field_value` (`field_value`), - KEY `wim_query_id` (`wmi_query_id`), - KEY `present` (`present`), - KEY `last_updated` (`last_updated`)) - ENGINE=InnoDB - COMMENT='Holds Device WMI Information'"); - - db_execute("CREATE TABLE IF NOT EXISTS `wmi_processes` ( - `pid` int(10) unsigned NOT NULL, - `taskid` int(10) unsigned NOT NULL, - `started` timestamp NOT NULL default CURRENT_TIMESTAMP, - PRIMARY KEY (`pid`)) - ENGINE=MEMORY - COMMENT='Running wmi collector processes';"); - - $exists = db_fetch_cell('SELECT id FROM data_input WHERE hash="4af550dfe8b451579054d038ad62ba3e"'); - - if (!$exists) { - $save = []; - $save['hash'] = '4af550dfe8b451579054d038ad62ba3e'; - $save['name'] = 'Get WMI Data'; - $save['input_string'] = ''; - $save['type_id'] = 7; - $id = sql_save($save, 'data_input'); - - if ($id) { - db_execute("INSERT INTO `data_input_fields` - (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) - VALUES ('e45cfa73589b88887725350a728d2ee9',$id,'The WMI Class Name','class','in','',0,'','','')"); - - db_execute("INSERT INTO `data_input_fields` - (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) - VALUES ('c5f782d783edec607f64bea9cccd533c',$id,'The WMI Column Name','column','in','',0,'','','')"); - } - } - - $exists = db_fetch_cell('SELECT id - FROM data_input - WHERE hash="42e584b81075f6ad6556e62afc509179"'); - - if (!$exists) { - $save = []; - $save['hash'] = '42e584b81075f6ad6556e62afc509179'; - $save['name'] = 'Get WMI Data (Indexed)'; - $save['input_string'] = ''; - $save['type_id'] = 8; - $id = sql_save($save, 'data_input'); - - if ($id) { - db_execute("INSERT INTO `data_input_fields` - (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) - VALUES ('fb6317f2c49e494007e968283576d5a8',$id,'The WMI Class Name','class','in','',0,'','','')"); - - db_execute("INSERT INTO `data_input_fields` - (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) - VALUES ('cfebf9aa08f98bc1bfda7de2ebe12d94',$id,'The WMI Column Name','column','in','',0,'','','')"); - - db_execute("INSERT INTO `data_input_fields` - (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) - VALUES ('41798400f48141c25bc2407b5f5b1573',$id,'Output Type ID','output_type','in','',0,'output_type','','')"); - - db_execute("INSERT INTO `data_input_fields` - (hash, data_input_id, name, data_name, input_output, update_rra, sequence, type_code, regexp_match, allow_nulls) - VALUES ('02cd18a75a17e0a7d4ca28bc224630e0',$id,'Output Value','output','out','on',0,'','','')"); - } - } -} /** * Reads this plugin's INFO file and returns its [info] section. Used by diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php index f1913c7..26f6de6 100644 --- a/tests/Security/PreparedStatementConsistencyTest.php +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -78,7 +78,7 @@ function wmi_test_extract_call_arguments($contents, $openParenPos) { describe('prepared statement consistency in wmi', function () { it('uses prepared DB helpers for every variable-bearing query', function () { $targetFiles = array( - 'functions.php', + 'includes/functions.php', 'poller_wmi.php', 'setup.php', 'wmi_accounts.php', @@ -89,7 +89,7 @@ function wmi_test_extract_call_arguments($contents, $openParenPos) { ); $allowedRawCalls = array( - 'functions.php' => array( + 'includes/functions.php' => array( "implode(', ', \$part)", // batch insert; every value already escaped via db_qstr() above ), 'setup.php' => array( diff --git a/tests/Unit/WmiLifecycleTest.php b/tests/Unit/WmiLifecycleTest.php index 2db15eb..9020372 100644 --- a/tests/Unit/WmiLifecycleTest.php +++ b/tests/Unit/WmiLifecycleTest.php @@ -17,6 +17,7 @@ */ beforeAll(function () { + require_once __DIR__ . '/../../includes/database.php'; require_once __DIR__ . '/../../setup.php'; }); diff --git a/tests/bin/patch-coverage.php b/tests/bin/patch-coverage.php index e65b53d..70c1ea6 100644 --- a/tests/bin/patch-coverage.php +++ b/tests/bin/patch-coverage.php @@ -160,6 +160,10 @@ function changed_lines($base_ref) { * Empty by default; add entries per repository as the need arises. */ $unmeasured_allowlist = [ + // Schema provisioning relocated verbatim from setup.php; only loaded from + // plugin_wmi_install() at install time, not exercisable in the isolated + // unit process, and asserted end-to-end by the lifecycle/install tests. + 'includes/database.php', ]; $unmeasured = array_values(array_diff(array_keys($changed), array_keys($measured))); $unexpected_unmeasured = array_values(array_diff($unmeasured, $unmeasured_allowlist)); diff --git a/wmi_accounts.php b/wmi_accounts.php index b707773..f51a88e 100644 --- a/wmi_accounts.php +++ b/wmi_accounts.php @@ -24,9 +24,9 @@ chdir('../../'); -include('./include/auth.php'); -include_once($config['base_path'] . '/plugins/wmi/functions.php'); -include_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); +require('./include/auth.php'); +require_once($config['base_path'] . '/plugins/wmi/includes/functions.php'); +require_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); $account_actions = [ 1 => __('Delete', 'wmi') diff --git a/wmi_queries.php b/wmi_queries.php index ab9692e..341c432 100644 --- a/wmi_queries.php +++ b/wmi_queries.php @@ -24,9 +24,9 @@ chdir('../../'); -include('./include/auth.php'); -include_once($config['base_path'] . '/plugins/wmi/functions.php'); -include_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); +require('./include/auth.php'); +require_once($config['base_path'] . '/plugins/wmi/includes/functions.php'); +require_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); $ds_actions = [ 1 => __('Delete', 'wmi') diff --git a/wmi_script.php b/wmi_script.php index de8641d..a65b1cd 100644 --- a/wmi_script.php +++ b/wmi_script.php @@ -22,11 +22,11 @@ +-------------------------------------------------------------------------+ */ -include_once(__DIR__ . '/../global/cli_check.php'); -include_once($config['base_path'] . '/lib/snmp.php'); +require_once(__DIR__ . '/../global/cli_check.php'); +require_once($config['base_path'] . '/lib/snmp.php'); if (!isset($called_by_script_server)) { - include_once(__DIR__ . '/../include/global.php'); + require_once(__DIR__ . '/../include/global.php'); array_shift($_SERVER['argv']); if (isset($_SERVER['argv'][0]) && $_SERVER['argv'][0] == 'wmi_script') { @@ -68,7 +68,7 @@ function wmi_script($hostname, $host_id, $wmiquery, $cmd = '', $arg1 = '', $arg2 = '') { global $config; - include_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); + require_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); $wmi = new Linux_WMI($host_id); $wmi->hostname = $hostname; diff --git a/wmi_tools.php b/wmi_tools.php index bdec0b6..c79fa9d 100644 --- a/wmi_tools.php +++ b/wmi_tools.php @@ -23,9 +23,9 @@ */ chdir('../../'); -include('./include/auth.php'); -include_once('./lib/snmp.php'); -include_once('./lib/utility.php'); +require('./include/auth.php'); +require_once('./lib/snmp.php'); +require_once('./lib/utility.php'); set_default_action(); @@ -599,7 +599,7 @@ function walk_host() { // Linux_WMI self-selects the PowerShell/CIM transport on a Windows // Cacti server and the wmic shell transport otherwise, so the same // client is used here regardless of $config['cacti_server_os']. - include_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); + require_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); $wmi = new Linux_WMI(); $wmi->hostname = $host; From 93eb1eac8c093efa298bffa4f7bcebbcc85bb786 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Wed, 30 Sep 2026 11:53:26 -0400 Subject: [PATCH 02/12] ci: allowlist web/CLI entry points for patch coverage The include->require standardization touched only top-level chdir+require lines in the web pages (auth.php) and CLI/script-server entry points (cli_check.php/global.php), which cannot be loaded into the isolated unit process and therefore cannot be covered. Move those six files out of phpunit.xml and into patch-coverage.php's \, matching the established plugin_thold pattern. setup.php/includes/functions.php/linux_wmi.php remain measured. --- phpunit.xml | 6 ------ tests/bin/patch-coverage.php | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/phpunit.xml b/phpunit.xml index bf9b642..38d0480 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -33,12 +33,6 @@ setup.php includes/functions.php linux_wmi.php - poller_wmi.php - wmi_accounts.php - wmi_queries.php - wmi_script.php - wmi_tools.php - script/wmi-script.php diff --git a/tests/bin/patch-coverage.php b/tests/bin/patch-coverage.php index 70c1ea6..ae144f1 100644 --- a/tests/bin/patch-coverage.php +++ b/tests/bin/patch-coverage.php @@ -164,6 +164,21 @@ function changed_lines($base_ref) { // plugin_wmi_install() at install time, not exercisable in the isolated // unit process, and asserted end-to-end by the lifecycle/install tests. 'includes/database.php', + + // Web UI pages: each chdir('../../') then require include/auth.php at the + // top level, so they cannot be loaded into the isolated unit process. + // Their structure is asserted by the navigation, realm-registration, + // prepared-statement and tab-wrapper tests rather than by execution. + 'wmi_accounts.php', + 'wmi_queries.php', + 'wmi_tools.php', + + // CLI/script-server entry points: each chdir + require cli_check.php / + // global.php at the top level (poller_wmi.php additionally forks background + // children), none of which is loadable in the isolated unit process. + 'poller_wmi.php', + 'wmi_script.php', + 'script/wmi-script.php', ]; $unmeasured = array_values(array_diff(array_keys($changed), array_keys($measured))); $unexpected_unmeasured = array_values(array_diff($unmeasured, $unmeasured_allowlist)); From 06ada9a40429f5b880fe676fee996be2e7574c84 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 12:49:48 -0400 Subject: [PATCH 03/12] Add manifest.json + upgrade-time file pruning Adds the fleet manifest.json (tombstones/expected/whitelist), a plugin_wmi_prune_files() run from plugin_wmi_upgrade() that removes the tombstoned functions.php and the dev-only tests/ tree, refuses any path resolving outside the plugin directory, and warns on files it cannot remove. Also repoints the api_device_new hook's include to the relocated includes/functions.php, adds manifest drift validation to CI, and unit tests for the prune helper and the device_new hook. --- .github/copilot-instructions.md | 4 + .github/workflows/plugin-ci-workflow.yml | 3 + manifest.json | 26 ++++ setup.php | 160 ++++++++++++++++++++- tests/Unit/PruneFilesTest.php | 170 +++++++++++++++++++++++ tests/Unit/WmiLifecycleTest.php | 17 ++- tests/bin/validate-manifest.php | 73 ++++++++++ tests/bootstrap-unit.php | 2 + 8 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 manifest.json create mode 100644 tests/Unit/PruneFilesTest.php create mode 100644 tests/bin/validate-manifest.php diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6578aab..f849d0b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -194,3 +194,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/`, `.git*`, and whitelisted paths). Custom customer CSS/theme files belong in `expected`, and stylesheets live in `css/` (not `themes/`). On upgrade, `plugin_wmi_prune_files()` deletes the tombstoned paths and the dev-only `tests/` tree, leaves `whitelist` and `.git*` 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 61b52b4..85484ca 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/wmi/tests/bin/validate-manifest.php + - name: Run apt-get update run: sudo apt-get update diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..4f9e268 --- /dev/null +++ b/manifest.json @@ -0,0 +1,26 @@ +{ + "tombstones": [ + "functions.php" + ], + "expected": [ + "CHANGELOG.md", + "INFO", + "LICENSE", + "README.md", + "includes/", + "index.php", + "linux_wmi.php", + "locales/", + "manifest.json", + "phpunit.xml", + "poller_wmi.php", + "script/", + "setup.php", + "templates/", + "wmi_accounts.php", + "wmi_queries.php", + "wmi_script.php", + "wmi_tools.php" + ], + "whitelist": [] +} diff --git a/setup.php b/setup.php index 4084f01..b4b0d65 100644 --- a/setup.php +++ b/setup.php @@ -162,6 +162,8 @@ function plugin_wmi_check_config() { * @return bool Always returns true. */ function plugin_wmi_upgrade() { + plugin_wmi_prune_files(); + return true; } @@ -817,7 +819,7 @@ function wmi_device_template_top() { function wmi_api_device_new($save) { global $config; - include_once($config['base_path'] . '/plugins/wmi/functions.php'); + include_once($config['base_path'] . '/plugins/wmi/includes/functions.php'); if (read_config_option('wmi_autocreate') == 'on') { if (!empty($save['id'])) { @@ -827,3 +829,159 @@ function wmi_api_device_new($save) { return $save; } + +/** + * 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 plugin_wmi_prune_files(): void { + global $config; + + $plugin_dir = $config['base_path'] . '/plugins/wmi'; + $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: wmi manifest.json could not be parsed; skipping file prune', false, 'WMI'); + + 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) { + return true; + } + + foreach ($whitelist as $entry) { + $entry = trim((string) $entry, '/'); + + if ($entry !== '' && ($rel === $entry || strncmp($rel, $entry . '/', strlen($entry) + 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. + $remove = $tombstones; + $remove[] = 'tests/'; + + foreach ($remove as $rel) { + $rel = trim((string) $rel, '/'); + + if ($rel === '' || $protected($rel)) { + 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: wmi prune refused to remove %s: path resolves outside the plugin directory (tampered manifest.json?)', $rel), false, 'WMI'); + + continue; + } + + if (is_dir($path) && !is_link($path)) { + $removed = plugin_wmi_rmtree($path); + } else { + $removed = @unlink($path); + } + + if (!$removed) { + cacti_log(sprintf('WARNING: wmi upgrade could not remove %s (check file/directory permissions)', $rel), false, 'WMI'); + } + } + + // 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' || $protected($entry) || isset($known[$entry])) { + continue; + } + + cacti_log(sprintf('WARNING: wmi upgrade found a file/directory not described in manifest.json: %s (left in place)', $entry), false, 'WMI'); + } +} + +/** + * Recursively deletes a directory and its contents. Symlinks are removed + * without being followed. Helper for plugin_wmi_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 plugin_wmi_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 (!plugin_wmi_rmtree($path)) { + $ok = false; + } + } elseif (!@unlink($path)) { + $ok = false; + } + } + + if (!@rmdir($dir)) { + $ok = false; + } + + return $ok; +} diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php new file mode 100644 index 0000000..edb3fc5 --- /dev/null +++ b/tests/Unit/PruneFilesTest.php @@ -0,0 +1,170 @@ + ['include/', 'oldfile.php', 'userdata/', 'gone.png'], + 'expected' => ['INFO', 'setup.php', 'includes/', 'manifest.json'], + 'whitelist' => ['userdata/'], + ]; + + $base = wmi_prune_fixture($manifest); + $plugin = $base . '/plugins/wmi'; + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + plugin_wmi_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(); + + // 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(); + + // 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'); +}); + +it('is a safe no-op when the manifest is missing', function () { + $base = sys_get_temp_dir() . '/wmi-prune-missing-' . uniqid(); + mkdir($base . '/plugins/wmi', 0777, true); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + plugin_wmi_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() . '/wmi-prune-bad-' . uniqid(); + $plugin = $base . '/plugins/wmi'; + 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 { + plugin_wmi_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 = wmi_prune_fixture($manifest); + $plugin = $base . '/plugins/wmi'; + $outside = $base . '/plugins/escapee.txt'; + file_put_contents($outside, 'precious user data'); + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + plugin_wmi_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('outside the plugin directory'); +}); + +it('warns when a tombstoned path cannot be removed', function () { + $manifest = [ + 'tombstones' => ['locked/'], + 'expected' => ['manifest.json'], + 'whitelist' => [], + ]; + + $base = wmi_prune_fixture($manifest); + $plugin = $base . '/plugins/wmi'; + 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; + + try { + plugin_wmi_prune_files(); + } finally { + $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'); diff --git a/tests/Unit/WmiLifecycleTest.php b/tests/Unit/WmiLifecycleTest.php index 9020372..2d18168 100644 --- a/tests/Unit/WmiLifecycleTest.php +++ b/tests/Unit/WmiLifecycleTest.php @@ -41,7 +41,16 @@ }); it('reports the upgrade as always successful', function () { - expect(plugin_wmi_upgrade())->toBeTrue(); + // Sandbox base_path so the upgrade-time prune (plugin_wmi_prune_files) + // runs against an empty temp tree, never the real checkout. + $restore = $GLOBALS['config']['base_path']; + $GLOBALS['config']['base_path'] = sys_get_temp_dir() . '/wmi-upg-' . uniqid(); + + try { + expect(plugin_wmi_upgrade())->toBeTrue(); + } finally { + $GLOBALS['config']['base_path'] = $restore; + } }); it('adds the wmi_account column and creates every table', function () { @@ -74,3 +83,9 @@ // 2 fields for the non-indexed query, 4 for the indexed query. expect($inserts)->toHaveCount(6); }); + +it('device_new hook loads the relocated library and returns its payload unchanged', function () { + $save = array('id' => 123); + + expect(wmi_api_device_new($save))->toBe($save); +}); diff --git a/tests/bin/validate-manifest.php b/tests/bin/validate-manifest.php new file mode 100644 index 0000000..aa9858b --- /dev/null +++ b/tests/bin/validate-manifest.php @@ -0,0 +1,73 @@ + Date: Wed, 30 Sep 2026 17:04:35 -0400 Subject: [PATCH 04/12] Address review: require_once in api_device_new hook; scan includes/database.php in prepared-statement test --- setup.php | 2 +- tests/Security/PreparedStatementConsistencyTest.php | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/setup.php b/setup.php index b4b0d65..c1e3fee 100644 --- a/setup.php +++ b/setup.php @@ -819,7 +819,7 @@ function wmi_device_template_top() { function wmi_api_device_new($save) { global $config; - include_once($config['base_path'] . '/plugins/wmi/includes/functions.php'); + require_once($config['base_path'] . '/plugins/wmi/includes/functions.php'); if (read_config_option('wmi_autocreate') == 'on') { if (!empty($save['id'])) { diff --git a/tests/Security/PreparedStatementConsistencyTest.php b/tests/Security/PreparedStatementConsistencyTest.php index 26f6de6..bb02df1 100644 --- a/tests/Security/PreparedStatementConsistencyTest.php +++ b/tests/Security/PreparedStatementConsistencyTest.php @@ -78,6 +78,7 @@ function wmi_test_extract_call_arguments($contents, $openParenPos) { describe('prepared statement consistency in wmi', function () { it('uses prepared DB helpers for every variable-bearing query', function () { $targetFiles = array( + 'includes/database.php', 'includes/functions.php', 'poller_wmi.php', 'setup.php', @@ -89,13 +90,15 @@ function wmi_test_extract_call_arguments($contents, $openParenPos) { ); $allowedRawCalls = array( + 'includes/database.php' => array( + 'data_input_fields', // install only; $id is sql_save()'s own just-inserted autoincrement id + ), 'includes/functions.php' => array( "implode(', ', \$part)", // batch insert; every value already escaped via db_qstr() above ), 'setup.php' => array( "IN(' . \$id . ')", // uninstall only; $id is a GROUP_CONCAT of this plugin's own data_input ids 'array_to_sql_or($data_sources', // uninstall only; ids come from the query above, not request input - 'data_input_fields', // install only; $id is sql_save()'s own just-inserted autoincrement id ), 'wmi_accounts.php' => array( '$sql_where', // built via db_qstr(); rows/page filters are FILTER_VALIDATE_INT From e206c3955f7f251565f415694ba4e1a1bead3f6d Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 19:48:15 -0400 Subject: [PATCH 05/12] Prune phpunit.xml on upgrade and ignore markdown-lint configs phpunit.xml ships in the repo for CI but is a dev-only test artifact, so it is removed from manifest.json 'expected', pruned from installs on upgrade (like tests/), and excluded from the manifest drift check. The retired markdown-lint configs (.mdlrc, .md_style.rb) are deleted, and .md* is now ignored like .git*: protected from pruning and excluded from drift if it reappears. --- manifest.json | 1 - setup.php | 8 +++++--- tests/Unit/PruneFilesTest.php | 8 ++++++++ tests/bin/validate-manifest.php | 8 ++++---- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/manifest.json b/manifest.json index 4f9e268..e43afbf 100644 --- a/manifest.json +++ b/manifest.json @@ -12,7 +12,6 @@ "linux_wmi.php", "locales/", "manifest.json", - "phpunit.xml", "poller_wmi.php", "script/", "setup.php", diff --git a/setup.php b/setup.php index c1e3fee..647cd7f 100644 --- a/setup.php +++ b/setup.php @@ -869,7 +869,7 @@ function plugin_wmi_prune_files(): void { $whitelist = isset($manifest['whitelist']) && is_array($manifest['whitelist']) ? $manifest['whitelist'] : []; $protected = function (string $rel) use ($whitelist): bool { - if (strncmp($rel, '.git', 4) === 0) { + if (strncmp($rel, '.git', 4) === 0 || strncmp($rel, '.md', 3) === 0) { return true; } @@ -888,9 +888,11 @@ function plugin_wmi_prune_files(): void { // cannot steer the prune outside of it. $plugin_real = realpath($plugin_dir); - // Remove tombstoned (moved/deleted) paths plus the dev-only tests/ tree. + // 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, '/'); @@ -942,7 +944,7 @@ function plugin_wmi_prune_files(): void { $entries = scandir($plugin_dir); foreach (($entries !== false ? $entries : []) as $entry) { - if ($entry === '.' || $entry === '..' || $entry === 'tests' || $protected($entry) || isset($known[$entry])) { + if ($entry === '.' || $entry === '..' || $entry === 'tests' || $entry === 'phpunit.xml' || $protected($entry) || isset($known[$entry])) { continue; } diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php index edb3fc5..65a39b5 100644 --- a/tests/Unit/PruneFilesTest.php +++ b/tests/Unit/PruneFilesTest.php @@ -27,6 +27,9 @@ function wmi_prune_fixture(array $manifest): string { file_put_contents($plugin . '/setup.php', "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.) @@ -68,6 +72,8 @@ function wmi_prune_fixture(array $manifest): string { 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(); @@ -76,6 +82,8 @@ function wmi_prune_fixture(array $manifest): string { 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 () { diff --git a/tests/bin/validate-manifest.php b/tests/bin/validate-manifest.php index aa9858b..c54fab5 100644 --- a/tests/bin/validate-manifest.php +++ b/tests/bin/validate-manifest.php @@ -5,8 +5,8 @@ +-------------------------------------------------------------------------+ | Validates that manifest.json's "expected" array matches the plugin's | | actual top-level tree, so the manifest that drives upgrade-time pruning | - | cannot silently drift. tests/, .git* and whitelisted (user-data) paths | - | are intentionally excluded. Exits non-zero on any drift. | + | cannot silently drift. tests/, phpunit.xml, .git* and .md* are | + | excluded, as are whitelisted (user-data) paths. Non-zero exit on drift. | +-------------------------------------------------------------------------+ */ @@ -39,11 +39,11 @@ $actual = []; foreach (scandir($root) as $entry) { - if ($entry === '.' || $entry === '..' || $entry === 'tests') { + if ($entry === '.' || $entry === '..' || $entry === 'tests' || $entry === 'phpunit.xml') { continue; } - if (strncmp($entry, '.git', 4) === 0 || isset($whitelistTop[$entry])) { + if (strncmp($entry, '.git', 4) === 0 || strncmp($entry, '.md', 3) === 0 || isset($whitelistTop[$entry])) { continue; } From 61cde8e88a5e8d101572e5b96c42af205f6dd26e Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 20:51:23 -0400 Subject: [PATCH 06/12] Move linux_wmi.php to includes/linux_wmi.php The Linux_WMI client library moves into includes/; includes/functions.php loads it at the top (so run_store_wmi_query() keeps working), and the console pages, poller, script and tests reference the new path. The old path is tombstoned so existing installs drop the stale top-level copy on upgrade. --- includes/functions.php | 4 ++-- linux_wmi.php => includes/linux_wmi.php | 0 manifest.json | 4 ++-- phpunit.xml | 2 +- script/wmi-script.php | 2 +- tests/Unit/WmiClientAutoloadTest.php | 17 +++++++++++++++++ tests/WmiCommandInjectionTest.php | 2 +- tests/WmiEmptySeparatorTest.php | 2 +- tests/WmiPowerShellTransportTest.php | 2 +- wmi_accounts.php | 2 +- wmi_queries.php | 2 +- wmi_script.php | 2 +- wmi_tools.php | 2 +- 13 files changed, 30 insertions(+), 13 deletions(-) rename linux_wmi.php => includes/linux_wmi.php (100%) create mode 100644 tests/Unit/WmiClientAutoloadTest.php diff --git a/includes/functions.php b/includes/functions.php index 6dd8b46..e936993 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -22,6 +22,8 @@ +-------------------------------------------------------------------------+ */ +require_once(__DIR__ . '/linux_wmi.php'); + /** * Renders this plugin's tabbed navigation (Queries, Authentication) atop * wmi_queries.php/wmi_accounts.php, highlighting the currently active tab @@ -445,8 +447,6 @@ function run_store_wmi_query($host_id, $wmi_query_id) { // Cacti server and the wmic shell transport otherwise (see // Linux_WMI::default_transport()), so the same client is used here // regardless of $config['cacti_server_os']. - require_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); - $wmi = new Linux_WMI(); $wmi->hostname = $host; $wmi->username = $username; diff --git a/linux_wmi.php b/includes/linux_wmi.php similarity index 100% rename from linux_wmi.php rename to includes/linux_wmi.php diff --git a/manifest.json b/manifest.json index e43afbf..c1af301 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,7 @@ { "tombstones": [ - "functions.php" + "functions.php", + "linux_wmi.php" ], "expected": [ "CHANGELOG.md", @@ -9,7 +10,6 @@ "README.md", "includes/", "index.php", - "linux_wmi.php", "locales/", "manifest.json", "poller_wmi.php", diff --git a/phpunit.xml b/phpunit.xml index 38d0480..20406df 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -32,7 +32,7 @@ setup.php includes/functions.php - linux_wmi.php + includes/linux_wmi.php diff --git a/script/wmi-script.php b/script/wmi-script.php index e63926c..91152df 100644 --- a/script/wmi-script.php +++ b/script/wmi-script.php @@ -66,7 +66,7 @@ function wmi_script($hostname, $host_id, $wmiquery, $cmd = '', $arg1 = '', $arg2 = '') { global $config; - require_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); + require_once($config['base_path'] . '/plugins/wmi/includes/linux_wmi.php'); $wmi = new Linux_WMI($host_id); $wmi->hostname = $hostname; diff --git a/tests/Unit/WmiClientAutoloadTest.php b/tests/Unit/WmiClientAutoloadTest.php new file mode 100644 index 0000000..a4678ba --- /dev/null +++ b/tests/Unit/WmiClientAutoloadTest.php @@ -0,0 +1,17 @@ +toBeTrue(); +}); diff --git a/tests/WmiCommandInjectionTest.php b/tests/WmiCommandInjectionTest.php index f8a27ba..8d3f344 100644 --- a/tests/WmiCommandInjectionTest.php +++ b/tests/WmiCommandInjectionTest.php @@ -20,7 +20,7 @@ function cacti_escapeshellarg($s) { } } -require_once __DIR__ . '/../linux_wmi.php'; +require_once __DIR__ . '/../includes/linux_wmi.php'; describe('wmi command injection hardening', function () { it('contains an injected unix hostname inside a quoted argument', function () { diff --git a/tests/WmiEmptySeparatorTest.php b/tests/WmiEmptySeparatorTest.php index 179e266..f5f6002 100644 --- a/tests/WmiEmptySeparatorTest.php +++ b/tests/WmiEmptySeparatorTest.php @@ -23,7 +23,7 @@ function cacti_escapeshellarg($s) { } } -require_once __DIR__ . '/../linux_wmi.php'; +require_once __DIR__ . '/../includes/linux_wmi.php'; /** A transport stub that records the request it received and returns canned rows built with a given separator. */ class Wmi_Test_Recording_Transport implements Wmi_Transport { diff --git a/tests/WmiPowerShellTransportTest.php b/tests/WmiPowerShellTransportTest.php index 706c8fc..c5bc2c8 100644 --- a/tests/WmiPowerShellTransportTest.php +++ b/tests/WmiPowerShellTransportTest.php @@ -20,7 +20,7 @@ function cacti_escapeshellarg($s) { } } -require_once __DIR__ . '/../linux_wmi.php'; +require_once __DIR__ . '/../includes/linux_wmi.php'; /** A runner that records what it was handed and returns canned CIM output. */ function wmi_test_recording_runner(array &$seen, array $stdout, int $exit = 0, string $stderr = ''): callable { diff --git a/wmi_accounts.php b/wmi_accounts.php index f51a88e..a37a9db 100644 --- a/wmi_accounts.php +++ b/wmi_accounts.php @@ -26,7 +26,7 @@ require('./include/auth.php'); require_once($config['base_path'] . '/plugins/wmi/includes/functions.php'); -require_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); +require_once($config['base_path'] . '/plugins/wmi/includes/linux_wmi.php'); $account_actions = [ 1 => __('Delete', 'wmi') diff --git a/wmi_queries.php b/wmi_queries.php index 341c432..d01f467 100644 --- a/wmi_queries.php +++ b/wmi_queries.php @@ -26,7 +26,7 @@ require('./include/auth.php'); require_once($config['base_path'] . '/plugins/wmi/includes/functions.php'); -require_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); +require_once($config['base_path'] . '/plugins/wmi/includes/linux_wmi.php'); $ds_actions = [ 1 => __('Delete', 'wmi') diff --git a/wmi_script.php b/wmi_script.php index a65b1cd..9ee147c 100644 --- a/wmi_script.php +++ b/wmi_script.php @@ -68,7 +68,7 @@ function wmi_script($hostname, $host_id, $wmiquery, $cmd = '', $arg1 = '', $arg2 = '') { global $config; - require_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); + require_once($config['base_path'] . '/plugins/wmi/includes/linux_wmi.php'); $wmi = new Linux_WMI($host_id); $wmi->hostname = $hostname; diff --git a/wmi_tools.php b/wmi_tools.php index c79fa9d..7dc7d32 100644 --- a/wmi_tools.php +++ b/wmi_tools.php @@ -599,7 +599,7 @@ function walk_host() { // Linux_WMI self-selects the PowerShell/CIM transport on a Windows // Cacti server and the wmic shell transport otherwise, so the same // client is used here regardless of $config['cacti_server_os']. - require_once($config['base_path'] . '/plugins/wmi/linux_wmi.php'); + require_once($config['base_path'] . '/plugins/wmi/includes/linux_wmi.php'); $wmi = new Linux_WMI(); $wmi->hostname = $host; From 06fd2f8703728a7a317167c2f36929e32874c5df Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 22:06:47 -0400 Subject: [PATCH 07/12] Harden prune against tampered-manifest path traversal The upgrade-time prune now rejects any tombstone containing '.'/'..' segments (which could escape the plugin directory or resolve to its root) and treats ancestors of whitelist entries as protected, so a tombstone on a parent directory can no longer delete a whitelisted file beneath it. --- setup.php | 14 ++++++++- tests/Unit/PruneFilesTest.php | 59 ++++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/setup.php b/setup.php index 647cd7f..cd4d24a 100644 --- a/setup.php +++ b/setup.php @@ -876,7 +876,9 @@ function plugin_wmi_prune_files(): void { foreach ($whitelist as $entry) { $entry = trim((string) $entry, '/'); - if ($entry !== '' && ($rel === $entry || strncmp($rel, $entry . '/', strlen($entry) + 1) === 0)) { + if ($entry !== '' && ($rel === $entry + || strncmp($rel, $entry . '/', strlen($entry) + 1) === 0 + || strncmp($entry, $rel . '/', strlen($rel) + 1) === 0)) { return true; } } @@ -901,6 +903,16 @@ function plugin_wmi_prune_files(): void { 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: wmi prune refused to remove %s: path contains a traversal segment (tampered manifest.json?)', $rel), false, 'WMI'); + + continue; + } + $path = $plugin_dir . '/' . $rel; if (!is_link($path) && !file_exists($path)) { diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php index 65a39b5..ae309f2 100644 --- a/tests/Unit/PruneFilesTest.php +++ b/tests/Unit/PruneFilesTest.php @@ -146,7 +146,7 @@ function wmi_prune_fixture(array $manifest): string { // 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('outside the plugin directory'); + expect(implode("\n", $GLOBALS['__test_cacti_log']))->toContain('a traversal segment'); }); it('warns when a tombstoned path cannot be removed', function () { @@ -176,3 +176,60 @@ function wmi_prune_fixture(array $manifest): string { })->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 = wmi_prune_fixture($manifest); + $plugin = $base . '/plugins/wmi'; + $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 { + plugin_wmi_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 = wmi_prune_fixture($manifest); + $plugin = $base . '/plugins/wmi'; + $restore = $GLOBALS['config']['base_path']; + + $GLOBALS['config']['base_path'] = $base; + + try { + plugin_wmi_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(); +}); From fe5af8507857766b4560362424aecf82388fc968 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 22:36:24 -0400 Subject: [PATCH 08/12] docs: update and align the Project Structure block Point the structure tree at the relocated files (libraries now under includes/, data files under docs/, stylesheets under css/) and align the trailing '# ...' comments to a single column so they no longer drift right. --- .github/copilot-instructions.md | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f849d0b..7c292a2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -25,20 +25,19 @@ When generating code for this repository: ## Project Structure ``` -wmi/ # Repository root (install to plugins/wmi/ in Cacti) -├── script/ # External wmic/collection helper scripts -├── templates/ # Data query/graph template XML -├── tests/ # Test suite -├── functions.php # Core hook callbacks and shared logic -├── linux_wmi.php # Linux (`wmic`) collection path -├── poller_wmi.php # Background poller entry point (CLI) -├── wmi_accounts.php # WMI credential administration -├── wmi_queries.php # WQL query administration -├── wmi_script.php # Ad hoc query/tool runner -├── wmi_tools.php # Diagnostic tools UI -├── INFO # Plugin metadata (name, version, compat) +wmi/ # Repository root (install to plugins/wmi/ in Cacti) +├── script/ # External wmic/collection helper scripts +├── templates/ # Data query/graph template XML +├── tests/ # Test suite +├── includes/ # functions.php (core hook callbacks/logic), database.php (schema), linux_wmi.php (wmic/CIM client) +├── poller_wmi.php # Background poller entry point (CLI) +├── wmi_accounts.php # WMI credential administration +├── wmi_queries.php # WQL query administration +├── wmi_script.php # Ad hoc query/tool runner +├── wmi_tools.php # Diagnostic tools 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 From f53df506a41f2fb35d6840f77638ae20bb12fdd6 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 22:59:56 -0400 Subject: [PATCH 09/12] docs/tests: standardize test header and correct manifest-contract notes - Use the full license header (from the plugin's own setup.php) in tests/Unit/PruneFilesTest.php instead of the abbreviated copyright banner. - Document that the manifest drift check and the upgrade-time prune also handle phpunit.xml and .md* files, matching the implemented behavior. --- .github/copilot-instructions.md | 2 +- tests/Unit/PruneFilesTest.php | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7c292a2..5497aa9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -196,4 +196,4 @@ existing code or adding new code, not just in dedicated cleanup passes: ## 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/`, `.git*`, and whitelisted paths). Custom customer CSS/theme files belong in `expected`, and stylesheets live in `css/` (not `themes/`). On upgrade, `plugin_wmi_prune_files()` deletes the tombstoned paths and the dev-only `tests/` tree, leaves `whitelist` and `.git*` 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. +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, `plugin_wmi_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/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php index ae309f2..678b56c 100644 --- a/tests/Unit/PruneFilesTest.php +++ b/tests/Unit/PruneFilesTest.php @@ -2,6 +2,23 @@ /* +-------------------------------------------------------------------------+ | 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. | + | | + | This program is distributed in the hope that it will be useful, | + | but WITHOUT ANY WARRANTY; without even the implied warranty of | + | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | + | GNU General Public License for more details. | + +-------------------------------------------------------------------------+ + | Cacti: The Complete RRDTool-based Graphing Solution | + +-------------------------------------------------------------------------+ + | This code is designed, written, and maintained by the Cacti Group. See | + | about.php and/or the AUTHORS file for specific developer information. | + +-------------------------------------------------------------------------+ + | http://www.cacti.net/ | +-------------------------------------------------------------------------+ */ From 1be48cfaaab38343fae7307e4e26c006b284e594 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 23:05:43 -0400 Subject: [PATCH 10/12] docs: use the full GPL v2 header in includes/database.php --- includes/database.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/includes/database.php b/includes/database.php index 4598216..a63d72c 100644 --- a/includes/database.php +++ b/includes/database.php @@ -7,8 +7,16 @@ | 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. | + | | + | This program is distributed in the hope that it will be useful, | + | but WITHOUT ANY WARRANTY; without even the implied warranty of | + | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | + | GNU General Public License for more details. | + +-------------------------------------------------------------------------+ + | Cacti: The Complete RRDTool-based Graphing Solution | +-------------------------------------------------------------------------+ - | Cacti: The Complete RRDtool-based Graphing Solution | + | This code is designed, written, and maintained by the Cacti Group. See | + | about.php and/or the AUTHORS file for specific developer information. | +-------------------------------------------------------------------------+ | http://www.cacti.net/ | +-------------------------------------------------------------------------+ From e1644d507ab448a9eaee9c38ff5e660abb5694e5 Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 30 Sep 2026 23:17:48 -0400 Subject: [PATCH 11/12] refactor: rename prune helpers to the plugin-slug function prefix The repository naming contract reserves the plugin__ prefix for plugin lifecycle / hook-registration functions; all other functions use the plain _ prefix. Rename the internal upgrade helpers accordingly: plugin__prune_files() -> _prune_files() plugin__rmtree() -> _rmtree() The call site, unit tests, and the copilot-instructions.md references are updated to match. No behavioral change. --- .github/copilot-instructions.md | 2 +- setup.php | 12 ++++++------ tests/Unit/PruneFilesTest.php | 16 ++++++++-------- tests/Unit/WmiLifecycleTest.php | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5497aa9..839b468 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -196,4 +196,4 @@ existing code or adding new code, not just in dedicated cleanup passes: ## 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, `plugin_wmi_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. +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, `wmi_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/setup.php b/setup.php index cd4d24a..59006bd 100644 --- a/setup.php +++ b/setup.php @@ -162,7 +162,7 @@ function plugin_wmi_check_config() { * @return bool Always returns true. */ function plugin_wmi_upgrade() { - plugin_wmi_prune_files(); + wmi_prune_files(); return true; } @@ -846,7 +846,7 @@ function wmi_api_device_new($save) { * @global array $config Cacti global configuration array; used to resolve * the plugin directory. */ -function plugin_wmi_prune_files(): void { +function wmi_prune_files(): void { global $config; $plugin_dir = $config['base_path'] . '/plugins/wmi'; @@ -932,7 +932,7 @@ function plugin_wmi_prune_files(): void { } if (is_dir($path) && !is_link($path)) { - $removed = plugin_wmi_rmtree($path); + $removed = wmi_rmtree($path); } else { $removed = @unlink($path); } @@ -966,14 +966,14 @@ function plugin_wmi_prune_files(): void { /** * Recursively deletes a directory and its contents. Symlinks are removed - * without being followed. Helper for plugin_wmi_prune_files(). + * without being followed. Helper for wmi_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 plugin_wmi_rmtree(string $dir): bool { +function wmi_rmtree(string $dir): bool { $entries = scandir($dir); $ok = true; @@ -985,7 +985,7 @@ function plugin_wmi_rmtree(string $dir): bool { $path = $dir . '/' . $entry; if (is_dir($path) && !is_link($path)) { - if (!plugin_wmi_rmtree($path)) { + if (!wmi_rmtree($path)) { $ok = false; } } elseif (!@unlink($path)) { diff --git a/tests/Unit/PruneFilesTest.php b/tests/Unit/PruneFilesTest.php index 678b56c..7c15744 100644 --- a/tests/Unit/PruneFilesTest.php +++ b/tests/Unit/PruneFilesTest.php @@ -23,7 +23,7 @@ */ /* - * Unit coverage for plugin_wmi_prune_files(): tombstone/tests removal, + * Unit coverage for wmi_prune_files(): tombstone/tests removal, * whitelist and .git protection, and logging of unaccounted-for entries. */ @@ -72,7 +72,7 @@ function wmi_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_wmi_prune_files(); + wmi_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } @@ -111,7 +111,7 @@ function wmi_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_wmi_prune_files(); + wmi_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } @@ -130,7 +130,7 @@ function wmi_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_wmi_prune_files(); + wmi_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } @@ -156,7 +156,7 @@ function wmi_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_wmi_prune_files(); + wmi_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } @@ -183,7 +183,7 @@ function wmi_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_wmi_prune_files(); + wmi_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; @chmod($plugin . '/locked/sub', 0700); @@ -212,7 +212,7 @@ function wmi_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_wmi_prune_files(); + wmi_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } @@ -242,7 +242,7 @@ function wmi_prune_fixture(array $manifest): string { $GLOBALS['config']['base_path'] = $base; try { - plugin_wmi_prune_files(); + wmi_prune_files(); } finally { $GLOBALS['config']['base_path'] = $restore; } diff --git a/tests/Unit/WmiLifecycleTest.php b/tests/Unit/WmiLifecycleTest.php index 2d18168..2866ee0 100644 --- a/tests/Unit/WmiLifecycleTest.php +++ b/tests/Unit/WmiLifecycleTest.php @@ -41,7 +41,7 @@ }); it('reports the upgrade as always successful', function () { - // Sandbox base_path so the upgrade-time prune (plugin_wmi_prune_files) + // Sandbox base_path so the upgrade-time prune (wmi_prune_files) // runs against an empty temp tree, never the real checkout. $restore = $GLOBALS['config']['base_path']; $GLOBALS['config']['base_path'] = sys_get_temp_dir() . '/wmi-upg-' . uniqid(); From 31b72fd09560dfcd0447fe102eb4b57fe4692af8 Mon Sep 17 00:00:00 2001 From: TheWitness Date: Thu, 1 Oct 2026 00:04:34 -0400 Subject: [PATCH 12/12] Load cli_check.php from Cacti's include directory in wmi_script.php The CLI/script-server bootstrap resolved to the non-existent plugins/global/cli_check.php. Point it at the parent Cacti include directory (../../include/cli_check.php) which ships cli_check.php and loads the global bootstrap. lib/snmp.php remains require_once. --- wmi_script.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wmi_script.php b/wmi_script.php index 9ee147c..988da82 100644 --- a/wmi_script.php +++ b/wmi_script.php @@ -22,7 +22,7 @@ +-------------------------------------------------------------------------+ */ -require_once(__DIR__ . '/../global/cli_check.php'); +require_once(__DIR__ . '/../../include/cli_check.php'); require_once($config['base_path'] . '/lib/snmp.php'); if (!isset($called_by_script_server)) {