在 .NET 8 中处理 tar 文件

Avatar
不若风吹尘
2024-09-05T10:15:46
63
0

早在 2022 年,.NET 7 就在基础类库中增加了对原生处理 tar 文件的支持。在这篇文章中,将介绍如何执行一些基本的 tar 文件操作,如何使用 tar 命令行工具来完成这些操作的,以及如何改用 .NET 内置的支持。然后我将讨论现有支持的各种限制。

什么是 tar 文件?

tar 文件(通常称为 tarball)是一种文件(通常以 .tar 作为后缀),它将多个文件合并成一个单一文件。在 Linux 和其他基于 *nix 的操作系统中,tar 文件非常常见,用于分发多个文件或存档/备份文件。在 Windows 上,更常见的是使用 .zip 文件来完成这些目的(尽管 Windows 现在也原生支持 tar 文件)。

.zip 文件不同,.tar 文件本身并不包含压缩功能,因此非常常见的是看到 .tar.gz 文件。这些文件是 “普通” 的 .tar 文件,随后使用了 gzip(基于与 ZIP 文件相同的 DEFLATE 算法)进行压缩。

从目录创建 tar 文件可以保留文件系统上文件的许多属性,例如:

在 .NET 7 之前,处理 tar 文件总是需要第三方库。NuGet 上有很多选择:

在 .NET 7 中,基础类库中增加了对 tar 文件的基本支持。在这篇文章的其余部分,我将展示如何使用这些 API 来执行 tar 文件的常见功能。

注意,虽然我在本文中使用的 API 也都存在于 .NET 7 中,但 .NET 8 包含了各种 Bug 修复,并且支持更多的 tar 文件特性和格式,这也是在本文中所使用的版本。

所有使用 tar 命令行的例子都是在 Linux 上运行的,但 .NET 代码应该可以在任何操作系统上运行。

创建一个 .tar.gz 归档

我们从最明显的地方开始——从现有目录创建 tar 文件。假设你有一个位于 home 目录中的文件夹,在 ~/my-files,你想分发这个文件夹。这还包括一个符号链接(myapp.so)和一个硬链接(someother.so):

$ ls -lR ~/my-files
/home/andrewlock/my-files:
total 1420
drwxr-xr-x 2 root root    4096 Aug 11 16:00 bin
drwxr-xr-x 2 root root    4096 Aug 11 15:57 docs
lrwxrwxrwx 1 root root      17 Aug 11 16:01 myapp.so -> ./bin/myapp.so
-rw-r--r-- 2 root root 1443232 Aug 11 15:56 someother.so

/home/andrewlock/my-files/bin:
total 3756
-rw-r--r-- 1 root root 2399608 Aug 11 15:55 myapp.so
-rw-r--r-- 2 root root 1443232 Aug 11 15:56 someother.so

/home/andrewlock/my-files/docs:
total 5896
-rw-r--r-- 1 root root      10 Aug 11 15:57 README
-rw-r--r-- 1 root root 6027280 Aug 11 15:57 someother.xml

使用 tar 创建 .tar.gz 归档文件

cd ~/my-files
tar -czvf ~/myarchive.tar.gz .

在这个例子中,我们将工作目录更改为 ~/my-files(如果我们是从 ~ 目录运行的,则 tar 会将 my-files 作为 tar 目录中路径名称的前缀)。传递给 tar 命令的标志表示:

  • -c 创建一个新的归档文件
  • -z 使用 gzip 压缩生成的 tar 文件
  • -v 列出正在处理的文件(可选)
  • -f <FILE> 将归档输出到文件 <FILE>

注意,如果你省略了 -z 标志,tar 将创建一个未压缩的 tar 文件。

使用 .NET 创建 .tar.gz 归档文件

那么我们如何在 .NET 中实现这一点呢?.NET 7 添加了 名为 TarFile 的类,其中包含了 用于创建 tar 归档文件的静态方法,因此你可能会认为可以这样做:

using System.Formats.Tar;

string sourceDir = "./my-files";
string outputFile = "./myarchive.tar"; // note this _doesn't_ create a valid .tar.gz file

TarFile.CreateFromDirectory(sourceDir, outputFile, includeBaseDirectory: false);

问题在于 TarFile 工具 处理 tar 格式,它 包含在处理 tar 文件时非常常见的 gzip 处理。幸运的是,使用 GZipStream 并自行处理文件和流的创建来添加对此的支持并不太难:

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

string sourceDir = "./my-files";
string outputFile = "./myarchive.tar.gz";

using FileStream fs = new(outputFile, FileMode.CreateNew, FileAccess.Write);
using GZipStream gz = new(fs, CompressionMode.Compress, leaveOpen: true);

TarFile.CreateFromDirectory(sourceDir, gz, includeBaseDirectory: false);

当你运行这段代码时,你会得到一个与使用 tar 命令生成的类似的 gzip 压缩包!

请注意,这里的细节很重要,因此生成的文件可能与 tar 生成的文件不完全相同。在文章末尾对此进行了更多讨论。

includeBaseDirectory 参数指定了你是否希望压缩包中的路径包含相对于当前工作目录的初始基础路径段。如果在上面的例子中将其设置为 true,则路径将被加上前缀 my-files/

现在我们可以使用 .NET 创建 .tar.gz 文件了,下面我们来看看如何解压这些文件。

解压 .tar.gz 归档文件

正如之前提到的,tar 文件的一个特性就是支持权限、硬链接/符号链接、所有者等。这不可避免地意味着你在解压归档文件时有很多选项,具体取决于你想保留什么和忽略什么。为了本节的目的,只看一些非常简单的例子。

使用 tar 解压 .tar.gz 归档文件

要使用 tar 工具将归档文件解压到当前工作目录,你可以使用如下命令:

tar -xzvf ~/my_archive.tar.gz

选项的含义如下:

  • -x 解压归档文件
  • -z 在处理前使用 gzip 解压缩文件
  • -v 列出正在处理的文件(可选)
  • -f <FILE> 将归档输出到文件 <FILE>

如果你想将文件输出到不同的目录,需要使用额外的参数 -C <DIR>,例如:

tar -xzvf ~/my_archive.tar.gz -C /path/to/dir

现在让我们看看如何使用 .NET 来实现这一功能。

使用 .NET 解压 .tar.gz 归档文件

和之前一样,TarFile 类 有一个非常有用的 ExtractToDirectory 方法,但该方法仅适用于 tar 文件,而不适用于被压缩的 tar.gz 文件。不过,我们仍然可以借助 GZipStream 类来解决这个问题,代码与之前的非常相似:

using System.Formats.Tar;

string sourceTar = "./myarchive.tar.gz";
string extractTo = "/path/to/dir";

using FileStream fs = new(sourceTar, FileMode.Open, FileAccess.Read);
using GZipStream gz = new(fs, CompressionMode.Decompress, leaveOpen: true);

TarFile.ExtractToDirectory(gz, extractTo, overwriteFiles: false);

这里提供的 .NET 代码中唯一可用的选项是 overwriteFiles;如果在解压过程中文件已存在,并且 overwriteFiles 不为 true,这将抛出一个 IOException

.NET 实现的解压通常与 tar 工具的表现类似,但也有一些差异,例如 解压绝对路径保留所有权,这些稍后讨论。

.tar.gz 归档中提取单个文件

有时你只想从归档中提取一个单独的文件,而不是整个归档。当归档非常大以至于完全解压变得困难或不可能时,这一点尤为重要。

使用 tar.tar.gz 归档中提取单个文件

要使用 tar 从归档中提取单个文件,只需在命令末尾添加该文件的路径即可。以下命令会提取归档内部路径为 ./bin/someother.so 的文件,并将其写入当前目录:

tar -xzvf ~/my_archive.tar.gz ./bin/someother.so

这些选项与全文提取中描述的相同,因此这里不再重复。

.tar.gz 归档文件中提取单个文件使用 .NET

不幸的是,我们没有更多针对.NET 的高级辅助工具来处理这个需求,所以我们需要退而使用较低级别的 TarReaderTarEntry API。

在下面的代码中,我们将现有的 .tar.gz 文件作为 FileStream 打开,并使用 GZipStream 进行解压缩,这与之前的示例类似。然后我们将这个流传递给一个 TarReader 实例,并遍历它找到的每个 TarEntry 。当我们找到名称正确的条目时,就提取该文件并退出。

string sourceTar = "./my_archive.tar.gz";
string pathInTar = "./bin/someother.so";
string destination = "./extractedFile.so";

// Open the source tar file, decompress, and pass stream to TarReader
using FileStream fs = new(sourceTar, FileMode.Open, FileAccess.Read);
using GZipStream gz = new(fs, CompressionMode.Decompress, leaveOpen: true);
using var reader = new TarReader(gz, leaveOpen: true);

// Loop through all the entries in the tar
while (reader.GetNextEntry() is TarEntry entry)
{
    // If the entry matches the required path, extract the file
    if (entry.Name == pathInTar)
    {
        Console.WriteLine($"Found '{pathInTar}', extracting to '{destination}");
        entry.ExtractToFile(destination, overwrite: false);
        return; // all done
    }
}

// If we get here, we didn't find the file

ExtractToFile 辅助函数可以提取文件和目录,但不会提取符号链接或硬链接;只有在提取整个归档文件时才会包含这些链接 。

列出 .tar.gz 中的所有文件而不进行提取

有时你实际上不需要从文件中提取任何内容,只是想查看其中包含的文件。本节将展示如何使用 tar 命令和 .NET 来实现这一点。

使用 tar 列出 .tar.gz 中的所有文件

要使用 tar 列出归档文件中的所有文件,你可以使用以下命令:

tar -tzvf ~/myarchive.tar.gz

这些选项大多是:

  • -t 列出归档文件的内容
  • -z 在处理前使用 gzip 解压缩文件
  • -v 详细列出文件(可选)
  • -f <FILE> 将归档输出到文件 <FILE>

-v 选项不是必需的,但添加它会输出关于每个条目的附加信息,类似于 ls -l

drwxr-xr-x root/root         0 2024-08-11 16:02 ./
lrwxrwxrwx root/root         0 2024-08-11 16:01 ./myapp.so -> ./bin/myapp.so
drwxr-xr-x root/root         0 2024-08-11 15:57 ./docs/
-rw-r--r-- root/root        10 2024-08-11 15:57 ./docs/README
-rw-r--r-- root/root   6027280 2024-08-11 15:57 ./docs/someother.xml
-rw-r--r-- root/root   1443232 2024-08-11 15:56 ./someother.so
drwxr-xr-x root/root         0 2024-08-11 16:00 ./bin/
-rw-r--r-- root/root   2399608 2024-08-11 15:55 ./bin/myapp.so
hrw-r--r-- root/root         0 2024-08-11 15:56 ./bin/someother.so link to ./someother.so

你可以在此处阅读 ls -l 的完整规范: https://pubs.opengroup.org/onlinepubs/9699919799/utilities/ls.html 但总的来说,这显示了以下信息:

  • 条目的类型(d 表示目录,- 表示文件,l 表示符号链接,h 表示硬链接)
  • 条目的权限
  • 所有者
  • 条目的大小(以字节为单位)
  • 修改时间
  • 路径(对于符号链接和硬链接还包括链接位置)

使用 .NET 列出 .tar.gz 文件中的所有文件

正如你所料,.NET 并没有内置的方法来打印这些信息。编写一个这样的方法虽然有点烦人,但并不难;tar 条目中包含的所有信息都暴露在 TarEntry 中。

下面的代码主要模仿了上面 tar-tzvf 格式的显示方式:

using System.Formats.Tar;
using System.Globalization;
using System.IO.Compression;

string sourceTar = "./myarchive.tar.gz"

// read the tar and loop through the entries
using FileStream fs = new(sourceTar, FileMode.Open, FileAccess.Read);
using GZipStream gz = new(fs, CompressionMode.Decompress, leaveOpen: true);
using var reader = new TarReader(gz, leaveOpen: true);

while (reader.GetNextEntry() is TarEntry entry)
{
    // Get the file descriptor
    char type = entry.EntryType switch
    {
        TarEntryType.Directory => 'd',
        TarEntryType.HardLink => 'h',
        TarEntryType.SymbolicLink => 'l',
        _ => '-',
    };

    // Construct the permissions e.g. rwxr-xr-x
    // Moved to a separate function just because it's a bit verbose
    string permissions = GetPermissions(entry);

    // Display the owner info. 0 is special (root) but .NET doesn't
    // expose the mappings for these IDs natively, so ignoring for now
    string ownerUser = entry.Uid == 0 ? "root" : entry.Uid.ToString(CultureInfo.InvariantCulture);
    string ownerGroup = entry.Gid == 0 ? "root" : entry.Gid.ToString(CultureInfo.InvariantCulture);

    // The length of the data and the modification date in bytes
    long size = entry.Length;
    DateTimeOffset date = entry.ModificationTime.UtcDateTime;

    // Match the display format used by tar -tzvf
    string path = entry.EntryType switch
    {
        TarEntryType.HardLink => $"{entry.Name} link to {entry.LinkName}",
        TarEntryType.SymbolicLink => $"{entry.Name} -> {entry.LinkName}",
        _ => entry.Name,
    };

    // Write the entry!
    Console.WriteLine($"{type}{permissions} {ownerUser}/{ownerGroup} {size,9} {date:yyyy-MM-dd hh:mm} {path}");
}

// Construct the permissions
static string GetPermissions(TarEntry entry)
{
    var userRead = entry.Mode.HasFlag(UnixFileMode.UserRead) ? 'r' : '-';
    var userWrite = entry.Mode.HasFlag(UnixFileMode.UserWrite) ? 'w' : '-';
    var userExecute = entry.Mode.HasFlag(UnixFileMode.UserExecute) ? 'x' : '-';
    var groupRead = entry.Mode.HasFlag(UnixFileMode.GroupRead) ? 'r' : '-';
    var groupWrite = entry.Mode.HasFlag(UnixFileMode.GroupWrite) ? 'w' : '-';
    var groupExecute = entry.Mode.HasFlag(UnixFileMode.GroupExecute) ? 'x' : '-';
    var otherRead = entry.Mode.HasFlag(UnixFileMode.OtherRead) ? 'r' : '-';
    var otherWrite = entry.Mode.HasFlag(UnixFileMode.OtherWrite) ? 'w' : '-';
    var otherExecute = entry.Mode.HasFlag(UnixFileMode.OtherExecute) ? 'x' : '-';

    return $"{userRead}{userWrite}{userExecute}{groupRead}{groupWrite}{groupExecute}{otherRead}{otherWrite}{otherExecute}";
}

当你运行上述命令时,得到的输出结果基本上和运行 tar -tzvf 一样:

drwxr-xr-x root/root         0 2024-08-11 15:02 ./
lrwxrwxrwx root/root         0 2024-08-11 15:01 ./myapp.so -> ./bin/myapp.so
drwxr-xr-x root/root         0 2024-08-11 14:57 ./docs/
-rw-r--r-- root/root        10 2024-08-11 14:57 ./docs/README
-rw-r--r-- root/root   6027280 2024-08-11 14:57 ./docs/someother.xml
-rw-r--r-- root/root   1443232 2024-08-11 14:56 ./someother.so
drwxr-xr-x root/root         0 2024-08-11 15:00 ./bin/
-rw-r--r-- root/root   2399608 2024-08-11 14:55 ./bin/myapp.so
hrw-r--r-- root/root         0 2024-08-11 14:56 ./bin/someother.so link to ./someother.so

挺不错的 🙂 这里有几个需要注意的地方:

  • 所有者被存储为当前用户和组的 ID。root 是一个众所周知的值(0),因此我们可以轻松解码它,但你不能轻易从 .NET 中获取其他用户的名称(你需要调用 id 命令或读取 /etc/passwd 文件)。
  • tar -tzvf 的输出显示的是本地时间的修改时间,而我使用了 UTC 时间,因为,你知道的,为什么不呢 😄

这就涵盖了在本文中想要讨论的主要操作。

注意事项、缺失功能和错误

在本文的最后一节中,描述了一些遇到的限制和与 tar 的不同之处。

我遇到的最大问题之一(最终成为我使用它的障碍)是 .NET 目前无法tar 归档文件中创建硬链接,不像 tar 工具那样。

在 Linux 中,硬链接 相对简单:硬链接是指文件名与其实际数据之间的链接。每个你创建的文件都以一个硬链接开始,但是你可以创建额外的硬链接,使得多个文件名指向相同的基础数据。

另一种类型的链接是符号链接。硬链接的优点在于它们对应用程序来说大多看起来像完全正常的文件,而应用程序需要特别处理符号链接。

我希望使用硬链接来消除 tar 文件内的文件重复。tar 工具(以及归档格式)都能很好地处理这个问题,保留硬链接,但.NET 目前在创建归档时不会保留该链接。任何硬链接都会作为额外的数据在生成的 .tar 文件中复制,增加了归档的大小以及提取后的扩展数据的大小。

你可以通过比较使用 tar 直接创建的归档与包含硬链接的目录使用.NET 创建的归档来实际看到这一点:

# For the `tar` utility 👇
$ tar -vtzf ./myarchive.tar.gz
drwxr-xr-x root/root         0 2024-08-11 16:02 ./
-rw-r--r-- root/root   1443232 2024-08-11 15:56 ./someother.so
drwxr-xr-x root/root         0 2024-08-11 16:00 ./bin/
hrw-r--r-- root/root         0 2024-08-11 15:56 ./bin/someother.so link to ./someother.so
👆# Note the 'h'

# For the .NET archive 👇
$ tar -vtzf ./myarchive.tar.gz
-rw-r--r-- root/root   1443232 2024-08-11 15:56 someother.so
drwxr-xr-x root/root         0 2024-08-11 16:00 bin/
-rw-r--r-- root/root   1443232 2024-08-11 15:56 bin/someother.so
👆# Normal file, not a hardlink

请注意,.NET 在展开 .tar 归档文件时会保留其中的任何硬链接。只是目前 .NET 无法在创建 .tar 归档文件时生成这些硬链接。

关于这种行为,已经有 一个两年前的问题,但看起来并没有得到太多关注。希望它能尽快得到解决 🤞

.NET 无法在解压过程中控制所有权

tar 工具有很多选项和标志,但我经常使用的一个是 --same-owner(通过使用 sudo 隐式使用),当我想确保归档文件中标记为 root 的文件在解压后仍然保持这种状态。

不幸的是,目前在 .NET 中没有办法做到这一点。你可能可以通过手动“修复”权限来绕过这个问题,但这确实应该是一个明确内置的选项。顺便说一句,有一个 旧问题 关于向实现中添加更多选项,并且控制所有者/组是提到的缺失功能之一。

.NET 无法处理绝对路径

通常不建议在 tar 文件中使用绝对路径,但如果你愿意的话还是可以使用的。tar 工具会自动将任何绝对路径转换为相对路径,但也提供了使用 --absolute-names 选项将其解压到实际路径的功能。

在使用 --absolute-names 解压时要非常小心,因为解压 tar 文件可能会覆盖系统上的几乎任何位置。

不幸的是,.NET 完全拒绝解压包含绝对路径的 tar 文件。相反,它会抛出一个 IOException

Unhandled exception. System.IO.IOException: Extracting the Tar entry '/bin/busybox' would have resulted in a link target outside the specified destination directory: '/tmp/extracted-alpine'

还有一个关于这个问题的 issue

总的来说,目前.NET 内置的功能对于大多数简单的情况来说应该已经足够好了,但不幸的是,一旦你超出了常见的 80% 情况,你可能会遇到一些限制。

总结

在本文中,描述了如何使用.NET 内置支持对 .tar.gz 文件执行一些常见操作。展示了如何将一个目录压缩成 .tar.gz 文件,如何将 .tar.gz 文件解压成目录,如何从目录中提取单个文件,以及如何列出目录内容而不提取文件。最后,讨论了当前.NET 实现中的一些限制。

源代码:本文示例源代码

[原文链接]:https://andrewlock.net/working-with-tar-files-in-dotnet/

Last Modification : 9/18/2024 11:34:41 PM


In This Document