Skip to content

Bug: responses() without arguments freezes internal arrays, breaking subsequent extract_responses() calls #581

@yurikoval

Description

@yurikoval

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_responses

Expected 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
end

The 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:

  1. imap.responses["UIDVALIDITY"] calls responses() with no args
  2. transform_values(&:freeze) freezes all arrays in @responses
  3. Later, extract_responses("ESEARCH") calls all.reject! on a frozen array
  4. FrozenError is 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"].last
  • imap.responses["EXISTS"].last
  • imap.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"].last

With:

imap.responses("TYPE").last

This calls responses(type) with a type argument, which returns @responses[type.to_s.upcase].dup.freeze without mutating the original.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions