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?

Tarを完全に理解したのでサブセットをTypeScriptで実装してみる

Posted at

2025年、初投稿です。明けましておめでとうございました。

皆さんは「Tarファイル」をご存知ですか?
僕は毎日のように扱っています。

今までは主にLinuxで使用されてきましたが、最近はWindowsでも標準で扱えるようになったので、見る機会も増えてきたのではないでしょうか。

今回はTarを完全に理解して、最低限のサブセットを実装できるようになるまでを解説してみようと思います。

経緯

個人開発のウェブアプリで、入力された複数のファイルを結合するために 外部ライブラリ非依存クライアント完結 な実装を検討していました。

当初は「ファイル名サイズ・本体サイズ・ファイル名・本体をワンセットとしてファイル数だけループ」というシンプルな独自設計を試しましたが、これだと仕様変更や不具合の際に、代替手段で簡単にファイルを復旧できません。

調査していくと、この独自設計はTarの設計に近いということが判明したので「いっそのことTarを自前で実装してしまおう」となり、今に至ります。

Tarとは

諸先輩方が既に丁寧な解説をしてくれていると思うので、掻い摘んでおさらいします。

正式名称は Tape Archive で、それぞれの先頭を取って TaAr > Tar となります。

複数のファイルを結合してひとつのファイルに束ねるアーカイブフォーマットで、名前にテープとあるように歴史は非常に古く、記憶装置としてテープを利用していた大昔から存在するフォーマットです。

ZIPとの相違点

同じアーカイブフォーマットとして有名な ZIP はPKWARE社の製品で、正式名称は PKZIP と言います。

Tarはファイルを束ねることだけに特化している (それしかできない) ため単純な構造をしているのに対し、ZIPは複雑な構造ですが圧縮や暗号化などの便利機能が仕様として組み込まれています。

つまりTarでZIPのようにファイルを束ねつつ圧縮や暗号化を行う場合は、それぞれの処理をライン作業のように連続して行う必要があります。

処理が分離しているメリットは、組み合わせ次第で圧縮や暗号化のフォーマットを柔軟に選択できるところにあります。
デメリットは、柔軟性の裏返しで、非IT系の人間には理解や使用のハードルが少し高いのかな、という想像です。

Gzip

ファイルの圧縮と伸張を行う可逆圧縮フォーマットで、ベースに Deflate という可逆圧縮アルゴリズムを使用し、ファイルシステム特有のヘッダ情報 (作成日時など) を付与したフォーマットです。

Gzipも圧縮伸張に特化している (それしかできない) ためアーカイブ機能などは無く、ひとつのファイルしか圧縮できません。

よく見る .tar.gz.tgz のような拡張子は「複数のファイルをTarで束ねた後にGzipで圧縮したファイル」を意味しています。

なおZIPと名前は似てますが全く無関係の別物です。
余談ですが、実はZIPも圧縮には内部でDeflateを使用しています。

Tarのバイナリ構造

まずはTarのバイナリ構造を見ていきます。

大雑把には「ヘッダ・本体をワンセットとしてファイル数だけループ」という構成となります。
前述の独自設計と似ていますね。

全体を通して1ブロック512バイトとしてブロック単位で処理します。
つまり、最終的な合計バイト数は512の倍数となります。

まずはオフセット表を見てみましょう。

オフセット (バイト) バイト数 フィールド名 内容例
0 100 ファイル名 config.json
100 8 モード 0000644
108 8 UID 0000000
116 8 GID 0000000
124 12 ファイルサイズ 00000049283
136 12 更新日時
(UNIXTIME)
14735003360
148 8 チェックサム \x20\x20\x20\x20\x20\x20\x20\x20
実測値
156 1 タイプ 0 2 5
157 100 リンク名 xxx
257 8 マジックナンバー
バージョン
ustar00 ustar\x20\x20
265 32 ユーザー名 my-user
297 32 グループ名 my-user
329 8 メジャーデバイス番号 0000000
337 8 マイナーデバイス番号 0000000
345 155 プレフィックス ./path/to/
500 12 (パディング) (固定) \0
512 512 * n ファイル本体 -
以後ループ

いつの間にかMermaidでパケット表を書けるようになっていた。

ヘッダ

ファイルサイズなどの数値は「8進数のASCII文字列」として記述し、余りの桁はゼロパディングします。
これはテープ時代にエンディアンの影響を受けないための策だったそうです。

文字列は基本的にNull終端します。
つまり8バイトのフィールドなら7桁まで使用可能です。

主要なフィールドを解説します。

マジックナンバー

Tarは長い歴史の中でPOSIX系とGNU系に分岐し、それぞれマジックナンバーが微妙に異なります。

  • POSIX系: ustar00
  • GNU系: ustar\x20\x20

基本的にはGNU系が主流のような気がします。

タイプ

Tarはディレクトリ・シンボリックリンク・デバイスファイルなど様々なファイルシステムを含めることができるので、ソレが何なのかを示すタイプを指定するフィールドがあります。

内容
0 \0 通常ファイル
\0 は後方互換、通常は 0 を使用
1 ハードリンク
2 シンボリックリンク
3 キャラクタデバイスファイル
4 ブロックデバイスファイル
5 ディレクトリ
6 FIFOファイル
7 (予約)

これ以外にも A-Z はカスタムタイプとしてユーザー実装で任意に割り当てることができます。

なお、タイプフィールドだけは1バイトしかないのでNull終端できませんが、問題ありません。

チェックサム

Tarのチェックサムは言葉を選ばず言うなら「ザル」です。
とはいえ正しく計算しないと動かないので、太古の遺産だと割り切るしかありません。

検証対象はヘッダのみ、本体は検証されません。
算出方法は「ヘッダの全バイトの合計値」となります。
単純に0バイト目から511バイト目までの値を足し算すればいいだけです。

...おわかりいただけただろうか...?

チェックサムのフィールドもヘッダに含まれており、そのままでは計算できません。

そのため「本来チェックサムが入る8バイト分はNull終端を含め全て \x20 (空白) として計算し、計算後に実測値を書き戻す」という設計になっています。

こんなトンデモ設計のせいかは分かりませんが、本来は8バイト7桁なはずなのに、開発中に検証した中では 110120\0\x20 のように「実測値6桁+Null終端+空白」という変なパターンを吐き出すTarプログラムを観測しました。

Tarなにもわからない........

リンク名

タイプが 12 の場合、リンク先の実ファイル名が設定されます。

デバイスナンバー

タイプが 34 の場合、デバイスファイルのデバイスナンバーが設定されます。

プレフィックス

ファイル名が100バイトを超える場合、このフィールドが101バイト目以降の地続きとして扱われます。

本体

ヘッダの終端から、ひたすら本体が続きます。

ブロック単位で処理する都合上、本体末尾の512バイトに満たない分はゼロパディングします。

末尾

全ファイルを結合したら、最後に1024バイト (2ブロック) ぶんのゼロパディングを付与して、めでたくTarファイルの完成となります。

仕様検討

大前提として「ウェブブラウザで動作」「外部ライブラリ非依存」「クライアント完結」があるため、必然的にJavaScriptかTypeScriptで実装する必要があります。

ウェブブラウザは純粋なファイルしか扱えない 1 ため、タイプは 0 固定となります。

取得できるメタデータもファイルサイズと更新日時くらいです。
モードは取得したいところですが、無理なので 644 固定で妥協します。

これらのことから、ウェブブラウザ用に機能を限定したサブセットとすることで、ヘッダの大部分はパラメータを決め打ちできそうです。

実装簡素化のため、ファイル名はプレフィックスを使用しない100バイト制限とします。

ついでに圧縮伸張機能も実装しますが、こちらも実装簡素化のためGzipのみ対応します。
GzipはTarと最も多く組み合わされる圧縮フォーマットなので、大半はこれで事が済みますし、何より Compression Streams API を使用することで超簡単に実装できるからです。

ZstdやXZなどは自前で実装しなければならず、今回の趣旨から外れるため断念します。

もちろん、純粋なJavaScriptなのでDenoやNode.jsでも動作します。

今回の仕様

  • 扱うのは純粋なファイルのみ
  • 階層なし (ディレクトリ構造を持たない)
  • モードは 644 固定
  • UID・GID・ユーザー名・グループ名は無視
  • 圧縮伸張機能 (Gzipのみ)
  • ファイル名は100バイト (プレフィックス不使用)

コード

基本的には、位置変数で読み進めたバイト数を管理しながらファイルの数だけループしていきます。

Gzip処理を担う CompressionStreamDecompressionStream はストリームAPIなので、バイナリとストリームを Response 経由で相互変換しなければならず、少し回りくどいです。

エンコード

まず初めに、最終的な合計バイト数を計算しバッファを確保しています。

文字列のNull終端は、末尾に \0 を付与するのではなく文字数より1バイト多く読み進めることで対応しています。2

511バイト目まで読み進めたらチェックサムを計算し、チェックサムフィールド (511バイト目から見ると364バイト手前) に算出値を書き込んでいます。

可変値 (ファイル名・ファイルサイズ・更新日時) について、最大文字数を超えることはまず無いと思いますが、念のため slice で制限しています。

TypeScript
async function tarEncode(files: File[], gzip?: boolean): Promise<Uint8Array> {
    const TAR_BLOCK_SIZE = 512;

    const tar = new Uint8Array(files.length * TAR_BLOCK_SIZE + files.reduce((n, {size}) => n + size + TAR_BLOCK_SIZE - size % TAR_BLOCK_SIZE, 0) + TAR_BLOCK_SIZE * 2);
    const encoder = new TextEncoder();

    for(let i = 0, j = 0; j < files.length; j++) {
        tar.set(encoder.encode(files[j].name).subarray(0, 100), i);
        i += 100;
        tar.set(encoder.encode("0000644"), i);
        i += 8;
        tar.set(encoder.encode("0000000"), i);
        i += 8;
        tar.set(encoder.encode("0000000"), i);
        i += 8;
        tar.set(encoder.encode(files[j].size.toString(8).slice(0, 11).padStart(11, "0")), i);
        i += 12;
        tar.set(encoder.encode(Math.floor(files[j].lastModified / 1000).toString(8).slice(0, 11).padStart(11, "0")), i);
        i += 12;
        tar.set(encoder.encode("        "), i);
        i += 8;
        tar.set(encoder.encode("0"), i);
        i += 1;
        i += 100;
        tar.set(encoder.encode("ustar  "), i);
        i += 8;
        i += 247;

        tar.set(encoder.encode(`${tar.subarray(i - TAR_BLOCK_SIZE, i).reduce((n, v) => n + v, 0).toString(8).slice(0, 6).padStart(6, "0")}\0 `), i - 364);

        tar.set(await files[j].bytes(), i);
        i += files[j].size + TAR_BLOCK_SIZE - files[j].size % TAR_BLOCK_SIZE;
    }

    return gzip ? await new Response(new Response(tar).body?.pipeThrough(new CompressionStream("gzip"))).bytes() : tar;
}

デコード

基本的には値の読取だけなので subarray で切り出していますが、チェックサム処理だけ書込が発生するため slice で切り出し、元のバッファには手を付けないようにしています。3

ループの初めに、末尾検知を入れています。

コマンドラインツールなどの別アプリケーションで作成され、ファイル以外を含む場合も考慮し、タイプが 0 以外なら次のヘッダまで読み飛ばしています。

TypeScript
async function tarDecode(tar: Uint8Array, gzip?: boolean): Promise<File[]> {
    const TAR_BLOCK_SIZE = 512;

    const _tar = gzip ? await new Response(new Response(tar).body?.pipeThrough(new DecompressionStream("gzip"))).bytes() : tar;
    const files: File[] = [];
    const encoder = new TextEncoder();
    const decoder = new TextDecoder();

    for(let i = 0; i < _tar.byteLength;) {
        if(i + TAR_BLOCK_SIZE * 2 === _tar.byteLength && _tar.subarray(i).every((v) => v === 0x00)) {
            break;
        }

        const name = decoder.decode(_tar.subarray(i, i += 100)).replaceAll("\0", "");
        i += 8;
        i += 8;
        i += 8;
        const size = parseInt(decoder.decode(_tar.subarray(i, i += 12)).slice(0, 11), 8);
        const time = parseInt(decoder.decode(_tar.subarray(i, i += 12)).slice(0, 11), 8);
        const checksum = decoder.decode(_tar.subarray(i, i += 8)).slice(0, 6);
        const type = decoder.decode(_tar.subarray(i, i += 1));
        i += 100;
        const magic = decoder.decode(_tar.subarray(i, i += 8)).slice(0, 7);
        i += 247;

        if(!magic.startsWith("ustar")) {
            throw new ReferenceError("Invalid tar magic.");
        }

        const header = _tar.slice(i - TAR_BLOCK_SIZE, i);
        header.set(encoder.encode("        "), TAR_BLOCK_SIZE - 364);

        if(checksum !== header.reduce((n, v) => n + v, 0).toString(8).slice(0, 6).padStart(6, "0")) {
            throw new ReferenceError("Invalid tar checksum.");
        }

        if(type !== "0" && type !== "\0") {
            i += size + TAR_BLOCK_SIZE - size % TAR_BLOCK_SIZE;
            continue;
        }

        const body = _tar.subarray(i, i += size);
        i += TAR_BLOCK_SIZE - size % TAR_BLOCK_SIZE;

        files.push(new File([body], name, {
            lastModified: time
        }));
    }

    return files;
}

型宣言を消せばそのままウェブブラウザで動作します。

2025-03時点においてChromium系のウェブブラウザは Blob.prototype.bytes が未実装のため、ポリフィルを実装する必要があります。

Polyfill
Blob.prototype.bytes ??= async function() {
    return new Uint8Array(await this.arrayBuffer());
}

Null合体代入 ??= のお陰で、シンプルに記述できるようになりましたね。

Chromium系だけ実装済で他は未実装なパターンが多い中、逆にChromium系だけ未実装は珍しいパターンだなと思いました。

動作検証

最後に、実際にコードを動かして動作検証してみます。

検証環境

  • OS: Windows 11 Build 26100
  • Runtime1: Deno v2.2.4 x64
  • Runtime2: BusyBox-w32 FRP-5579 x64 Unicode

手順

下準備として、適当なファイルを数個ほど生成しておきます。
(今回は test1 test2 test3)

生成したファイルを tarEncode に入力して、生成したTarバイナリをファイルに出力します。

生成したTarファイルを tar コマンドなどの既存プログラムで読み取ることができれば、実装が正しいことを検証できます。

Tarファイルを tarDecode に入力して、復元された元のデータをファイルに出力します。
ファイルに出力する際、サフィックスなどを付けて別名にすることで、ハッシュ値を検証できます。

6E6725F7-55C2-4CBC-8B41-F0CD391F150A.png

おわりに

最初は複雑そうな印象でしたが、ひとつずつ紐解いていけば、意外と簡単でした。
いい自由研究になったと思います。

参考にしたサイト

  1. 実際には、ファイルシステムを直接操作してディレクトリなども扱えるようにした、非常に高機能な Filesystem Access API も存在しますが、まだ実験的なので除外しています。
    とはいえ基礎インターフェースは既に主要ウェブブラウザで実装済みたいです。

  2. TypedArray (の実体のArrayBuffer) は、インスタンス生成時に ゼロフィル されます。

  3. TypedArrayの subarray はビューだけ切り出すのに対し slice は実体のバッファごと切り出します。
    ビューをいくら切り出しても、ビューが指すバッファのメモリアドレスは同じなので、活用するとメモリ消費を抑えられます。
    バッファごと切り出すと、切り出したバッファを新たなメモリアドレスにコピーするので、多用しすぎるとメモリを浪費します。

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?