A Url validation rule, and per-call-site messages - #176
Conversation
Two Laravel parity gaps, both found while trying to move a consumer's
credential form onto the rules and discovering the move would be a
downgrade.
Url: Laravel has it and this package did not, so every consumer validating a
typed-in endpoint wrote the same startsWith('http://') pair by hand and each
drew its own conclusion about a scheme it had not thought of. It checks a
scheme from its allowlist and a non-empty host, and nothing else: it reaches
no network and resolves no host, because a rule answering a form field
synchronously cannot know any of that.
The allowlist is the security-shaped half. Uri.parse accepts
javascript:alert(1) and file:///etc/passwd without complaint; both have a
scheme and both parse cleanly, and nothing else keeps them out of a field
whose value becomes a request or a link.
Whitespace is rejected before parsing, because Uri does not treat it as an
error: Uri.tryParse('http://exa mple.com') succeeds and percent-encodes the
space into the host as exa%20mple.com, which no DNS lookup can resolve.
Measured, and Laravel rejects it too via FILTER_VALIDATE_URL.
messages: a rule's message came from its own key and nothing else, so a
screen wanting 'Şifre gerekli.' rather than the catalogue's generic
':attribute alanı zorunludur.' had to abandon the rules and hand-roll a
closure, which is the thing the rules exist to prevent.
The value is a KEY rather than a sentence: an override taking a sentence
would make every consumer using it monolingual. Rule parameters still reach
it, so :attribute and :schemes work in an override.
Keyed by a new Rule.name derived from the message key rather than from
runtimeType, and that choice is load-bearing: runtimeType.toString() is
minified in a Flutter web release build, so a messages map keyed on it would
match in development and silently stop matching in production.
Rule gains a const constructor and the four stateless rules declare one.
Additive; a rule with its own non-const constructor is unaffected.
Three mutations run: removing the whitespace guard, the empty-host guard, and
the messages lookup each turn their own tests red.
I asserted that runtimeType.toString() is minified in a Flutter web release
build without a source, in four places. Two first-party ones say something
stronger and narrower:
dart2js minifies class names and carries a branch for reporting them,
if (JS_GET_FLAG('MINIFIED')) return 'minified:$rawClassName'
(dart-sdk/lib/_internal/js_runtime/lib/js_helper.dart:107).
And Flutter's framework declines to use it at all outside asserts:
objectRuntimeType (foundation/object.dart) returns runtimeType.toString()
only when asserts are enabled and a caller-supplied constant otherwise,
because 'calling toString on a runtime type is a non-trivial operation'.
So the honest claim is that it is not a dependable identifier in a release
build, which is broader than web and better evidenced than what I wrote.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Note Kodizm (AI-generated). May contain mistakes; verify before acting. The Major
Minor
Tests
Checks I ran
CHANGELOG/doc/skill patches read; no other file in the pull request was left unreviewed. |
…atter (#177) The stamp comment said Skill v0.1.31 while the frontmatter said 0.1.34. The review that spotted it called the drift pre-existing; it is not. At the 0.0.14 release both read 0.1.31, and the three bumps that opened the gap are mine: #174, #176 and #175 each raised the frontmatter and left the comment alone. The stamp is what a reader checks to see whether the skill was verified against the current API surface, so one that lags by three revisions says the opposite of what it is for.
Two Laravel parity gaps, both found by trying to move a consumer's credential form onto the rules and discovering the move would be a downgrade.
UrlLaravel has
url; this package did not, so every consumer validating a typed-in endpoint wrote the samestartsWith('http://')pair by hand and each drew its own conclusion about a scheme it had not thought of.It checks a scheme from its allowlist and a non-empty host, and nothing else. It reaches no network and resolves no host, because a rule answering a form field synchronously cannot know any of that, and a rule that pretended to would be wrong in the direction that blocks a valid address.
The allowlist is the security-shaped half.
Uri.parseacceptsjavascript:alert(1)andfile:///etc/passwdwithout complaint; both have a scheme and both parse cleanly. Nothing else keeps them out of a field whose value becomes a request or a link.Whitespace is rejected before parsing, because
Uridoes not treat it as an error. Measured:Uri.tryParse('http://exa mple.com')succeeds and percent-encodes the space into the host asexa%20mple.com, an address no DNS lookup can resolve. Laravel rejects it too, viaFILTER_VALIDATE_URL.messagesA rule's message came from its own key and nothing else, so a screen wanting
Şifre gerekli.rather than the catalogue's generic:attribute alanı zorunludur.had to abandon the rules and hand-roll a closure, which is the thing the rules exist to prevent. This is Laravel's thirdValidator::makeargument.The value is a key, not a finished sentence. An override taking a sentence would make every consumer using it monolingual, which is the opposite of what the rules are for. Rule parameters still reach it, so
:attributeand:schemeswork in an override, and a key with no sentence renders as itself, which istrans's own contract.The one decision worth reviewing
The map is keyed by a new
Rule.name, derived from the rule's message key (validation.requiredgivesrequired) rather than fromruntimeType.That is load-bearing.
runtimeType.toString()is not a dependable identifier in a release build, so a messages map keyed on it would match in development and silently stop matching in production. Two first-party sources:if (JS_GET_FLAG('MINIFIED')) return 'minified:$rawClassName';(dart-sdk/lib/_internal/js_runtime/lib/js_helper.dart:107).objectRuntimeType(foundation/object.dart) returnsruntimeType.toString()only when asserts are enabled and a caller-supplied constant otherwise, because "callingtoStringon a runtime type is a non-trivial operation".A message key is a literal in the source and survives both.
Also
Rulegains a const constructor and the four stateless rules (Required,Email,Accepted,Url) declare one. Additive: a rule with its own non-const constructor is unaffected. A stateless rule is written inline in a widget'sbuild, where a const instance is one allocation that never happens again.Gates
dart analyze— no issuesdart format .— no diffflutter test— 1580 green, 18 of them newCHANGELOG.md,doc/digging-deeper/validation.md(two new sections plus TOC and anchors),skills/magic-framework/references/forms-validation.md,SKILL.mdversion bumped