System.Formats.Tarの使い方

# はじめに

.NET 7から、tarの読み書き機能がベースライブラリに追加されたので、メモ代わりに使い方を書く。
なお、この記事ではtarに関する説明はあまりしないので、下記リンクのような解説ページを参照のこと。
参考URL: https://www.gnu.org/software/tar/manual/html_section/Formats.html

# 使える条件

* .NET 7以降であること。
    * .NET 6では扱えない。多分UnixFileModeを使っているため

TARの読み書きはサードパーティ製のものが既にあるので、net6.0以前で使いたい場合はそちらを使う事。

* [SharpZipLib](https://github.com/icsharpcode/SharpZipLib)
* [sharpcompress](https://github.com/adamhathcock/sharpcompress)

# シンプルな使い方

`System.Formats.Tar.TarFile`クラスのユーティリティを使えば、tarファイルの圧縮、展開ができる。
なお、シンボリックリンク、通常ファイル以外のファイル(キャラクタデバイスファイルとか)も対応している。
生成、展開ともに同期版と非同期版両方が用意してある。

形式はPOSIX.1-2001(pax)フォーマット、通常ファイルの権限は0644になる。

## 生成

`void TarFile.CreateFromDirectory`または`Task TarFile.CreateFromDirectoryAsync`を使用する。
非同期版はCancellationTokenで途中キャンセルが可能。

どちらも展開先はファイルパスで指定するか、あるいはStreamで指定する。
tgzを作りたい場合はStreamにして、GZipStream等と組み合わせる必要がある。

* [CreateFromDirectory](https://learn.microsoft.com/ja-jp/dotnet/api/system.formats.tar.tarfile.createfromdirectory?view=net-8.0)
* [CreateFromDirectoryAsync](https://learn.microsoft.com/ja-jp/dotnet/api/system.formats.tar.tarfile.createfromdirectoryasync?view=net-8.0)

## 展開

`void TarFile.ExtractToDirectory`または`Task TarFile.ExtractToDirectoryAsync` を使用する。
こちらも非同期版はCancellationTokenで途中キャンセル可能。

ソースはファイルパスで指定するか、あるいはStreamで指定する。
tgzから展開する場合はGZipStream等と組み合わせてStreamで指定する必要がある。

* [ExtractToDirectory](https://learn.microsoft.com/ja-jp/dotnet/api/system.formats.tar.tarfile.extracttodirectory?view=net-8.0)
* [ExtractToDirectoryAsync](https://learn.microsoft.com/ja-jp/dotnet/api/system.formats.tar.tarfile.extracttodirectoryasync?view=net-8.0)

## 使用例

```csharp
using System.Formats.Tar;

{
    // アーカイブの生成
    // tgz等を生成したい場合はStream版を使う
    await TarFile.CreateFromDirectoryAsync("srcdir", "result.tar", true);
    // using var stm = File.Create("result.tar");
    // await TarFile.CreateFromDirectoryAsync("srcdir", stm, true);
}
{
    // アーカイブの展開
    // tgz等を展開したい場合はStream版を使う。
    await TarFile.ExtractToDirectoryAsync("result.tar", "dstdir", true);
    // using var stm = File.OpenRead("result.tar");
    // await TarFile.ExtractToDirectoryAsync(stm, "dstdir", true);
}
```

# 柔軟な生成、展開をしたい場合

TarFileはあるディレクトリ以下を一律に扱う。しかし、実運用では不都合になる場合もある。
例えば、特定パターンの除外、権限の設定等。
より柔軟な生成・展開を行いたい場合、"TarWriter"と"TarReader"を使う必要がある。

## 生成

アーカイブ生成には[TarWriter](https://learn.microsoft.com/dotnet/api/system.formats.tar.tarwriter?view=net-8.0)を使用する。
まず最初に`new TarWriter(Stream s, TarEntryFormat f, bool leaveOpen)`で書き込み先インスタンスを作成する。
ここでTarEntryFormatを指定することにより、生成するtarファイルの形式が決まる。
基本的にはPaxを選んでおけば間違いはない。V7は最も古い形式で制約も多いのでまず使うことはないだろう。
古いアーカイバーではPaxを認識しないことがたまにあるので、そういう時はUstarを選ぶ必要があるかもしれない。

使用可能なTarEntryFormatは、下記URLを参照のこと。
https://learn.microsoft.com/dotnet/api/system.formats.tar.tarentryformat?view=net-8.0

全てのエントリの書き込みが終わった後にDisposeすれば、書き込みは完了となる。

### エントリの追加

TarWriterインスタンスを作成したら、そこに個々のエントリを追加していく。
この時、最初に指定したTarEntryFormatごとに生成するエントリのインスタンスが異なってくるので注意する。
それぞれの対応は以下のようになる。

|TarEntryFormat|対応するクラス|
|--------------|------------|
|Gnu|[GnuTarEntry](https://learn.microsoft.com/dotnet/api/system.formats.tar.gnutarentry?view=net-8.0)|
|Pax|[PaxTarEntry](https://learn.microsoft.com/ja-jp/dotnet/api/system.formats.tar.paxtarentry?view=net-8.0)|
|Ustar|[UstarEntry](https://learn.microsoft.com/ja-jp/dotnet/api/system.formats.tar.ustartarentry?view=net-8.0)|
|V7|[V7TarEntry](https://learn.microsoft.com/ja-jp/dotnet/api/system.formats.tar.v7tarentry?view=net-8.0)|

どのエントリクラスも、[TarEntry](https://learn.microsoft.com/dotnet/api/system.formats.tar.tarentry?view=net-8.0)がベースとなっており、

1. 名前(ディレクトリ名含む)をコンストラクタで決定
2. 権限、UID、GID等の各種追加属性の設定
3. `Stream DataStream`プロパティに、データ部分を入れたStreamインスタンスをセット
    * データ部が無いタイプ(ディレクトリエントリ等)あるいは空の場合はnullでも可
4. [`TarWriter.WriteEntry(TarEntry)`](https://learn.microsoft.com/ja-jp/dotnet/api/system.formats.tar.tarwriter.writeentry?view=net-8.0#system-formats-tar-tarwriter-writeentry(system-formats-tar-tarentry))で書き込み
    * Streamはこの後破棄してOK
    * [WriteEntryAsync](https://learn.microsoft.com/dotnet/api/system.formats.tar.tarwriter.writeentryasync?view=net-8.0)もある

という流れとなる。

## 展開

[TarReader](https://learn.microsoft.com/dotnet/api/system.formats.tar.tarreader?view=net-8.0)インスタンスを使用する。
`new TarReader(Stream stm, bool leaveOpen)`でインスタンスを生成する。
TarEntryFormatは内部で識別する仕様となっているので指定の必要は無い。

### エントリの走査と展開

[`TarEntry? TarReader.GetNextEntry(bool copyData = false)`](https://learn.microsoft.com/ja-jp/dotnet/api/system.formats.tar.tarreader.getnextentry?view=net-8.0)で、エントリを先頭から順番に取り出す仕様となっている。
戻り値にnullが返ってきた時が終端となる。
copyDataをtrueにすると、データ部分を新しく生成したMemoryStreamに乗せてTarEntryを作成する。
大きいデータが来ると予想される場合は注意しよう。
なお、GetNextEntryには時間がかかる場合もあるので、そのような場合には[`Task TarReader.GetNextEntryAsync(bool copyData, CancellationToken)`](https://learn.microsoft.com/dotnet/api/system.formats.tar.tarreader.getnextentryasync?view=net-8.0#system-formats-tar-tarreader-getnextentryasync(system-boolean-system-threading-cancellationtoken)) を使用する。
`TarEntryFormat TarEntry.Format`で形式が判別可能なので、名前とデータ部分以外の情報が欲しい場合は、
個別エントリにキャストを行う。

## 使用例

```csharp
// using System.Formats.Tar;
// pax生成
using (var f = File.Create("out-pax.tar"))
using (var tw = new TarWriter(f, TarEntryFormat.Pax, false))
{
    {
        string longname = new string('a', 512);
        var entry = new PaxTarEntry(TarEntryType.RegularFile, longname);
        using (var mstm = new MemoryStream())
        {
            byte[] data = new byte[64];
            data.AsSpan().Fill(1);
            mstm.Write(data.AsSpan());
            mstm.Seek(0, SeekOrigin.Begin);
            entry.DataStream = mstm;
            tw.WriteEntry(entry);
        }
    }
    {
        var entry = new PaxTarEntry(TarEntryType.RegularFile, "b.txt");
        using (var mstm = new MemoryStream())
        {
            byte[] data = new byte[32];
            data.AsSpan().Fill(2);
            mstm.Write(data.AsSpan());
            mstm.Seek(0, SeekOrigin.Begin);
            entry.DataStream = mstm;
            tw.WriteEntry(entry);
        }
    }
}
// 展開
using (var f = File.OpenRead("out-pax.tar"))
using (var tr = new TarReader(f))
{
    while (true)
    {
        var entry = tr.GetNextEntry();
        if (entry == null)
        {
            break;
        }
        // 本体データを読み込まずに次へ行くことも可
        Console.WriteLine($"{entry.Name},{entry.Format}");
    }
}

```

## 使用例2: tgzを作成、展開するやり方

tgzを圧縮、展開するやり方も掲載しておく。
今はtar.xz等も出回っているが、圧縮/展開Streamを作るところで[xz.net](https://www.nuget.org/packages/XZ.NET)や[sharpcompress(展開のみ)](https://www.nuget.org/packages/sharpcompress/)が追加で必要になる。

```csharp
using System.Formats.Tar;
using System.IO.Compression;

{
    using var f = File.Create("out.tgz");
    using var fgz = new GZipStream(f, CompressionLevel.Optimal);
    using var tw = new TarWriter(fgz, TarEntryFormat.Pax);
    var entry = new PaxTarEntry(TarEntryType.RegularFile, "x.txt");
    using var mstm = new MemoryStream((new byte[128]).Select(_ => (byte)'a').ToArray());
    entry.DataStream = mstm;
    tw.WriteEntry(entry);
}
{
    using var f = File.OpenRead("out.tgz");
    using var fgz = new GZipStream(f, CompressionMode.Decompress);
    using var tr = new TarReader(fgz);
    var entry = tr.GetNextEntry();
    if(entry == null)
    {
        throw new NullReferenceException();
    }
    Console.WriteLine($"{entry.Name}, {entry.EntryType}");
}
```

# 終わりに

tarが標準で使えるようになったことで、依存がより少ないパッケージが作れるようになったのは大きい。
`tar.*`の`*`部分を判別してくれず、別途Streamを分けないといけないのは書いてると煩わしいと思うかもしれないが、そこにリソースを割くよりもAPIの簡潔さを優先させたという判断は納得できる。
非Windows環境で多く使われているTarを正式サポートしたことが、より.NETを普遍的に動かしたいという姿勢を示しているようでいて、興味深い。