From 26df04687128c7f97da8aa1440e549125a455ce6 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Wed, 5 Aug 2026 10:40:20 +0900 Subject: [PATCH] =?UTF-8?q?UTF-8=20=EC=9D=B4=20=EC=95=84=EB=8B=8C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EC=97=B4=EC=96=B4=EC=84=9C=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=EC=9D=84=20=ED=8C=8C=EA=B4=B4=ED=95=98?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83=EC=9D=84=20=EB=A7=89=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readFile 이 무조건 fs.readFile(abs, "utf8") 이었다. UTF-8 이 아닌 파일은 디코딩에서 U+FFFD 로 바뀌고, 그 상태로 저장하면 원본이 사라진다. 되돌릴 방법이 없다. 재현했다. UTF-16 파일은 열고 Ctrl+S 만 눌러도 16바이트가 20바이트가 됐다. CP949 로 저장된 한글 파일은 `// �ȳ� ����` 로 뜨고, 한 글자만 고쳐 저장하면 파일 전체가 사라진다. 윈도우에서 아주 흔한 인코딩이다. electron/encoding.cjs 에 순수 판정을 두고(BOM·NUL·UTF-8 왕복 비교), readFile 이 아니라고 판단하면 열지 않고 이유를 돌려준다. 렌더러는 그 코드를 4개국어 안내 문구로 바꿔 편집기 자리에 띄운다 — 왜 안 열리는지 말해 주지 않으면 그냥 고장 난 편집기로 보인다. 다른 인코딩을 읽어 주는 것은 기능이고, 조용히 부수지 않는 것은 그 전에 지켜야 할 일이다. 실제 앱 검증 7/7 (고치기 전 2/5), 단위 954개 통과. --- ide/electron/encoding.cjs | 42 +++++++++++++++++++++++ ide/electron/encoding.d.cts | 5 +++ ide/electron/main.cjs | 9 ++++- ide/src/editor/MonacoPane.tsx | 14 +++++++- ide/src/engine/encoding.test.ts | 59 +++++++++++++++++++++++++++++++++ ide/src/i18n/dict/enc.ts | 9 +++++ ide/src/i18n/messages.ts | 3 +- 7 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 ide/electron/encoding.cjs create mode 100644 ide/electron/encoding.d.cts create mode 100644 ide/src/engine/encoding.test.ts create mode 100644 ide/src/i18n/dict/enc.ts diff --git a/ide/electron/encoding.cjs b/ide/electron/encoding.cjs new file mode 100644 index 0000000..2dcab3a --- /dev/null +++ b/ide/electron/encoding.cjs @@ -0,0 +1,42 @@ +// 이 바이트들을 텍스트 편집기에 실어도 되는가 — **순수한 판정**. +// +// readFile 이 무조건 fs.readFile(abs, "utf8") 이었다. UTF-8 이 아닌 파일은 디코딩에서 +// 깨진 글자(U+FFFD)로 바뀌고, 그 상태로 저장하면 **원본이 파괴된다.** 되돌릴 방법이 +// 없다. +// +// 실제로 재현했다. UTF-16 파일은 열고 Ctrl+S 만 눌러도 16바이트가 20바이트가 됐다. +// CP949 로 저장된 한글 파일은 화면에 `// �ȳ� ����` 로 뜨고, 거기서 한 글자만 고쳐 +// 저장하면 파일 전체가 사라진다. 윈도우에서 아주 흔한 인코딩이다. +// +// 다른 인코딩을 **읽어 주는 것**은 기능이고, 조용히 부수지 않는 것은 그 전에 지켜야 +// 할 일이다. 그래서 지금은 판정만 하고, 아니면 열지 않고 이유를 말한다. + +/** + * @returns null 이면 UTF-8 텍스트로 안전하다. 아니면 왜 아닌지. + * "utf16le" | "utf16be" — 바이트 순서 표식이 붙은 UTF-16 + * "binary" — NUL 바이트가 있다(텍스트가 아니다) + * "not-utf8" — UTF-8 로 해석되지 않는 바이트가 있다(CP949·Shift-JIS·Latin-1 …) + */ +function detect(buf) { + if (!buf || typeof buf.length !== "number") return null; + if (buf.length >= 2 && buf[0] === 0xFF && buf[1] === 0xFE) return "utf16le"; + if (buf.length >= 2 && buf[0] === 0xFE && buf[1] === 0xFF) return "utf16be"; + // NUL 은 텍스트 파일에 있을 이유가 없다. UTF-16 은 위에서 걸렀으므로 여기 오는 + // NUL 은 진짜 바이너리다(확장자만 텍스트인 파일이 흔하다). + if (buf.indexOf(0) >= 0) return "binary"; + // 왕복시켜 본다 — 디코딩이 손실 없이 됐다면 다시 인코딩한 바이트가 같아야 한다. + // 깨진 바이트는 U+FFFD 로 바뀌므로 길이부터 달라진다. + const round = Buffer.from(buf.toString("utf8"), "utf8"); + return round.equals(buf) ? null : "not-utf8"; +} + +/** IPC 로 넘길 오류 메시지. 렌더러가 앞부분을 보고 안내 문구를 고른다. */ +const PREFIX = "SCHUTZ_ENCODING:"; +function errorFor(kind) { return PREFIX + kind; } +function kindOf(message) { + const s = String(message || ""); + const i = s.indexOf(PREFIX); + return i < 0 ? null : s.slice(i + PREFIX.length).trim().split(/\s/)[0]; +} + +module.exports = { detect, errorFor, kindOf, PREFIX }; diff --git a/ide/electron/encoding.d.cts b/ide/electron/encoding.d.cts new file mode 100644 index 0000000..ecdb8b6 --- /dev/null +++ b/ide/electron/encoding.d.cts @@ -0,0 +1,5 @@ +// encoding.cjs 의 타입. 메인 프로세스 코드지만 판정 부분은 테스트에서 그대로 부른다. +export declare function detect(buf: Buffer | null | undefined): null | "utf16le" | "utf16be" | "binary" | "not-utf8"; +export declare function errorFor(kind: string): string; +export declare function kindOf(message: unknown): string | null; +export declare const PREFIX: string; diff --git a/ide/electron/main.cjs b/ide/electron/main.cjs index bf9e54c..1ba5284 100644 --- a/ide/electron/main.cjs +++ b/ide/electron/main.cjs @@ -285,13 +285,20 @@ ipcMain.handle("schutz:readTree", async (_e, root) => { return { root, name: path.basename(root), entries, branch, truncated: entries.length >= MAX_ENTRIES || depthCapped }; }); +const encoding = require("./encoding.cjs"); ipcMain.handle("schutz:readFile", async (_e, root, rel) => { const abs = safeJoin(root, rel); const st = await fs.stat(abs); if (st.size > MAX_FILE_BYTES) { throw new Error("파일이 너무 큽니다 (" + Math.round(st.size / 1024) + " KB)"); } - return await fs.readFile(abs, "utf8"); + // 바이트로 읽어 UTF-8 인지 먼저 본다. 예전엔 무조건 "utf8" 로 디코딩해서, UTF-8 이 + // 아닌 파일은 깨진 글자로 열리고 **저장하는 순간 원본이 파괴됐다.** 되돌릴 수 없다. + // (UTF-16 파일은 열고 Ctrl+S 만 눌러도 바이트가 바뀌는 것을 확인했다.) + const buf = await fs.readFile(abs); + const kind = encoding.detect(buf); + if (kind) throw new Error(encoding.errorFor(kind)); + return buf.toString("utf8"); }); /** 원자적 쓰기 — 같은 디렉터리에 임시 파일로 쓴 뒤 rename. 도중에 죽어도 반쪽짜리 파일이 남지 않는다. diff --git a/ide/src/editor/MonacoPane.tsx b/ide/src/editor/MonacoPane.tsx index b1152a8..eacbb91 100644 --- a/ide/src/editor/MonacoPane.tsx +++ b/ide/src/editor/MonacoPane.tsx @@ -5,6 +5,18 @@ import { getEditorPrefs, codeFontStack } from "../settings"; import * as projectModels from "./projectModels"; import { t } from "../i18n"; import { useLang } from "../i18n/useLang"; + +/** 메인이 "UTF-8 이 아니라 안 열었다" 고 답한 경우를 사람 말로 바꾼다. + * 왜 안 열리는지 말해 주지 않으면 그냥 고장 난 편집기로 보인다. */ +function encodingMessage(raw: string): string { + const mark = "SCHUTZ_ENCODING:"; + const i = raw.indexOf(mark); + if (i < 0) return raw; + const kind = raw.slice(i + mark.length).trim().split(/\s/)[0]; + if (kind === "binary") return t("enc.binary"); + if (kind === "utf16le" || kind === "utf16be") return t("enc.utf16"); + return t("enc.notUtf8"); +} // @ts-ignore — monaco-vim 타입 미제공 import { initVimMode } from "monaco-vim"; @@ -252,7 +264,7 @@ function MonacoPaneImpl({ root, rel, onDirtyChange, onSaved, onConfirm, onStatus }) .catch(e => { if (disposed) return; - setError(e instanceof Error ? e.message : String(e)); + setError(encodingMessage(e instanceof Error ? e.message : String(e))); setState("error"); }); } diff --git a/ide/src/engine/encoding.test.ts b/ide/src/engine/encoding.test.ts new file mode 100644 index 0000000..c3a783c --- /dev/null +++ b/ide/src/engine/encoding.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { detect, errorFor, kindOf, PREFIX } from "../../electron/encoding.cjs"; + +const U8 = (s: string) => Buffer.from(s, "utf8"); + +describe("detect", () => { + it("평범한 UTF-8 은 통과", () => { + expect(detect(U8("export const a = 1;\n"))).toBeNull(); + expect(detect(U8("한글도 UTF-8 이면 괜찮다\n"))).toBeNull(); + expect(detect(U8(""))).toBeNull(); + }); + + it("UTF-8 BOM 은 UTF-8 이다 — 표식이 있다고 막으면 안 된다", () => { + expect(detect(Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), U8("a\n")]))).toBeNull(); + }); + + it("UTF-16 은 바이트 순서 표식으로 알아본다", () => { + expect(detect(Buffer.concat([Buffer.from([0xFF, 0xFE]), Buffer.from("hi", "utf16le")]))).toBe("utf16le"); + expect(detect(Buffer.from([0xFE, 0xFF, 0x00, 0x68]))).toBe("utf16be"); + }); + + it("CP949 한글은 UTF-8 로 해석되지 않는다 — 열면 깨지고 저장하면 원본이 사라진다", () => { + // "안녕" (EUC-KR) + expect(detect(Buffer.from([0xBE, 0xC8, 0xB3, 0xE7]))).toBe("not-utf8"); + }); + + it("Latin-1 도 마찬가지", () => { + expect(detect(Buffer.from([0x63, 0x61, 0x66, 0xE9]))).toBe("not-utf8"); // café + }); + + it("NUL 이 있으면 텍스트가 아니다 — 확장자만 텍스트인 파일이 흔하다", () => { + expect(detect(Buffer.from([0x61, 0x00, 0x62]))).toBe("binary"); + }); + + it("UTF-16 을 바이너리보다 먼저 알아본다 — 둘 다 NUL 이 있다", () => { + expect(detect(Buffer.from([0xFF, 0xFE, 0x68, 0x00]))).toBe("utf16le"); + }); + + it("빈 값에도 던지지 않는다", () => { + expect(detect(null as any)).toBeNull(); + expect(detect(undefined as any)).toBeNull(); + }); +}); + +describe("errorFor / kindOf", () => { + it("왕복한다", () => { + expect(kindOf(errorFor("not-utf8"))).toBe("not-utf8"); + expect(kindOf(errorFor("utf16le"))).toBe("utf16le"); + }); + + it("Electron 이 앞에 덧붙여도 찾는다 — IPC 오류는 감싸여서 온다", () => { + expect(kindOf("Error invoking remote method 'schutz:readFile': Error: " + PREFIX + "binary")).toBe("binary"); + }); + + it("다른 오류는 null", () => { + expect(kindOf("파일이 너무 큽니다 (5000 KB)")).toBeNull(); + expect(kindOf(undefined)).toBeNull(); + }); +}); diff --git a/ide/src/i18n/dict/enc.ts b/ide/src/i18n/dict/enc.ts new file mode 100644 index 0000000..5dadbf8 --- /dev/null +++ b/ide/src/i18n/dict/enc.ts @@ -0,0 +1,9 @@ +// enc 도메인 — UTF-8 이 아닌 파일을 열려고 했을 때. +// +// 예전엔 무조건 UTF-8 로 디코딩해서 깨진 글자로 열렸고, 저장하는 순간 원본이 +// 파괴됐다. 이제 열지 않고 이유를 말한다 — 왜 안 되는지 모르면 고장으로 보인다. +export const dict: Record = { + "enc.notUtf8": { ko: "이 파일은 UTF-8 이 아닙니다. 열면 글자가 깨지고 저장하면 원본이 사라지므로 열지 않았습니다. UTF-8 로 변환한 뒤 다시 열어 주세요.", en: "This file is not UTF-8. Opening it would garble the text and saving would destroy the original, so it was not opened. Convert it to UTF-8 and try again.", de: "Diese Datei ist nicht UTF-8. Das Öffnen würde den Text zerstören und das Speichern das Original vernichten — sie wurde nicht geöffnet. Bitte in UTF-8 konvertieren.", ja: "このファイルは UTF-8 ではありません。開くと文字化けし、保存すると原本が失われるため開きませんでした。UTF-8 に変換してから開いてください。" }, + "enc.utf16": { ko: "이 파일은 UTF-16 입니다. 아직 UTF-8 만 다룰 수 있어 열지 않았습니다 — 열었다면 저장하는 순간 원본이 사라졌을 것입니다.", en: "This file is UTF-16. Only UTF-8 is supported, so it was not opened — saving it would have destroyed the original.", de: "Diese Datei ist UTF-16. Es wird nur UTF-8 unterstützt, daher wurde sie nicht geöffnet — beim Speichern wäre das Original verloren gegangen.", ja: "このファイルは UTF-16 です。現在 UTF-8 のみ対応のため開きませんでした — 保存していれば原本が失われていました。" }, + "enc.binary": { ko: "텍스트 파일이 아닙니다(NUL 바이트가 있습니다). 편집기로 열면 내용이 망가집니다.", en: "This is not a text file (it contains NUL bytes). Opening it in the editor would corrupt it.", de: "Das ist keine Textdatei (sie enthält NUL-Bytes). Im Editor zu öffnen würde sie beschädigen.", ja: "テキストファイルではありません(NUL バイトがあります)。エディターで開くと壊れます。" }, +}; diff --git a/ide/src/i18n/messages.ts b/ide/src/i18n/messages.ts index 840bfe7..b4595b7 100644 --- a/ide/src/i18n/messages.ts +++ b/ide/src/i18n/messages.ts @@ -16,6 +16,7 @@ import { dict as d_runfile } from "./dict/runfile"; import { dict as d_confirm } from "./dict/confirm"; import { dict as d_extask } from "./dict/extask"; import { dict as d_extview } from "./dict/extview"; +import { dict as d_enc } from "./dict/enc"; import { dict as d_flowtree } from "./dict/flowtree"; import { dict as d_dbg } from "./dict/dbg"; import { dict as d_mcpui } from "./dict/mcpui"; @@ -44,7 +45,7 @@ export type Msg = { ko: string; en: string; de: string; ja: string }; export const MESSAGES: Record = { ...d_dap, ...d_data, ...d_exth, ...d_mcpc, ...d_media, ...d_model, ...d_mono, ...d_oai, ...d_reg, ...d_key, - ...d_confirm, ...d_extask, ...d_extview, ...d_runfile, ...d_gitp, ...d_flowtree, ...d_dbg, ...d_mcpui, ...d_modal, ...d_cmds, ...d_palette, ...d_extd, ...d_misc, ...d_chat2, ...d_engine, ...d_tour, ...d_open, ...d_mode, ...d_cliimp, + ...d_confirm, ...d_extask, ...d_extview, ...d_enc, ...d_runfile, ...d_gitp, ...d_flowtree, ...d_dbg, ...d_mcpui, ...d_modal, ...d_cmds, ...d_palette, ...d_extd, ...d_misc, ...d_chat2, ...d_engine, ...d_tour, ...d_open, ...d_mode, ...d_cliimp, ...d_sc1, ...d_sc2, ...d_sc3, ...d_sc4, ...d_sc5, ...d_eng, ...d_plug, ...d_review, ...d_cloud, // ── 공통 ──────────────────────────────────────────────── "common.next": { ko: "다음", en: "Next", de: "Weiter", ja: "次へ" },