Skip to content

fix: properly transcode CSV content to UTF-8 on import (Sentry DROPLET-SHIPPING-OPTIONS-1) - DRO-237 - #58

Open
bliss-bot-next wants to merge 4 commits into
mainfrom
fix/sentry-droplet-shipping-options-1
Open

fix: properly transcode CSV content to UTF-8 on import (Sentry DROPLET-SHIPPING-OPTIONS-1) - DRO-237#58
bliss-bot-next wants to merge 4 commits into
mainfrom
fix/sentry-droplet-shipping-options-1

Conversation

@bliss-bot-next

@bliss-bot-next bliss-bot-next commented May 16, 2026

Copy link
Copy Markdown

Sentry Issue

DROPLET-SHIPPING-OPTIONS-1: Encoding::UndefinedConversionError - "\xC2" from ASCII-8BIT to UTF-8
Project: droplet-shipping-options · Events: ~40+ · Users: Multiple

Diagnosis

Surface Error

Encoding::UndefinedConversionError: "\xC2" from ASCII-8BIT to UTF-8 raised in rates_controller.rb during CSV import when a user uploads a file exported from Excel or another tool that produces non-ASCII characters (e.g. currency symbols, accented names).

Root Cause

String#force_encoding("UTF-8") only relabels the string's encoding — it tells Ruby "pretend this is UTF-8" without converting any bytes. When the string actually contains non-UTF-8 byte sequences (Windows-1252, Latin-1, or raw binary), subsequent operations (write, string interpolation, CSV parsing) raise Encoding::UndefinedConversionError because Ruby tries to interpret those bytes as UTF-8 and fails.

What a Quick Fix Would Look Like

Rescuing the Encoding::UndefinedConversionError and returning a user-facing error message — this would hide the crash but not fix the encoding and would still produce garbled output in downstream CSV parsing.

Solution

Approach

Replace force_encoding("UTF-8") with encode("UTF-8", "binary", invalid: :replace, undef: :replace).

String#encode with source encoding "binary" treats each byte independently (no multi-byte assumptions), transcodes to UTF-8, and replaces any byte sequences that cannot be represented in UTF-8 with the Unicode replacement character (). This means:

  • Valid UTF-8 content passes through unchanged
  • Windows-1252 / Latin-1 content is transcoded correctly for printable chars
  • Truly undecodable bytes are replaced rather than raising

Why This Is a Root Fix (Not a Bandaid)

The crash site is temp_file.write(csv_content) — Ruby refuses to write a string it believes is UTF-8 but contains non-UTF-8 bytes. force_encoding created a lie: the string said it was UTF-8 but wasn't. The fix ensures the string actually is UTF-8 before anything tries to use it, preventing this entire class of encoding errors from reaching the write call, CSV parser, or any downstream service.

Changes

  • app/controllers/rates_controller.rb: replaced force_encoding("UTF-8")encode("UTF-8", "binary", invalid: :replace, undef: :replace) and updated the comment to describe what actually happens

Future Work

If lossless preservation of the original encoding matters (e.g. for auditing), the replacement characters could be logged. For the current use case (CSV import), lossy replacement is preferable to a 500 error.


Generated by Sentry Auto-Fixer Agent

Fixes DRO-237

@bliss-bot-next
bliss-bot-next requested a review from jake-bliss May 16, 2026 02:20
@linear-code

linear-code Bot commented Jul 15, 2026

Copy link
Copy Markdown

DRO-237

@jake-bliss jake-bliss changed the title fix: properly transcode CSV content to UTF-8 on import (Sentry DROPLET-SHIPPING-OPTIONS-1) fix: properly transcode CSV content to UTF-8 on import (Sentry DROPLET-SHIPPING-OPTIONS-1) - DRO-237 Jul 15, 2026
@jake-bliss jake-bliss closed this Jul 15, 2026
@jake-bliss jake-bliss reopened this Jul 15, 2026
jake-bliss and others added 2 commits July 15, 2026 15:10
…TIONS-1)

The PR's encode(...) fix had no test proving it does anything: the existing
service-level test suite never exercised the controller's encoding step, so
the fix could regress silently.

Added a controller test that posts a CSV containing a lone invalid byte
(0xC2, matching the reported crash) through process_import. Verified locally
that this test fails on the pre-fix force_encoding("UTF-8") code (the row
gets rejected with a 422 "Unable to read CSV file" instead of importing,
since force_encoding leaves the string invalid and CSV.parse's
CSV::InvalidEncodingError is swallowed as a generic read failure) and passes
with the encode(..., invalid: :replace, undef: :replace) fix (the row
imports successfully with the bad byte replaced).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Transcoding the upload with `encode("UTF-8", "binary", ...)` treats every
byte as standalone, so each byte of a multi-byte character is undefined in
ASCII-8BIT and gets replaced with U+FFFD. That turned the crash into silent
corruption: an already-valid UTF-8 export lost every accented character, a
Windows-1252 export was not actually transcoded, and the UTF-8 BOM became
three replacement characters that `delete_prefix!` could no longer strip.

Strip the BOM at the byte level first, then relabel as UTF-8 and only
transcode from Windows-1252 when the bytes are not valid UTF-8. Valid UTF-8
now passes through untouched, legacy single-byte content is transcoded to
its real characters, and undecodable bytes are still replaced rather than
raising Encoding::UndefinedConversionError.

Covered by tests for valid UTF-8 preservation and for a BOM-prefixed
Windows-1252 export, alongside the existing lone-0xC2 regression test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jake-bliss

Copy link
Copy Markdown

/greptileai review

The valid_encoding? check is all-or-nothing, so a single bad byte anywhere
in the upload re-reads the entire file as Windows-1252. A file that is
genuinely UTF-8 apart from one truncated trailing sequence therefore comes
back mojibaked: "Café Express" + "\xC3" imports as "Café ExpressÃ", with
every legitimate accented character destroyed to salvage one damaged byte.

Scrubbing instead is not the answer either. It fixes the corruption case but
silently deletes every accented character out of a genuine Windows-1252
export, turning "Señor Freight" into "Seor Freight".

Weigh the two readings against each other. Valid multi-byte UTF-8 sequences
essentially never occur by accident in Windows-1252 text, because an accented
Windows-1252 character is almost always followed by an ASCII byte and ASCII
bytes are never UTF-8 continuation bytes. So content that decodes to intact
multi-byte characters is UTF-8, and the bytes that do not decode are damage
to scrub. Counting the intact sequences against the damaged ones, rather than
looking only for the presence of one, keeps a single accidental byte pair in
a Windows-1252 file from flipping the whole file to the wrong reading.

Extract the whole decision into CsvEncoding.to_utf8 and call it from both
RatesController#process_import and RateCsvImportService#read_csv_file. The
service still carried the old force_encoding + BOM-strip logic, so anything
constructing the service directly got "Unable to read CSV file" for
Windows-1252 or BOM-prefixed content that the controller path handled fine.
The call is idempotent, so the controller normalizing before it parks the
bytes in a temp file and the service normalizing again on read do not fight.

Strengthen the import tests: the lone-0xC2 case now pins the exact imported
name rather than a four-character prefix, so dropping or replacing the byte
no longer passes. Add coverage for mixed valid/corrupt UTF-8, Windows-1252
without a BOM, and the direct service path, plus a unit test for the module.
Verified by reverting to each wrong implementation in turn: pre-PR
force_encoding (16 failures), unconditional binary transcode (19), the
all-or-nothing check this commit replaces (5), and naive scrub-only (12).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jake-bliss

Copy link
Copy Markdown

Fixed: one bad byte was destroying every accented character in the file

The valid_encoding? gate this PR added is all-or-nothing, so a single damaged byte anywhere in the upload re-reads the entire file as Windows-1252. Verified in Ruby against the code as it stood:

input:  "Café Express".b + "\xC3"     # valid UTF-8 body, one truncated trailing sequence
result: "Café ExpressÃ"              # "Café" destroyed to salvage one damaged byte

Switching to scrub is not the answer either — it breaks the case this PR was originally about:

input:       "Se\xF1or Freight".b     # genuine Windows-1252
scrub:       "Seor Freight"           # the ñ silently deleted
previously:  "Señor Freight"          # correct

The two inputs need opposite treatment, and the old code had no way to tell them apart.

Detection strategy

Valid multi-byte UTF-8 sequences essentially never occur by accident in Windows-1252 text: an accented Windows-1252 character is almost always followed by an ASCII letter or punctuation mark, and ASCII bytes are never UTF-8 continuation bytes. So content that decodes to intact multi-byte characters is UTF-8, and the bytes that do not decode are damage to scrub.

Rather than treating a single intact sequence as proof, the two readings are counted against each other and the one that loses fewer characters wins. That matters for the ambiguous middle — a Windows-1252 file that happens to contain one accidental C3 A9 pair:

input:                  "Se\xF1or M\xFCller caf\xE9 \xC3\xA9".b
presence-only rule:     "Se�or M�ller caf� é"        # 3 real characters lost
counting rule (chosen): "Señor Müller café é"        # only the accidental pair mojibaked

Proof

CsvEncoding.to_utf8 exercised directly (all 20 checks pass):

1. valid UTF-8 passes through byte-for-byte identical
PASS bytes unchanged                                "Caf\xC3\xA9 Express,Se\xC3\xB1or Freight,\xE2\x82\xAC9.99"
PASS decodes correctly                              "Café Express,Señor Freight,€9.99"
PASS tagged UTF-8 and valid                         ["UTF-8", true]

2. genuine Windows-1252 -> Señor
PASS Se\xF1or Freight                               "Señor Freight"
PASS cp1252 with several accents                    "Señor Müller café “quoted”"

3. UTF-8 with a truncated tail keeps Café intact
PASS Café Express + trailing \xC3                   "Café Express�"
PASS multi-byte body, one bad byte mid-string       "Café,�,Señor,€5"

4. the original Sentry input (lone 0xC2) no longer raises
raw bytes:            "Expr\xC2ess Shipping,US,CA,0,5,9.99,5.00"
old code (force_encoding only) -> CSV.parse:
     RAISED CSV::InvalidEncodingError
PASS normalized                                     "ExprÂess Shipping,US,CA,0,5,9.99,5.00"
PASS CSV.parse after fix                            "no error"

5. BOM stripped in every combination
PASS BOM + valid UTF-8                              "Café"
PASS BOM + cp1252                                   "Señor"
PASS BOM + UTF-8 + bad tail                         "Café�"
PASS BOM + sentry 0xC2                              "ExprÂess"
PASS BOM + pure ASCII                               "plain"
PASS BOM only                                       ""

6. the five Windows-1252-undefined bytes degrade, never raise
PASS 0x81 0x8D 0x8F 0x90 0x9D                       "A�����B"
PASS undefined bytes mixed with defined ones        "Señor�Freight"
PASS BOM + undefined bytes                          "�"

IDEMPOTENCE + edge cases
PASS to_utf8 is idempotent                          "Señor� Café"
PASS empty string                                   ""
PASS nil passes through                             nil

Shared extraction (addresses the Medium finding)

app/services/rate_csv_import_service.rb still carried the old force_encoding("UTF-8") + BOM-strip logic, so only the controller path benefited from this PR — calling the service directly with Se\xF1or Freight or BOM-prefixed Windows-1252 returned "Unable to read CSV file".

Both call sites now go through one implementation, CsvEncoding.to_utf8 (app/services/csv_encoding.rb):

  • RatesController#process_import normalizes before parking the bytes in a temp file, so the auto-correction round trip re-reads valid UTF-8.
  • RateCsvImportService#read_csv_file normalizes on read, so a direct caller (job, console, future code) gets identical treatment.

The call is idempotent — already-valid UTF-8 short-circuits — so the two do not fight.

Test strengthening

The Expr\xC2ess test only asserted start_with?("Expr"), so deleting or replacing 0xC2 both passed. It now pins the exact imported name "ExprÂess Shipping".

Added: mixed valid/corrupt UTF-8 (controller + service), Windows-1252 without a BOM, the three direct-service-call cases, and a CsvEncodingTest unit suite.

Every test was verified to actually fail by swapping CsvEncoding.to_utf8 for each wrong implementation in turn and re-running:

Reverted implementation Failures across the 3 encoding test files
pre-PR force_encoding("UTF-8") + BOM strip 16 failures
PR commit 1: unconditional encode("UTF-8", "binary", ...) 19 failures
PR head under review: all-or-nothing valid_encoding? 5 failures
naive scrub-only 12 failures
this commit 0 failures (57 runs, 220 assertions)

Full suite locally: 324 runs, 875 assertions, 0 failures, 0 errors. Rubocop clean (141 files), Brakeman clean (0 warnings), yarn test green.

(validate-linear-issue is red for an unrelated org-wide reason — Linear changed its bot identity and the shared action still hard-codes the old one. Not touched.)

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.

2 participants