Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions lib/rrd.php
Original file line number Diff line number Diff line change
Expand Up @@ -1740,7 +1740,8 @@ function rrdtool_function_update(array $update_cache_array, mixed $rrdtool_pipe
$successful_updates = [];

// lets count the number of rrd files processed
$rrds_processed = 0;
$rrds_processed = 0;
$supports_skip_past_updates = null;

foreach ($update_cache_array as $rrd_path => $rrd_fields) {
if (cacti_sizeof($rrd_fields['times'])) {
Expand Down Expand Up @@ -1778,7 +1779,7 @@ function rrdtool_function_update(array $update_cache_array, mixed $rrdtool_pipe
$rrd_update_values .= $value;
}

if (cacti_version_compare(get_rrdtool_version(), '1.5', '>=')) {
if (($supports_skip_past_updates ??= cacti_version_compare(get_rrdtool_version(), '1.5', '>='))) {
$update_options = '--skip-past-updates';
} else {
$update_options = '';
Expand Down Expand Up @@ -2039,11 +2040,12 @@ function rrd_function_process_graph_options(int $graph_start, int $graph_end, ar
global $image_types;

// define some variables
$scale = '';
$rigid = '';
$unit_value = '';
$version = get_rrdtool_version();
$unit_exponent_value = '';
$scale = '';
$rigid = '';
$unit_value = '';
$version = get_rrdtool_version();
$supports_graph_formatters = null;
$unit_exponent_value = '';

if ($graph['auto_scale'] == 'on') {
switch ($graph['auto_scale_opts']) {
Expand Down Expand Up @@ -2304,31 +2306,31 @@ function rrd_function_process_graph_options(int $graph_start, int $graph_end, ar

break;
case 'legend_position':
if (cacti_version_compare($version, '1.4', '>=')) {
if (($supports_graph_formatters ??= cacti_version_compare($version, '1.4', '>='))) {
if (!empty($value)) {
$graph_opts .= '--legend-position ' . cacti_escapeshellarg($value) . RRD_NL;
}
}

break;
case 'legend_direction':
if (cacti_version_compare($version, '1.4', '>=')) {
if (($supports_graph_formatters ??= cacti_version_compare($version, '1.4', '>='))) {
if (!empty($value)) {
$graph_opts .= '--legend-direction ' . cacti_escapeshellarg($value) . RRD_NL;
}
}

break;
case 'left_axis_formatter':
if (cacti_version_compare($version, '1.4', '>=')) {
if (($supports_graph_formatters ??= cacti_version_compare($version, '1.4', '>='))) {
if (!empty($value)) {
$graph_opts .= '--left-axis-formatter ' . cacti_escapeshellarg($value) . RRD_NL;
}
}

break;
case 'right_axis_formatter':
if (cacti_version_compare($version, '1.4', '>=')) {
if (($supports_graph_formatters ??= cacti_version_compare($version, '1.4', '>='))) {
if (!empty($value)) {
$graph_opts .= '--right-axis-formatter ' . cacti_escapeshellarg($value) . RRD_NL;
}
Expand Down
83 changes: 83 additions & 0 deletions tests/Unit/Core/Rrd/RrdCapabilityNativeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php
// Copyright (C) 2026 The Cacti Group. GPL-2.0-or-later.
require_once dirname(__DIR__, 4) . '/lib/rrd.php';

test('native graph options apply capabilities consistently to every formatter', function () {
global $config;
$savedConfig = $config;
$savedSession = $_SESSION;
$savedDatechar = $GLOBALS['datechar'] ?? null;

try {
$GLOBALS['datechar'] = [1 => '/'];
$options = ['storage_location' => '', 'path_rrdtool_default_font' => '', 'font_method' => 0, 'rrdtool_watermark' => '', 'default_date_format' => GD_Y_MO_D, 'default_datechar' => 1, 'graph_watermark' => '', 'graph_watermark_rrd' => ''];
$config[OPTIONS_CLI] = array_replace($config[OPTIONS_CLI] ?? [], $options);
$_SESSION[OPTIONS_WEB] = array_replace($_SESSION[OPTIONS_WEB] ?? [], $options);
$_SESSION[OPTIONS_USER] = ['default_date_format' => GD_Y_MO_D, 'default_datechar' => 1, 'custom_fonts' => ''];
$graph = ['auto_scale' => 'on', 'auto_scale_opts' => '1', 'lower_limit' => '0', 'upper_limit' => '100', 'auto_scale_log' => '', 'scale_log_units' => '', 'auto_scale_rigid' => '', 'unit_value' => '', 'unit_exponent_value' => '', 'height' => '120', 'width' => '500', 'image_format_id' => 3, 'title_cache' => 'Capabilities', 'vertical_label' => 'Values', 'legend_position' => 'south', 'legend_direction' => 'topdown', 'left_axis_formatter' => 'numeric', 'right_axis_formatter' => 'numeric'];
$data = ['graph_start' => 1700000000, 'graph_end' => 1700003600, 'graph_theme' => 'modern'];
$command = rrd_function_process_graph_options(1700000000, 1700003600, $graph, $data);

foreach (['legend-position', 'legend-direction', 'left-axis-formatter', 'right-axis-formatter'] as $flag) {
expect(str_contains($command, '--' . $flag))->toBe(version_compare(get_rrdtool_version(), '1.4', '>='));
}
} finally {
$config = $savedConfig;
$_SESSION = $savedSession;
$GLOBALS['datechar'] = $savedDatechar;
}
});

test('native updates retain timestamp order and acknowledgement across two RRD files', function () {
global $config;
$savedConfig = $config;
$savedSession = $_SESSION;
$directory = sys_get_temp_dir() . '/cacti-rrd-capabilities-' . bin2hex(random_bytes(8));
mkdir($directory, 0700);
$binary = $directory . '/rrdtool';
$log = $directory . '/commands';
$pipe = null;

try {
// Replace only the external executable; Cacti's command and transport code remain native.
file_put_contents($binary, '#!' . PHP_BINARY . "\n<?php\nwhile ((\$line = fgets(STDIN)) !== false) { file_put_contents(" . var_export($log, true) . ", \$line, FILE_APPEND); echo \"OK u:0.0 s:0.0 r:0.0\\n\"; flush(); }\n");
chmod($binary, 0700);
$options = ['path_rrdtool' => $binary, 'storage_location' => '', 'path_rrdtool_default_font' => ''];
$config[OPTIONS_CLI] = array_replace($config[OPTIONS_CLI] ?? [], $options);
$_SESSION[OPTIONS_WEB] = array_replace($_SESSION[OPTIONS_WEB] ?? [], $options);
$pipe = __rrd_init(false);
expect(rrdtool_is_process($pipe))->toBeTrue();
$updates = [];

foreach (['first', 'second'] as $name) {
$path = $directory . '/' . $name . '.rrd';
touch($path);
$updates[$path] = ['local_data_id' => 0, 'data_template_id' => 0, 'times' => [1700000180 => ['value' => 44], 1700000060 => ['value' => 42], 1700000120 => ['value' => 43]]];
}
$acknowledged = [];
expect(rrdtool_function_update($updates, $pipe, $acknowledged))->toBe(6);
expect(rrdtool_function_update([], $pipe))->toBe(0);
$commands = file($log, FILE_IGNORE_NEW_LINES);
expect($commands)->toHaveCount(6);

foreach ($commands as $index => $command) {
expect($command)->toContain('--template value ' . [1700000060, 1700000120, 1700000180][$index % 3] . ':' . [42, 43, 44][$index % 3]);
expect(str_contains($command, '--skip-past-updates'))->toBe(version_compare(get_rrdtool_version(), '1.5', '>='));
}

foreach ($acknowledged as $timestamps) {
expect($timestamps)->toBe([1700000060 => true, 1700000120 => true, 1700000180 => true]);
}
} finally {
if ($pipe !== null) {
rrd_close($pipe);
}
$config = $savedConfig;
$_SESSION = $savedSession;

foreach (glob($directory . '/*') as $path) {
unlink($path);
}
rmdir($directory);
}
});
25 changes: 25 additions & 0 deletions tests/Unit/Core/Rrd/RrdVersionCheckReuseTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php
// Copyright (C) 2026 The Cacti Group. GPL-2.0-or-later.
use Symfony\Component\Process\Process;

test('RRD capabilities are reused within calls and refreshed between calls', function () {
$root = dirname(__DIR__, 4);
$process = new Process([PHP_BINARY, $root . '/tests/fixtures/rrd-version-checks.php', $root]);
$process->mustRun();
$result = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);

foreach ($result['cases'] as $case) {
expect($case['updates'])->toBe(6)
->and($case['update_comparisons'])->toBe(1)
->and($case['graph_comparisons'])->toBe(1);

foreach ($case['commands'] as $command) {
expect(str_contains($command, '--skip-past-updates'))->toBe(version_compare($case['version'], '1.5.0', '>='));
}

foreach (['legend-position', 'legend-direction', 'left-axis-formatter', 'right-axis-formatter'] as $flag) {
expect(str_contains($case['graph'], '--' . $flag))->toBe(version_compare($case['version'], '1.4.0', '>='));
}
}
expect($result['empty_update_comparisons'])->toBe(0);
});
93 changes: 93 additions & 0 deletions tests/fixtures/rrd-version-checks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php
// Isolated behavioral fixture: retain production statements and rename I/O boundaries.
$root = $argv[1] ?? dirname(__DIR__, 2);
require dirname(__DIR__) . '/bootstrap-unit.php';
require CACTI_PATH_LIBRARY . '/rrd.php';
$state = ['version' => '1.4.0', 'comparisons' => 0, 'commands' => []];
function version_fixture_compare($left, $right, $operator) {
$GLOBALS['state']['comparisons']++;

return cacti_version_compare($left, $right, $operator);
}
function version_fixture_version() {
return $GLOBALS['state']['version'];
}
function version_fixture_exists(...$args) {
return true;
}
function version_fixture_execute($command, ...$args) {
$GLOBALS['state']['commands'][] = is_string($command) ? $command : rrdtool_build_command($command);

return '';
}
function version_fixture_load($path, $name) {
$source = file_get_contents($path);
$start = strpos($source, 'function ' . $name . '(');
$end = strpos($source, "\nfunction ", $start + 1);

if ($start === false) {
throw new RuntimeException('Missing production function');
}
$function = substr($source, $start, $end === false ? null : $end - $start);
$map = [$name => 'version_fixture_' . $name, 'cacti_version_compare' => 'version_fixture_compare', 'get_rrdtool_version' => 'version_fixture_version', 'file_exists' => 'version_fixture_exists', 'rrdtool_file_exists' => 'version_fixture_exists', 'rrdtool_execute' => 'version_fixture_execute'];

foreach (['cacti_has_control_chars', 'cacti_rrdtool_valid_path', 'cacti_rrdtool_valid_ds_name', 'cacti_rrdtool_valid_ds_template'] as $helper) {
$map[$helper] = 'version_fixture_' . $helper;
}
$compiled = '';

foreach (token_get_all('<?php ' . $function) as $token) {
if (is_array($token)) {
if ($token[0] !== T_OPEN_TAG) {
$compiled .= $token[0] === T_STRING ? ($map[$token[1]] ?? $token[1]) : $token[1];
}
} else {
$compiled .= $token;
}
}
$file = tempnam(sys_get_temp_dir(), 'rrd-version');

try {
file_put_contents($file, "<?php\n" . $compiled);
include $file;
} finally {
unlink($file);
}
}

if (str_contains(file_get_contents($root . '/lib/functions.php'), 'function cacti_rrdtool_valid_path(')) {
foreach (['cacti_has_control_chars', 'cacti_rrdtool_valid_path', 'cacti_rrdtool_valid_ds_name', 'cacti_rrdtool_valid_ds_template'] as $helper) {
version_fixture_load($root . '/lib/functions.php', $helper);
}
}
version_fixture_load($root . '/lib/rrd.php', 'rrdtool_function_update');
$graphFile = is_file($root . '/lib/rrd_graph_options.php') ? $root . '/lib/rrd_graph_options.php' : $root . '/lib/rrd.php';
$graphFunction = str_ends_with($graphFile, 'rrd_graph_options.php') ? 'rrdtool_build_graph_options' : 'rrd_function_process_graph_options';
version_fixture_load($graphFile, $graphFunction);
$runGraph = 'version_fixture_' . $graphFunction;
$config[OPTIONS_CLI] = array_replace($config[OPTIONS_CLI] ?? [], ['storage_location' => '', 'path_rrdtool_default_font' => '', 'font_method' => 0, 'rrdtool_watermark' => '', 'default_date_format' => GD_Y_MO_D, 'default_datechar' => 1]);
$_SESSION[OPTIONS_USER] = ['default_date_format' => GD_Y_MO_D, 'default_datechar' => 1, 'custom_fonts' => ''];
$graph = ['auto_scale' => 'on', 'auto_scale_opts' => '1', 'lower_limit' => '0', 'upper_limit' => '100', 'auto_scale_log' => '', 'scale_log_units' => '', 'auto_scale_rigid' => '', 'unit_value' => '', 'unit_exponent_value' => '', 'height' => '120', 'width' => '500', 'image_format_id' => 3, 'title_cache' => 'Version fixture', 'vertical_label' => 'Values', 'legend_position' => 'south', 'legend_direction' => 'topdown', 'left_axis_formatter' => 'numeric', 'right_axis_formatter' => 'numeric'];
$updates = [];

foreach (['/tmp/version-first.rrd', '/tmp/version-second.rrd'] as $path) {
$updates[$path] = ['local_data_id' => 0, 'data_template_id' => 0, 'times' => [1700000060 => ['value' => 42], 1700000120 => ['value' => 43], 1700000180 => ['value' => 44]]];
}
$results = [];

foreach (['1.3.0', '1.4.0', '1.5.0', '1.8.0', '1.3.0'] as $version) {
$state['version'] = $version;
$state['comparisons'] = 0;
$state['commands'] = [];
$count = version_fixture_rrdtool_function_update($updates);
$updateComparisons = $state['comparisons'];
$commands = $state['commands'];
$state['comparisons'] = 0;
$metadata = $graph;
$options = ['graph_start' => 1700000000, 'graph_end' => 1700003600, 'graph_theme' => 'modern'];
$command = $runGraph(1700000000, 1700003600, $metadata, $options);
$results[] = ['version' => $version, 'updates' => $count, 'update_comparisons' => $updateComparisons, 'commands' => $commands, 'graph_comparisons' => $state['comparisons'], 'graph' => $command, 'metadata' => $metadata, 'options' => $options];
}
$state['comparisons'] = 0;
version_fixture_rrdtool_function_update([]);
echo json_encode(['cases' => $results, 'empty_update_comparisons' => $state['comparisons']], JSON_THROW_ON_ERROR), "\n";