Skip to content

Commit b1ff62a

Browse files
chrisbbreuerclaude
andcommitted
fix(repo): stop git corrupting binaries, and drop 7 dead PNGs it already ate (#2433)
`.gitattributes` was one line, `* text=auto`, with no binary rules. So git treated `.png` as text and rewrote every `0D 0A` inside it to `0A` on commit. A PNG opens `89 50 4E 47 0D 0A 1A 0A`; the tracked ones open `89 50 4E 47 0A 1A 0A 00`. 14 were committed already broken in 2024 and stayed that way for over a year, until the frontend build started reading them and `ts-images` refused - correctly, they are not PNGs any more. 87 tracked files match the extensions now marked binary, and every one of them was exposed to this. That rule has to land before anything is re-added, or the same normalisation eats the replacement on commit. The damage cannot be undone: only `0D` bytes that preceded `0A` were dropped, so nothing records which surviving `0A` used to have one. Of the 14, seven are referenced by nothing at all - `git grep` finds no use of `public/images/screenshots/*.png` in any template, script, doc or style - so they are deleted rather than carried as debt. The other seven ARE referenced (`Testimonials.stx` uses the five avatars, the Vue and web examples use the favicons) and need their originals re-added, which this repository cannot supply: the only commit that ever touched them, `970bd55b92`, already carried the damage. `tracked-images-are-valid.test.ts` checks magic bytes on every tracked image - 34 today, 27 valid. The seven are listed as known-corrupt, and a second test fails if any of them turns out to be VALID, so repairing one forces its removal from the list instead of leaving a permanent exemption. Verified both ways: a newly added broken PNG is named, and so is a knownCorrupt file given a good header. Refs #2433 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 38ac01a commit b1ff62a

9 files changed

Lines changed: 128 additions & 0 deletions

File tree

.gitattributes

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,35 @@
11
* text=auto
2+
3+
# Binary formats must never be line-ending normalised.
4+
#
5+
# With `* text=auto` alone, git treated `.png` as text and rewrote every
6+
# `0D 0A` inside it to `0A` on commit. That destroys the file: a PNG starts
7+
# `89 50 4E 47 0D 0A 1A 0A`, and 14 tracked images were committed already
8+
# broken in 2024 and stayed that way for over a year (stacksjs/stacks#2433).
9+
# The damage is not reversible - only the `0D` bytes that happened to precede
10+
# `0A` were dropped, so there is no way to tell which survivors used to have
11+
# one.
12+
#
13+
# 87 tracked files match these extensions, and every one of them was exposed.
14+
*.png binary
15+
*.jpg binary
16+
*.jpeg binary
17+
*.gif binary
18+
*.webp binary
19+
*.avif binary
20+
*.ico binary
21+
*.icns binary
22+
*.woff binary
23+
*.woff2 binary
24+
*.ttf binary
25+
*.otf binary
26+
*.eot binary
27+
*.pdf binary
28+
*.zip binary
29+
*.gz binary
30+
*.mp4 binary
31+
*.mov binary
32+
*.mp3 binary
33+
*.wav binary
34+
*.dmg binary
35+
*.wasm binary
-181 KB
Binary file not shown.
-179 KB
Binary file not shown.
-86.3 KB
Binary file not shown.
-174 KB
Binary file not shown.
-93.4 KB
Binary file not shown.
-186 KB
Binary file not shown.
-149 KB
Binary file not shown.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Every tracked image really is the format its extension claims.
3+
*
4+
* `.gitattributes` was a single `* text=auto` line with no binary rules, so git
5+
* treated `.png` as text and rewrote every `0D 0A` inside it to `0A` on commit.
6+
* A PNG opens `89 50 4E 47 0D 0A 1A 0A`; these became `89 50 4E 47 0A 1A 0A 00`.
7+
* 14 images were committed already broken in 2024 and stayed that way for over
8+
* a year, until the frontend build started reading them and `ts-images`
9+
* correctly refused (stacksjs/stacks#2433).
10+
*
11+
* The corruption is not reversible - only `0D` bytes that preceded `0A` were
12+
* dropped, so nothing records which survivors used to have one. The files have
13+
* to be re-added from their originals.
14+
*
15+
* This checks magic bytes rather than decoding: it is the cheap half, it is
16+
* what actually broke, and it would have caught this on the commit that
17+
* introduced it.
18+
*/
19+
import { describe, expect, it } from 'bun:test'
20+
import { readFileSync } from 'node:fs'
21+
import { extname, join } from 'node:path'
22+
import { $ } from 'bun'
23+
24+
const root = new URL('../../../../../', import.meta.url).pathname
25+
26+
/** First bytes each format must start with. */
27+
const signatures: Record<string, Uint8Array[]> = {
28+
'.png': [new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])],
29+
'.jpg': [new Uint8Array([0xFF, 0xD8, 0xFF])],
30+
'.jpeg': [new Uint8Array([0xFF, 0xD8, 0xFF])],
31+
'.gif': [new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]), new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])],
32+
'.webp': [new Uint8Array([0x52, 0x49, 0x46, 0x46])],
33+
'.ico': [new Uint8Array([0x00, 0x00, 0x01, 0x00])],
34+
}
35+
36+
/**
37+
* Files known to be destroyed, still tracked because something references them
38+
* and no original is available in this repository - the only commit that ever
39+
* touched them already carried the damage. Replace the file, then delete its
40+
* line here; the test below fails if one of these turns out to be valid, so the
41+
* list cannot rot into a permanent exemption.
42+
*/
43+
const knownCorrupt = new Set<string>([
44+
'public/images/avatars/avatar-1.png',
45+
'public/images/avatars/avatar-2.png',
46+
'public/images/avatars/avatar-3.png',
47+
'public/images/avatars/avatar-4.png',
48+
'public/images/avatars/avatar-5.png',
49+
'storage/framework/libs/examples/vue/favicon.png',
50+
'storage/framework/libs/examples/web/favicon.png',
51+
])
52+
53+
function startsWith(head: Uint8Array, signature: Uint8Array): boolean {
54+
return signature.every((byte, index) => head[index] === byte)
55+
}
56+
57+
async function trackedImages(): Promise<Array<{ file: string, valid: boolean }>> {
58+
const tracked = (await $`git ls-files`.cwd(root).quiet()).text().split('\n').filter(Boolean)
59+
60+
return tracked
61+
.filter(file => extname(file).toLowerCase() in signatures)
62+
.map((file) => {
63+
let head: Uint8Array
64+
try {
65+
head = readFileSync(join(root, file)).subarray(0, 12)
66+
}
67+
catch {
68+
// Absent from the working tree mid-rebase; not this test's business.
69+
return { file, valid: true }
70+
}
71+
72+
const accepted = signatures[extname(file).toLowerCase()]!
73+
return { file, valid: accepted.some(signature => startsWith(head, signature)) }
74+
})
75+
}
76+
77+
describe('tracked images', () => {
78+
it('all start with their format\'s magic bytes', async () => {
79+
const broken = (await trackedImages())
80+
.filter(image => !image.valid && !knownCorrupt.has(image.file))
81+
.map(image => image.file)
82+
83+
expect(broken.sort()).toEqual([])
84+
})
85+
86+
it('lists no file under knownCorrupt that has since been repaired', async () => {
87+
const images = await trackedImages()
88+
const repaired = images.filter(image => image.valid && knownCorrupt.has(image.file)).map(image => image.file)
89+
90+
// Removing the line is the last step of the fix, not an optional tidy-up:
91+
// left in place, it would exempt a good file from ever being checked again.
92+
expect(repaired.sort()).toEqual([])
93+
})
94+
})

0 commit comments

Comments
 (0)