Deep-freeze a field's message: so it can't be mutated in place - #79
Merged
Merged
Conversation
A field's message: (String or Hash form) was never frozen the way default:/example:/in: already are. Since a Permittable.fields group's field hashes are spliced into every contract that use's them BY REFERENCE (not copied), an unfrozen message: string could be mutated via one contract's violation detail and permanently corrupt the text for every other contract sharing that group — contradicting field_group.rb's own documented invariant that a group is "safely shared by any number of contracts." Route both the String and Hash forms through the existing freeze_authored helper (deep_dup then deep_freeze), same as default:/example:/in:, so a Hash message's values are recursively frozen too. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
A field's
message:option (String or Hash) was never deep-frozen, unlikedefault:/example:/in:.validate_message!setfield[:message] = spec.transform_keys(&:to_sym).freezefor the Hash form — freezing only the top-level Hash, leaving its String values mutable — and did nothing at all to freeze the String form.field_group.rbdocuments an explicit invariant: aPermittable.fieldsgroup is "frozen on construction and its fields are frozen hashes, so one group is safely shared by any number of contracts." Butusesplices a group's field hashes into a contract BY REFERENCE, not by copy (ContractBuilder#usejust does@fields << field). So when two unrelated Contracts share a group viause, they hold the exact samefield[:message]object.Concretely:
One request's violation-detail string could be mutated (accidentally, e.g. by a consumer doing
message + suffixmistakenly written asmessage << suffix, or deliberately) and permanently corrupt the message for every other contract sharing that field group, for the life of the process.The fix
Route both the String and Hash forms of
message:through the samefreeze_authoredhelper (deep_dupthendeep_freeze) thatdefault:/example:/in:already use invalidate_message!(lib/permittable.rb):This freezes a COPY (not the caller's object), recursively — so a Hash message's String values are frozen too, not just the outer Hash.
Verification
spec/field_group_spec.rb: twoPermittable::Contractsuse-ing the samePermittable.fieldsgroup; asserts the sharedmessage:is frozen (FrozenErroron mutation attempt) and that both contracts keep reading the same intact, un-corrupted object. Confirmed to FAIL before the fix (expected FrozenError but nothing was raised) and PASS after.ASDF_RUBY_VERSION=3.2.2 bundle exec rspec— 852 examples, 0 failuresASDF_RUBY_VERSION=3.2.2 bundle exec rubocop lib/permittable.rb spec/field_group_spec.rb— no offenses🤖 Generated with Claude Code