Skip to content

Return INI values as written instead of substituting PHP constants - #248

Open
LukeTowers wants to merge 2 commits into
developfrom
fix/ini-constant-substitution
Open

LukeTowers wants to merge 2 commits into
developfrom
fix/ini-constant-substitution

Conversation

@LukeTowers

@LukeTowers LukeTowers commented Sep 26, 2026 •

Copy link
Copy Markdown
Member

Ini::parse() hands its content to parse_ini_string() with the default scanner, which replaces any token matching the name of a defined PHP constant with that constant's value. The parser's output then depends on the state of the host process rather than on the document it was given: a settings block containing title = PHP_VERSION comes back as the interpreter's version string instead of the text its author wrote, and a constant name used inside a bracketed key (a[PHP_VERSION] = x) becomes that value as an array key.

This extends the placeholder technique the parser already uses for ${...} interpolation. Before parsing, a marker is prefixed to each token in the document that names a currently defined constant, so the native parser cannot resolve it; the marker is stripped again in a recursive restore walk (restorePlaceholders(), which runs after the existing ${ restoration). A token is only marked when defined() reports a constant of that name, and only as a whole token, so a document naming none of them is handed to the native parser byte for byte. INI_SCANNER_RAW was considered and rejected: it breaks multi-line quoted values, leaves \" escapes unprocessed and drops keyword coercion.

Behaviour changes

  • A token matching the name of a defined PHP constant now stays as written, where it was previously replaced by that constant's value. This covers a token adjacent to a quoted run (foo = CONST"x"), repeated tokens in one value, tokens in array and nested-key values, and a constant name inside a bracketed key, which now becomes that key's own text. A bracketed key offset is also the one position where the native scanner resolves on, off, yes, no and none against the constant table rather than coercing them, so those are returned as written there too, indented (a[ yes] = x) or not.
  • An INI operator expression over constant names — the php.ini bitmask idiom, a = E_ALL & ~E_NOTICE — is now evaluated as if those constants were undefined, so it yields 0 rather than a number computed from their values. Winter's own INI documents contain no such expression.
  • A keyword-named constant standing between two | characters is marked as well, because the offset rule applies to that delimiter too. Where the previous parser lexed a = b|yes|c as a keyword and rejected the whole document, the value now parses as 0, the same result a = b|notakeyword|c already gave. It takes a defined constant named on, off, yes, no or none to reach at all.
  • A failure inside the marking pre-pass — a PCRE backtrack or recursion limit on a pathological document — now throws RuntimeException rather than being handed on as null, which the native parser would have parsed as nothing at all.
  • Everything else parses as before: in a value the keywords true, false, on, off, yes, no, none and null still coerce to 1/'', and true, false and null do so in a bracketed key as well; quoted values, escape sequences and multi-line quoted values are unchanged; section headers and bare key names are untouched; ${...} is still returned literally; and a document whose own text contains the parser's internal marker keeps it.
  • restoreDollarBrace(), which has shipped since v1.2.13, keeps its name, its behaviour and its call contract: parse() still hands it the whole parsed array, so an override that walks array nodes as well as strings keeps working.

Tests

16 new tests in tests/Parse/IniTest.php, including testPhpConstantNamesAreNotSubstituted, testInterpreterConstantsAreNotSubstituted, testConstantNamesInBracketedKeysAreNotSubstituted, testKeywordNamedConstantsInBracketedKeysAreNotSubstituted, testKeywordNamedConstantsInIndentedBracketedKeysAreNotSubstituted, testKeywordsCoercedInBracketedKeysAreUnchanged, testKeywordCoercionIsIndependentOfDefinedConstantNames, testShortConstantNamesAreNotMarkedInsideLongerTokens, testMultilineQuotedValuesAreUnaffected, testDocumentTextMatchingTheInternalMarkerIsPreserved, testReleasedRestoreHookStillReceivesArrayNodes, testRenderedConstantNameRoundTrips and testAFailedIdentifierPrePassIsReported.

The suite is green at 855 tests / 3430 assertions with the 9 pre-existing skips (baseline 839), and phpcs is clean on both changed files. A parity check over 31 documents against the unpatched parser — quoted and multi-line values, every keyword in value and offset position, section headers, comments, numeric and hex forms, list keys, ${...} — differs only in the four cases the behaviour list above describes. Measured cost on this checkout's real corpus: 0.82 ms → 1.99 ms for all 148 settings blocks together, 0.12 ms → 0.31 ms for the largest single block.

Winter's modules/cms has two tests that fail until this merges, since they assert the parser's behaviour from the CMS side.

Summary by CodeRabbit

  • Bug Fixes
    • INI parsing now preserves defined PHP constant names as literal text in values and bracketed keys, rather than replacing them with their values.
    • Reserved INI keywords continue to use native parsing behavior, including when similarly named PHP constants are defined.
    • Parsing now reports an error if it cannot safely process constant-like identifiers. Existing variable interpolation behavior is unchanged.

`Ini::parse()` hands its content to `parse_ini_string()` with the default scanner, which replaces any token matching the name of a defined PHP constant with that constant's value. The parser's output then depends on the state of the host process rather than on the document it was given: a settings block containing `title = PHP_VERSION` came back as the interpreter's version string instead of the text its author wrote, and a constant name used inside a bracketed key (`a[PHP_VERSION] = x`) became that value as an array key. This extends the placeholder technique the parser already uses for `${...}` interpolation - before parsing, a marker is prefixed to each token in the document that names a currently defined constant so the native parser cannot resolve it, and the marker is stripped again in a recursive restore walk (`restorePlaceholders()`, which runs after the existing `${` restoration). A token is only marked when `defined()` reports a constant of that name, and only as a whole token, so a document naming none of them is handed to the native parser byte for byte. Switching to `INI_SCANNER_RAW` was considered and rejected: it breaks multi-line quoted values, leaves `\"` escapes unprocessed and drops keyword coercion.

Behaviour changes:

- A token that happens to match the name of a defined PHP constant now stays as written, where it was previously replaced by that constant's value. This covers a token adjacent to a quoted run (`foo = CONST"x"`), repeated tokens in one value, tokens in array and nested-key values, and a constant name inside a bracketed key, which now becomes that key's own text. A bracketed key offset is also the one position where the native scanner resolves `on`, `off`, `yes`, `no` and `none` against the constant table rather than coercing them, so those are returned as written there too. The offset may be indented (`a[ yes] = x`): the native scanner skips that whitespace before it resolves the offset, so the marker does too.
- An INI operator expression over constant names - the `php.ini` bitmask idiom, `a = E_ALL & ~E_NOTICE` - is now evaluated as if those constants were undefined, so it yields `0` rather than a number computed from their values. Winter's own INI documents contain no such expression.
- A keyword-named constant standing between two `|` characters is now marked as well, because the offset rule applies to that delimiter too - `parsePreProcess()` uses it in keys. Where the previous parser lexed `a = b|yes|c` as a keyword and rejected the whole document, the value now parses as the bitwise-or of two unresolved tokens, `0`, which is the result `a = b|notakeyword|c` already gave. It takes a defined constant named `on`, `off`, `yes`, `no` or `none` to reach this at all.
- A failure inside the marking pre-pass, meaning a PCRE backtrack or recursion limit on a pathological document, now throws `RuntimeException`. Previously the pass could return null and that null would be handed to the native parser, which parses it as nothing at all.
- Everything else parses as before: in a value the keywords `true`, `false`, `on`, `off`, `yes`, `no`, `none` and `null` still coerce to `1`/`''`, and `true`, `false` and `null` do so in a bracketed key as well; quoted values, escape sequences and multi-line quoted values are unchanged; section headers and bare key names are untouched; `${...}` is still returned literally; and a document whose own text contains the parser's internal marker keeps it.
- `restoreDollarBrace()`, which has shipped since v1.2.13, keeps its name, its behaviour and its call contract: `parse()` still hands it the whole parsed array, so an override that walks array nodes as well as strings keeps working.

Tests in `tests/Parse/IniTest.php`: `testPhpConstantNamesAreNotSubstituted`, `testInterpreterConstantsAreNotSubstituted`, `testConstantNamesInBracketedKeysAreNotSubstituted`, `testKeywordNamedConstantsInBracketedKeysAreNotSubstituted`, `testKeywordsCoercedInBracketedKeysAreUnchanged`, `testQuotedConstantNamesAreUnchanged`, `testReservedKeywordsAreStillCoerced`, `testKeywordCoercionIsIndependentOfDefinedConstantNames`, `testShortConstantNamesAreNotMarkedInsideLongerTokens`, `testMultilineQuotedValuesAreUnaffected`, `testDocumentTextMatchingTheInternalMarkerIsPreserved`, `testReleasedRestoreHookIsStillCalled`, `testReleasedRestoreHookStillReceivesArrayNodes`, `testRenderedConstantNameRoundTrips`, `testKeywordNamedConstantsInIndentedBracketedKeysAreNotSubstituted` and `testAFailedIdentifierPrePassIsReported`.
@coderabbitai

coderabbitai Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Warning

Review limit reached

Next included review available in 3 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: d287e82a-0564-481e-88b6-d499cb92eda9

📥 Commits

Reviewing files that changed from the base of the PR and between 051322f and 16db857.

📒 Files selected for processing (2)
  • src/Parse/Ini.php
  • tests/Parse/IniTest.php

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 293d2b23-adf2-4928-be72-f488a7ed7895

📥 Commits

Reviewing files that changed from the base of the PR and between e97198d and 051322f.

📒 Files selected for processing (2)
  • src/Parse/Ini.php
  • tests/Parse/IniTest.php

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


Walkthrough

The INI parser now escapes defined PHP constant names before calling parse_ini_string() and restores those names in parsed keys and values. It retains native coercion for reserved keywords, with separate handling for selected keywords in bracketed offsets. Added tests cover values, keys, sections, round trips, parser errors, and the protected restoration hook.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to 05132

The change is ready to merge after normal checks; no actionable parsing regression was established.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 05132

Parsed settings will now retain PHP constant names instead of substituting their process values. The existing protection against environment-variable interpolation remains, and no new privileged path was identified. Uses of these settings outside this package were not available to assess.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The evidenced reach is the shared parser and existing settings consumers, with 17 direct dependents attributed to the changed parser ranges. The available evidence does not establish a tenant, service, or privileged-sink exposure for those consumers.

Trust Boundaries and Controls

  • observed — The parser still accepts document text and returns an array; it adds no loader or evaluator in the changed flow. Both identifier masking and the pre-existing interpolation mask operate before native parsing.

Resilience and Maintainability Implications

  • observed — If the new regex pre-pass fails, parsing stops with an exception rather than falling through to native parsing with unmasked constant names.

Hardening Proposals

  • proposed — Consumers that use parsed settings for security-sensitive decisions should validate their expected literal values and permitted keys at the consumer boundary during adoption of the changed output contract.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preserving INI values instead of substituting defined PHP constants.
Docstring Coverage ✅ Passed Docstring coverage is 90.91% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 22 functions across 2 files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

`testAFailedIdentifierPrePassIsReported` forced the failure by lowering
`pcre.backtrack_limit`, which does not produce one on every PCRE build: it passed locally
and failed on all eight CI jobs. The replacement is a seam — `replaceIdentifiers()` — that
the test overrides to return null directly, so the guard is exercised the same way
everywhere.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant