From 1b667dc26119a650fb5d2b813838a0e99e55f04c Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:44:53 -0700 Subject: [PATCH] fix(cli): externalize/internalize corrupt binary files under .squad/ readSync/writeSync round-trips file contents through a UTF-8 string, so any byte >= 0x80 becomes U+FFFD. Because externalize deletes the source right after the copy, non-UTF-8 files are silently destroyed and internalize can't restore them. Copy bytes via storage.copySync at all three copy sites (externalize, internalize, copyDirRecursive) instead. Fixes #1489 --- .../fix-externalize-binary-corruption.md | 5 ++++ .../squad-cli/src/cli/commands/externalize.ts | 20 ++++++---------- test/external-state.test.ts | 24 +++++++++++++++++++ 3 files changed, 36 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-externalize-binary-corruption.md diff --git a/.changeset/fix-externalize-binary-corruption.md b/.changeset/fix-externalize-binary-corruption.md new file mode 100644 index 000000000..0e5a9de8a --- /dev/null +++ b/.changeset/fix-externalize-binary-corruption.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +Fix `squad externalize`/`internalize` corrupting non-UTF-8 files under `.squad/`. Both commands copied files through a UTF-8 string round-trip (`readSync`/`writeSync`), which replaced every byte >= 0x80 with U+FFFD and, because the source was deleted right after, destroyed the only intact copy. They now copy bytes via `storage.copySync`, so binary state (diagrams, archives, a future SQLite state db) survives the round trip intact. diff --git a/packages/squad-cli/src/cli/commands/externalize.ts b/packages/squad-cli/src/cli/commands/externalize.ts index ceb23b68f..bf90d469d 100644 --- a/packages/squad-cli/src/cli/commands/externalize.ts +++ b/packages/squad-cli/src/cli/commands/externalize.ts @@ -70,11 +70,9 @@ export function runExternalize(projectDir: string, projectKey?: string): void { copyDirRecursive(src, dest); storage.deleteDirSync?.(src); } else { - const content = storage.readSync(src); - if (content != null) { - storage.mkdirSync(path.dirname(dest), { recursive: true }); - storage.writeSync(dest, content); - } + // Copy bytes, not a UTF-8 string round-trip — readSync/writeSync + // corrupts any non-UTF-8 file, and the source is deleted right after. + storage.copySync(src, dest); storage.deleteSync?.(src); } movedCount++; @@ -158,10 +156,8 @@ export function runInternalize(projectDir: string): void { if (storage.isDirectorySync(src)) { copyDirRecursive(src, dest); } else { - const content = storage.readSync(src); - if (content != null) { - storage.writeSync(dest, content); - } + // Byte-accurate copy (see externalize branch above). + storage.copySync(src, dest); } movedCount++; } @@ -206,10 +202,8 @@ function copyDirRecursive(src: string, dest: string): void { if (storage.isDirectorySync(srcPath)) { copyDirRecursive(srcPath, destPath); } else { - const content = storage.readSync(srcPath); - if (content != null) { - storage.writeSync(destPath, content); - } + // Byte-accurate copy (see externalize branch above). + storage.copySync(srcPath, destPath); } } } diff --git a/test/external-state.test.ts b/test/external-state.test.ts index 874dbacee..7361c32b0 100644 --- a/test/external-state.test.ts +++ b/test/external-state.test.ts @@ -248,6 +248,30 @@ describe('runExternalize / runInternalize', () => { expect(readFileSync(path.join(squadDir, 'log', 'session.md'), 'utf-8')).toBe('## Session 1\n'); }); + // T4b: binary files survive externalize → internalize byte-for-byte (#1489) + it('preserves non-UTF-8 binary files across the round-trip', () => { + // Every byte value 0x00–0xFF: invalid as UTF-8, so a string round-trip + // would replace bytes >= 0x80 with U+FFFD and change the length. + const original = Buffer.alloc(256); + for (let i = 0; i < 256; i++) original[i] = i; + const assetsDir = path.join(squadDir, 'assets'); + mkdirSync(assetsDir, { recursive: true }); + writeFileSync(path.join(assetsDir, 'diagram.png'), original); + + runExternalize(projectDir); + + // The externalized copy must be identical bytes, not a lossy decode. + const key = deriveProjectKey(projectDir); + const externalDir = resolveExternalStateDir(key, false); + const externalized = readFileSync(path.join(externalDir, 'assets', 'diagram.png')); + expect(externalized.equals(original)).toBe(true); + + runInternalize(projectDir); + + const restored = readFileSync(path.join(squadDir, 'assets', 'diagram.png')); + expect(restored.equals(original)).toBe(true); + }); + // T5: runInternalize cleans config — external-state fields removed it('removes stateLocation/projectKey/teamRoot from config after internalize', () => { // Seed config with an extra field so config.json survives (not deleted)