-
Notifications
You must be signed in to change notification settings - Fork 34
Description
Bug: responses() without arguments freezes internal arrays, breaking subsequent extract_responses() calls
Summary
In net-imap 0.6.0 with Ruby 3.4, calling imap.responses["TYPE"] (hash accessor syntax) inadvertently freezes the internal @responses arrays, causing FrozenError when extract_responses() later attempts to call reject! on those frozen arrays.
Environment
- net-imap version: 0.6.0
- Ruby version: 3.4.2
- Config:
responses_without_block: :frozen_dup(default in 0.6.0)
Steps to Reproduce
require 'net/imap'
# Simulate internal behavior
imap = Net::IMAP.new('imap.example.com', port: 993, ssl: true)
imap.authenticate('PLAIN', '[email protected]', 'password')
imap.select('INBOX')
# This triggers the bug - calls responses() without type argument
uid_validity = imap.responses["UIDVALIDITY"].last
# Later, when net-imap internally calls extract_responses...
# FrozenError: can't modify frozen Array: []
imap.uid_search("ALL") # Fails with FrozenError in extract_responsesExpected Behavior
According to the official documentation:
For thread-safety, the returned array is a frozen copy of the internal array.
Calling imap.responses["TYPE"] should return a frozen copy without side effects on the internal @responses hash. The internal arrays should remain unfrozen and mutable for internal operations like extract_responses.
Actual Behavior
FrozenError: can't modify frozen Array: []
from /usr/local/bundle/gems/net-imap-0.6.0/lib/net/imap.rb:3284:in 'Array#reject!'
from /usr/local/bundle/gems/net-imap-0.6.0/lib/net/imap.rb:3284:in 'block in Net::IMAP#extract_responses'
The error occurs when extract_responses() attempts to call all.reject! on a frozen array.
Root Cause
When imap.responses["TYPE"] is called, it invokes responses() without arguments, triggering the :frozen_dup code path:
# lib/net/imap.rb ~line 3230
def responses(type = nil)
if block_given?
synchronize { yield(type ? @responses[type.to_s.upcase] : @responses) }
elsif type
synchronize { @responses[type.to_s.upcase].dup.freeze }
else
case config.responses_without_block
when :frozen_dup
synchronize {
responses = @responses.transform_values(&:freeze) # ← BUG HERE
responses.default_proc = nil
responses.default = [].freeze
return responses.freeze
}
# ...
end
end
endThe bug: @responses.transform_values(&:freeze) mutates the original array objects by freezing them, even though it creates a new hash. The method calls .freeze on each array value in the original @responses hash.
This violates the documented contract that states the method should return "a frozen copy of the internal array" - instead, it's freezing the internal array itself and returning a frozen hash containing those frozen arrays.
This means:
imap.responses["UIDVALIDITY"]callsresponses()with no argstransform_values(&:freeze)freezes all arrays in@responses- Later,
extract_responses("ESEARCH")callsall.reject!on a frozen array FrozenErroris raised
Proof of Concept
# Demonstrate transform_values(&:freeze) mutates original
responses = Hash.new {|h, k| h[k] = [] }
responses["SEARCH"] = [1, 2, 3]
puts "Before: #{responses['SEARCH'].frozen?}" # => false
result = responses.transform_values(&:freeze)
puts "After: #{responses['SEARCH'].frozen?}" # => true (MUTATED!)
responses["SEARCH"].reject! { false } # FrozenError!Impact
This affects any code using the hash accessor syntax imap.responses["TYPE"] instead of calling imap.responses(type) with a type argument. Common cases include:
imap.responses["UIDVALIDITY"].lastimap.responses["EXISTS"].lastimap.responses["RECENT"].last
Suggested Fix
The :frozen_dup implementation should create frozen copies as documented, not freeze the original arrays:
when :frozen_dup
synchronize {
responses = @responses.transform_values { |v| v.dup.freeze } # ← Freeze a copy
responses.default_proc = nil
responses.default = [].freeze
return responses.freeze
}Alternatively, document that hash accessor syntax should never be used and users must call responses(type) with a type argument.
Workaround
Replace all instances of:
imap.responses["TYPE"].lastWith:
imap.responses("TYPE").lastThis calls responses(type) with a type argument, which returns @responses[type.to_s.upcase].dup.freeze without mutating the original.