在 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 条目的结构
上图显示单个 TAR 条目的结构。TAR 文件只是此格式条目的序列。
512 字节长的文件头总是写在文件数据前面 - 这产生更简单的程序结构,因为它使你能够顺序读取文件而无需在条目之间保存信息。在某种程度上,此方法对于将文件写入必须顺序写入数据的磁带或光盘也是必要的。
ZIP 文件不遵循此概念 - 它们在文件末尾有中央目录。你不能顺序读取数据,但与 TAR 相比,你可以添加或删除文件而无需重写整个归档。
头包含确定文件长度的文件大小字段。二进制文件数据紧跟在头之后。为了保持 512 字节块结构,最后一个块用 NUL 字符填充,除非文件大小可被 512 整除。
在某些情况下 - 例如目录 - 头大小字段设置为零,下一个块是另一个头。
解码 TAR 八进制数
头中的所有数字,特别是文件大小,在 TAR 中编码为带尾随零的八进制数。数字表示为 ASCII 字符。
此外,八进制数可能(但不必须)包含尾随 NUL。
实现此功能没有产生正确结果,我必须将最左边 NUL(或空格字符)左侧的字符解释为最低有效数字,以重现 tar tzvf archive.tar.gz 产生的文件大小(测试阶段使用了 GNU TAR 1.26)。
#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++ 中我们可以添加成员函数,例如解码文件大小。这提高了类的可用性。
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 流
此实现明确不支持(虽然可能不会崩溃):
- 除普通文件和目录外的任何文件类型(包括符号链接)
- 大文件
- 此处未列出的任何扩展
- 广泛的单元测试(可能在未来某个时候添加,但不要等待)
- ANSI-C 实现。可能可行,但解压缩远不如 boost::iostreams 即插即用。
完整源代码:
/**
* 在 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*) ¤tFileHeader, 512);
//当找到全零块时,TAR 归档在此结束
if(memcmp(¤tFileHeader, 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*) ¤tFileHeader, 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();
}