Skip to content

fix: six bugs found while auditing the library - #6

Merged
Halvanhelv merged 1 commit into
mainfrom
fix/six-bugs
Sep 7, 2026
Merged

Halvanhelv merged 1 commit into
mainfrom
fix/six-bugs

Conversation

@Halvanhelv

Copy link
Copy Markdown
Owner

Each of these was reproduced against the real library before being fixed, and each has a regression test. Version bumped to 2.1.0 — all are fixes, but DeepLDiff::Request::Error is a new public constant.

1. The options hash was consumed, not read

request.rb:27,31 called Hash#delete on the caller's hash.

opts = { from: :en, to: :ru }
DeepLDiff.translate("hello", opts)  # => "HELLO"
opts                                 # => {} ← emptied
DeepLDiff.translate("hello", opts)  # NoMethodError: undefined method 'detected_source_language'

A frozen hash — which is what a hash of settings kept in a constant is — raised FrozenError on the very first call. The request copies the hash in its initializer now.

2. The chunker measured three different things against one limit

line measured
next_chunk? CGI.escape(value).size — escaped
update_chunk value.size — raw, accumulated
validate_value_size value.size — raw

So the escaped size of the incoming value was added to a running total of raw sizes. CGI.escape inflates Cyrillic sixfold:

Chunker.new(["яяяяя"] * 4, limit: 50).call
# before → one chunk, 120 escaped — 2.4× over the limit
# after  → four chunks, 30 escaped each

Chunker.new(["я" * 40], limit: 50).call
# before → accepted, 240 escaped
# after  → Error: Too long part 240 > 50

Chunking is the only guard against DeepL's request-size limit, and it did not work for any non-Latin text. Everything is measured escaped now — that is what goes over the wire.

3. A detected source language never matched the target

detect_language returns a String; :to is usually a Symbol. from == to could not fire:

DeepLDiff.translate("привет", { to: :ru })
# before → paid for and translated into its own language
# after  → returned unchanged, only the detection call is made

Compared case-insensitively as strings now. The cache key is deliberately left alone so existing cached entries stay valid.

4. A short API response surfaced two layers away

updates.shift returned nil, which reached Spacing.restore as NoMethodError: undefined method 'strip' for nil. Now:

DeepLDiff::Request::Error: API returned 1 translations for 2 values

Raised at the call site, before a nil can be written to the cache.

5. Non-string scalars crashed

nil  → NoMethodError: undefined method 'empty?' for nil
42   → NoMethodError: undefined method 'empty?' for an instance of Integer
:sym → TypeError: no implicit conversion of Symbol into String

The emptiness check no longer assumes a String, the tokenizer ignores non-strings, and such values pass through untouched. nil keeps collapsing to "" inside a structure, exactly as before — that behavior is covered by an existing test and stays.

6. The count limit was off by one

tail.texts.size > count_limit was tested after the value was added, so a chunk held count_limit + 1 texts:

Chunker.new(%w[a] * 10, count_limit: 3).call
# before → [4, 4, 2]
# after  → [3, 3, 3, 1]

The existing test had the off-by-one baked into its expectation; it is corrected here.

Test plan

  • bundle exec rake test — 46 runs, 75 assertions, 0 failures, on Ruby 4.0.5 and 3.2.4 (was 34 runs).
  • bundle exec rubocop — no offenses.
  • Every one of the six original reproductions was re-run against the fixed library and now behaves correctly.
  • Coverage 97.57% → 97.97% (338/345).

https://claude.ai/code/session_01Pda49PcgziFVnibkKKjXRE

Each of these was reproduced before being fixed, and each has a
regression test.

The options hash was consumed rather than read. #from and #to called
Hash#delete on the caller's hash, so a second call with the same hash
lost :from and :to and fell through to language detection, and a frozen
hash -- which is what a hash of settings kept in a constant is -- raised
FrozenError on the first call. The request copies the hash now.

The chunker measured three different things against one limit.
#next_chunk? added the escaped size of the incoming value to a running
total of raw String#size, #update_chunk accumulated the raw size, and
#validate_value_size compared the raw size. Since CGI.escape inflates
Cyrillic sixfold, a chunk of Russian text ran several times over the
limit, and a single value whose request size was five times the limit
passed validation. Everything is measured as escaped now, which is what
actually goes over the wire.

A detected source language never matched the target. #detect_language
returns a String while :to is usually a Symbol, so `from == to` could
not fire and the text was paid for and translated into its own
language. The two are compared case-insensitively as strings now.

A short response from the API shifted nils into the results, which
surfaced as a NoMethodError on nil.strip two layers away in Spacing.
Request::Error is raised at the call site instead.

Scalars other than strings crashed. nil and Integer raised NoMethodError
on #empty?, and Symbol raised TypeError inside Ox. The emptiness check no
longer assumes a String, the tokenizer ignores non-strings, and such
values are passed through untouched. nil keeps collapsing to "" inside a
structure, as before.

The count limit was off by one. #next_chunk? tested `>` after the value
had been added, so a chunk held count_limit + 1 texts. The existing test
had the off-by-one baked into its expectation.
@Halvanhelv
Halvanhelv merged commit 61b845d into main Sep 7, 2026
5 checks passed
@Halvanhelv
Halvanhelv deleted the fix/six-bugs branch September 7, 2026 15:12
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