📂

[Go]tarのReaderにReadAtを実装する(ついでにfs.FSにもする)

2025/03/08に公開

[Go]tarのReaderにReadAtを実装する(ついでにfs.FSにもする)

案外できた

実装成果物は以下で管理されます。

https://github.com/ngicks/go-fsys-helper/tree/main/tarfs

Overview

  • archive/tarが返してくる*tar.Readerio.ReaderAtを実装しません。
    • 入力/出力をstreamとして処理できるようにするため当然ではあります。
  • tarの中身がPDFのようなランダムアクセスを必要とするファイルなどであると、上記より一旦展開するほかありません。
    • gzipはランダムアクセス可能ではないのでtar.gzならそもそも一旦展開は必須です。
    • ただし、zstdなど、オプションによりランダムアクセス可能にできる圧縮方式もあります。
    • .tar.zstdにし、tarの中身を読むときio.ReaderAtを実装できれば「一旦展開して書き出す」を挟まないでよくなります。
  • 「一旦展開」で殆どの場面でこまならないですが、例えばdocker containercontainer fsをread-onlyにしていたりするとき、書き込めるディレクトリのマウントが必要になったりするので避けたいです。
  • まあ要するに理屈上いらん処理を書いててなんかもやもやしていたわけです。
  • tarをfsとしてmountできるOSSが存在していることは知っていたため、これができることだとはわかっていました。

ここまでがbackgroundです

  • prior artとしてgithub.com/nlepage/go-tarfsがあります
    • これはtarを受けとってfs.FSを返すもの
  • 上記に倣うと、tar.NewReaderに渡すio.Reader実装を、Readで読んだbyte数を記録できるものにしておくことで、tar headerのオフセットを所得できます。
  • 実際のファイルコンテンツのtarファイル内でのオフセットは、headerの終わりと*tar.HeaderSizeフィールドから取得できます
  • 元のファイル内のfile content bodyの位置さえわかれば*io.SectionReaderio.ReaderAtを実装した形で読み込み可能です。
  • ただし、sparse(疎) fileの取り扱いだけがこれだけでは済みません。
  • なぜならば、*tar.Readerがtar headerに存在するsparse情報を捨ててしまうため
  • そこでsparse情報を取得する必要があります。
  • 実装方針に以下の2案がありました。
    • 前提としてtarの解析処理はなるだけGoのstdに任せる
      • 何かの修正があるたびに追従するのは個人では現実的ではありません。
      • Goのモジュールシステムではgo.modに書かれたバージョンにかかわらず、コンパイルするときのtoolchainのstd libが使われます。つまり、新しいコンパイラとstdライブラリのセットでコンパイルされればarchive/tarを使っている部分は何もしなくても修正を受けることができます。
    • (1) *tar.Readerをすべて読み切って、tarファイル内の実データと突き合わせることでholeを再建する
    • (2) archive/tarのソースの一部をコピーし、sparse情報を取り出せるようにする。
  • (1)は大きなファイルで時間がかかりすぎて使い物にならない可能性が高いため却下
    • そもそもファイルを疎にしたいのはコアダンプなど容量はデカいけど実際に何かが書かれている領域は小さい時であるため。
  • (2)はsparse情報を取り出すだけなら今後も追従しなきゃいけない変更も少なさそうだしこちらに決定。
  • sparse holeの情報さえあれば以前作ったライブラリで仮想的にio.ReaderAtを結合して1つのio.ReaderAtのように見せることができます。
  • ということでできた

~完~

前提知識

最低限A Tour of Goはこなしている
会社の同僚を想定しますので基本的な話を盛り込みたい。間違っていたりした場合はおしえていただけるとさいわいです。

環境

$ go version
go version go1.24.0 linux/amd64

前提

Goでtarを扱うにはarchive/tarを使う

Goのstdの範疇でtarを扱うにはarchive/tarを用います。

今回はtarを読み込む話しかしないので、読み込みのサンプルを以下に掲載します。

playground

package main

import (
    "archive/tar"
    "bytes"
    "compress/gzip"
    "encoding/base64"
    "fmt"
    "io"
)

var treeTarGzBase64 = `H4sIAAAAAAAAA+2YTW7DIBBGWecUPoENZAbOg622yyg/VquevkNRlLRSf1gMScT3Nkg2EkjPD1mM` +
    `k1HHCpE5jy6yvR7PGEcxeLcN7OS5s+y9GVh/a8asx1M6DIN5Taf08vTzvL/ePyjjNM+z8jdQ5Z+9` +
    `+HdepsN/A4r/ZVkUv4Eq/zH3762F/yZc/O/X9U1njSw4EP3in7/7j178W53tfKVz/9n65tabADfj` +
    `un+l/P/RP136D5z7Z9qi/xbskX/XlP7ndFBco6p/Cvn/P5JD/y0Q8+i/Y879vyuuUdN/DO6zf3mN` +
    `/hsg5tF/x4xTSume7v+Iy/0f4f6nBcX/826nuEbd/x+X859w/rdAzOP8BwAAAAAAAIAO+ABC8URH` +
    `ACgAAA==`

func main() {
    treeTarGz, err := base64.StdEncoding.DecodeString(treeTarGzBase64)
    if err != nil {
        panic(err)
    }
    gr, err := gzip.NewReader(bytes.NewReader(treeTarGz))
    if err != nil {
        panic(err)
    }
    defer gr.Close()

    tr := tar.NewReader(gr)
    for {
        hdr, err := tr.Next()
        if err != nil {
            if err == io.EOF {
                break
            } else {
                panic(err)
            }
        }
        fmt.Printf("name = %q", hdr.Name)
        switch hdr.Typeflag {
        case tar.TypeReg, tar.TypeRegA:
            content, err := io.ReadAll(tr)
            if err != nil {
                panic(err)
            }
            fmt.Printf(", regular file, size = %d, content = %q\n", hdr.Size, string(content))
        case tar.TypeDir:
            fmt.Printf(", dir\n")
        default:
            fmt.Printf(", unknown(%d)\n", hdr.Typeflag)
        }
    }
}

/*
name = "./", dir
name = "./bbb/", dir
name = "./bbb/ccc/", dir
name = "./bbb/ccc/quux", regular file, size = 5, content = "quux\n"
name = "./bbb/ccc/qux", regular file, size = 4, content = "qux\n"
name = "./bbb/bar", regular file, size = 4, content = "bar\n"
name = "./bbb/baz", regular file, size = 4, content = "baz\n"
name = "./aaa/", dir
name = "./aaa/foo", regular file, size = 4, content = "foo\n"
*/
  • tar.NewReader*tar.Readerを作成
  • ReaderNext methodを呼び出すと次の*tar.Headerまで読み込んでそれを返し,
  • ReaderRead methodでNextが返したheaderが示すファイルエントリの中身を読み込めます。

Nextを呼び出すと*tar.Readerの中身が次のエントリの内容にセットされるような感じです。

初見だとちょっとわかりにくい気がします(筆者はちょっと混乱しました)。ですがtarのデータ構造とstreamで処理したいという方針を鑑みればこうなるのもわかるかと思います。
つまり、streamとして処理するのであれば、次のデータが準備できた時には前のデータにはアクセスできないということです。そのためstatefulに作る方針になりがちです。

これは[Go]続・なるだけすべてをiteratorにする#利点で説明した「ScanやNextメソッドで次のデータを準備し、データが存在するときtrueを返すパターン」でのデータの繰り返し表現です。*bufio.Scanner*sql.Rowsと同じようなAPI方式ですね。これらのどちらかをすでに使ったことがある場合は理解しやすいかもしれません。

ちなみにソースに埋め込まれたtarの内容はGNU tar(1.35)で作成しています。

io.ReaderAtはとっても便利

まずどうしてio.ReaderAtが実装されているのかうれしいのかという話を前提としてします。

  • いくつかの処理はランダムアクセスができたほうが楽
    • *http.RequestGetBodyフィールドの実装
    • PDFなどランダムアクセスによって効率的に処理できるフォーマット
    • これらに対してランダムアクセス性がないともう一度開きなおす、事前にバッファして置くなどが必要
      • それらの処理がどの程度高価なのか事前にわからないことがある。ものすごく大きなPDFをバッファに乗せたらメモリが足りなくてOut Of Memoryでkillされることもあり得ます。
  • io.Seekerに比べてio.ReaderAtは規約が厳しく、使う側が楽
    • seekはio.Readerなどが内部的に持つ現在のオフセット値を変更するという処理であるので、concurrentに呼び出すと一種のrace conditionとなる
    • その点ReadAtはconcurrentに複数回呼び出されてもよいという規約になっている
  • *io.SectionReaderとの組み合わせによって楽に扱える

色々考えることが減るのでいいわけですね。

sparse file

この先sparse fileとかsparse holeとか単にholeというワードが出てきますが意味は以下です

  • sparseは疎という意味で、ファイルサイズに対して、実際のストレージの割り当てが少ないファイルのことを言います
  • holeというのはこの「実際にはストレージが割り当てられていない」場所のことを言います。
  • holeを読み込むとき、linuxでは単に0x00として読み込まれます
  • データベースや、コアダンプ(プログラムがクラッシュしたときにそのプログラムのメモリをそのまま書き出すやつ)は、実際のデータ容量に対してなにも書き込んでいない領域がたくさんあるのが普通です。こういったものを素直に0x00部分にもストレージを割り当ててしまうと容量があっという間に枯渇してしまう(こともあるため)、割り当てないでおくということができます。
  • Advanced Programming in the UNIX Environmentとか読んでおいてください

*tar.Readerはio.ReaderAtを実装しない

*tar.ReaderのAPI docを見ればわかる通り、これはio.ReaderAtを実装しません。
前述通りstreamとして処理できることを念頭にされているため当然ではあります。

基本的な話: そもそもtarとは

会社の同僚に説明するならtarがどういうフォーマットかなどを軽く説明しておいてほうがいいと思うので先に説明します。
知ってる人も多いことでしょうからtarの内容にランダムアクセス性が欲しくなる時まで飛ばしてください。

tar

tarというファイルフォーマットとは何ぞやという話を一応入れておきます。

tarは複数のファイル/ディレクトリを1つのファイルにまとめるアーカイバです。データの圧縮は仕様に含まれませんので、圧縮が必要な場合はtarをさらにgzipzstdで圧縮します。

以下の各リンクで説明されています。

FreeBSDGNU(linux)のtarコマンドのマニュアルです

https://man.freebsd.org/cgi/man.cgi?tar(1)

https://man7.org/linux/man-pages/man1/tar.1.html

お互い微妙に違うのがわかりますね。

フォーマットや来歴の話はWikipedia(英語版)のtar(computing)の項目で説明されています。

https://en.wikipedia.org/wiki/Tar_(computing)

GNUのtar manualにヘッダーフォーマットやsparse file, dumpdirやsnapshot周りの話が説明されています。

https://www.gnu.org/software/tar/manual/html_node/Tar-Internals.html#Tar-Internals

IBMtarのフォーマットの説明があります。GNU tarとは微妙に異なるフォーマットとして説明されています。

https://www.ibm.com/docs/en/aix/7.1?topic=files-tarh-file

上記各リンクで述べられているところによると、

  • tarTape ARchiveをとったもの
  • tarballとなぞらえた呼称(tarballはなんでもくっつけてひとまとめにしてしまうから)
  • 磁気テープのようなファイルシステムのないところにファイルを保存するために必要であった

とあります。今日でも多用されていますがオリジナルの開発時期はversion 7 Unixと同時期、とあります。(≒初期リリースは1979年)
Goarchive/tar内部の実装でもv7という呼称が出現します。

Punched card(wikipedia)によると1980年代あたりに磁気テープが紙のパンチカードを置き換えだしたらしいです。

磁気テープは文字通り、テープ状の紙/プラスティックフィルムに磁性のある粉とか液とかを封入するなり塗布するなりしたものををリール(ボビン)に巻き付け、テープを送って磁気を読み取る/磁化することで情報の読み取り/書き込みを行います。(参考)

これに書き込むことを意図されたデータフォーマットであるならばいくつかのことが示唆されることになります。

  • シーケンシャルアクセスのみを前提とする
    • テープの送り量がデータアクセス位置となります。
    • HDDやCD/BDのようなディスク媒体のように周方向にデータセグメントを分けながら半径方向に読み取り機を動かすようなことはできません。ランダムアクセス性は低いです。
  • 末尾にジャンクデータが残っていることがありうる
    • 磁気テープは磁化することで情報を書き込みますので繰り返しの書き込みができます。
    • 送りのスピードが遅いならわざわざジャンクデータを消そうとしないこともあるでしょう。

うーんまだなんかありそうだけど筆者はこの程度しか言えないなあ。

フォーマットはPOSIXに載ったりドロップしたりをしている・・・とあります。
どちらにせよPOSIXは買わないと読めないはずなのでここでリンクを張ることができません。そのためフォーマットの完全な説明はリンク先や、archive/tarのソースコードに当たってもらうとします。
ここではいくつかの特徴を説明します。

block size = 512 bytes
+--------------+
| header block | 1 to many
+--------------+
|  data block  | 1 to many
+--------------+
|  data block  |
+--------------+
       .
       . variable length
       .
+--------------+
| header block | extended header
+--------------+
|  data block  | extension data only relavant to next file
+--------------+
| header block |
+--------------+
|  data block  |
+--------------+
       .
       . variable length
       .
+--------------+
|  empty block | all `0x00`
+--------------+
|  empty block |
+--------------+
|  junk data   | EOF or junk
|              |
       .
       .
       .
  • データは512バイトのブロック単位で書き込まれます。
  • ヘッダー、データ、ヘッダー、データの繰り返しで表現されます。
    • データの部分がファイルの中身です。
    • いくつかはデータ部がないものがあります
      • e.g. Symlink, Char dev, size 0のregular fileなど
  • いくつかの拡張ヘッダーは次のファイルに拡張情報を与えるものがあります。
  • いくつかの拡張ヘッダーは通常のヘッダーの後に現れて補足的な情報を与えます。
  • ヘッダーから開始さえできていれば、どこかでちょん切ったり、逆に末尾に新しいエントリを追加してかまいません。
    • 実際インクリメンタルアップデートを行ったtarがテストデータとしてGoのstd内にありました。
  • EOFか、0x00のみで構成されるブロックが二つ連続して並んでいるとアーカイブ末尾となります。
  • GNU, old GNU, PAX, UStarなどいくつかの仕様/拡張仕様があります。

streamが有利な場面

tarがstreamとして処理可能な利点は送受するときに順次処理できることです。
zipなどのファイルへのランダムアクセスが必要となるフォーマットであればファイルのダウンロードが終わってからようやく展開の処理ができるようになります。
tarではダウンロード中に展開処理ができるため、サイズが大きければこちらのほうが速いということもあるでしょうね。

streamの利点を生かした手技には、例えば以下のようにsshでディレクトリを送る時にtarしてun-tarするというものがあります

tar -cf - -C /path/to/src . | ssh ${target} "tar -xf - -C /path/to/dst"

(出展はどっかにいってしまったので書けないのですが、scpのプロトコルを調べている時に見つけた記事の末尾に)scpを使ってディレクトリをコピーするとファイルチェックを毎度するためパフォーマンスが悪い、ローカルでtarして結果をパイプしリモートでun-tarするほうが速いと聞いたことがあります。
(ただし今でもそうなのかはわかりません。scp(1)#HISTORYにある通りOpenSSH 9.0よりscpsftpを使用して送信するのがデフォルトになったためです。)

c.f. zip

Goarchive/zipのトップからリンクが張られていますが、以下がarchive/zipが参照するzipのフォーマットです。

https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT

記載のとおり、4.3.16 End of central directory record4.3.15 Zip64 end of central directory locator, 4.3.14 Zip64 end of central directory record などをファイル末尾から読み込み、そこから 4.3.12 Central directory structure にseek back、central directory structureに各file headerへのオフセットが入っています。
つまり、基本的には読み込みにランダムアクセス性を必要とします。

zipはフォーマットとしてincremental updateが可能なようです。その中でも「ファイルを消した」をcentral directoryの追記によって表現可能なようです。
specを見る限りstream-decodeは特定の条件において可能です。この「消した」の表現を尊重する面で余計なファイルを書き出してしまうという微妙さはありますが。
(実際「すべてではないが大概のzipには可能」というstackoverflowでの回答があります: 参考)

tarの内容にランダムアクセス性が欲しくなる時

例えば以下が思いつきます。

  • tarファイルにランダムアクセス可能であり
  • tar内部のファイルがランダムアクセスを必要とし
    • e.g. PDF(参考:PDF 1.7 spec, 7.5.5 File Trailerより、PDFはファイル末尾から読み込む。更に別のFile Tailerに向けてseek backする必要があるときがある。)
  • tarファイルを展開したくないとき
    • e.g. tarが非常に大きく、ファイルは一時的に利用されるのみ、もしくはファイルシステム経由でアクセスすされることはないので展開したファイルの配置が不要である
    • e.g. アプリがcontainer内で動作しており、container fsがread-only modeにされているとき、やらんでいいなら書き込めるディレクトリをマウントしたくない。

tarに内包されたファイルの特定のオフセット内を*io.SectionReader(包まれる側にio.ReaderAtの実装を必要とする)で包んで*http.RequestGetBodyから返せるようにするっていうのがいちばん思いつきましたが、
代替する方法はいくらでもあるので立て付け上では上記のようなことになると思います。

prior arts

archivemount

下記を用いるとFUSEによりアーカイブファイルをmount可能です。
このアーカイブの中にはtarも含まれます。

https://github.com/cybernoid/archivemount

これを使用したことはありませんが、seekできないのにファイスシステムとしてアクセス可能と言い張るのは無理があるので、tar内部のファイルへのランダムアクセスが可能なのは間違いないことでしょう。

github.com/nlepage/go-tarfs

github.com/nlepage/go-tarfsは、Goのモジュールでtarを受け取ってfs.FSとしてアクセス可能にします。

このライブラリでもio.ReaderAtは実装されません。

このライブラリがどのように動作するのかというと、

まず以下のように、

https://github.com/nlepage/go-tarfs/blob/v1.2.1/fs.go#L22-L75

  • tar.NewReaderに読み込まれたバイト数をカウントできるio.Readerを渡します。
  • *tar.ReaderNextを順次呼び、読み込まれたバイト数-512(blockSize)=ヘッダー開始地点オフセットを記録しておきます。
  • このようにしてファイル情報を収集します。

fs.FSとしてファイルを開く際には以下のように、

https://github.com/nlepage/go-tarfs/blob/v1.2.1/entry.go#L58-L75

  • 記録しておいたヘッダー開始地点から始まるように元のio.ReaderAt*io.SectionReaderで包みます。
  • これをtar.NewReaderに渡し、最初のエントリを読み込ませて返します。

tarがフォーマットとして途中から分割しても正常に動作することを利用した巧みな実装だといえます。

ただし以下の点で正しくないと思われます。

  • r.Count()-blockSizeは正しくない
    • Goarchive/tarが一部の拡張ヘッダーの、次のヘッダーにメタデータを与える系のヘッダーを読み込んだ際、それはユーザーに返さずに次のヘッダーを読み込んでメタデータをマージしてから返します
    • つまり1度のNext呼び出しは複数ヘッダー(とデータ)を読み込んでいることもありうるため、単に-blockSizeでは足りず、もしかしたら-blockSize*2かもしれないし、それ以上かもしれないということです。
    • PAX extended headerGNU.sparse.が指定されていた場合、これが無視されることになります。
      • それに関するissueが上がっていないところを見ると実用上sparse fileは使われていないのかもしれないですね。

実装

github.com/nlepage/go-tarfsは便利で利用していました。作者に感謝です。

ただやっぱりio.ReaderAtが欲しい。あるとio.ReaderAtを利用する他のライブラリにそのまま渡せて便利だから・・・
なので作ることにします。

実装方針

  • 各エントリのheader start/end offset, file content start/end offsetをとることで、*io.SectionReaderと組み合わせることでファイルを読めるようにします。
  • tarのheader自体の解析はarchive/tarに任せます。
    • それに何かの修正があるたびに追従するのはできると思いますが、気づいてから取り込むまでにラグができるのは望ましくありません。
    • Goのモジュールシステムではgo.modに書かれたバージョンにかかわらず、コンパイルするときのtoolchainのstd libが使われます。つまり、新しいコンパイラとstdライブラリのセットでコンパイルされればarchive/tarを使っている部分は何もしなくても修正を受けることができます。
  • github.com/nlepage/go-tarfsと違い、元からio.ReaderAtを引数とします。
  • 上記実装に倣い、tar.NewReaderに読み込まれたバイト数をカウントできるio.Readerを渡します。
  • *tar.ReaderNextを呼び、この時点での読み込まれたバイト数が、エントリのheader end offset兼file content start offsetとなりますのでこれを記録します。
  • 上記実装とは違い、header start offsetは、1つ前のエントリのfile content end offsetを512バイトのブロック単位になるようにパディングしたものを得ます。
  • file conent end offset = (file content start offset) + tar.Header.Sizeで取得します。
    • ただし、これはsparse fileおよび各種LinkName系のエントリに対しては正しくありません。
  • sparseのhole情報を何とかして得ます。
  • 得たsparse holeの情報から、

offsetの収集

前述通りあるファイルエントリへのheader, file contentの開始/終了オフセットを集めますので以下の型を定義します。

https://github.com/ngicks/go-fsys-helper/blob/dd69bbd94d5c28ce6d557906cfd3e5d454839e0d/tarfs/headers.go#L27-L32

headerEnd - headerStartは常に512となるとは限りません。archive/tarPAX extended headerなどを読み込むと、それをユーザーに返さずに次のヘッダーを読み込んで情報をマージしてからユーザーに返すためです。

github.com/nlepage/go-tarfsに倣い、Readされたバイト数を記録するReaderを定義します。

https://github.com/ngicks/go-fsys-helper/blob/dd69bbd94d5c28ce6d557906cfd3e5d454839e0d/tarfs/headers.go#L93-L116

Seekが実装されていますが、これはarchive/tarデータを読み捨てる際にSeekが実装されていれば使うからです。

offsetの収集は以下のようにできます。github.com/nlepage/go-tarfsとほぼ同じです。(ただしGo 1.23で追加されたiteratorの形でですが。)

func tryMapsCollect[K comparable, V any](keyMapper func(V) K, seq iter.Seq2[V, error]) (map[K]V, error) {
    collected := make(map[K]V)
    for v, err := range seq {
        if err != nil {
            return collected, err
        }
        collected[keyMapper(v)] = v
    }
    return collected, nil
}

func tryCollectHeaderOffsets(seq iter.Seq2[*headerOffset, error]) (map[string]*headerOffset, error) {
    return tryMapsCollect(func(ho *headerOffset) string { return path.Clean(ho.h.Name) }, seq)
}

func iterHeaders(r io.ReaderAt) iter.Seq2[*headerOffset, error] {
    return func(yield func(*headerOffset, error) bool) {
        countingR := &countingReader{R: io.NewSectionReader(r, 0, math.MaxInt64-1)}
        tr := tar.NewReader(countingR)

        var (
            prev *headerOffset
            blk  block
        )
        for {
            h, err := tr.Next()
            if err != nil {
                if err == io.EOF {
                    break
                } else {
                    yield(nil, fmt.Errorf("read tar archive: %w", err))
                    return
                }
            }

            headerEnd := countingR.Count

            hh := &headerOffset{h: h, headerEnd: headerEnd, bodyStart: headerEnd}
            if prev != nil {
                // bodyEnd padded to 512 bytes block boundary
                hh.headerStart = prev.bodyEnd + (-prev.bodyEnd)&(blockSize-1)
            }

            switch hh.h.Typeflag {
            case tar.TypeLink, tar.TypeSymlink, tar.TypeChar, tar.TypeBlock, tar.TypeDir, tar.TypeFifo,
                tar.TypeCont, tar.TypeXHeader, tar.TypeXGlobalHeader,
                tar.TypeGNULongName, tar.TypeGNULongLink:
                // They may have size for name.
                hh.bodyEnd = hh.bodyStart
            default:
                hh.bodyEnd = hh.bodyStart + int(hh.h.Size)
            }

            if !yield(hh, nil) {
                return
            }
            prev = hh
        }
    }
}

*tar.Readerが0バイトしか読まないにもかかわらず、*tar.Header.Sizeが0でないことがテストデータ上あったため、常にhh.bodyEnd = hh.bodyStart + int(hh.h.Size)としてはだめで、とりあえず上記のようにswitch-caseしています。

こうして収集した情報を用いると、以下のように*io.SectionReaderを用いてファイルの内容を読みだすことができます。

type seekReadReaderAt interface {
    io.Reader
    io.ReaderAt
    io.Seeker
}

func makeReader(ra io.ReaderAt, h *header) seekReadReaderAt {
    return io.NewSectionReader(ra, int64(h.bodyStart), int64(h.bodyEnd)-int64(h.bodyStart))
}

sparse情報の再解析

ただしこれだけではsparse fileを取り扱うことができません。

GNU Tar Manual: Storing Sparse Filesによると、
GNU tarは3通りの記法でsparse(疎) fileの格納が可能であるとあります。実際にはほかの拡張によって別な記法があるかもしれませんが少なくともGoarchive/tarはこの3つのみをサポートします。

https://github.com/golang/go/blob/go1.24.1/src/archive/tar/reader.go#L192-L213

https://github.com/golang/go/blob/go1.24.1/src/archive/tar/reader.go#L469-L517

https://github.com/golang/go/blob/go1.24.1/src/archive/tar/reader.go#L215-L258

https://github.com/golang/go/blob/go1.24.1/src/archive/tar/reader.go#L519-L587

https://github.com/golang/go/blob/go1.24.1/src/archive/tar/reader.go#L589-L622

問題はこれらの情報はGoarchive/tarがハンドルしきってしまい(GNU PAX sparse format version 0.1を除いて)ユーザーにsparseの情報が返ってこないことがです。つまり、何とかして自前でこれらの情報を解析しなお必要があります。
前述通りtarにはいろんな拡張仕様があり1から10まで自前で実装するのは資料に当たるのが大変そうという意味で困難ですのでarchive/tarのソースをコピーして改変するのが最も現実的な解法かと思います。

わお、stdのソースをコピーして改変。したくないですねえ。ですがよくよくarchive/tarのソースを読んでいるとsparseの情報解析しなおすだけなら案外少量のコピーで行けそうです。

archive/tarnext methodを読むと、handleSparseはここで呼ばれていることがわかります。
rawHdrという名前で、解析された最後のheader blockを渡しています。

https://github.com/golang/go/blob/go1.24.1/src/archive/tar/reader.go#L69-L173

必要最小限だけをまねると以下のようになります

https://github.com/ngicks/go-fsys-helper/blob/dd69bbd94d5c28ce6d557906cfd3e5d454839e0d/tarfs/headers.go#L118-L146

headerのTypeflagtar.TypeXHeaderや、tar.TypeGNULongNameのとき、archive/tarnextでは情報を解析したりしています。
すでに解析済みであるので要するにこれらの時はdata sectionを読み飛ばせばいいので上記の通りになります。

handleSparseFile, readOldGNUSparseMap, readGNUSparsePAXHeadersarchive/tarでは*tar.Readerのmethodでしたがこれらを単なる関数になるように若干改変します。

https://github.com/ngicks/go-fsys-helper/blob/dd69bbd94d5c28ce6d557906cfd3e5d454839e0d/tarfs/headers.go#L148-L216

以下がarchive/tarから未改変のままコピーされたコード群です。コメントアウトされたものを含んでも400行以下で少量にとどめることができました。

https://github.com/ngicks/go-fsys-helper/blob/dd69bbd94d5c28ce6d557906cfd3e5d454839e0d/tarfs/copied_go_std.go

このsparse情報の再建部分ををoffsetの収集部分に盛り込むと以下のようになります。

https://github.com/ngicks/go-fsys-helper/blob/dd69bbd94d5c28ce6d557906cfd3e5d454839e0d/tarfs/headers.go#L34-L91

sparse file readerの作成

github.com/ngicks/go-fsys-helper/stream

を定義しておいたので、これらを組み合わせてsparse hole部分を0x00で読み込むio.ReaderAtが完成します。

https://github.com/ngicks/go-fsys-helper/blob/dd69bbd94d5c28ce6d557906cfd3e5d454839e0d/tarfs/reader.go#L9-L47

test

で、この考え方は正しいの?というのが気になるので、テストを行います

$(go env GOROOT)/src/archive/tar/testdata/以下にarchive/tarがテストに用いているtarファイル群があるのでこれをコピーしてこれを*tar.Readerで読みこんだ内容と、iterHeadersmakeReaderで作ったio.Readerから読み込んだ内容が同じであるかをチェックします。

...今考えると別にファイルをコピーしておく必要はないですね。まあいいでしょう。(多分go:embedするつもりでこうしていたんでしょう)

実際には以下のステップを踏みます

  • setup_test.go:
    • go mod edit -jsonでこのモジュールのgo.modに書かれた内容をJSONで取得します。
    • 内容からgo.modgo versionを取得します(現在go 1.24.0ですので以後1.24.0と書きます。)
    • go install golang.org/dl/go1.24.0@latest
    • go1.24.0 downloadでsdkをダウンロード
    • go1.24.0 env GOROOTでstd libraryが格納されたディレクトリパスを取得
    • cp ${GOROOT}/src/archive/tar/testdata/* ./testdata/go1.24.0/
  • headers_test.go:
    • *tar.Readerで読みこんだファイル内容を収集
    • iterHeadersmakeReaderで作ったio.Readerから読み込んだ内容を収集
    • bytes.Equalで比較

gnu-sparse-big.tar, pax-sparse-big.tarはテストから除外しました。内容が大きいせいかテストがタイムアウトするためです。
他のすべてでbytes.Equalで一致したためこの実装はある程度正しいようです。

テスト実行者のgo versionによらずに同じtestdataを引っ張ってこれるからこのほうがいいかな~っていう薄い考えでこういうセットアップ実装にしましたが、go.modにtoolchainを指定してしまったほうがよかったかもですね。(ciと相性悪そうだし)

fs.FS interfaceを実装する

fs.FSとして利用したいのでそうなるようにinterfaceを整えます。

とは言え特段ここに関しては述べることはありません。
前述のiterHeadersで収集した情報をもとにfile system風のtrieを構築し、ファイルをopenする際には前述のmakeReaderを呼び出すようにします。

staticなdirentry interfaceとOpenで開かれたstatefulなopenDirentry interfaceを定義します。開かれたファイルにはReadで読み込んだカーソル位置などのstateがありますが、保存されているデータそのものはダイナミックに変化しないことを期待します。

https://github.com/ngicks/go-fsys-helper/blob/dd69bbd94d5c28ce6d557906cfd3e5d454839e0d/tarfs/ent.go#L9-L20

これらはfiledirによって実装されます。

https://github.com/ngicks/go-fsys-helper/blob/dd69bbd94d5c28ce6d557906cfd3e5d454839e0d/tarfs/entfile.go

https://github.com/ngicks/go-fsys-helper/blob/dd69bbd94d5c28ce6d557906cfd3e5d454839e0d/tarfs/entdir.go

fileはopen時に前述のmakeReaderを呼びます。

https://github.com/ngicks/go-fsys-helper/blob/dd69bbd94d5c28ce6d557906cfd3e5d454839e0d/tarfs/entfile.go#L17-L23

diraddChildでdirentryの追加、

https://github.com/ngicks/go-fsys-helper/blob/dd69bbd94d5c28ce6d557906cfd3e5d454839e0d/tarfs/entdir.go#L26-L56

openChildで子要素の読み込みを行います。これらでtrieの構築と探索を行うわけですね。

https://github.com/ngicks/go-fsys-helper/blob/dd69bbd94d5c28ce6d557906cfd3e5d454839e0d/tarfs/entdir.go#L58-L78

なのでNewはrootとなるdirdirentryaddChildするだけになります。

https://github.com/ngicks/go-fsys-helper/blob/dd69bbd94d5c28ce6d557906cfd3e5d454839e0d/tarfs/fs.go#L13-L82

再帰呼び出しだとstackが深くなるためfor-loopで処理できたほうが計算効率はいいと思いますがシンプルにしたかったのでこんなもんで良しとします。

こちらも同じく*tar.Readerで読みこんだ内容と*tarfs.FSで読み込んだ内容が一致するかをテストします。
さらに、fstest.TestFSfs.FSとしての挙動が正しいのかもテストします。

regular fileとdirectory以外は無視する実装なので適当にそれらしかないtarを用意して実行します。

https://github.com/ngicks/go-fsys-helper/blob/dd69bbd94d5c28ce6d557906cfd3e5d454839e0d/tarfs/fs_test.go

パスしました。

おわりに

  • 前から欲しかったtario.ReaderAtで読める実装が用意できました。
  • こういった実装はあまり見ないので、多分需要は少ないんだと思います。
  • tarというフォーマット自体をしらべたので理解が深まってよかったです。

今後は

  • readerにio.WriterToを実装させる
    • 今の実装ではsparse fileのholeが単なる0x00として読み込まれるためコピー時に非効率です
    • linuxや他のunix系のシステムではlseek(2)でファイルの終端を超えてseekを行うとholeが作られますが、windowsでは違うというのを聞いたことがあるため調査が大変そうなのでやめておきました
    • そもそもsparseのあるファイルを取り扱わないかも
    • 現状archive/tar自身もsparse fileの書き込みでholeを作る実装になっていません(#22735)ので問題ないといえばないです。
  • symlink supportを加える
    • #49580より(おそらく)Go1.25からfs.ReadLinkFSが追加され、さらに
    • #67002よりGo1.25から*os.RootMkdirAllSymlinkなどGo1.24では実装が見送られたmethod群が実装されますので、これと組み合わせて使えるようにする意図もあります。
GitHubで編集を提案

Discussion