Skip to content

Deep-freeze a field's message: so it can't be mutated in place - #79

Merged
VSN2015 merged 1 commit into
masterfrom
fix/frozen-field-message
Sep 29, 2026
Merged

VSN2015 merged 1 commit into
masterfrom
fix/frozen-field-message

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 28, 2026

Copy link
Copy Markdown
Owner

The bug

A field's message: option (String or Hash) was never deep-frozen, unlike default:/example:/in:. validate_message! set field[:message] = spec.transform_keys(&:to_sym).freeze for 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.rb documents an explicit invariant: a Permittable.fields group is "frozen on construction and its fields are frozen hashes, so one group is safely shared by any number of contracts." But use splices a group's field hashes into a contract BY REFERENCE, not by copy (ContractBuilder#use just does @fields << field). So when two unrelated Contracts share a group via use, they hold the exact same field[:message] object.

Concretely:

group = Permittable.fields { required :age, :integer, message: { missing: "age please" } }
a = Permittable::Contract.define { use group }
b = Permittable::Contract.define { use group }

msg = a.call({}).violations.first[:message]   # "age please" — unfrozen
msg << " NOW"                                  # succeeds silently

b.call({}).violations.first[:message]          # => "age please NOW" — corrupted for B too

One request's violation-detail string could be mutated (accidentally, e.g. by a consumer doing message + suffix mistakenly written as message << 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 same freeze_authored helper (deep_dup then deep_freeze) that default:/example:/in: already use in validate_message! (lib/permittable.rb):

if spec.is_a?(String)
  field[:message] = freeze_authored(spec)
  return
end
...
field[:message] = freeze_authored(spec.transform_keys(&:to_sym))

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

  • New regression test in spec/field_group_spec.rb: two Permittable::Contracts use-ing the same Permittable.fields group; asserts the shared message: is frozen (FrozenError on 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 failures
  • ASDF_RUBY_VERSION=3.2.2 bundle exec rubocop lib/permittable.rb spec/field_group_spec.rb — no offenses

🤖 Generated with Claude Code

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>
@VSN2015
VSN2015 merged commit 3ab5401 into master Sep 29, 2026
16 checks passed
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