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?

別タブからの上書きで失われたlocalStorageをLevelDBとNTFSの空き領域から復旧した話

0
Posted at

はじめに

サーバを持たない静的なWebアプリ——ブラウザだけで動き、データを丸ごと localStorage に置く類のデータ管理ツールを使っていました。一覧に項目を1件ずつ登録していくもので、その日は54件まで育てていました。

これを2つのタブで開いていたのが事故のはじまりです。作業していたのはタブAで、54件まで積み上げていました。ところが、19件だった頃の状態を抱えたまま放置していたタブBで2件ほど追加して保存したところ、一覧は21件に巻き戻りました。タブAで積み上げた35件が丸ごと消えたわけです。

原因はアプリの実装がごく普通の

localStorage.setItem(KEY, JSON.stringify(wholeList));

だったことです。差分更新ではなく毎回リスト全体を書くので、タブ間は素朴な last-write-wins になります。仕様どおりの挙動であって、バグですらありません。

この記事は、そこから

  1. Chromium系ブラウザの localStorage がディスク上でどう保存されているかを読み解き
  2. なぜ素朴な grep では見つからないのかを突き止め
  3. LevelDB のコンパクションによって履歴が消えていた事実を確認し
  4. NTFSの未割り当て領域から削除済みファイルの残骸を回収して、最終的に上書き直前の状態を復元した

までの記録です。外部ツールに頼らず、依存関係ゼロの純Pythonで snappy / SST / WAL / MANIFEST / NTFS を自前パースしています。

この記事での呼び方: 実アプリを特定しない形にするため、localStorage のキー名は AppItemList、オリジンは https://example.github.io に置き換えています。一覧の1件をエントリ、エントリを直列化した12文字前後のBase64風文字列をトークンと呼びます。localStorage に入る値の実体は「トークンのJSON配列」です。このトークンが圧縮の効かない文字列であるという性質が、後半で決め手になります。

前提: 自分が管理する自分のPC・自分のデータが対象です。他人の端末や他人のデータに対して同じことをしてはいけません。


TL;DR

  • Chromiumの localStorage は Local Storage/leveldb/LevelDB として保存される
  • grep 'キー名' が当たらないのは Snappy圧縮LevelDBのprefix圧縮の合わせ技のため
  • 過去バージョンは残り得るが、コンパクションが走ると(snapshotに保護されていない限り)最新版以外は捨てられる
  • 捨てられた後でも、削除されたSST/WALの実体がNTFSの空きクラスタに残っていることがある
  • 値が Base64 のような圧縮の効きにくい文字列だと、Snappy圧縮後も平文のまま残ることがある。今回はこれがシグネチャ検索の突破口になった
  • 結果として、16世代分のスナップショットを回収し、上書き直前の状態を完全復元できた

1. Chromium の localStorage のディスク表現

Braveを含むChromium系ブラウザは、localStorage をプロファイル配下の LevelDB に保存しています。

C:\Users\<user>\AppData\Local\BraveSoftware\Brave-Browser\User Data\Default\Local Storage\leveldb\

中身はこんな構成です。

000711.ldb      3844196   ← SST (Sorted String Table)。実データ
012966.ldb      2715316
019024.log        40621   ← WAL (write-ahead log)。memtableの永続化用
019026.ldb      5603194
CURRENT              16   ← 現在のMANIFESTを指す
LOCK                  0
LOG              194164   ← 人間可読の動作ログ。今回の主役
MANIFEST-000001 1686329   ← ファイル構成の変更履歴

キーと値のエンコーディング

LevelDB自体は単なるバイト列のKVSなので、Chromiumはその上に独自のレイアウトを載せています。

LevelDBキー 意味
VERSION DBバージョン
META:<origin> オリジンごとのメタデータ
_<origin>\x00\x01<キー名> 実際の localStorage レコード

値とキー名の文字列には先頭1バイトのエンコーディング識別子が付きます。

  • 0x01 … Latin1(1文字1バイト)
  • 0x00 … UTF-16LE

つまり https://example.github.ioAppItemList というキーは、生バイト列としては

5f 68 74 74 70 73 3a 2f 2f ... 2e 69 6f 00 01 41 70 70 49 74 65 6d 4c 69 73 74
 _  h  t  t  p  s  :  /  /       .  i  o    ␀ ␁  A  p  p  I  t  e  m  L  i  s  t

となります。

これを踏まえたデコーダはこれだけです。

def decode_ls_key(key: bytes):
    """_<origin>\x00\x01<script_key> を (origin, key) に分解する"""
    if not key.startswith(b"_"):
        return None
    sep = key.find(b"\x00")
    if sep == -1:
        return None
    origin = key[1:sep].decode("utf-8", "replace")
    return origin, decode_ls_string(key[sep + 1:])


def decode_ls_string(buf: bytes) -> str:
    """先頭バイトが 0x00=UTF-16LE / 0x01=Latin1"""
    if not buf:
        return ""
    if buf[0] == 0x01:
        return buf[1:].decode("latin-1")
    if buf[0] == 0x00:
        return buf[1:].decode("utf-16-le", "replace")
    return buf.decode("utf-8", "replace")

2. なぜ grep で見つからないのか

まずやったのは、バックアップした .ldb / .log に対する素朴な検索でした。

$ grep -a -c 'AppItemList' *.ldb *.log
# → 全ファイル 0 ヒット

理由は2つあり、片方だけ潰しても当たりません

2-1. Snappy圧縮

SSTのデータブロックは既定でSnappy圧縮されています。まずここで平文が消えます。

2-2. LevelDBのprefix圧縮(こちらが厄介)

展開してもまだ当たりません。 LevelDBはブロック内で直前のキーとの共通接頭辞を省略するからです。

同じオリジンのキーはソート順で隣接するため、たとえば

_https://example.github.io\x00\x01AppConfig
_https://example.github.io\x00\x01AppItemList     ← 目的のキー
_https://example.github.io\x00\x01AppState

のように並びます。AppConfig の次に AppItemList が来ると、共通接頭辞 App が省略され、ブロックには ItemList しか入りません。したがって展開後のバイト列を探しても AppItemList という連続した文字列が見つからないわけです。

ただし、これは「絶対に存在しない」という意味ではありません。data blockには一定間隔で restart point が置かれ、そこだけは shared=0、つまりキー全体がそのまま格納されるからです(既定では16エントリごと)。目的のキーがたまたまrestart pointに当たっていれば、完全な形で見つかり得ます。

つまりこれは確率の問題であって、フォーマット上の保証ではありません。今回はこうなりました。

全ファイルを展開後に検索 → "AppItemList" は 0 ヒット

運が悪かった、とも言えます。しかしいずれにせよキー名を頼りにSSTを探す方針は当てにならないと分かったのが収穫でした。

2-3. ところが「値」は平文で残る

一方、値の中身は素直に出てきます

019027.ldb の生バイト列(未展開)に対して:
  "AppItemList"  → 0 ヒット
  値に含まれるトークン文字列 → 1 ヒット

Snappyは繰り返しを後方参照(copyタグ)に置き換える圧縮です。Base64のような繰り返しの乏しい文字列は圧縮しようがなく、literalとしてそのまま出力されるため、圧縮ブロックの中でも平文で読めてしまいます。

念のため補足すると、これも保証ではありません。トークン内に一致が見つかればcopyタグに置き換わりますし、そもそもLevelDBは圧縮しても十分小さくならないブロックを非圧縮のまま保存するので、「まずSnappyで平文が消える」自体が常に成り立つわけでもありません。今回は実測でトークンが平文のまま残っていた、というのが正確なところです。

とはいえ、キー名は消えるのに値は残るというこの非対称性が、後半のディスクカービングで決定的に効いてきます。


3. 依存ゼロでLevelDBを読む

dfindexeddb などのツールもありますが、環境構築でつまずくのが嫌だったので純Pythonで書きました。全部で300行程度です。

読み飛ばしガイド: この章は各フォーマットの実装詳細です。**「で、履歴はどこへ消えたのか」を先に知りたい方は §4 へ飛んでください。**話の筋は途切れません。

3-1. Snappy展開

生Snappyフォーマット(フレーミングなし)は、varintで展開後サイズ、続いてタグストリームです。

def snappy_decompress(data: bytes) -> bytes:
    ulen, pos = get_varint32(data, 0)
    out = bytearray()
    n = len(data)
    while pos < n:
        tag = data[pos]
        t = tag & 0x03
        if t == 0:                                  # literal
            ln = tag >> 2
            pos += 1
            if ln >= 60:                            # 長さが後続バイトに入る
                extra = ln - 59
                ln = int.from_bytes(data[pos:pos + extra], "little")
                pos += extra
            ln += 1
            out += data[pos:pos + ln]
            pos += ln
        else:                                       # copy
            if t == 1:
                ln = 4 + ((tag >> 2) & 0x07)
                off = ((tag >> 5) << 8) | data[pos + 1]
                pos += 2
            elif t == 2:
                ln = (tag >> 2) + 1
                off = int.from_bytes(data[pos + 1:pos + 3], "little")
                pos += 3
            else:
                ln = (tag >> 2) + 1
                off = int.from_bytes(data[pos + 1:pos + 5], "little")
                pos += 5
            start = len(out) - off
            if off >= ln:
                out += out[start:start + ln]
            else:                                   # 重なりコピー
                for i in range(ln):
                    out.append(out[start + i])
    assert len(out) == ulen
    return bytes(out)

重なりコピー(off < ln)の処理を忘れると、繰り返しパターンで壊れるので注意。

3-2. SST (.ldb) パーサ

末尾48バイトのFooterから辿ります。マジックは 0xdb4775248b80fb57

MAGIC = 0xDB4775248B80FB57

def parse_sst(path):
    size = os.path.getsize(path)
    with open(path, "rb") as f:
        f.seek(size - 48)
        footer = f.read(48)
        assert struct.unpack_from("<Q", footer, 40)[0] == MAGIC

        p = 0
        _mi_off, p = get_varint64(footer, p)   # metaindex handle
        _mi_size, p = get_varint64(footer, p)
        idx_off, p = get_varint64(footer, p)   # index handle
        idx_size, p = get_varint64(footer, p)

        index_block, _ = read_block(f, idx_off, idx_size)
        for _k, handle in parse_block_entries(index_block):
            boff, bsize = parse_block_handle(handle)
            block, ctype = read_block(f, boff, bsize)
            for ikey, val in parse_block_entries(block):
                # internal key = user_key + 8バイト(seq<<8 | type)
                trailer = struct.unpack_from("<Q", ikey, len(ikey) - 8)[0]
                yield {
                    "user_key": ikey[:-8],
                    "seq": trailer >> 8,
                    "type": {0: "deletion", 1: "value"}.get(trailer & 0xFF),
                    "value": val,
                }

ブロックのエントリはprefix圧縮されているので、直前キーを保持しながら復元します。

def parse_block_entries(block: bytes):
    num_restarts = struct.unpack_from("<I", block, len(block) - 4)[0]
    restart_ofs = len(block) - 4 - num_restarts * 4
    pos, last_key = 0, b""
    while pos < restart_ofs:
        shared, pos = get_varint32(block, pos)
        non_shared, pos = get_varint32(block, pos)
        vlen, pos = get_varint32(block, pos)
        key = last_key[:shared] + block[pos:pos + non_shared]   # ← prefix復元
        pos += non_shared
        value = block[pos:pos + vlen]
        pos += vlen
        last_key = key
        yield key, value

ここで取れる sequence number が後の時系列復元に効きます。

3-3. WAL (.log) パーサ

WALは 32 KiB ブロックに、crc(4) + length(2) + type(1) のヘッダを持つ物理レコードが並びます。typeは FULL / FIRST / MIDDLE / LAST で、断片を再結合するとWriteBatchになります。

BLOCK_SIZE = 32768

def read_log_payloads(path):
    data = open(path, "rb").read()
    pos, pending = 0, bytearray()
    while pos + 7 <= len(data):
        if BLOCK_SIZE - (pos % BLOCK_SIZE) < 7:      # ブロック末尾のパディング
            pos += BLOCK_SIZE - (pos % BLOCK_SIZE)
            continue
        _crc, length, rtype = struct.unpack_from("<IHB", data, pos)
        pos += 7
        if rtype == 0 and length == 0:               # ゼロ埋め → 次ブロックへ
            pos = (pos // BLOCK_SIZE + 1) * BLOCK_SIZE
            continue
        payload = data[pos:pos + length]
        pos += length
        if rtype == 1:      yield bytes(payload)             # FULL
        elif rtype == 2:    pending = bytearray(payload)     # FIRST
        elif rtype == 3:    pending += payload               # MIDDLE
        elif rtype == 4:                                     # LAST
            pending += payload
            yield bytes(pending)
            pending = bytearray()

WriteBatchは seq(8) + count(4) のあとに tag(1) + varint長キー [+ varint長値] が並ぶだけです。

重要: WALは無圧縮です。つまりキーも値も平文。これは後で効きます。

なお、このパーサは crc を読み飛ばしています。正常に存在しているWALを読むだけなら実害はありませんが、後半のようにディスクの生バイト列を相手にするなら話が別で、ランダムなバイト列がたまたまヘッダの形に見えることがあります。カービングに使うならCRC32C(masked)の検証と、ブロック境界・断片状態の整合チェックを入れてください。


4. 診断 —— 履歴はどこへ消えたか

この章は2段構えです。①なぜ旧バージョンが消えたのかを突き止め、そのうえで②消えたデータがどのファイルに入っていたのかを特定します。②まで分かってはじめて、次章のディスクカービングで「何を、どういう形で探せばいいか」が決まります。

パーサができたので全ファイルを走査しました。結果は無情でした。

000711.ldb:  291 records,    0 hits
012966.ldb:  493 records,    0 hits
019026.ldb: 2598 records,    0 hits
019027.ldb:  569 records,    1 hits   ← 現在値のみ
019024.log:   56 records,    0 hits

目的の AppItemList は1バージョンしか存在しません。 しかも他のキーには旧バージョンが残っているのに、です。

キー 旧バージョン 新バージョン
AppConfig 012966.ldb seq=265042 019027.ldb seq=393483
AppState 012966.ldb seq=265059 019027.ldb seq=393481
AppItemList なし 019027.ldb seq=393465

網羅性の確認

「パーサが取りこぼしているのでは?」を潰すため、ファイルの全バイトを構造で説明できるかを確認しました。index blockが参照する全data block、metaindex block、filter block、末尾48バイトのFooter、そして各blockの5バイトtrailer(圧縮種別1 + CRC4)を合計し、ファイルサイズと突き合わせます。

019027.ldb  size=2950635  blocks=27  covered=2950635  (100.0%)

全ファイルで一致。どのバイトも既知の構造に属しており、indexから参照されない孤立領域は存在しないと確認できました。

(厳密には「バイト範囲を説明できた」ことと「全エントリを正しくデコードできた」ことは別問題です。後者はエントリ境界とrestart arrayが破綻なく末尾まで読み切れたことで担保しています。)

犯人はコンパクション

LevelDBの LOG に決定的な記録がありました。

09:05:49  Level-0 table #19021: 57631 bytes OK
09:11:11  Delete type=0 #19020                     ← WAL削除
09:15:42  Compacting 4@0 + 3@1 files
09:15:42  Generated table #19026@0: 2598 keys
09:15:42  Generated table #19027@0: 569 keys
09:15:42  Delete type=2 #19015 / #19016 / #19017
09:15:42  Delete type=2 #19019 / #19021 / #19023 / #19025

コンパクションは同一キーについて不要になった旧バージョンを捨て、入力ファイルを削除します。 正確には、生存中のsnapshotから見えるバージョンは保持されるのですが、localStorageの読み書きは都度完結するのでsnapshotは残りません。結果として最新版だけが生き残ります。

バックアップを取ったのはこの8分後。手遅れでした。

MANIFESTでseqと時刻を対応づける

MANIFESTのVersionEditには、生成されたSSTのファイル番号last_sequence が記録されています。LOG にも同じファイル番号がタイムスタンプ付きで現れるので、ファイル番号をキーに両者を突き合わせると、sequence number → 時刻の対応が復元できます。

TAG_LAST_SEQUENCE, TAG_NEW_FILE, TAG_DELETED_FILE = 4, 7, 6

def parse_edit(buf):
    edit, pos = {"new_files": [], "deleted_files": []}, 0
    while pos < len(buf):
        tag, pos = get_varint32(buf, pos)
        if tag == TAG_LAST_SEQUENCE:
            edit["last_sequence"], pos = get_varint64(buf, pos)
        elif tag == TAG_NEW_FILE:
            lvl, pos = get_varint32(buf, pos)
            num, pos = get_varint64(buf, pos)
            size, pos = get_varint64(buf, pos)
            smallest, pos = get_slice(buf, pos)
            largest, pos = get_slice(buf, pos)
            edit["new_files"].append({"level": lvl, "number": num, "size": size})
        # ... 他のタグ
    return edit

これで分かったこと。

時刻 flush先 seq範囲
08:56:00 L0 #19019 393337–393410
09:05:49 L0 #19021 (57 KB) 393411–393513
09:11:11 L0 #19023 393514–393540
09:15:42 L0 #19025 393541–393573

ここで注意したいのは、seq=393465 は「失われたデータ」ではなく「上書きしてしまった書き込み」だということです。019027.ldb に唯一生き残っていた21件の値がこれにあたります。その seq が #19021 の範囲(393411–393513)に収まっている——つまり事故の瞬間の書き込みは #19021 に flush されていました。そして#19021は削除済みです。

ひとつ断っておくと、この seq 範囲は VersionEdit の last_sequence の差分から引いたものです。last_sequenceLogAndApply 時点のDB全体の値なので、そのSSTが実際に含む範囲はこれより狭いことがあります。厳密に確定するなら new_file レコードの smallest / largest internal key を見るべきでした。ただし #19019〜#19025 はどれも同じコンパクションでまとめて削除されているので、どのファイルに入っていたにせよ「削除済みのL0ファイルの中」であることは変わりません。以降はその前提で進みます。

探しているのはその直前のバージョンですが、ここでLevelDBの性質が効いてきます。

memtableのflushは、同一キーの全バージョンをそのままSSTに書き出す。

memtableは (user_key, seq) をキーにしたskiplistなので、同じキーの複数バージョンが共存します。flush時に重複排除は行われません。つまり**#19021には上書き前と上書き後の両方が入っていた**可能性が高い。

ここまでを1枚にまとめると、こうなります。


5. NTFSの空き領域から削除済みファイルを拾う

ここで諦めかけたのですが、削除されたのはファイルシステム上の管理情報であって、クラスタの中身は残っている可能性があることを思い出しました。

この章でやることを先に示しておきます。939 GB のディスクから、最終的に16世代のスナップショットに行き着くまでの絞り込みです。

ここから先を試す前に: 生ボリュームを読む作業は、手順を間違えると復旧対象そのものを潰します。最低限、①ボリュームは読み取り専用でのみ開く、②カービング結果の出力先を復旧対象と同じドライブにしない、③原本には一切書かない、の3点を守ってください。詳細は §9 にまとめています。

5-1. まずシステムの復元を確認(不発)

Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore'
# RPSessionInterval : 0   → システムの保護は無効

Windows 11ではシステムの保護が既定で無効なことが多く、復元ポイントからの復旧は期待できません。

なお、これで確認できるのは「System Restoreによる復元ポイントがない」ことだけです。シャドウコピー自体はバックアップソフトなど他の経路でも作られるので、本来は vssadmin list shadows /for=C: で実体を列挙すべきでした(今回は未確認のまま次へ進んでいます)。

5-2. Phase 1: $MFT から削除ファイルを直接復元する(不発)

NTFSのMFTレコードには、削除後もファイル名とデータランが残っていることがあります。残っていればクラスタを直接読めるので圧倒的に速い。

ブートセクタからMFTの位置を得て、レコードを総なめします。

class Volume:
    def __init__(self, path):
        self.fh = open(path, "rb", buffering=0)   # \\.\C: を読み取り専用で
        boot = self.fh.read(512)
        assert boot[3:11] == b"NTFS    "
        self.sector_size = struct.unpack_from("<H", boot, 0x0B)[0]
        spc = boot[0x0D]
        if spc > 0x80:                            # 負数表現なら 2^(256-spc)
            spc = 1 << (256 - spc)
        self.cluster_size = self.sector_size * spc
        self.mft_lcn = struct.unpack_from("<Q", boot, 0x30)[0]
        rec = struct.unpack_from("<b", boot, 0x40)[0]
        self.mft_record_size = rec * self.cluster_size if rec > 0 else 1 << (-rec)

注意点として、ブートセクタの mft_lcn が指すのは $MFT の先頭エクステントだけです。$MFT もまた1つのファイルであり、成長にともなって断片化します。742MBを連続領域として読むと途中から別物を読むことになるので、実際にはMFTレコード#0($MFT 自身)をまず読み、その $DATA のデータランを辿って全体を走査しています。

MFTレコードを読むときは fixup(update sequence array) の適用を忘れずに。各セクタ末尾2バイトが検証値に置き換えられているので、元に戻さないと壊れたデータを読みます。

def apply_fixup(rec, sector_size):
    usa_off, usa_count = struct.unpack_from("<HH", rec, 0x04)
    rec = bytearray(rec)
    usn = rec[usa_off:usa_off + 2]
    for i in range(usa_count - 1):
        pos = (i + 1) * sector_size - 2
        if bytes(rec[pos:pos + 2]) != bytes(usn):
            return None                    # 検証値が合わない = 壊れたレコード
        rec[pos:pos + 2] = rec[usa_off + 2 + i * 2: usa_off + 4 + i * 2]
    return bytes(rec)

削除済み判定は、レコードのフラグ(オフセット 0x16)のbit0が0であること。$FILE_NAME(0x30) から名前を、$DATA(0x80) からデータランを取り出します。

データランのデコードはこれだけです。

def decode_runs(buf, pos):
    runs, lcn = [], 0
    while pos < len(buf):
        header = buf[pos]; pos += 1
        if header == 0:
            break
        len_bytes, off_bytes = header & 0x0F, (header >> 4) & 0x0F
        length = int.from_bytes(buf[pos:pos + len_bytes], "little")
        pos += len_bytes
        if off_bytes == 0:                       # sparse
            runs.append((None, length)); continue
        off = int.from_bytes(buf[pos:pos + off_bytes], "little", signed=True)
        pos += off_bytes
        lcn += off                               # 前のLCNからの相対
        runs.append((lcn, length))
    return runs

結果:

[phase1] $MFT=742MB records=724224
[phase1] done: 724057 records, 26 deleted leveldb-like files, 0 with signatures

削除済みのLevelDBファイルは26個見つかったものの、全部が事故より後の世代。目的のファイルのMFTレコードは既に再利用されていました。

5-3. Phase 2: $Bitmap で未割り当てクラスタだけを舐める

ボリューム全体を舐めるのは無駄です。まだ再割り当てされていない旧クラスタは「未割り当て」状態のままなので、$Bitmap(MFTレコード#6)を読んでそこだけスキャンします。

(逆に言うと、これは探索範囲を意図的に狭める手法です。旧クラスタが既に別ファイルへ再割り当てされ、部分的にしか上書きされていない場合、残骸は割り当て済みクラスタの側に残ります。そこまで拾いたければ全面スキャンが要りますが、今回は速度を優先しました。)

ボリューム全体   939 GB
未割り当て        57 GB   ← ここだけ読めばいい(約1/16)
def load_bitmap(vol):
    off = vol.mft_lcn * vol.cluster_size + 6 * vol.mft_record_size   # $Bitmap
    info = parse_record(vol.read_at(off, vol.mft_record_size), vol.sector_size)
    return read_runs(vol, info["data_runs"], info["data_size"])

def free_extents(bitmap, total_clusters):
    start = None
    for c in range(total_clusters):
        allocated = bitmap[c >> 3] & (1 << (c & 7))
        if not allocated:
            if start is None:
                start = c
        elif start is not None:
            yield start, c - start
            start = None
    if start is not None:              # ← 末尾が空きで終わる場合を忘れずに
        yield start, total_clusters - start

最後の2行は地味ですが必須です。これを落とすと、ボリューム末尾の空き領域がまるごとスキャン対象から抜け落ちます。

5-4. シグネチャの設計がキモ

ここで §2 の観察が効きます。

探すもの 効く対象 理由
キー名の平文 削除済みWAL WALは無圧縮でキーがそのまま入る
値のトークン 削除済みSST Base64は圧縮できずliteralで残る

逆に、SSTに対してキー名を探しても当てになりません(prefix圧縮のため。§2-2 のとおり restart point に当たれば残り得ますが、期待はできません)。ここを理解していないとシグネチャ設計を間違えます。

なお結果を先に言うと、回収できた16世代のうち15世代はWriteBatchレコードとして、つまりWALの形式のまま取り出せました(残る1世代は前後の構造が壊れており、裸のJSON配列として拾っています)。どのシグネチャがどのヒットを生んだかまでは記録していないので厳密な内訳は出せませんが、キー名が平文で入っているWALの価値は結果にはっきり出ています。

5-5. 性能:シグネチャ1個は「全データ1周」

最初は23個のシグネチャで回して 0.85 GB/min、残り65分の見積もりでした。生ディスクのI/Oとしては遅すぎます。

原因は明らかで、シグネチャ1個につきデータを1周するからです。23個なら実効23倍。

対策は2つ。

  1. シグネチャを4個に削る。 同じリストのスナップショットどうしは大半のトークンを共有するので、代表的なトークンを数個だけ見れば感度はほとんど落ちません
  2. シャード並列化。 空きエクステントを i % N == shard で分割し、6プロセスで走らせる
shard0  7.3/ 7.3 GB done    shard3   7.5/ 7.5 GB done
shard1  5.5/ 5.5 GB done    shard4  12.4/12.4 GB done
shard2 16.9/16.9 GB done    shard5   7.3/ 7.3 GB done
TOTAL 56.9/56.9 GB  hits=36

65分が数分になりました。

注意: 各ワーカーが個別に $Bitmap を読むと、スキャン中に空き領域が変化してエクステント番号がずれます。厳密にやるなら親プロセスで一度読んで分配すべきです。


6. 回収したバイト列からデータを取り出す

ヒットした領域をダンプしたら、そこからWriteBatchレコードを抜き出します。キーの直後にvarintで値の長さが入っているので、素直に読めます。

KEY_SIG = b"_https://example.github.io\x00\x01AppItemList"

def extract(data: bytes):
    pos = 0
    while True:
        i = data.find(KEY_SIG, pos)
        if i == -1:
            return
        pos = i + 1
        p = i + len(KEY_SIG)
        vlen, p = get_varint32(data, p)
        if not vlen or vlen > 200_000:
            continue
        raw = data[p:p + vlen]
        s = raw[1:] if raw[:1] == b"\x01" else raw   # Latin1マーカーを剥がす
        yield s.decode("latin-1")

なお、多GBのダンプに対して欲張った正規表現を投げてはいけません\["[A-Za-z0-9]{12}[^"]{0,40}"(?:,"...")*\] のようなパターンは破滅的バックトラックを起こし得ます。手書きのスキャナのほうが速くて安全です。

def scan_array(buf, start, limit=200000):
    """buf[start] == '[' から、引用符付きトークンのJSON配列を手で舐める"""
    pos, end, items = start + 1, min(len(buf), start + limit), []
    while pos < end:
        if buf[pos] == 0x5D:                     # ']'
            return items
        if buf[pos] != 0x22:                     # '"'
            return None
        pos += 1
        tok_start = pos
        while pos < end and buf[pos] != 0x22:
            pos += 1
        items.append(buf[tok_start:pos].decode("latin-1"))
        pos += 1
        if pos < end and buf[pos] == 0x2C:       # ','
            pos += 1
    return None

結果

distinct valid snapshots: 16
  count=54  occurrences=18  method=writebatch
  count=35  occurrences=4   method=writebatch
  count=34  occurrences=4   method=writebatch
  count=31  occurrences=16  method=writebatch
  ...
  count=19  occurrences=4   method=writebatch

16世代分のスナップショットが出てきました。1件追加するたびに save() が走る実装だったので、作業の途中経過がそのまま残っていたわけです。

method は抽出経路を表しています。writebatch は削除済みWALのWriteBatchレコードとして取れたもの、array は前後の構造が壊れていて裸のJSON配列としてしか拾えなかったものです。今回は16世代中15世代が writebatch で、残る1世代のみ array でした。


7. 復元結果の検証

出てきたものをそのまま信じるのは危険なので、多角的に検証しました。

7-1. 系統の一貫性

各世代が次世代の先頭部分になっているかを調べます。

19件 → 20件 → 21件 → 22件 → 23件 → 28件 → 29件 → 30件 → 31件 → 34件 → 35件 → 54件
(すべて prefix 一致)

きれいな1本の系統になりました。数字が12個しかないのは、件数が同じ世代が複数あるためです(21件が3世代、22件が2世代、29件が2世代)。重複を戻すと16世代になります。件数が同じでも別スナップショットになるのは、エントリを追加せずに既存エントリを編集した保存があったからです。

ただし、トークンをそのまま突き合わせても一致しません。トークンはエントリ全体(種別・レベル・各種パラメータ)を直列化した文字列なので、既存エントリのレベルを1つ書き換えただけで別のトークンになるからです。実際、世代が進むほど現在値と一致するトークンは減っていきました(19個 → 10個)。事故直前のタブでは、新規追加と並行して既存エントリの編集も進んでいたわけです。

そこで、トークンではなく「編集で変化しない項目」の並びで比較しました。今回はエントリの種別だけを抜き出した列を使い、これできれいな包含関係が確認できています。復元データの突き合わせで同一性の判定に使ってよいのは不変な項目だけ——これは踏みやすい罠だと思います。

7-2. 独立した証跡とのクロスチェック

アプリは別キーに「前回警告を出した時点の件数」を保存していました。

{ "warnItems": 19, "warnDate": "2026-08-11" }

系統の起点が 19件。この数字が完全に一致しました。別経路の証跡が同じ値を示すのは、再構成が正しいことの強い裏付けになります。

7-3. アプリの deserialize を通す

最後に、アプリ本体の deserialize ロジックをPythonに移植し、全世代・全エントリを通しました。

全16世代・全エントリで deserialize エラー 0

7-4. 書き戻し用データの生成と往復検証

アプリにはCSVインポート機能があったので、復元データをCSVに変換しました。ここでも 往復検証をしています。

  1. 復元データ → CSV を生成
  2. アプリのCSV取り込みロジック(parseRow / parseCsvRow)をPythonに移植
  3. 生成したCSVを取り込み直し、元データと全フィールドを比較
rows=56  roundtrip_mismatch=0

56行の内訳は、カービングで回収した54件 + 上書き後のタブに残っていた新規2件です。事故後に追加された2件も捨てたくなかったので、マージした状態を書き戻し用データにしました。

インポータの実装を読むと、内部の英語名と翻訳名の両方を逆引きマップに登録していました。つまり英語名で書けばUI言語に依存せず取り込めます。エクスポータではなくインポータを読むのが正解でした。


8. 副産物:MANIFESTから作業量を推定する

本筋からは外れますが、調査の途中で面白い副産物が出たので書いておきます。

MANIFESTの last_sequence の増分を並べると、作業していた時間帯がはっきり出ます。

#18997   +42     ← 通常時
#18999   +44
#19003   +27
#19008  +115     ← ここから作業開始
#19010   +85
#19012   +52
#19014   +77
#19019   +74
#19021  +103     ← 事故
#19023   +27     ← 通常時に戻る

この時間帯は「1回の保存につき META: の2レコード」で推移していることを実データで確認できたので、通常時からの超過分を2で割れば操作回数が概算できます。アプリのログを一切持たなくても、ブラウザのストレージ層だけから「いつ・どれくらい作業したか」が復元できるわけです。フォレンジックとして地味に強力だと思いました。

ただし sequence number はそのLevelDB全体のあらゆる変更に振られる番号です。同じDBを共有する他オリジンの書き込みや削除、メタデータ更新も混ざるので、「2で割る」がいつでも成り立つわけではありません。今回はそれを実データで確かめたうえでの概算です。


9. 作業中に守ったこと

データ復旧では追加の破壊が最大のリスクです。以下を徹底しました。いま事故った直後という方は、この章と §10 の「事故ってしまったら」を先に読んでください。

  • 原本には絶対に書かない。 バックアップをさらにコピーし、作業対象はコピーのみ。作業前後で md5 が一致することを確認
  • 元のLevelDBを開かない。 LevelDBを正規に open すると LOCK や recovery が走り、状態が変わります。自前パーサで生読みする理由の半分はこれです
  • ボリュームは読み取り専用で開く。 open(r"\\.\C:", "rb") のみ。書き込み系APIは一切呼ばない
  • 出力先を慎重に。 カービング結果をC:に書くと、復旧したいクラスタを潰しかねません。理想は別ドライブ。今回は「シグネチャがヒットしたときだけダンプする」設計にして書き込み量を 19 MB に抑えました
  • 時間との勝負。 今回の環境では、ブラウザが動いている間はおよそ10分おきに新しいファイルが生成されていました(LevelDBのflushは本来サイズや書き込み量で決まるので、周期が保証されるわけではありません)。いずれにせよ、放置するほど上書きされます

10. 教訓と再発防止

localStorage を使うアプリの実装側

根本原因は「リスト全体を毎回書く」ことではなく、他タブの更新を検知していないことです。

// 他タブの更新を検知する
window.addEventListener("storage", (e) => {
  if (e.key === KEY) {
    // 自分の状態を作り直す or ユーザーに警告する
  }
});

より堅くするなら楽観的並行制御を入れます。

function save(list, expectedVersion) {
  const current = JSON.parse(localStorage.getItem(KEY) ?? "null");
  if (current && current.version !== expectedVersion) {
    throw new ConflictError();   // 上書きせず、マージかリロードを促す
  }
  localStorage.setItem(KEY, JSON.stringify({ version: expectedVersion + 1, list }));
}

ただし、これで競合が消えるわけではありません。 Web Storage には読み取りと書き込みをひとまとめにする compare-and-swap がないので、2つのタブが同じ version を読んでから両方とも検査を通過し、順に上書きする余地が残ります。窓は極端に狭くなりますが、ゼロにはなりません。

厳密に直列化したいなら、保存処理全体を Web Locks API で囲むのが手軽です。

await navigator.locks.request("app-item-list", async () => {
  // ここは同一オリジンの全タブを通して1つずつしか実行されない
  save(list, expectedVersion);
});

そもそも書き込みが競合しうるデータなら、トランザクションを持つ IndexedDB に移すか、書き手を1タブに限定する設計にしたほうが素直です。BroadcastChannel でタブ間に更新を配るのも有効です。

使う側

  • 重要なデータを持つWebアプリを複数タブで開かない
  • エクスポート機能があるなら定期的に使う(今回のアプリにもありました)
  • 事故に気づいたら、まずブラウザを触るのをやめる。リロードも追加操作もクラスタの上書きリスクを上げます

事故ってしまったら

  1. 可能なら、その端末への書き込みを止めて別媒体にボリュームイメージを取る。 これが最も安全です。以降の手順はすべて、この余裕がない場合の妥協案だと思ってください
  2. すぐにプロファイルをコピーする。 ブラウザの終了・継続はトレードオフです。終了させると保留中の状態が flush されて上書きが進む一方、起動したままでも flush とコンパクションは進みます。今回は「コピーを最優先する」と決めて起動したまま作業しました
  3. コンパクションが走る前ならバックアップだけで復旧できる。今回は8分間に合わなかった
  4. 間に合わなくても未割り当て領域に残っている可能性がある。ただし時間が経つほど絶望的になる

おわりに

「上書きしてしまった」の一言で片付きそうな事故でしたが、掘ってみると

  • Chromiumのストレージレイアウト
  • LevelDBのLSM-tree、コンパクション、prefix圧縮
  • Snappyの圧縮特性
  • NTFSの$MFTと$Bitmap
  • シグネチャ設計とスキャンの性能特性

と、普段あまり触らない層が芋づる式に出てきて、結果的にかなり勉強になりました。

最終的に16世代のスナップショットを回収し、上書き直前の状態を完全に復元できました。失われたと思っていたものが戻ってきたときの安堵は、なかなかのものです。

外部ツールに頼らず自前で書いたのは意地の面もありますが、「なぜ見つからないのか」を説明できないと、次の一手が決まらないという実利がありました。grep が当たらない理由がprefix圧縮だと分かったからこそ、「SSTのキー名は当てにできないが、WALには平文で入っている」「値のトークンは圧縮が効かず残りやすい」というシグネチャ設計に辿り着けています。

同じ事故に遭った方の助けになれば幸いです。


参考

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?