在 C++ 中读取 TAR 文件

本文描述了使用 Boost IOStreams 在 C++ 中读取 TAR 归档(包括 .tar.gz 和 .tar.bz2)的方法。

你可以使用 libtar 来做这个,但原始版本自 2003 年以来未更新,且不提供对 TAR 归档内部结构的灵活性和洞察力。

我强烈建议在阅读本文之前仔细阅读英文 Wikipedia 文章GNU TAR 格式手册

为什么 TAR 编程很糟糕

从 Linux 用户的角度来看,TAR 很棒。使用 TAR 无处不在,而且有效 - 你不仅可以用简单命令归档 TB 级数据,tar 还让你能够自己选择最佳压缩方法。

然而,以编程方式访问 TAR 归档并不那么容易。

起初,TAR 似乎很简单。一切都存储在固定大小的 512 字节块中,每个头字段都有固定长度,你不需要在不同头之间保存任何状态。

对于最基本的情况这确实有效,但在现实世界中,它永远不会按预期工作。* 没有"唯一"TAR 规范,但有"基本“tar、USTAR 和供应商特定(GNU)扩展 * 基本 TAR 仅支持最多 100 字符文件名。GNU TAR 和 USTAR 支持扩展文件名 * TAR 文件不仅可以包含文件和目录,还可以包含符号链接、硬链接、字符设备、块设备、FIFO、稀疏文件… * 根据 Wikipedia,时间戳分辨率未在任何地方定义

TAR 条目的结构

Structure of a TAR entry diagram

上图显示单个 TAR 条目的结构。TAR 文件只是此格式条目的序列。

512 字节长的文件头总是写在文件数据前面 - 这产生更简单的程序结构,因为它使你能够顺序读取文件而无需在条目之间保存信息。在某种程度上,此方法对于将文件写入必须顺序写入数据的磁带或光盘也是必要的。

ZIP 文件不遵循此概念 - 它们在文件末尾有中央目录。你不能顺序读取数据,但与 TAR 相比,你可以添加或删除文件而无需重写整个归档。

头包含确定文件长度的文件大小字段。二进制文件数据紧跟在头之后。为了保持 512 字节块结构,最后一个块用 NUL 字符填充,除非文件大小可被 512 整除。

在某些情况下 - 例如目录 - 头大小字段设置为零,下一个块是另一个头。

解码 TAR 八进制数

头中的所有数字,特别是文件大小,在 TAR 中编码为带尾随零的八进制数。数字表示为 ASCII 字符。

此外,八进制数可能(但不必须)包含尾随 NUL。

实现此功能没有产生正确结果,我必须将最左边 NUL(或空格字符)左侧的字符解释为最低有效数字,以重现 tar tzvf archive.tar.gz 产生的文件大小(测试阶段使用了 GNU TAR 1.26)。

decode_tar_octal.cpp
#define ASCII_TO_NUMBER(num) ((num)-48) //将 ASCII 数字转换为相应数字

/**
 * 解码 TAR 八进制数。
 * 忽略第一个 NUL 或空格字符之后的所有内容。
 * @param data 指向 size 字节长八进制编码的指针
 * @param size 数据指针指向的字段大小
 * @return
 */
static uint64_t decodeTarOctal(char* data, size_t size = 12) {
    unsigned char* currentPtr = (unsigned char*) data + size;
    uint64_t sum = 0;
    uint64_t currentMultiplier = 1;
    //跳过最后一个 NUL/空格字符之后的所有内容
    //在某些 TAR 归档中,大小字段有非尾随 NUL/空格,所以这是必要的
    unsigned char* checkPtr = currentPtr; //这用于检查最后一个 NUL/空格字符在哪里
    for (; checkPtr >= (unsigned char*) data; checkPtr--) {
        if ((*checkPtr) == 0 || (*checkPtr) == ' ') {
            currentPtr = checkPtr - 1;
        }
    }
    for (; currentPtr >= (unsigned char*) data; currentPtr--) {
        sum += ASCII_TO_NUMBER(*currentPtr) * currentMultiplier;
        currentMultiplier *= 8;
    }
    return sum;
}

头数据结构

虽然在技术上可以支持经典 tar 文件格式,但在几乎任何用例中都不必要,因为 USTAR 格式(引入额外的头标志)已标准化近 25 年,几乎任何 tar 都应支持它。

此页面提供了原始 star(tar 的一种"风味”)的 TAR 头 C 结构体,但在 C++ 中我们可以添加成员函数,例如解码文件大小。这提高了类的可用性。

TARFileHeader.cpp
struct TARFileHeader {
    char filename[100]; //NUL 终止
    char mode[8];
    char uid[8];
    char gid[8];
    char fileSize[12];
    char lastModification[12];
    char checksum[8];
    char typeFlag; //对于非 UStar 格式也称为链接指示器
    char linkedFileName[100];
    //USTar 特定字段 -- 在非 USTAR 版本中用 NUL 填充
    char ustarIndicator[6]; //"ustar" -- 第 6 个字符可能是 NUL 但结果显示不必须
    char ustarVersion[2]; //00
    char ownerUserName[32];
    char ownerGroupName[32];
    char deviceMajorNumber[8];
    char deviceMinorNumber[8];
    char filenamePrefix[155];
    char padding[12]; //无意义,但与校验和相关

    /**
     * @return 当且仅当
     */
    bool isUSTAR() {
        return (memcmp("ustar", ustarIndicator, 5) == 0);
    }

    /**
     * @return 文件大小(字节)
     */
    size_t getFileSize() {
        return decodeTarOctal(fileSize);
    }

    /**
     * 当且仅当头校验和正确时返回 true
     * @return
     */
    bool checkChecksum() {
        //我们需要将校验和设置为零
        char originalChecksum[8];
        memcpy(originalChecksum, checksum, 8);
        memset(checksum, ' ', 8);
        //计算校验和 -- 有符号和无符号
        int64_t unsignedSum = 0;
        int64_t signedSum = 0;
        for (int i = 0; i < sizeof (TARFileHeader); i++) {
            unsignedSum += ((unsigned char*) this)[i];
            signedSum += ((signed char*) this)[i];
        }
        //复制回校验和
        memcpy(checksum, originalChecksum, 8);
        //解码原始校验和
        uint64_t referenceChecksum = decodeTarOctal(originalChecksum);
        return (referenceChecksum == unsignedSum || referenceChecksum == signedSum);
    }
};

将各部分组合在一起

现在唯一剩下要做的是处理 TAR 文件本身。

以下程序读取 TAR 文件,列出其内容并将每个文件加载到内存中。打印文件和目录名。

如果你有改进建议或发现 bug,请评论!

此实现支持 * GNU 特定长文件名扩展 * USTAR 文件名前缀扩展 * 有符号和无符号校验和计算(默认不激活)* USTAR 标志检查(默认不激活)* NUL 填充块结束标记(而不是 EOF)* .tar.gz 和 .tar.bz2 加上自动确定解压缩 * 它使用 boost::iostreams,所以你可以轻松地将文件输入替换为任何可想象的东西,例如 TCP 流

此实现明确支持(虽然可能不会崩溃):

完整源代码:

read_tar.cpp
/**
 * 在 C++ 中读取 TAR 文件
 * 示例代码
 *
 * (C) Uli Köhler 2013
 * 在 CC-By 3.0 Germany 下许可:http://creativecommons.org/licenses/by/3.0/de/legalcode
 *
 * 编译方式:
 *   g++ -o cpptar cpptar.cpp -lboost_iostreams -lz -lbz2
 */
#include <cstdlib>
#include <cassert>
#include <cstdio>
#include <fstream>
#include <cmath>
#include <iostream>
#include <boost/iostreams/device/file.hpp>
#include <boost/iostreams/filtering_stream.hpp>
#include <boost/iostreams/filter/gzip.hpp>
#include <boost/iostreams/filter/bzip2.hpp>
//检查文件扩展名
#include <boost/algorithm/string.hpp>

using namespace std;
using namespace boost::iostreams;

#define ASCII_TO_NUMBER(num) ((num)-48) //将 ASCII 数字转换为相应数字 (assuming it is an ASCII digit)

/**
 * 解码 TAR 八进制数。
 * 忽略第一个 NUL 或空格字符之后的所有内容。
 * @param data 指向 size 字节长八进制编码的指针
 * @param size 数据指针指向的字段大小
 * @return
 */
static uint64_t decodeTarOctal(char* data, size_t size = 12) {
    unsigned char* currentPtr = (unsigned char*) data + size;
    uint64_t sum = 0;
    uint64_t currentMultiplier = 1;
    //跳过最后一个 NUL/空格字符之后的所有内容
    //在某些 TAR 归档中,大小字段有非尾随 NUL/空格,所以这是必要的
    unsigned char* checkPtr = currentPtr; //这用于检查最后一个 NUL/空格字符在哪里
    for (; checkPtr >= (unsigned char*) data; checkPtr--) {
        if ((*checkPtr) == 0 || (*checkPtr) == ' ') {
            currentPtr = checkPtr - 1;
        }
    }
    for (; currentPtr >= (unsigned char*) data; currentPtr--) {
        sum += ASCII_TO_NUMBER(*currentPtr) * currentMultiplier;
        currentMultiplier *= 8;
    }
    return sum;
}

struct TARFileHeader {
    char filename[100]; //NUL 终止
    char mode[8];
    char uid[8];
    char gid[8];
    char fileSize[12];
    char lastModification[12];
    char checksum[8];
    char typeFlag; //对于非 UStar 格式也称为链接指示器
    char linkedFileName[100];
    //USTar 特定字段 -- 在非 USTAR 版本中用 NUL 填充
    char ustarIndicator[6]; //"ustar" -- 第 6 个字符可能是 NUL 但结果显示不必须
    char ustarVersion[2]; //00
    char ownerUserName[32];
    char ownerGroupName[32];
    char deviceMajorNumber[8];
    char deviceMinorNumber[8];
    char filenamePrefix[155];
    char padding[12]; //无意义,但与校验和相关

    /**
     * @return 当且仅当
     */
    bool isUSTAR() {
        return (memcmp("ustar", ustarIndicator, 5) == 0);
    }

    /**
     * @return 文件大小(字节)
     */
    size_t getFileSize() {
        return decodeTarOctal(fileSize);
    }

    /**
     * 当且仅当头校验和正确时返回 true
     * @return
     */
    bool checkChecksum() {
        //我们需要将校验和设置为零
        char originalChecksum[8];
        memcpy(originalChecksum, checksum, 8);
        memset(checksum, ' ', 8);
        //计算校验和 -- 有符号和无符号
        int64_t unsignedSum = 0;
        int64_t signedSum = 0;
        for (int i = 0; i < sizeof (TARFileHeader); i++) {
            unsignedSum += ((unsigned char*) this)[i];
            signedSum += ((signed char*) this)[i];
        }
        //复制回校验和
        memcpy(checksum, originalChecksum, 8);
        //解码原始校验和
        uint64_t referenceChecksum = decodeTarOctal(originalChecksum);
        return (referenceChecksum == unsignedSum || referenceChecksum == signedSum);
    }
};

int main(int argc, char** argv) {
    if (argc < 2) {
        cerr << "Usage: " << argv[0] << " <TAR archive>" << endl;
        return 1;
    }
    ifstream fin(argv[1], ios_base::in | ios_base::binary);
    filtering_istream in;
    //根据压缩格式,选择正确的解压缩器
    string filename(argv[1]);
    if (boost::algorithm::iends_with(filename, ".gz")) {
        in.push(gzip_decompressor());
    } else if (boost::algorithm::iends_with(filename, ".bz2")) {
        in.push(bzip2_decompressor());
    } else if (boost::algorithm::iends_with(filename, ".tar")) {
        //不需要解压缩过滤器
    } else {
        cerr << "Unknown file suffix: " << filename << endl;
        return 1;
    }
    in.push(fin);
    //初始化一个零填充块用于比较(零填充头块 --> TAR 归档结束)
    char zeroBlock[512];
    memset(zeroBlock, 0, 512);
    //开始读取
    bool nextEntryHasLongName = false;
    while (in) { //如果到达文件末尾或发生任何错误则停止
        TARFileHeader currentFileHeader;
        //读取文件头。
        in.read((char*) &currentFileHeader, 512);
    //当找到全零块时,TAR 归档在此结束
    if(memcmp(&currentFileHeader, zeroBlock, 512) == 0) {
        cout << "Found TAR end\n";
        break;
    }
    //取消注释以检查所有头校验和
    //互联网上似乎有 TAR 包含单个不匹配校验和的头,即使大多数头匹配。
    //这可能表示代码错误。
    //assert(currentFileHeader.checkChecksum());

        //如果你需要 USTAR 功能,取消注释以检查 USTAR
        //assert(currentFileHeader.isUSTAR());

        //将文件名转换为 std::string 以便更容易处理
    //长度 100+ 的文件名需要特殊处理
    //(只有 USTAR 支持 101+ 字符文件名,但在非 USTAR 归档中前缀为 0 因此被忽略)
        string filename(currentFileHeader.filename, min((size_t)100, strlen(currentFileHeader.filename)));
    //---如果你不想支持长文件名,移除下一个块---
    size_t prefixLength = strlen(currentFileHeader.filenamePrefix);
    if(prefixLength > 0) { //如果有文件名前缀,将其添加到字符串。参见 `man ustar`LON
        filename = string(currentFileHeader.filenamePrefix, min((size_t)155, prefixLength)) + "/" + filename; //min 限制:规范不需要,但我们想安全
    }
        //忽略目录,只处理普通文件(符号链接目前完全被忽略,可能导致错误)
        if (currentFileHeader.typeFlag == '0' || currentFileHeader.typeFlag == 0) { //普通文件
        //处理 GNU TAR 长文件名 -- 当前块只包含文件名,而下一个块包含元数据
        if(nextEntryHasLongName) {
        //从当前头设置文件名
        filename = string(currentFileHeader.filename);
        //下一个头包含元数据,所以在读取元数据之前替换头
        in.read((char*) &currentFileHeader, 512);
        //重置长名称标志
        nextEntryHasLongName = false;
        }
        //现在当前文件头中的元数据有效 -- 我们可以读取值。
            size_t size = currentFileHeader.getFileSize();
            //记录我们找到了文件
            cout << "Found file '" << filename << "' (" << size << " bytes)\n";
            //将文件读入内存
            //  这对非常大的文件不起作用 -- 在那里使用流式方法!
            char* fileData = new char[size + 1]; //+1: Place a terminal NUL to allow interpreting the file as cstring (you can remove this if unused)
            in.read(fileData, size);
            //-------在此处放置处理文件内容的代码---------
            delete[] fileData;
            //在 tar 归档中,每个文件使用整个 512 字节块
            //因此我们现在必须跳过填充字节。
            size_t paddingBytes = (512 - (size % 512)) % 512; //填充到 512 字节需要多长
            //简单地忽略填充
            in.ignore(paddingBytes);
    //----如果你只想处理普通文件,移除 else if 和 else 分支---
        } else if (currentFileHeader.typeFlag == '5') { //目录
        //目前长目录名未正确处理
            cout << "Found directory '" << filename << "'\n";
        } else if(currentFileHeader.typeFlag == 'L') {
        nextEntryHasLongName = true;
    } else {
        //既不是普通文件也不是目录(符号链接等)-- 目前被静默忽略
        cout << "Found unhandled TAR Entry type " << currentFileHeader.typeFlag << "\n";
    }
    }
    //Cleanup
    fin.close();
}

Check out similar posts by category: Algorithms, C/C++