|
| 1 | +# Settings Validation |
| 2 | + |
| 3 | +## Why this exists |
| 4 | + |
| 5 | +Cacti settings are written from several places: the web UI (`settings.php`), |
| 6 | +CLI utilities under `cli/`, and (planned) JSON-RPC endpoints. Each of those |
| 7 | +paths previously carried its own ad-hoc checks for the same setting, and the |
| 8 | +checks drifted apart as code was added. The intent of `CactiSettings::validate()` |
| 9 | +is to put the constraint definition next to the setting itself so every write |
| 10 | +path uses the same rules. |
| 11 | + |
| 12 | +A setting in `include/global_settings.php` declares its constraints alongside |
| 13 | +the existing keys (`method`, `default`, `max_length`, ...). The validator runs |
| 14 | +the entire posted set in one pass and returns a `{name => message}` map. An |
| 15 | +empty map means the input is valid. |
| 16 | + |
| 17 | +### Why constraints are declared as closures |
| 18 | + |
| 19 | +`include/global_settings.php` is loaded from `include/global.php` *before* |
| 20 | +`include/vendor/autoload.php`. Eagerly instantiating `new Assert\NotBlank()` |
| 21 | +inside the array literal would fatal at file-load time because the Symfony |
| 22 | +Validator classes are not yet loadable. Wrapping each constraint in an arrow |
| 23 | +function (`fn() => new Assert\NotBlank()`) defers the instantiation until |
| 24 | +`CactiSettings::validate()` runs the closures. The `use Symfony\Component\Validator\Constraints as Assert;` |
| 25 | +import at the top of the file is a compile-time alias and does not trigger |
| 26 | +autoloading. |
| 27 | + |
| 28 | +## How `CactiSettings::validate` is used in `settings.php` |
| 29 | + |
| 30 | +`save_settings()` builds a snapshot of posted values via Cacti's request-var |
| 31 | +helpers (`gnrv`) and calls the validator before writing any rows: |
| 32 | + |
| 33 | +```php |
| 34 | +require_once(CACTI_PATH_LIBRARY . '/CactiSettings.php'); |
| 35 | + |
| 36 | +$snapshot = []; |
| 37 | +foreach ($settings[grv('tab')] as $field_name => $field_array) { |
| 38 | + $snapshot[$field_name] = gnrv($field_name); |
| 39 | +} |
| 40 | + |
| 41 | +$violations = CactiSettings::validate($snapshot, $settings); |
| 42 | + |
| 43 | +if (cacti_sizeof($violations) > 0) { |
| 44 | + foreach ($violations as $name => $message) { |
| 45 | + $_SESSION['sess_error_fields'][$name] = $name; |
| 46 | + $_SESSION['sess_field_values'][$name] = $snapshot[$name] ?? ''; |
| 47 | + raise_message('cacti_settings_' . $name, ..., MESSAGE_LEVEL_ERROR); |
| 48 | + } |
| 49 | + header('Location: settings.php?tab=...'); |
| 50 | + exit; |
| 51 | +} |
| 52 | +``` |
| 53 | + |
| 54 | +When violations are present the request is rejected before any `REPLACE INTO settings` |
| 55 | +runs. The user is redirected back to the same tab with the error highlighted. |
| 56 | + |
| 57 | +## How to add constraints to a setting |
| 58 | + |
| 59 | +1. Open `include/global_settings.php` and find the setting definition. |
| 60 | +2. Add a `'constraints'` key holding an array of closures, each returning a |
| 61 | + Symfony constraint instance. |
| 62 | +3. The `use Symfony\Component\Validator\Constraints as Assert;` alias is |
| 63 | + already imported at the top of the file, so constraints read as |
| 64 | + `fn() => new Assert\Range(...)`. |
| 65 | +4. Wrap any custom `message:` argument in `__()` so the message participates |
| 66 | + in Cacti i18n. |
| 67 | + |
| 68 | +Example: |
| 69 | + |
| 70 | +```php |
| 71 | +'snmp_timeout' => [ |
| 72 | + 'friendly_name' => __('Timeout'), |
| 73 | + 'method' => 'textbox', |
| 74 | + 'default' => '500', |
| 75 | + 'max_length' => '10', |
| 76 | + 'constraints' => [ |
| 77 | + fn() => new Assert\Regex(pattern: '/^\d+$/', message: __('must be a positive integer (milliseconds).')), |
| 78 | + fn() => new Assert\Range(min: 1, max: 600000), |
| 79 | + ], |
| 80 | +], |
| 81 | +``` |
| 82 | + |
| 83 | +When a `Choice` list duplicates a canonical array from `global_arrays.php`, |
| 84 | +derive the choices from that array inside the closure rather than restating |
| 85 | +the values: |
| 86 | + |
| 87 | +```php |
| 88 | +'constraints' => [ |
| 89 | + fn() => new Assert\Choice(choices: array_merge( |
| 90 | + array_keys($GLOBALS['poller_intervals']), |
| 91 | + array_map('strval', array_keys($GLOBALS['poller_intervals'])) |
| 92 | + )), |
| 93 | +], |
| 94 | +``` |
| 95 | + |
| 96 | +Both string and integer forms of the keys are accepted because `$_POST` |
| 97 | +values arrive as strings while the canonical keys are integers. |
| 98 | + |
| 99 | +## Constraint types used in the pilot |
| 100 | + |
| 101 | +See the [Symfony Validator reference](https://symfony.com/doc/6.4/validation.html) |
| 102 | +for the full catalog. The pilot uses: |
| 103 | + |
| 104 | +- `Assert\NotBlank` -- value is present and non-empty. |
| 105 | +- `Assert\Length` -- string length within `min`/`max`. |
| 106 | +- `Assert\Range` -- numeric value within `min`/`max`. |
| 107 | +- `Assert\Choice` -- value is one of an enumerated list. Use this for any |
| 108 | + setting that is rendered as a `drop_array`. |
| 109 | +- `Assert\Regex` -- value matches a pattern. Useful for "is this an integer |
| 110 | + string" since `$_POST` values arrive as strings. |
| 111 | +- `Assert\Positive` -- value is strictly greater than zero. |
| 112 | + |
| 113 | +## Migration template for the next batch |
| 114 | + |
| 115 | +For each setting you want to constrain: |
| 116 | + |
| 117 | +1. Identify the setting's existing implicit rule (e.g. `method` is `drop_array` |
| 118 | + with a fixed key set, or `max_length` is `'255'`). |
| 119 | +2. Translate that rule to a constraint object. `drop_array` becomes |
| 120 | + `Assert\Choice` over the array keys. `max_length` becomes `Assert\Length`. |
| 121 | + Numeric textboxes typically need `Assert\Regex` plus `Assert\Range`. |
| 122 | +3. Add a unit test in `tests/Unit/CactiSettingsTest.php` that exercises the |
| 123 | + new constraint with a synthetic definition (do not depend on |
| 124 | + `include/global_settings.php` from the test). |
| 125 | +4. Run `composer test` and confirm the new case passes. |
| 126 | + |
| 127 | +## Translator wiring (known limitation) |
| 128 | + |
| 129 | +`CactiSettings::validator()` builds the validator with |
| 130 | +`Validation::createValidator()` and does not pass a `TranslatorInterface`. |
| 131 | +Symfony's default English messages render regardless of the active Cacti |
| 132 | +locale. Custom messages declared in `global_settings.php` should be wrapped |
| 133 | +in `__()` so the operator-facing text is translatable through Cacti's own |
| 134 | +i18n stack. Wiring a `TranslatorInterface` so that the framework's built-in |
| 135 | +constraint messages also translate is a planned follow-up. |
| 136 | + |
| 137 | +## Defense in depth |
| 138 | + |
| 139 | +The constraint layer supplements existing checks rather than replacing them. |
| 140 | + |
| 141 | +- The form-render method (`drop_array`, `dirpath`, `filepath`, `textbox_password`) |
| 142 | + still enforces its implicit rules in `save_settings()`. For example, |
| 143 | + `dirpath` continues to verify the directory exists before storing. |
| 144 | +- Any post-save handler (cache-clear, poller restart, log rotation) keeps |
| 145 | + whatever validation it already performs. |
| 146 | +- The constraint check runs first and short-circuits the write, so downstream |
| 147 | + handlers see only values that already cleared the declared rules. |
0 commit comments