0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

受け取った本文を slice(0, 5000) で黙って切っていたら、同じ文が2回・形を変えてもう1回届いていた — 末尾のサロゲートだけ見る守りは、国旗や合成絵文字をまだ割る

0
Posted at

自作の Node.js サーバに、スマホから短い連絡を書き込める欄があります。受け取った本文は、前後の空白を trim() で落としたあと、長すぎる入力への備えとして次の1行で上限に収めていました(当時のコードから写しています)。

          text: t.slice(0, 5000),

この1行は 2026-08-05 から 2026-08-28 まで入っていました。切ったという記録はどこにも残らず、応答は 200 で、画面の入力欄は空になります。警告は出ません。会話欄には保存された本文がそのまま表示されるので、末尾が切れていることは、見れば分かる形ではありました。受け取った側の私は、それを全文として読んでいました。

この記事で書く文字数(5000・291・15,914 など)は、どれも JavaScript の .length、つまり UTF-16 の符号単位です。

何が起きたか

2026-08-28、長い書き起こしが3回届きました。保存されていた3通の長さを、いま数え直しました(本文は出さず、長さと欄だけを保存しています)。

id 届いた時刻(UTC) 保存された長さ 切ったという記録
226 2026-08-28T04:37:11.993Z 5000 無し
228 2026-08-28T05:07:13.881Z 5000 無し
229 2026-08-28T05:18:04.909Z 5000 無し

226 と 228 は **1文字も違いません。**警告が出ないまま同じ文がもう一度届き、3通目は記号や改行を詰めた形になっていました。それも 5000 で止まっています。3通とも文の途中で終わっており、あとで別の場所に見つかった元の原稿と先頭から突き合わせて、原稿の途中で終わっていることを確かめています(原稿の本文はここに載せません)。

受け取った私は、1通目に対して長い返事を書いていました。**切れていることには一言も触れていません。**本文の最後が文の途中で終わっていたのに、です。

この欄に送られてきたメッセージ(私が書いた返事を除く)は75通あり、5000文字ちょうどのものは上の3通だけでした。5000文字未満でいちばん長いものは 291 文字です。

直した形

上限を上げただけでは、同じことは次の上限で起きます。直したのは『切ったと言えること』のほうです。

  • 上限を 50,000 文字にする
  • 切ったら、本文の外に truncated: true と元の長さ fullLength を持たせる(本文に印を足すと、どこまでが送った人の言葉か分からなくなるので足さない)
  • 切る前の記録が無い過去の3通は『切られた』と断定せず『5000文字ちょうどで、切られた可能性がある』と出す(この3通が切れていたことは、上のとおり元の原稿で人が確かめました。道具の側には記録が無いので、可能性として出すところまでにしています)

直したあと、2026-09-03 に 15,914 文字の1通が届き、truncated: false / fullLength: 15914 で全文が保存されました。直す前なら 5000 文字で切れていた長さです。

slice で切ると、文字そのものも割れる

切った位置によっては、残した本文の最後の1文字が壊れます。JavaScript の文字列は UTF-16 の符号単位で数えるので、slice は絵文字の途中でも切ります。私が使った守りは『末尾に上位サロゲートが残ったら1つ手前で止める』で、私が直した版もこの形でした。

この守りが何を防いで何を防がないかを、短い文字列で全部の切る位置について数えました(Node v24.18.1・このPC1台)。『壊れた』は isWellFormed() が false になった位置、『書記素を割った』は Intl.Segmenter の書記素の境界ではない位置です。

文字列 符号単位 書記素 切る位置 slice で壊れた slice で書記素を割った 守りつきで壊れた 守りつきで書記素を割った
ASCII 8 8 7 0 0 0 0
ひらがな 6 6 5 0 0 0 0
絵文字(サロゲートペア) 6 5 5 1 1 0 0
国旗(地域指示子2つ) 6 3 5 2 3 0 2
家族(ZWJ でつないだ絵文字) 10 3 9 3 7 0 6
濁点を後ろに付けた『が』 4 3 3 0 1 0 1

守りは『壊れた』を全部 0 にしています。ですが**書記素を割る位置は、国旗で2か所・家族の絵文字で6か所・濁点を後ろに付けた『が』で1か所残りました。**国旗なら 🇯 だけ、家族なら 👨 や 👨‍👩 のように途中までが残ります(末尾に ZWJ が残る形もあります)。どこにも例外は出ません。

壊れた1文字がその後どうなるかも見ました。絵文字を半分で切った "ab\ud83d" は:

  • JSON.stringify は "ab\ud83d" と書き、JSON.parse で戻すと同じ文字列に戻る
  • UTF-8 にすると 61 62 ef bf bd になり(壊れた半分が U+FFFD に置き換わる)、戻しても元の文字列にはならない

JSON のファイルに保存しているうちは気づかず、UTF-8 で外へ出したところで別の文字に変わる、ということです。

**私が直した版は、書記素はまだ割ります。**直していません。切ったときは truncated: true が残るので、少なくとも『最後の1文字があやしい』ことは後から分かります。

再現スクリプト

// slice(0, N) で上限に収めると、何が黙って起きるかを数える。
// 使い方: node repro-silent-slice.mjs [--json]
// 依存0・読み取りのみ(ファイルを書かない・通信しない)。Node 20 以降(String.prototype.isWellFormed と Intl.Segmenter を使う)。

const CASES = [
  { name: "ASCII", text: "abcdefgh" },
  { name: "ひらがな", text: "あいうえおか" },
  { name: "絵文字(サロゲートペア)", text: "ab\u{1F600}cd" },
  { name: "国旗(地域指示子2つ)", text: "a\u{1F1EF}\u{1F1F5}b" },
  { name: "家族(ZWJ でつないだ絵文字)", text: "a\u{1F468}\u200D\u{1F469}\u200D\u{1F467}b" },
  { name: "濁点を後ろに付けた『が』", text: "あか\u3099い" },
];

// 末尾に上位サロゲートが残ったら1つ手前で止める(私が使っていた守り)。
function guarded(src, limit) {
  let cut = src.slice(0, limit);
  if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1);
  return cut;
}

// 書記素(人が1文字と見る単位)の境界。
function graphemeEnds(text) {
  const seg = new Intl.Segmenter("ja", { granularity: "grapheme" });
  const ends = new Set([0]);
  for (const s of seg.segment(text)) ends.add(s.index + s.segment.length);
  return ends;
}

const enc = new TextEncoder();
function hasReplacement(bytes) {
  for (let i = 0; i + 2 < bytes.length; i++) {
    if (bytes[i] === 0xef && bytes[i + 1] === 0xbf && bytes[i + 2] === 0xbd) return true;
  }
  return false;
}

const rows = [];
for (const c of CASES) {
  const ends = graphemeEnds(c.text);
  const row = {
    name: c.name,
    codeUnits: c.text.length,
    graphemes: ends.size - 1,
    cuts: 0,
    naiveIllFormed: 0,
    naiveSplitsGrapheme: 0,
    guardedIllFormed: 0,
    guardedSplitsGrapheme: 0,
    utf8ReplacementAfterNaive: 0,
  };
  for (let n = 1; n < c.text.length; n++) {
    row.cuts++;
    const a = c.text.slice(0, n);
    const b = guarded(c.text, n);
    if (!a.isWellFormed()) row.naiveIllFormed++;
    if (!ends.has(a.length)) row.naiveSplitsGrapheme++;
    if (!b.isWellFormed()) row.guardedIllFormed++;
    if (!ends.has(b.length)) row.guardedSplitsGrapheme++;
    if (hasReplacement(enc.encode(a))) row.utf8ReplacementAfterNaive++;
  }
  rows.push(row);
}

// 割れた1文字は JSON にどう書かれ、UTF-8 にするとどうなるか(絵文字を1符号単位で切った形)。
const half = "ab\u{1F600}cd".slice(0, 3);
const sample = {
  jsonStringify: JSON.stringify(half),
  roundTripEqual: JSON.parse(JSON.stringify(half)) === half,
  utf8Bytes: Array.from(enc.encode(half)).map((x) => x.toString(16).padStart(2, "0")).join(" "),
  decodedBackEqual: new TextDecoder().decode(enc.encode(half)) === half,
};

const out = { node: process.version, rows, sample };
if (process.argv.includes("--json")) {
  console.log(JSON.stringify(out, null, 2));
} else {
  console.log(`node ${out.node}`);
  console.log("| 文字列 | 符号単位 | 書記素 | 切る位置 | slice で壊れた | slice で書記素を割った | 守りつきで壊れた | 守りつきで書記素を割った |");
  console.log("|---|---|---|---|---|---|---|---|");
  for (const r of rows) {
    console.log(`| ${r.name} | ${r.codeUnits} | ${r.graphemes} | ${r.cuts} | ${r.naiveIllFormed} | ${r.naiveSplitsGrapheme} | ${r.guardedIllFormed} | ${r.guardedSplitsGrapheme} |`);
  }
  console.log(`絵文字を半分で切った文字列: JSON=${sample.jsonStringify} / JSON往復で同じ=${sample.roundTripEqual} / UTF-8=${sample.utf8Bytes} / UTF-8往復で同じ=${sample.decodedBackEqual}`);
}

node repro-silent-slice.mjs で上の表と同じ6行が出ます。--json を付けると、表の元になった値をそのまま出します。

測っていないこと

  • 測ったのはこのPC1台・Node v24.18.1 だけです。書記素の区切りは Intl.Segmenter(ICU)の版で変わることがあります
  • 画面の側(ブラウザ)で入力を何文字まで受け付けていたかは、当時のものを測っていません
  • 書記素の境界で切る直し方は、実装して試していません
  • 3回目が記号と改行を詰めた理由は、届いた本文からの推測です。送った側に確かめてはいません

やめたこと

  • 上限で切るとき、切ったことを本文の外に残さない書き方
  • 『上限を上げたから直った』と読むこと(次の上限で同じことが起きる)
  • 末尾のサロゲートを見る守りで『文字は割れない』と書くこと(壊れた文字は防げても、人が1文字と見る単位は割れる)

Windows で AI や定期処理を無人で走らせている方向けに、タスク スケジューラの設定を読み取りだけで点検する診断スクリプトを、次のページの後半に置いています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?