Skip to content

S3: 104 objects are stored latin-1-expanded — migration must precede the read_bytes fix on any env #2116

Description

@kwit75

Warning

#2114 is not safe to apply to an environment until that environment's bucket is migrated. It repairs binary reads that never worked and simultaneously breaks binary objects that did work. Staging is already migrated. Production is not.

What is in the buckets

Before #2114, Store.write_bytes had no S3 implementation, so it fell back to data.decode('latin-1')write_file.encode('utf-8'). Every byte above 0x7F became two. Objects written that way are stored expanded.

feature-1.png   stored     231241 bytes   \xc2\x89PNG
                unwrapped  153258 bytes   \x89PNG

\xc2\x89 is the UTF-8 encoding of 0x89. Inflation ~1.5x.

The old, apparently-broken read path handled these correctly. Expanded data is valid UTF-8, so read_file decoded it and .encode('latin-1') returned the original bytes. The round trip was self-consistent.

After #2114, read_bytes returns the stored bytes verbatim — the expanded ones. They are served through shell.py:366 and app_handler.py:150, so browsers receive corrupt fonts and images.

Measured, staging

104 of 778 objects, all under the system org, in the built-in apps:

app objects
rocketride.pipeBuilder 80
rocketride.home 12
rocketride.benchmark 8
rocketride.aparavi 4

By type: 43 woff2, 41 woff, 19 png, 1 gif.

Staging migrated and verified — full rescan, 778 objects, zero remaining expanded.

Detecting these is harder than it looks — two traps

Magic bytes do not work for fonts. wOF2 and wOFF are ASCII, so they survive the expansion unchanged. A classifier keyed on the signature reports an expanded font as healthy. My first pass found 18 of 104 for exactly this reason.

Use the length field instead: WOFF and WOFF2 both carry total file size as a big-endian uint32 at offset 8. Expanded ⇒ declared ≠ actual.

Extensions lie. install.png is a JPEG. Sniff content, not the name.

Safe procedure per environment

  1. Enumerate objects; skip pure-ASCII (latin-1 and UTF-8 agree — indifferent)
  2. Keep only those that decode as UTF-8 with all codepoints < 256
  3. Unwrap: data.decode('utf-8').encode('latin-1')
  4. Write back only if the unwrapped bytes validate in the format's own terms — WOFF length field, PNG 8-byte signature, JPEG SOI+EOI. Never on a guess.
  5. Rescan to confirm zero remain

Step 4 is what makes this safe to re-run: an already-migrated object fails the unwrap check and is left alone.

Origin

I asserted in #2114 that no existing object changes meaning, on the evidence of a single sampled remoteEntry.js that happened to be pure ASCII. That was a sample reported as a population.

🤖 Generated with Claude Code

https://claude.ai/code/session_015nTVr6jfSFYm1GppxbjghP

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions