Skip to content

Phase 7a Sanitizer clean-room (no ProcessWire residue) - #7

Merged
bigin merged 1 commit into
mainfrom
phase-7a-sanitizer
May 2, 2026
Merged

Phase 7a Sanitizer clean-room (no ProcessWire residue)#7
bigin merged 1 commit into
mainfrom
phase-7a-sanitizer

Conversation

@bigin

@bigin bigin commented May 2, 2026

Copy link
Copy Markdown
Owner

First sub-phase of the field-type-system rewrite. The Sanitizer is the foundation 7b and 7c will lean on, so it lands first and small.

Why a clean-room rewrite

The 1.x Sanitizer carried roughly a dozen unported $this->wire(...) calls inherited from a ProcessWire ancestor — see docs/imanager-analysis.md §4.3. The wire() method doesn't exist on iManager, so any of those code paths that fired would Fatal at runtime. Substantial parts of the file were latent-broken rather than truly working.

Rather than port-fix each one, this PR ships a fresh Imanager\Validation\Sanitizer from scratch with only the methods the upcoming field-type plugins (7b / 7c) actually need.

Public API

Method Purpose
text(string, int = 255) Single-line: strip control chars, collapse whitespace, trim, truncate by Unicode codepoint count
multiline(string, int = 65535) Preserve newlines, normalize CRLF / CR → LF
slug(string, int = 128) URL-safe identifier with iconv ASCII transliteration
identifier(string, int = 30) PHP-style [A-Za-z_][A-Za-z0-9_]*; prepends _ if it would otherwise start with a digit
filename(string, int = 128) basename() + explicit / and \\ stripping (defense-in-depth against cross-platform path-separator smuggling)
email(string) Returns the validated address, or null
url(string) Returns the validated http/https URL, or null
int(mixed, ?int min, ?int max) Coerce + optional clamp
float(mixed, ?float min, ?float max) Coerce + optional clamp
bool(mixed) FILTER_VALIDATE_BOOLEAN; non-truthy / non-falsy → false
entities(string) htmlspecialchars(ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5)
markdown(string) Parsedown in safe mode (raw HTML escaped)
html(string) HTMLPurifier with conservative tag allowlist

Parsedown and HTMLPurifier are lazy-instantiated and overridable via constructor injection — the Sanitizer is cheap to wire into the container even when only the lightweight string methods are exercised.

Acceptance criteria (Phase 7a slice of plan §7 Phase 7)

  • Imanager\Validation\Sanitizer exists at the location the plan placed it
  • Zero references to wire() or any other ProcessWire artifact
  • Real Composer dependencies for Markdown (erusev/parsedown) and HTML purification (ezyang/htmlpurifier)
  • Method coverage sufficient for the 17 built-in field types coming in 7b/7c

Verification

Check Result
PHP-CS-Fixer 0 issues
PHPStan level 8 no errors
Psalm level 3 no errors, 99.34% type inference
PHPUnit 324 tests, 644 assertions (266 from Phase 6 + 58 new)

Notes

  • HTMLPurifier's on-disk cache is disabled (Cache.DefinitionImpl = null) so we don't surprise anyone with a write into a directory the framework's host owner doesn't expect. Performance hit on first call is acceptable; if it bites later, we'll wire a configurable cache path through the container.
  • entities() emits ' for the single quote (HTML5-flag behavior). The 1.x Sanitizer would have emitted ' (HTML4 default). Templates that scrape sanitizer output looking for ' will need to be updated — but no production templates today depend on this, so we take the cleaner default.
  • slug() uses iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', ...). Transliteration coverage varies across libcs (glibc, musl, BSD); the test that exercises the path with naïve résumé only asserts the result is slug-shaped (/^[a-z0-9-]+$/), not its exact bytes. If we ever need deterministic transliteration we'll switch to intl's Transliterator, but that requires
    ext-intl which isn't in composer.json today.
  • The 1.x Sanitizer also exposed a purify() method that just delegated to wire('modules')->get('MarkupHTMLPurifier') — it's intentionally not preserved under the same name. The new equivalent is html().

Phase 7a — first sub-phase of the field-type-system rewrite.

The 1.x Sanitizer carried roughly a dozen unported $this->wire(...) calls
left over from a ProcessWire ancestor. Any code path that touched them
would Fatal at runtime (the methods don't exist on the iManager class),
so substantial parts of the file were latent-broken rather than truly
working — see docs/imanager-analysis.md §4.3.

This phase ships a fresh Imanager\Validation\Sanitizer with only the
methods the upcoming field-type plugins (7b/7c) actually need:

  text         — single-line: strip control chars, collapse whitespace,
                 trim, truncate by Unicode codepoints
  multiline    — preserve newlines, normalize CRLF/CR -> LF
  slug         — URL-safe identifier with iconv ASCII transliteration
  identifier   — PHP-style [A-Za-z_][A-Za-z0-9_]*, prepends `_` if it
                 would otherwise start with a digit
  filename     — basename + explicit / and \ stripping (defense-in-depth
                 against cross-platform path-separator smuggling)
  email        — RFC-ish, returns null on invalid
  url          — http/https only, returns null on invalid
  int / float  — coerce + optional [min, max] clamp
  bool         — FILTER_VALIDATE_BOOLEAN, defaults to false
  entities     — htmlspecialchars(ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5)
  markdown     — Parsedown in safe mode (raw HTML escaped)
  html         — HTMLPurifier with conservative tag allowlist

Markdown and HTMLPurifier are lazy-instantiated and overridable via
constructor injection — the Sanitizer is cheap to wire into the
container even when only the lightweight string methods are exercised.

58 new tests covering the whole API (control-character handling,
Unicode truncation, slug transliteration, identifier rewriting,
path-separator stripping, scheme allowlisting, boolean truthy/falsy
matrix, HTML5 entity quirks, raw-HTML escaping in safe markdown,
HTMLPurifier disallow). 324 tests / 644 assertions in total;
PHPStan 8 + Psalm 3 clean.
@bigin
bigin merged commit 2df89d2 into main May 2, 2026
4 checks passed
@bigin
bigin deleted the phase-7a-sanitizer branch May 2, 2026 07:48
bigin added a commit that referenced this pull request May 2, 2026
Final sub-phase of the field-type-system rewrite. With this PR all 17 iManager 1.x field types have been ported to the Phase-7b plugin contract.                                                                                                                                                                                                                                         
                                                                                                                                                                                                                                                                                                                                                                                           
  ## What's in                                                                                                                                                                                                                                                                                                                                                                             
                                                                                                                                                                                                                                                                                                                                                                                           
  ### Seven full implementations                                                                                                                                                                                                                                                                                                                                                           
                                                                                                                                                                                                                                                                                                                                                                                           
  | Plugin | Notes |                                                                                                                                                                                                                                                                                                                                                                       
  |---|---|                                                                                                                                                                                                                                                                                                                                                                                
  | `EditorFieldType` | Rich-text source field. Modes: **`markdown`** (default, `Sanitizer::multiline`) or `html` (`Sanitizer::html` via HTMLPurifier). Renders a textarea with `data-editor-mode` for the Phase-14 admin JS to swap in a real WYSIWYG. Storage is the *source*; display rendering is the template's job. |                                                                
  | `DatepickerFieldType` | ISO date string in, Unix timestamp out. INTEGER affinity so the indexed generated column from Phase 4 can do range queries with plain integer comparisons. Accepts `YYYY-MM-DD`, full datetimes, or anything `strtotime()`-grokable. |                                                                                                                         
  | `DropdownFieldType` | Single-select. Validates membership in the config-declared `options` map. Auto-prepends a blank choice for optional fields. |                                                                                                                                                                                                                                    
  | `DecimalFieldType` | Float with min/max clamp and configurable precision. Step on the rendered input is derived from precision. |                                                                                                                                                                                                                                                      
  | `MoneyFieldType` | `Decimal` plus currency context. Tolerates locale-specific input formatting (`1.234,56`, `1,234.56`, `€ 1.234,56`, `$1,234.56`) by detecting the dominant separator. |                                                                                                                                                                                              
  | `PasswordFieldType` | bcrypt-hashes plaintext on save; NEVER echoes the stored hash back to the form. **Empty input is a sentinel for "leave the existing hash alone"**: `validate()` returns `null`; the editor controller (Phase 14) interprets `null` as a write-skip. minLength enforced. |                                                                                        
  | `ArrayListFieldType` | `list<string>`. Editor input is a textarea; both newline- and comma-separated input parse. `maxItems` and `itemMaxLength` enforced. |                                                                                                                                                                                                                           
                                                                                                                                                                                                                                                                                                                                                                                           
  ### Three Phase-13 stubs                                                                                                                                                                                                                                                                                                                                                                 
                                                                                                                                                                                                                                                                                                                                                                                           
  | Plugin | Stub behavior |                                                                                                                                                                                                                                                                                                                                                               
  |---|---|                                                                                                                                                                                                                                                                                                                                                                                
  | `FilepickerFieldType` | Treats value as opaque sanitized filename; renders plain text input with a `data-field="filepicker"` upgrade marker. Phase 13 will replace `render()` with a populated `<select>` driven by the upload directory. |                                                                                                                                            
  | `FileuploadFieldType` | Pass-through `list<array>` of file metadata; emits `<input type="file" multiple>`. |                                                                                                                                                                                                                                                                           
  | `ImageuploadFieldType` | Same as Fileupload with `accept="image/*"`. |                                                                                                                                                                                                                                                                                                                 
                                                                                                                                                                                                                                                                                                                                                                                           
  Each stub satisfies the `FieldTypePlugin` contract so the registry can hold all 17 built-ins from day one; only `render()` (and the validation pipeline that touches actual files) needs to grow when Phase 13 wires the upload-storage layer.                                                                                                                                           
                                                                                                                                                                                                                                                                                                                                                                                           
  ## Acceptance criteria — Phase 7 closed                                                                                                                                                                                                                                                                                                                                                  
                                                                                                                                                                                                                                                                                                                                                                                           
  - [x] **7a** Sanitizer clean-room (PR #7)                                                                                                                                                                                                                                                                                                                                                
  - [x] **7b** `FieldTypePlugin` interface, `FieldTypeRegistry`, six built-ins (PR #8)                                                                                                                                                                                                                                                                                                     
  - [x] **7c** Remaining 11 types — this PR                                                                                                                                                                                                                                                                                                                                                
    - 7 full implementations                                                                                                                                                                                                                                                                                                                                                               
    - 3 stubs marked for Phase 13                                                                                                                                                                                                                                                                                                                                                          
    - 1 already covered (the 1.x "summernote-Editor" maps to `EditorFieldType` here, no separate type)                                                                                                                                                                                                                                                                                     
                                                                                                                                                                                                                                                                                                                                                                                           
  ## Verification                                                                                                                                                                                                                                                                                                                                                                          
                                                                                                                                                                                                                                                                                                                                                                                           
  | Check | Result |                                                                                                                                                                                                                                                                                                                                                                       
  |---|---|                                                                                                                                                                                                                                                                                                                                                                                
  | PHP-CS-Fixer | 0 issues |                                                                                                                                                                                                                                                                                                                                                              
  | PHPStan level 8 | no errors |                                                                                                                                                                                                                                                                                                                                                          
  | Psalm level 3 | no errors, 99.32% type inference |                                                                                                                                                                                                                                                                                                                                     
  | PHPUnit | **426 tests, 858 assertions** (375 from 7b + 51 new) |                                                                                                                                                                                                                                                                                                                       
                                                                                                                                                                                                                                                                                                                                                                                           
  ## Notes                                                                                                                                                                                                                                                                                                                                                                                 
                                                                                                                                                                                                                                                                                                                                                                                           
  - `EditorFieldType` defaults to **markdown mode**. Per discussion, this is safer (no raw HTML by default) and gives nicer source diffs in version control or Git-driven content workflows. Projects that need WYSIWYG-with-HTML can flip the `mode` config key.                                                                                                                          
  - `PasswordFieldType`'s "empty == no change" sentinel is documented in its class-level PHPDoc. If we ever want a "blank the password" admin action, it's a separate explicit operation, not an accidental form save.                                                                                                                                                                     
  - `MoneyFieldType` stores as `float`. For projects that need exact arithmetic (financial reports, rounding-safe summation) the recommendation is to store cents in an `IntegerFieldType` instead — that pattern is documented in the class PHPDoc.                                                                                                                                       
  - `MoneyFieldType` currency lives on the *field config*, not on each row. If a use case ever needs per-row currency, it should be a separate field plugin (a small struct serialized to JSON) — explicitly noted.                                                                                                                                                                        
  - The three Phase-13 stubs have minimal validation by design: they accept what they're given without trying to verify file existence or mime type. Strict validation needs the upload pipeline; piggy-backing it onto the plugin interface now would lock in a design that's better made when we have the surrounding storage code.
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