feat(cli): add unified command frontend and normalize legacy failures (#7903)

* feat(cli): add unified command frontend and normalize legacy failures

Add the Symfony-backed bin/cacti entry point while preserving legacy argument, terminal, signal, and exit-status behavior. Fold in the focused legacy CLI correctness fixes from #7906 without adding its unused parser abstraction.

Keep path containment and the PHP 8.3 floor out of this review; the latter remains in #7939.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(cli): enforce PHP floor and deny web dispatch

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
This commit is contained in:
Thomas Vincent authored and GitHub committed 2026-09-07 18:36:55 -07:00
1 parent 49b10f8046
commit 1ae479d4f5
31 files changed
+1556 -362

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
name: CLI Coverage
on:
push:
branches: [develop]
paths:
- '.github/workflows/cli-coverage.yml'
- '.github/actions/setup-php-composer/**'
- 'composer.json'
- 'composer.lock'
- 'bin/cacti'
- 'bin/.htaccess'
- 'include/cli_check.php'
- 'include/cli_only.php'
- 'lib/Console/**'
- 'phpunit-cli-coverage.xml'
- 'tests/fixtures/Console/**'
- 'tests/.htaccess'
- 'tests/index.php'
- 'tests/Unit/Core/Cli/CactiApplicationTest.php'
- 'tests/Unit/Core/Cli/CactiEntrypointSecurityTest.php'
pull_request:
branches: [develop]
paths:
- '.github/workflows/cli-coverage.yml'
- '.github/actions/setup-php-composer/**'
- 'composer.json'
- 'composer.lock'
- 'bin/cacti'
- 'bin/.htaccess'
- 'include/cli_check.php'
- 'include/cli_only.php'
- 'lib/Console/**'
- 'phpunit-cli-coverage.xml'
- 'tests/fixtures/Console/**'
- 'tests/.htaccess'
- 'tests/index.php'
- 'tests/Unit/Core/Cli/CactiApplicationTest.php'
- 'tests/Unit/Core/Cli/CactiEntrypointSecurityTest.php'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: cli-coverage-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
COMPOSER_ALLOW_SUPERUSER: 1
jobs:
coverage:
name: CLI 100% coverage
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- name: Setup locked PHP dependencies
uses: ./.github/actions/setup-php-composer
with:
php-version: '8.2'
coverage: xdebug
extensions: intl, mysql, gd, ldap, gmp, xml, curl, json, mbstring
dependency-mode: locked-cli-coverage
install-args: --prefer-dist --no-progress --no-interaction
- name: Enforce line coverage
env:
XDEBUG_MODE: coverage
run: |
set -euo pipefail
composer test:cli-coverage
+10
View File
@@ -0,0 +1,10 @@
# Apache 2.4
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
# Apache 2.2
<IfModule !mod_authz_core.c>
Order Allow,Deny
Deny from all
</IfModule>
Executable
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env php
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
declare(strict_types = 1);
use Cacti\Console\CactiApplication;
use Cacti\Console\Input\RawArgvInput;
$root = dirname(__DIR__);
$autoload = $root . '/include/vendor/autoload.php';
require_once $root . '/include/cli_only.php';
if (!is_file($autoload)) {
fwrite(STDERR, "Cacti dependencies are not installed. Run composer install.\n");
exit(1);
}
require $autoload;
$application = new CactiApplication($root);
exit($application->run(new RawArgvInput()));
+1
View File
@@ -0,0 +1 @@
Add a unified bin/cacti command line entry point over the legacy cli scripts and activate the shared CLI-only constants for CLI entrypoints
+2 -2
View File
@@ -32,7 +32,7 @@ include_once(CACTI_PATH_LIBRARY . '/api_tree.php');
$parms = $_SERVER['argv'];
array_shift($parms);
if (sizeof($parms)) {
if (cacti_sizeof($parms)) {
// setup defaults
$siteName = ''; // Site Name
$siteAddr1 = ''; // Site Address 1
@@ -380,7 +380,7 @@ function mapDevices(int $siteId, bool $doMap) : void {
}
}
$numMatched = sizeof($matchedDevices);
$numMatched = cacti_sizeof($matchedDevices);
if ($numMatched) {
echoQuiet(PHP_EOL);
+2 -2
View File
@@ -231,13 +231,13 @@ if ((file_exists($keypair_path . '/package.pem') || file_exists($keypair_path .
if ($privKey === false) {
print "FATAL: Unable to open your private key $privkey" . PHP_EOL;
exit(0);
exit(1);
}
if ($pubKey === false) {
print "FATAL: Unable to open your public key $pubkey" . PHP_EOL;
exit(0);
exit(1);
}
} else {
print 'NOTE: Generating custom public/private key pair.' . PHP_EOL;
+1
View File
@@ -52,6 +52,7 @@ if (cacti_sizeof($parms)) {
switch ($arg) {
case '-id':
case '--id':
case '--host-id':
$host_id = $value;
+4
View File
@@ -184,6 +184,10 @@ if (cacti_sizeof($parms)) {
default:
print "ERROR: Invalid Argument: ($arg)" . PHP_EOL . PHP_EOL;
display_help();
exit(1);
}
}
} else {
+1 -1
View File
@@ -225,7 +225,7 @@ if (!$result) {
print 'ERROR: Remove Spikes experienced errors' . PHP_EOL;
print $spiker->get_errors();
exit(-1);
exit(1);
} else {
print $spiker->get_output();
+8 -8
View File
@@ -36,7 +36,7 @@ require_once('./include/cli_check.php');
$parms = $_SERVER['argv'];
array_shift($parms);
if (sizeof($parms)) {
if (cacti_sizeof($parms)) {
$data_template_id = false;
$displayDataTemplates = false;
$debug_mode = false;
@@ -164,7 +164,7 @@ if ($displayDataTemplates) {
print 'Known Data Templates: (id, name)' . PHP_EOL;
if (sizeof($data_templates)) {
if (cacti_sizeof($data_templates)) {
foreach ($data_templates as $data_template) {
print $data_template['id'] . "\t" . $data_template['name'] . PHP_EOL;
}
@@ -179,13 +179,13 @@ if ($displayDataTemplates) {
if (!$data_template_id) {
print 'ERROR: You must supply a valid data template id to run this script!' . PHP_EOL;
exit(0);
exit(1);
}
if (!$backup_folder) {
print 'ERROR: You must define a backup folder!' . PHP_EOL;
exit(0);
exit(1);
} else {
$tmp_xml_file = $tmp_backup_folder . 'tmp_rrdresize.xml';
$tmp_rrd_file = $tmp_backup_folder . 'tmp_rrdfile.rrd';
@@ -240,7 +240,7 @@ if ($scanned_directory['folders']) {
if (!mkdir($tmp_backup_folder)) {
print 'ERROR: Unable to create a backup folder!' . PHP_EOL;
exit(0);
exit(1);
}
// create log file
@@ -261,7 +261,7 @@ if ($scanned_directory['folders']) {
$dt_hosts = [];
if (sizeof($db_hosts) > 0) {
if (cacti_sizeof($db_hosts) > 0) {
foreach ($db_hosts as $key => $value) {
$dt_hosts[] = $value['host_id'];
}
@@ -280,7 +280,7 @@ if ($scanned_directory['folders']) {
$dt_data_sources = [];
if (sizeof($db_data_sources) > 0) {
if (cacti_sizeof($db_data_sources) > 0) {
foreach ($db_data_sources as $key => $value) {
$dt_data_sources[] = $value['local_data_id'];
}
@@ -361,7 +361,7 @@ if ($scanned_directory['folders']) {
[$local_data['data_template_id']]
);
$data_template_ds_counter = sizeof($data_template_ds);
$data_template_ds_counter = cacti_sizeof($data_template_ds);
$data_template_data_settings = db_fetch_assoc_prepared('SELECT *
FROM data_template_data_rra
LEFT JOIN rra
+1 -1
View File
@@ -278,7 +278,7 @@ if (strlen($response)) {
} else {
print 'FATAL: RRDTool not found in configuration or path.' . PHP_EOL . 'Please insure RRDTool can be found using one of these methods!' . PHP_EOL;
exit(-1);
exit(1);
}
/* The dump files and the backups were previously named from the RRD basename
+8
View File
@@ -56,12 +56,14 @@
"slim/slim": "^4.14",
"stevenmaguire/oauth2-keycloak": "^6.1.0",
"stevenmaguire/oauth2-microsoft": "^2.2",
"symfony/console": "^7.4",
"symfony/filesystem": "^7.4",
"symfony/finder": "^6.4 || ^7.4",
"symfony/http-foundation": "^7.4",
"symfony/lock": "^7.4",
"symfony/mailer": "^7.4",
"symfony/notifier": "^7.4",
"symfony/process": "^7.4",
"symfony/validator": "^7.4"
},
"require-dev": {
@@ -81,6 +83,7 @@
"hardening-patterns": "php tests/tools/check_hardening_patterns.php",
"vendor-policy": "php tests/tools/check_vendor_policy.php",
"php-cs-fixit": "php-cs-fixer fix ",
"test:cli-coverage": "@php include/vendor/bin/pest --configuration=phpunit-cli-coverage.xml --coverage --coverage-text --min=100",
"install-hooks": "bash -c 'install -m 755 tests/tools/pre-commit \"$(git rev-parse --git-path hooks/pre-commit)\"'",
"test": "include/vendor/bin/pest --display-warnings",
"sink-guard": "bash tests/security/verify_sink_inventory.sh",
@@ -114,6 +117,11 @@
],
"minimum-stability": "stable",
"prefer-stable": true,
"autoload": {
"psr-4": {
"Cacti\\Console\\": "lib/Console/"
}
},
"config": {
"sort-packages": true,
"vendor-dir": "./include/vendor",
Generated
+345 -345
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "22cda0241ac22aff9c2fe23ef7abb3c6",
"content-hash": "33ddeb7d701bf817126ad36feb9a92e0",
"packages": [
{
"name": "doctrine/lexer",
@@ -1935,6 +1935,104 @@
},
"time": "2017-06-07T13:42:47+00:00"
},
{
"name": "symfony/console",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0",
"reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-mbstring": "~1.0",
"symfony/service-contracts": "^2.5|^3",
"symfony/string": "^7.2|^8.0"
},
"conflict": {
"symfony/dependency-injection": "<6.4",
"symfony/dotenv": "<6.4",
"symfony/event-dispatcher": "<6.4",
"symfony/lock": "<6.4",
"symfony/process": "<6.4"
},
"provide": {
"psr/log-implementation": "1.0|2.0|3.0"
},
"require-dev": {
"psr/log": "^1|^2|^3",
"symfony/config": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/lock": "^6.4|^7.0|^8.0",
"symfony/messenger": "^6.4|^7.0|^8.0",
"symfony/process": "^6.4|^7.0|^8.0",
"symfony/stopwatch": "^6.4|^7.0|^8.0",
"symfony/var-dumper": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Console\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Eases the creation of beautiful and testable command line interfaces",
"homepage": "https://symfony.com",
"keywords": [
"cli",
"command-line",
"console",
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v7.4.18"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-08-25T14:18:37+00:00"
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.7.1",
@@ -2814,6 +2912,88 @@
],
"time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-intl-grapheme",
"version": "v1.41.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
"reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d",
"reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"suggest": {
"ext-intl": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Grapheme\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's grapheme_* functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"grapheme",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-07-28T08:25:59+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.42.0",
@@ -3316,17 +3496,82 @@
"time": "2026-07-01T12:47:55+00:00"
},
{
"name": "symfony/service-contracts",
"version": "v3.7.1",
"name": "symfony/process",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
"reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0"
"url": "https://github.com/symfony/process.git",
"reference": "058d17fc284cce14efb2385783b55014a461b176"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0",
"reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0",
"url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176",
"reference": "058d17fc284cce14efb2385783b55014a461b176",
"shasum": ""
},
"require": {
"php": ">=8.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Process\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/process/tree/v7.4.18"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-08-21T17:40:08+00:00"
},
{
"name": "symfony/service-contracts",
"version": "v3.7.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
"reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257",
"reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257",
"shasum": ""
},
"require": {
@@ -3380,7 +3625,7 @@
"standards"
],
"support": {
"source": "https://github.com/symfony/service-contracts/tree/v3.7.1"
"source": "https://github.com/symfony/service-contracts/tree/v3.7.3"
},
"funding": [
{
@@ -3400,7 +3645,98 @@
"type": "tidelift"
}
],
"time": "2026-06-16T09:55:08+00:00"
"time": "2026-07-27T15:39:01+00:00"
},
{
"name": "symfony/string",
"version": "v7.4.15",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
"reference": "e394af32256bf9e7bf80849d95e589167c10097b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b",
"reference": "e394af32256bf9e7bf80849d95e589167c10097b",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-intl-grapheme": "~1.33",
"symfony/polyfill-intl-normalizer": "~1.0",
"symfony/polyfill-mbstring": "~1.0"
},
"conflict": {
"symfony/translation-contracts": "<2.5"
},
"require-dev": {
"symfony/emoji": "^7.1|^8.0",
"symfony/http-client": "^6.4|^7.0|^8.0",
"symfony/intl": "^6.4|^7.0|^8.0",
"symfony/translation-contracts": "^2.5|^3.0",
"symfony/var-exporter": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"files": [
"Resources/functions.php"
],
"psr-4": {
"Symfony\\Component\\String\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
"homepage": "https://symfony.com",
"keywords": [
"grapheme",
"i18n",
"string",
"unicode",
"utf-8",
"utf8"
],
"support": {
"source": "https://github.com/symfony/string/tree/v7.4.15"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-07-28T07:33:02+00:00"
},
{
"name": "symfony/translation-contracts",
@@ -7734,104 +8070,6 @@
],
"time": "2026-06-05T06:23:12+00:00"
},
{
"name": "symfony/console",
"version": "v7.4.17",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/962e18f09ebe68a49039b4c82fc0ea4871824fca",
"reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-mbstring": "~1.0",
"symfony/service-contracts": "^2.5|^3",
"symfony/string": "^7.2|^8.0"
},
"conflict": {
"symfony/dependency-injection": "<6.4",
"symfony/dotenv": "<6.4",
"symfony/event-dispatcher": "<6.4",
"symfony/lock": "<6.4",
"symfony/process": "<6.4"
},
"provide": {
"psr/log-implementation": "1.0|2.0|3.0"
},
"require-dev": {
"psr/log": "^1|^2|^3",
"symfony/config": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/lock": "^6.4|^7.0|^8.0",
"symfony/messenger": "^6.4|^7.0|^8.0",
"symfony/process": "^6.4|^7.0|^8.0",
"symfony/stopwatch": "^6.4|^7.0|^8.0",
"symfony/var-dumper": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Console\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Eases the creation of beautiful and testable command line interfaces",
"homepage": "https://symfony.com",
"keywords": [
"cli",
"command-line",
"console",
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v7.4.17"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-08-21T12:09:28+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v7.4.8",
@@ -7903,88 +8141,6 @@
],
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/polyfill-intl-grapheme",
"version": "v1.41.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
"reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d",
"reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"suggest": {
"ext-intl": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Grapheme\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's grapheme_* functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"grapheme",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-07-28T08:25:59+00:00"
},
{
"name": "symfony/polyfill-php81",
"version": "v1.38.1",
@@ -8145,71 +8301,6 @@
],
"time": "2026-05-26T12:51:13+00:00"
},
{
"name": "symfony/process",
"version": "v7.4.17",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
"reference": "058d17fc284cce14efb2385783b55014a461b176"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176",
"reference": "058d17fc284cce14efb2385783b55014a461b176",
"shasum": ""
},
"require": {
"php": ">=8.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Process\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/process/tree/v7.4.17"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-08-21T17:40:08+00:00"
},
{
"name": "symfony/stopwatch",
"version": "v7.4.8",
@@ -8276,97 +8367,6 @@
],
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/string",
"version": "v7.4.15",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
"reference": "e394af32256bf9e7bf80849d95e589167c10097b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b",
"reference": "e394af32256bf9e7bf80849d95e589167c10097b",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-intl-grapheme": "~1.33",
"symfony/polyfill-intl-normalizer": "~1.0",
"symfony/polyfill-mbstring": "~1.0"
},
"conflict": {
"symfony/translation-contracts": "<2.5"
},
"require-dev": {
"symfony/emoji": "^7.1|^8.0",
"symfony/http-client": "^6.4|^7.0|^8.0",
"symfony/intl": "^6.4|^7.0|^8.0",
"symfony/translation-contracts": "^2.5|^3.0",
"symfony/var-exporter": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"files": [
"Resources/functions.php"
],
"psr-4": {
"Symfony\\Component\\String\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
"homepage": "https://symfony.com",
"keywords": [
"grapheme",
"i18n",
"string",
"unicode",
"utf-8",
"utf8"
],
"support": {
"source": "https://github.com/symfony/string/tree/v7.4.15"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-07-28T07:33:02+00:00"
},
{
"name": "symfony/var-exporter",
"version": "v6.4.42",
+4 -3
View File
@@ -31,10 +31,11 @@
*
* Any source wishing to check whether the system is in
* CLI or WEB mode should use the CACTI_WEB and
* CACTI_CLI constants
define('CACTI_CLI_ONLY', true);
* CACTI_CLI constants.
*/
require_once(__DIR__ . '/cli_only.php');
/* We are not talking to the browser */
// We are not talking to the browser
$no_http_headers = true;
// Make sure CLI's are have minimum settings
+27
View File
@@ -0,0 +1,27 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
/* Keep this guard independent of global.php so lightweight launchers can
* reject web requests without opening the database merely to show --help or
* --version. Full legacy scripts continue through cli_check.php afterwards. */
if (PHP_SAPI !== 'cli') {
http_response_code(403);
print 'FATAL: This file can only be called from the command line.' . PHP_EOL;
exit(1);
}
if (!defined('CACTI_CLI_ONLY')) {
define('CACTI_CLI_ONLY', true);
}
+73
View File
@@ -0,0 +1,73 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
declare(strict_types = 1);
namespace Cacti\Console;
use Cacti\Console\Command\LegacyScriptCommand;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class CactiApplication extends Application {
public function __construct(private readonly string $root) {
parent::__construct('Cacti', $this->readVersion());
foreach (LegacyCommandMap::commands() as $script => $command) {
$this->add(new LegacyScriptCommand($command, $script, $this->root));
}
}
/**
* Dispatch a legacy command before Symfony can claim its flags.
*
* Application::doRun() answers --version/-V itself and rewrites --help/-h
* into the help command, and it reads both straight off the token vector,
* so dropping them from the input definition changes nothing. Forty-five
* scripts under cli/ carry their own --version and --help handling, so a
* legacy command owns its whole flag namespace and Symfony must not read
* any of it.
*/
#[\Override]
public function doRun(InputInterface $input, OutputInterface $output): int {
$name = $this->getCommandName($input);
if ($name !== null && $name !== '') {
try {
$command = $this->find($name);
} catch (\Throwable) {
// Unknown or ambiguous; let Symfony report it as it always has.
$command = null;
}
if ($command instanceof LegacyScriptCommand) {
/* -q belongs to the legacy script too. Symfony has already applied
* it to this output, which would swallow the child's stream while
* the flag is also forwarded to the child. */
$output->setVerbosity(OutputInterface::VERBOSITY_NORMAL);
return $command->run($input, $output);
}
}
return parent::doRun($input, $output);
}
private function readVersion(): string {
$version = @file_get_contents($this->root . '/include/cacti_version');
return $version === false ? 'unknown' : trim($version);
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
declare(strict_types = 1);
namespace Cacti\Console\Command;
use Cacti\Console\Input\RawArgvInput;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Exception\ProcessSignaledException;
use Symfony\Component\Process\Exception\LogicException as ProcessLogicException;
use Symfony\Component\Process\Process;
final class LegacyScriptCommand extends Command {
/** @var \Closure(array<int, string>): Process */
private readonly \Closure $process_factory;
/** @var \Closure(): bool */
private readonly \Closure $tty_probe;
/** @var (\Closure(int, callable): void)|null */
private readonly ?\Closure $signal_registrar;
public function __construct(
string $name,
private readonly string $script,
private readonly string $root,
?callable $process_factory = null,
?callable $tty_probe = null,
callable|false|null $signal_registrar = null,
) {
parent::__construct($name);
$this->process_factory = $process_factory === null
? static fn (array $command): Process => new Process($command, null, null, STDIN, null)
: \Closure::fromCallable($process_factory);
$this->tty_probe = $tty_probe === null
? static fn (): bool => defined('STDIN') && defined('STDOUT') && defined('STDERR') && Process::isTtySupported() && stream_isatty(STDIN) && stream_isatty(STDOUT) && stream_isatty(STDERR)
: \Closure::fromCallable($tty_probe);
$signals_available = function_exists('pcntl_async_signals') && function_exists('pcntl_signal');
if ($signal_registrar === false || ($signal_registrar === null && !$signals_available)) {
$this->signal_registrar = null;
} elseif ($signal_registrar !== null) {
$this->signal_registrar = \Closure::fromCallable($signal_registrar);
} else {
$this->signal_registrar = static function (int $signal, callable $handler): void {
pcntl_async_signals(true);
pcntl_signal($signal, $handler);
};
}
$this->setAliases([$script]);
$this->setDescription(sprintf('Runs the legacy cli/%s.php command.', $script));
$this->setHelp(sprintf(
'This compatibility command forwards arguments, input, output, and the exit status to cli/%s.php.',
$script
));
$this->ignoreValidationErrors();
}
#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int {
if (!$input instanceof RawArgvInput) {
throw new \LogicException('Legacy commands require RawArgvInput.');
}
$arguments = $input->argumentsAfterCommand(array_values(array_merge([$this->getName()], $this->getAliases())));
/* Inherit the caller's directory. The script path is absolute, and the
* legacy scripts resolve relative path arguments against the cwd with no
* normalization. Anchoring here would send a relative output into the
* web-served root instead of the directory the operator ran it from. */
$process = ($this->process_factory)(
array_merge([PHP_BINARY, $this->root . '/cli/' . $this->script . '.php'], $arguments)
);
$process->setTimeout(null);
$this->forwardSignals($process);
try {
/* Several scripts under cli/ prompt with fgets(STDIN). Behind a pipe
* the prompt sits in a buffer while the child blocks on the read, so
* hand the terminal over whenever there is one. */
if ($this->canUseTty()) {
$process->setTty(true);
return $process->run();
}
/* Process::buildCallback() appends every byte to a php://temp stream
* as well as calling this callback, and nothing ever reads it back.
* With no timeout, a long poller run spooled its whole output to disk
* for no reader. */
$process->disableOutput();
return $process->run(function (string $type, string $buffer) use ($output): void {
$target = $type === Process::ERR && $output instanceof ConsoleOutputInterface
? $output->getErrorOutput()
: $output;
$target->write($buffer, false, OutputInterface::OUTPUT_RAW);
});
} catch (ProcessSignaledException $exception) {
/* A child the kernel killed has no exit code of its own, and Process
* throws rather than returning one. Report it the way a shell does so
* cron and systemd see 137 or 143, not a rendered stack trace. */
return 128 + $exception->getSignal();
}
}
/**
* Whether this invocation owns a terminal it can hand to the child.
*/
private function canUseTty(): bool {
return ($this->tty_probe)();
}
/**
* Pass a termination signal on to the child.
*
* Without this a SIGTERM to bin/cacti leaves the legacy script running,
* possibly mid-write to the database, with nothing left to reap it.
*/
private function forwardSignals(Process $process): void {
if ($this->signal_registrar === null) {
return;
}
foreach ([SIGTERM, SIGINT, SIGHUP] as $signal) {
($this->signal_registrar)($signal, static function (int $received) use ($process): void {
try {
$process->signal($received);
} catch (ProcessLogicException) {
/* The child can exit between signal delivery and Process::signal().
* Its real exit status remains authoritative in that race. */
}
});
}
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
declare(strict_types = 1);
namespace Cacti\Console\Input;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputDefinition;
final class RawArgvInput extends ArgvInput {
/** @var list<string> */
private readonly array $rawTokens;
/** @param list<string>|null $argv */
public function __construct(?array $argv = null, ?InputDefinition $definition = null) {
$argv ??= $_SERVER['argv'] ?? [];
$this->rawTokens = array_values(array_slice($argv, 1));
parent::__construct($argv, $definition);
}
/**
* @param list<string|null> $names
* @return list<string>
*/
public function argumentsAfterCommand(array $names): array {
/* Symfony resolves abbreviated command names, so the typed token is
* often neither the registered name nor an alias. Without the token it
* actually consumed, an abbreviation matches nothing and the legacy
* script is spawned with an empty argv, silently taking its
* no-argument path. */
$names[] = $this->getFirstArgument();
foreach ($this->rawTokens as $offset => $token) {
if (in_array($token, $names, true)) {
/* Drop the command token, keep everything else. getFirstArgument()
* skips leading options, so 'bin/cacti --help poller:rebuild-cache'
* dispatches with the flag ahead of the name; slicing only forward
* handed the script an empty argv and it rebuilt every cache. */
return array_merge(
array_slice($this->rawTokens, 0, $offset),
array_slice($this->rawTokens, $offset + 1)
);
}
}
throw new \LogicException('The resolved command token is missing from the raw argument vector.');
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
declare(strict_types = 1);
namespace Cacti\Console;
final class LegacyCommandMap {
/** @var array<string, string> Legacy script basename to command name. */
private const COMMANDS = [
'add_data_query' => 'data-query:add',
'add_datasource' => 'data-source:add',
'add_device' => 'device:add',
'add_graph_template' => 'graph-template:add',
'add_graphs' => 'graph:add',
'add_group' => 'group:add',
'add_group_perms' => 'group:permissions:add',
'add_perms' => 'permissions:add',
'add_site' => 'site:add',
'add_tree' => 'tree:add',
'analyze_database' => 'database:analyze',
'apply_automation_rules' => 'automation:apply-rules',
'audit_database' => 'database:audit',
'audit_graph_template_inputs' => 'graph-template:audit-inputs',
'batchgapfix' => 'rrd:batch-gap-fix',
'change_device' => 'device:change',
'clone_device_template' => 'device-template:clone',
'convert_tables' => 'database:convert-tables',
'copy_user' => 'user:copy',
'fetch_plugins' => 'plugin:fetch',
'fix_mediumint' => 'database:fix-mediumint',
'float_rrdfiles' => 'rrd:float-files',
'genkey' => 'security:generate-key',
'genmanifest' => 'package:generate-manifest',
'host_update_template' => 'device:update-template',
'import_package' => 'package:import',
'import_template' => 'template:import',
'input_whitelist' => 'security:input-whitelist',
'install_cacti' => 'system:install',
'md5sum' => 'integrity:md5',
'migrate_poller' => 'poller:migrate',
'plugin_manage' => 'plugin:manage',
'poller_data_sources_reapply_names' => 'poller:data-sources:reapply-names',
'poller_graphs_reapply_names' => 'poller:graphs:reapply-names',
'poller_output_empty' => 'poller:clear-empty-output',
'poller_reindex_hosts' => 'poller:reindex',
'poller_replicate' => 'poller:replicate',
'push_out_hosts' => 'poller:push-hosts',
'rebuild_poller_cache' => 'poller:rebuild-cache',
'refresh_csrf' => 'security:refresh-csrf',
'remove_broken_graphs' => 'graph:remove-broken',
'remove_device' => 'device:remove',
'remove_graphs' => 'graph:remove',
'removespikes' => 'rrd:remove-spikes',
'reorder_data_query' => 'data-query:reorder',
'repair_database' => 'database:repair',
'repair_graphs' => 'graph:repair',
'repair_templates' => 'template:repair',
'rrdresize' => 'rrd:resize',
'show_perms' => 'permissions:show',
'splice_rrd' => 'rrd:splice',
'sqltable_to_php' => 'developer:sql-table-to-php',
'structure_rra_paths' => 'rrd:structure-paths',
'update_heartbeat' => 'rrd:update-heartbeat',
'upgrade_database' => 'database:upgrade',
'version' => 'system:version',
];
/** @return array<string, string> Legacy script basename to command name. */
public static function commands(): array {
return self::COMMANDS;
}
}
+17
View File
@@ -0,0 +1,17 @@
<phpunit bootstrap="tests/bootstrap-unit.php" beStrictAboutOutputDuringTests="false">
<php>
<ini name="error_reporting" value="-1"/>
<env name="CACTI_TEST_BOOTSTRAP" value="1"/>
</php>
<source>
<include>
<directory suffix=".php">lib/Console</directory>
</include>
</source>
<testsuites>
<testsuite name="CLI coverage">
<file>tests/Unit/Core/Cli/CactiApplicationTest.php</file>
<file>tests/Unit/Core/Cli/CactiEntrypointSecurityTest.php</file>
</testsuite>
</testsuites>
</phpunit>
+10
View File
@@ -0,0 +1,10 @@
# Apache 2.4
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
# Apache 2.2
<IfModule !mod_authz_core.c>
Order Allow,Deny
Deny from all
</IfModule>
@@ -0,0 +1,438 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
declare(strict_types = 1);
use Cacti\Console\CactiApplication;
use Cacti\Console\Command\LegacyScriptCommand;
use Cacti\Console\Input\RawArgvInput;
use Cacti\Console\LegacyCommandMap;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Tester\ApplicationTester;
use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\LogicException as ProcessLogicException;
use Symfony\Component\Process\Exception\ProcessSignaledException;
final class CactiControllableLegacyProcess extends Process {
public bool $tty_enabled = false;
public bool $output_disabled = false;
public bool $running = false;
public int|null $received_signal = null;
public function __construct(private readonly int $result = 0, private readonly int|null $term_signal = null) {
parent::__construct(['true']);
}
public function setTimeout(float|null $timeout): static {
return $this;
}
public function setTty(bool $tty): static {
$this->tty_enabled = $tty;
return $this;
}
public function disableOutput(): static {
$this->output_disabled = true;
return $this;
}
public function run(callable|null $callback = null, array $env = []): int {
if ($this->term_signal !== null) {
throw new ProcessSignaledException($this);
}
return $this->result;
}
public function getTermSignal(): int {
return $this->term_signal ?? 0;
}
public function isRunning(): bool {
return $this->running;
}
public function signal(int $signal): static {
if (!$this->running) {
throw new ProcessLogicException('Cannot send signal on a non running process.');
}
$this->received_signal = $signal;
return $this;
}
}
it('registers every executable legacy CLI script exactly once', function (): void {
$root = dirname(__DIR__, 4);
$non_commands = ['cacti', 'index'];
$scripts = array_map(
static fn (string $path): string => basename($path, '.php'),
glob($root . '/cli/*.php') ?: []
);
$mapped = array_keys(LegacyCommandMap::commands());
$missing = array_values(array_diff($mapped, $scripts));
$unexpected = array_values(array_diff($scripts, $mapped, $non_commands));
expect($missing)->toBe([], 'Every mapped command must name an existing cli/*.php script.')
->and($unexpected)->toBe([], 'Classify new non-command files in $non_commands, or add commands to LegacyCommandMap.')
->and(array_unique(array_values(LegacyCommandMap::commands())))
->toHaveCount(count($mapped));
});
it('lists commands without bootstrapping the database', function (): void {
$root = dirname(__DIR__, 4);
$application = new CactiApplication($root);
$application->setAutoExit(false);
$tester = new ApplicationTester($application);
$status = $tester->run(['command' => 'list', '--format' => 'json']);
$commands = json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR)['commands'];
$names = array_column($commands, 'name');
expect($status)->toBe(0)
->and($names)->toContain('device:add', 'database:audit', 'poller:reindex', 'rrd:resize');
});
it('reports an unknown version when the version file is unavailable', function (): void {
$application = new CactiApplication('/path/that/does/not/exist');
expect($application->getVersion())->toBe('unknown');
});
it('leaves unknown commands to Symfony after legacy lookup fails', function (): void {
$application = new CactiApplication(dirname(__DIR__, 4));
$application->setAutoExit(false);
$application->setCatchExceptions(false);
expect(fn (): int => $application->run(new RawArgvInput(['bin/cacti', 'not:a:command']), new BufferedOutput()))
->toThrow(Symfony\Component\Console\Exception\CommandNotFoundException::class);
});
it('hands a terminal directly to a legacy process', function (): void {
$process = new CactiControllableLegacyProcess(17);
$command = new LegacyScriptCommand(
'probe:run',
'probe',
dirname(__DIR__, 3) . '/fixtures/Console',
static fn (array $command): Process => $process,
static fn (): bool => true,
false
);
expect($command->run(new RawArgvInput(['bin/cacti', 'probe:run']), new BufferedOutput()))->toBe(17)
->and($process->tty_enabled)->toBeTrue()
->and($process->output_disabled)->toBeFalse();
});
it('maps a signaled legacy process to the shell exit status', function (): void {
$process = new CactiControllableLegacyProcess(term_signal: SIGKILL);
$command = new LegacyScriptCommand(
'probe:run',
'probe',
dirname(__DIR__, 3) . '/fixtures/Console',
static fn (array $command): Process => $process,
static fn (): bool => false,
false
);
expect($command->run(new RawArgvInput(['bin/cacti', 'probe:run']), new BufferedOutput()))->toBe(128 + SIGKILL);
});
it('relays registered termination signals only while the child is running', function (): void {
$process = new CactiControllableLegacyProcess();
$handlers = [];
$command = new LegacyScriptCommand(
'probe:run',
'probe',
dirname(__DIR__, 3) . '/fixtures/Console',
static fn (array $command): Process => $process,
static fn (): bool => false,
static function (int $signal, callable $handler) use (&$handlers): void {
$handlers[$signal] = $handler;
}
);
$command->run(new RawArgvInput(['bin/cacti', 'probe:run']), new BufferedOutput());
$process->running = true;
$handlers[SIGTERM](SIGTERM);
expect($process->received_signal)->toBe(SIGTERM)
->and($handlers)->toHaveKeys([SIGTERM, SIGINT, SIGHUP]);
$process->running = false;
$process->received_signal = null;
$handlers[SIGINT](SIGINT);
expect($process->received_signal)->toBeNull();
});
it('preserves the child status when a signal arrives before process startup', function (): void {
$process = new CactiControllableLegacyProcess(17);
$command = new LegacyScriptCommand(
'probe:run',
'probe',
dirname(__DIR__, 3) . '/fixtures/Console',
static fn (array $command): Process => $process,
static fn (): bool => false,
static function (int $signal, callable $handler): void {
if ($signal === SIGTERM) {
$handler($signal);
}
}
);
expect($command->run(new RawArgvInput(['bin/cacti', 'probe:run']), new BufferedOutput()))->toBe(17)
->and($process->received_signal)->toBeNull();
});
it('forwards raw legacy arguments and preserves the exit status', function (): void {
$root = dirname(__DIR__, 3) . '/fixtures/Console';
$application = new Application('test');
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('probe:run', 'probe', $root));
$input = new RawArgvInput(['bin/cacti', 'probe:run', '--arbitrary=value', '-x', 'plain']);
$output = new BufferedOutput();
$status = $application->run($input, $output);
expect($status)->toBe(23)
->and($output->fetch())->toBe('["--arbitrary=value","-x","plain"]probe-error');
});
it('routes child stderr separately for console outputs and accepts aliases', function (): void {
$root = dirname(__DIR__, 3) . '/fixtures/Console';
$application = new Application('test');
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('probe:run', 'probe', $root));
$output = new ConsoleOutput(decorated: false);
$errorOutput = new BufferedOutput();
$output->setErrorOutput($errorOutput);
$status = $application->run(
new RawArgvInput(['bin/cacti', 'probe', 'alias-value']),
$output
);
expect($status)->toBe(23)
->and($errorOutput->fetch())->toBe('probe-error');
});
it('forwards arguments when the command name is abbreviated', function (): void {
$root = dirname(__DIR__, 3) . '/fixtures/Console';
$application = new Application('test');
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('probe:run', 'probe', $root));
$output = new BufferedOutput();
$status = $application->run(
new RawArgvInput(['bin/cacti', 'pro:r', '--arbitrary=value', 'plain']),
$output
);
expect($status)->toBe(23)
->and($output->fetch())->toBe('["--arbitrary=value","plain"]probe-error');
});
it('leaves --help, -h, --version and -V to the legacy script', function (string $flag): void {
$application = new CactiApplication(dirname(__DIR__, 4));
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console'));
$output = new BufferedOutput();
$status = $application->run(new RawArgvInput(['bin/cacti', 'probe:run', $flag]), $output);
expect($status)->toBe(23)
->and($output->fetch())->toBe(sprintf('["%s"]probe-error', $flag));
})->with(['--help', '-h', '--version', '-V']);
it('forwards a flag written before the command name', function (string $flag): void {
$application = new CactiApplication(dirname(__DIR__, 4));
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console'));
$output = new BufferedOutput();
// getFirstArgument() skips leading options, so the command dispatches with
// the flag ahead of its name. Slicing only forward handed the script an
// empty argv, and a script whose flags are all optional then ran its
// default path.
$status = $application->run(new RawArgvInput(['bin/cacti', $flag, 'probe:run']), $output);
expect($status)->toBe(23)
->and($output->fetch())->toBe(sprintf('["%s"]probe-error', $flag));
})->with(['--help', '-h', '--version', '-V']);
it('preserves the order of flags on both sides of the command name', function (): void {
$application = new CactiApplication(dirname(__DIR__, 4));
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console'));
$output = new BufferedOutput();
$status = $application->run(
new RawArgvInput(['bin/cacti', '-v', 'probe:run', '--arbitrary=value', 'plain']),
$output
);
expect($status)->toBe(23)
->and($output->fetch())->toBe('["-v","--arbitrary=value","plain"]probe-error');
});
it('still answers --version and --help for the application itself', function (): void {
$application = new CactiApplication(dirname(__DIR__, 4));
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$output = new BufferedOutput();
expect($application->run(new RawArgvInput(['bin/cacti', '--version']), $output))->toBe(0)
->and($output->fetch())->toStartWith('Cacti');
});
it('does not swallow legacy output when -q is passed through', function (): void {
$application = new CactiApplication(dirname(__DIR__, 4));
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console'));
$output = new BufferedOutput();
$status = $application->run(new RawArgvInput(['bin/cacti', 'probe:run', '-q', 'plain']), $output);
expect($status)->toBe(23)
->and($output->fetch())->toBe('["-q","plain"]probe-error');
});
it('streams large legacy output without retaining it in the parent', function (): void {
$root = dirname(__DIR__, 3) . '/fixtures/Console';
$application = new Application('test');
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('bulk:run', 'bulk', $root));
$output = new BufferedOutput();
$status = $application->run(new RawArgvInput(['bin/cacti', 'bulk:run', '20000']), $output);
// 20000 lines is past the 1MB php://temp threshold Process spools at.
expect($status)->toBe(0)
->and(strlen($output->fetch()))->toBe(20000 * 100);
});
it('reports a killed legacy script as the shell does', function (): void {
if (!function_exists('pcntl_fork') || !function_exists('posix_kill') || !defined('SIGKILL')) {
$this->markTestSkipped('pcntl and posix signal support are required.');
}
$root = dirname(__DIR__, 3) . '/fixtures/Console';
$runner = dirname(__DIR__, 3) . '/fixtures/Console/kill_runner.php';
$descriptor = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open([PHP_BINARY, $runner, $root], $descriptor, $pipes);
expect($process)->toBeResource();
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
// 128 + SIGKILL, the status a shell reports for the same death.
expect(trim($stdout))->toBe('137')
->and($stderr)->not->toContain('ProcessSignaledException');
});
it('runs the legacy script in the directory the caller was in', function (): void {
$root = dirname(__DIR__, 3) . '/fixtures/Console';
$original = getcwd();
$elsewhere = sys_get_temp_dir();
try {
chdir($elsewhere);
$application = new Application('test');
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('pwd:run', 'pwd', $root));
$output = new BufferedOutput();
$application->run(new RawArgvInput(['bin/cacti', 'pwd:run']), $output);
// A relative path argument has to mean what it would have meant to
// `php cli/<script>.php` from the same shell.
expect(realpath(trim($output->fetch())))->toBe(realpath($elsewhere));
} finally {
chdir((string) $original);
}
});
it('rejects parsed inputs that cannot preserve the raw argument vector', function (): void {
$command = new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console');
expect(fn () => $command->run(new ArrayInput([]), new BufferedOutput()))
->toThrow(LogicException::class, 'Legacy commands require RawArgvInput.');
});
it('fails closed when no command token is present', function (): void {
$input = new RawArgvInput(['bin/cacti', '--version']);
expect(fn (): array => $input->argumentsAfterCommand(['probe:run', 'probe']))
->toThrow(LogicException::class, 'resolved command token is missing');
});
it('uses the server argument vector when one is not supplied', function (): void {
$original = $_SERVER['argv'] ?? null;
$_SERVER['argv'] = ['bin/cacti', 'probe:run', 'server-value'];
try {
$input = new RawArgvInput();
expect($input->argumentsAfterCommand(['probe:run']))->toBe(['server-value']);
} finally {
if ($original === null) {
unset($_SERVER['argv']);
} else {
$_SERVER['argv'] = $original;
}
}
});
it('runs the installed CLI entry point without application bootstrap', function (): void {
$root = dirname(__DIR__, 4);
$process = new Process([PHP_BINARY, $root . '/bin/cacti', 'list', '--raw']);
expect($process->run())->toBe(0)
->and($process->getOutput())->toContain('device:add')
->and($process->getOutput())->toContain('system:version');
});
it('fails clearly when the CLI entry point has no installed dependencies', function (): void {
$fixture = sys_get_temp_dir() . '/cacti-cli-no-deps-' . bin2hex(random_bytes(8));
mkdir($fixture . '/bin', 0777, true);
mkdir($fixture . '/include', 0777, true);
copy(dirname(__DIR__, 4) . '/bin/cacti', $fixture . '/bin/cacti');
copy(dirname(__DIR__, 4) . '/include/cli_only.php', $fixture . '/include/cli_only.php');
$process = new Process([PHP_BINARY, $fixture . '/bin/cacti']);
expect($process->run())->toBe(1)
->and($process->getErrorOutput())->toContain('Cacti dependencies are not installed');
});
@@ -0,0 +1,68 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
test('the unified CLI entrypoint loads the CLI-only guard before autoloading', function () : void {
$source = file_get_contents(dirname(__DIR__, 4) . '/bin/cacti');
expect($source)->not->toBeFalse()
->and($source)->toContain("require_once \$root . '/include/cli_only.php';");
if ($source === false) {
return;
}
expect(strpos($source, 'include/cli_only.php'))->toBeLessThan(strpos($source, 'require $autoload'));
});
test('the CLI-only guard rejects non-CLI SAPIs before application bootstrap', function () : void {
$source = file_get_contents(dirname(__DIR__, 4) . '/include/cli_only.php');
expect($source)->not->toBeFalse()
->and($source)->toContain("PHP_SAPI !== 'cli'")
->and($source)->toContain('http_response_code(403)')
->and($source)->toContain('exit(1)');
});
test('the bin directory denies web access under supported Apache versions', function () : void {
$source = file_get_contents(dirname(__DIR__, 4) . '/bin/.htaccess');
expect($source)->not->toBeFalse()
->and($source)->toContain('Require all denied')
->and($source)->toContain('Order Allow,Deny')
->and($source)->toContain('Deny from all');
});
test('every executable Console fixture loads the CLI-only guard', function () : void {
$fixtures = glob(dirname(__DIR__, 3) . '/fixtures/Console/cli/*.php');
expect($fixtures)->not->toBeFalse()->not->toBeEmpty();
foreach ($fixtures ?: [] as $fixture) {
expect(file_get_contents($fixture))->toContain("require_once dirname(__DIR__, 4) . '/include/cli_only.php';");
}
});
test('the tests directory denies browsing and direct Apache access', function () : void {
$root = dirname(__DIR__, 4);
$deny = file_get_contents($root . '/tests/.htaccess');
$redirect = file_get_contents($root . '/tests/index.php');
expect($deny)->not->toBeFalse()
->and($deny)->toContain('Require all denied')
->and($deny)->toContain('Deny from all')
->and($redirect)->not->toBeFalse()
->and($redirect)->toContain("header('Location:../index.php')");
});
test('TTY handoff requires stderr to remain attached to the terminal', function () : void {
$source = file_get_contents(dirname(__DIR__, 4) . '/lib/Console/Command/LegacyScriptCommand.php');
expect($source)->not->toBeFalse()
->and($source)->toContain("stream_isatty(STDERR)");
});
@@ -0,0 +1,29 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
test('rrdresize reports a missing required data template as failure', function () : void {
$source = file_get_contents(dirname(__DIR__, 4) . '/cli/rrdresize.php');
expect($source)->not->toBeFalse();
if ($source === false) {
return;
}
$start = strpos($source, 'if (!$data_template_id)');
expect($start)->not->toBeFalse();
if ($start === false) {
return;
}
$body = substr($source, $start, 220);
expect($body)->toContain('exit(1);')
->not->toContain('exit(0);');
});
+11
View File
@@ -0,0 +1,11 @@
<?php
require_once dirname(__DIR__, 4) . '/include/cli_only.php';
$lines = (int) ($argv[1] ?? 1);
for ($i = 0; $i < $lines; $i++) {
fwrite(STDOUT, str_repeat('x', 99) . "\n");
}
exit(0);
+8
View File
@@ -0,0 +1,8 @@
<?php
require_once dirname(__DIR__, 4) . '/include/cli_only.php';
fwrite(STDOUT, json_encode(array_slice($argv, 1), JSON_THROW_ON_ERROR));
fwrite(STDERR, 'probe-error');
exit(23);
+7
View File
@@ -0,0 +1,7 @@
<?php
require_once dirname(__DIR__, 4) . '/include/cli_only.php';
fwrite(STDOUT, (string) getcwd());
exit(0);
+12
View File
@@ -0,0 +1,12 @@
<?php
require_once dirname(__DIR__, 4) . '/include/cli_only.php';
/* Publish the pid so the runner can signal exactly this process, then wait
* long enough to be killed but not long enough to stall a suite if it is not. */
if (isset($argv[1])) {
file_put_contents($argv[1], (string) getmypid());
}
fwrite(STDOUT, "ready\n");
sleep(5);
+57
View File
@@ -0,0 +1,57 @@
<?php
/*
* Runs a legacy command whose child is killed mid-flight, and prints the status
* LegacyScriptCommand reports. Its own process because the kill has to land on
* a child this script owns.
*/
declare(strict_types = 1);
require_once dirname(__DIR__, 3) . '/include/vendor/autoload.php';
use Cacti\Console\Command\LegacyScriptCommand;
use Cacti\Console\Input\RawArgvInput;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Output\BufferedOutput;
$root = $argv[1];
$pidfile = tempnam(sys_get_temp_dir(), 'cacti-sleeper-');
$application = new Application('test');
$application->setAutoExit(false);
$application->setCatchExceptions(false);
$application->add(new LegacyScriptCommand('sleeper:run', 'sleeper', $root));
if (function_exists('pcntl_fork') && function_exists('posix_kill')) {
$pid = pcntl_fork();
if ($pid === 0) {
/* Signal the pid the child published, never a pattern match: pgrep
* would reach every matching process on the host, so two suite runs on
* one runner would kill each other's children. */
$deadline = microtime(true) + 5;
$target = 0;
while ($target === 0 && microtime(true) < $deadline) {
$published = is_file($pidfile) ? trim((string) file_get_contents($pidfile)) : '';
if ($published !== '') {
$target = (int) $published;
} else {
usleep(20000);
}
}
if ($target > 0) {
posix_kill($target, SIGKILL);
}
exit(0);
}
}
$status = $application->run(new RawArgvInput(['bin/cacti', 'sleeper:run', $pidfile]), new BufferedOutput());
@unlink($pidfile);
print $status . "\n";
+3
View File
@@ -0,0 +1,3 @@
<?php
header('Location:../index.php');
@@ -604,6 +604,7 @@ header_redirect ./support.php header('Location: support.php?tab=summary');
header_redirect ./templates_export.php header('Location: templates_export.php');
header_redirect ./templates_import.php header('Location: templates_import.php');
header_redirect ./templates_import.php header('Location: templates_import.php');
header_redirect ./tests/index.php header('Location:../index.php');
header_redirect ./tree.php header('Location: tree.php');
header_redirect ./tree.php header('Location: tree.php');
header_redirect ./tree.php header('Location: tree.php');
Can't render this file because it contains an unexpected character in line 22 and column 251.