这篇只是记一下最近用 Go 包 libarchive 的思路。
目标很普通:把一个目录打成 tar.zst,解包时也尽量保留原来的文件系统语义。真正费时间的地方不在遍历目录,而是各种边角:非 ASCII 文件名、软链接、硬链接、稀疏文件、设备节点、权限、owner、xattr,还有一些错误只会在 close 阶段冒出来。
如果只用 Go 标准库 archive/tar 手写,普通文件当然很快能跑通,但后面大概率会变成不断补特例。所以我最后还是把 libarchive 当成底层实现,行为尽量贴近 bsdtar。
先写出目标命令#
我觉得用 libarchive 时,第一步不是翻 API,而是先把自己想要的行为写成一条 bsdtar 命令。
比如创建一个 zstd 压缩的 tar:
bsdtar --zstd -c -f rootfs.tar.zst -C "$root" .更具体一点:
bsdtar --format=pax --zstd -b 20 -c -f rootfs.tar.zst -C "$root" .这里几个参数都不是随便写的。
-C "$root" . 表示先进入目录,再从 . 开始归档。这样归档里的路径就是相对这个目录的路径,不会带上外层目录名。自己在 Go 里 strip prefix 也能做,但这种路径处理很容易在 symlink、..、绝对路径上出问题。
--format=pax 这里实际对应到 libarchive 里更接近 pax restricted。它在需要扩展字段时使用 pax,同时尽量保持传统 tar 的兼容性。
-b 20 是 bsdtar 默认的 block size,也就是 20 * 512。如果目标是“像 bsdtar 一样”,这些默认值也算行为的一部分。
读 bsdtar 源码比猜 API 靠谱#
libarchive 的 API 文档要看,但只看 API 名字很容易误判。bsdtar 本身就是 libarchive 的一个很好的调用样例。
我主要看的文件是:
tar/bsdtar.c
tar/write.c
tar/read.c阅读顺序大概是:
- 先看
bsdtar.c里怎么初始化默认值,比如 block size、locale、extract flags。 - 再看
write.c里的tar_mode_c -> write_archive -> write_hierarchy -> write_file。 - 最后看
read.c的解包逻辑,确认默认 extract 行为。
不用照搬所有东西。bsdtar 是 CLI,要处理环境变量、交互确认、stdin/stdout、append、list、pattern、passphrase 等一堆场景。写成库函数时,只保留自己需要的那部分就行。
尤其是 TAR_READER_OPTIONS / TAR_WRITER_OPTIONS 这类环境变量。我不想让同一段库代码在不同 shell 环境下产出不同结果,所以这部分没有跟。
C locale 要先处理#
Go 程序启动后,C runtime 不一定会按 shell 环境设置 locale。也就是说,就算 shell 里的 locale 显示是 UTF-8,cgo 调进去的 C 库也可能还在默认的 C locale。
这个问题在非 ASCII 路径上会直接炸,比如:
Can't translate pathname '...Főtanúsítvány.crt' to UTF-8bsdtar 启动时会调用:
setlocale(LC_ALL, "");Go 里也要显式做一次。因为 locale 是进程全局状态,我一般会用 sync.Once 包住,避免每次创建归档时都去改它:
var (
cLocaleOnce sync.Once
cLocaleErr error
)
func ensureCLocale() error {
cLocaleOnce.Do(func() {
locale := C.CString("")
defer C.free(unsafe.Pointer(locale))
if C.setlocale(C.LC_ALL, locale) == nil {
cLocaleErr = errors.New("failed to set C locale from environment")
}
})
return cLocaleErr
}这里不要想着用 archive_entry_set_pathname_utf8() 之类的 API 绕过去。路径是 archive_read_disk 从文件系统读出来的,正确做法是让 libarchive 处在正确的 C locale 里。
Writer 初始化#
创建 tar.zst 时,我会把 writer 初始化成接近 bsdtar 的默认行为:
writer := C.archive_write_new()
C.archive_write_set_format_pax_restricted(writer)
C.archive_write_set_bytes_per_block(writer, C.int(20*512))
C.archive_write_set_bytes_in_last_block(writer, C.int(-1))
C.archive_write_add_filter_zstd(writer)
C.archive_write_open_filename(writer, filename)几个点需要注意。
archive_write_set_format_pax_restricted() 比直接用普通 pax 更接近 bsdtar 默认行为。
archive_write_set_bytes_in_last_block(writer, -1) 让 libarchive 按格式默认值处理最后一个 block。不要只设置前面的 block size 就结束。
还有一个很容易漏的地方:archive_write_close() 的返回值必须检查。有些写入错误不会在 archive_write_data() 时暴露,而是到 close/flush 才返回。
用 read_disk 读目录树#
归档目录树时,不要先 filepath.WalkDir,再自己拼 header 喂给 libarchive。bsdtar 用的是 archive_read_disk,这也是 libarchive 里处理文件系统细节的入口。
reader := C.archive_read_disk_new()
C.archive_read_disk_set_symlink_physical(reader)
C.archive_read_disk_set_behavior(reader, C.ARCHIVE_READDISK_MAC_COPYFILE)
C.archive_read_disk_set_standard_lookup(reader)
C.archive_read_disk_open(reader, C.CString("."))archive_read_disk_set_symlink_physical() 很重要。它表示不跟随 symlink。归档 rootfs、chroot 目录或者任何类似目录树时,都不应该不小心跟到外面去。
archive_read_disk_set_standard_lookup() 会查 uid/gid 对应的 user/group name。tar 里不只有数字 uid/gid,uname/gname 也是元数据的一部分。
Hardlink 不能当普通文件写#
hardlink 是我一开始最容易低估的地方。
同一个 inode 有多个路径时,归档里应该记录 hardlink 关系,而不是把文件内容重复写几份。bsdtar 用的是 link resolver:
archive_entry_linkresolver_new()
archive_entry_linkresolver_set_strategy(resolver, archive_format(a))
archive_entry_linkify(resolver, &entry, &sparse_entry)archive_entry_linkify() 不是简单改一下 entry。它可能把当前 entry 缓存在 resolver 里,当前什么都不写;也可能返回一个 hardlink entry;遍历结束后,还可能有 pending entry 需要继续 flush。
所以代码结构要跟着这个状态机走:
- 扫描目录树时,对每个 entry 调
archive_entry_linkify()。 - linkify 返回要写的 entry,才写 header 和 data。
- 扫描结束后,继续调用 linkify,把 resolver 里剩下的 entry 写完。
还有一个细节:pending entry 写数据时,原来的 disk reader 已经不在那个文件的位置了。bsdtar 的处理方式是用 archive_entry_sourcepath(entry) 重新 open 一次,再读 header 和 data。这个地方如果省掉,hardlink 相关场景很容易写错。
Sparse file 要看 offset#
创建归档时,bsdtar 默认会读取 sparse 信息,除非用户显式传 --no-read-sparse。对应到 libarchive,就是不要设置 ARCHIVE_READDISK_NO_SPARSE。
写数据时也不能假设数据块是连续的。archive_read_data_block() 会返回 offset:
if offset > progress {
// write zero padding
}
archive_write_data(writer, buff, size)这和 bsdtar 的 copy_file_data_block() 是一个思路。
这里要分清两件事:
- 创建归档时,是否读取 sparse 信息。
- 解包时,是否把连续 zero 尽量恢复成磁盘 hole。
前者影响归档内容怎么记录,后者影响落盘效果。如果解包后也想尽量恢复 sparse file,需要启用 ARCHIVE_EXTRACT_SPARSE。
fd 生命周期别偷懒#
cgo 包 C 库时,fd 生命周期要说清楚。
Go 的 *os.File 有自己的生命周期,C 代码也可能在一段时间里持有 fd。只要 C 还可能读写这个 fd,Go 侧就不能提前 close,也不能让对象只靠 finalizer 活着。
如果一个 fd 要交给 C 长时间使用,我更倾向于 dup 一份给 C。这样 Go 自己的 *os.File 和 C 那边的 fd 不会互相抢所有权。
比如用 pipe 把一个 Go io.Reader 接到 libarchive reader:
pr, pw, err := os.Pipe()
archive_read_open_fd(a, C.int(pr.Fd()), C.size_t(blockSize))这时至少要保证:
pr在archive_read_close(a)之前不能 close。- 写端
pw在 source goroutine 退出时 close。 - context cancel 时要能 close
pw,让 C 侧阻塞的 read 结束。 - 最后要等 source goroutine 返回,避免 goroutine 泄漏。
C API 如果说“接管 fd”,就按它的所有权规则来。如果只是“借用 fd”,Go 侧必须保证 fd 活得够久。
返回值不是只有成功和失败#
libarchive 的返回值不是简单的 0/-1。
常见状态有:
ARCHIVE_OK
ARCHIVE_WARN
ARCHIVE_RETRY
ARCHIVE_FAILED
ARCHIVE_FATAL
ARCHIVE_EOF不同阶段对这些状态的处理不一样。
写 header 时,ARCHIVE_FATAL 基本就该停;有些 warning 可以记录后继续;只有 header 写成功,并且 entry size 大于 0,才继续写 data。
读目录树时,ARCHIVE_EOF 是正常结束;ARCHIVE_FATAL / ARCHIVE_FAILED 应该返回错误;部分 warning 可以跳过当前 entry 继续。
所以不要写成:
if r != C.ARCHIVE_OK {
return err
}这通常会比 bsdtar 更脆,也更容易在真实文件系统上误停。
chdir 是语义,不只是路径处理#
-C root . 不是把字符串前缀删掉。
如果要复刻这个行为,比较直接的做法是保存当前 cwd,chdir(root),然后从 "." 开始归档,函数退出时再恢复 cwd。
这样归档里的路径天然是相对 root 的路径,不需要自己 strip prefix。
代价也很明显:cwd 是进程全局状态。这样的 API 不适合在同一进程里并发调用多次。如果后面真的需要并发归档,要么避免全局 cwd,要么把归档任务隔离到单独 worker/process 里。
测试别只测普通文件#
这类代码如果只测“一个普通文件能打包解包”,基本测不出什么。
我会至少覆盖这些东西:
- UTF-8 pathname,例如
Főtanúsítvány.crt - symlink,确认没有被 follow 成普通文件
- hardlink,解包后
os.SameFile()为 true - sparse file,内容和大小都正确
- 输出文件不能放在被归档目录内部
- close/flush 阶段的错误能返回
最值得单独测的是 UTF-8 pathname 和 hardlink。前者能抓住 C locale 问题,后者能抓住 link resolver 的状态机问题。普通 happy path 基本碰不到这两个坑。
我最后留下的检查清单#
下次再用 cgo 包 C 库,我大概会按这个顺序检查:
- 有没有先参考这个 C 库自己的 CLI 工具,而不是只看 API 名字?
- C runtime 有没有需要显式初始化的状态,比如
setlocale()? - C 对象的 new/open/close/free 有没有配对?
- fd 是借用还是转移所有权?Go 侧对象会不会提前 close 或被回收?
- 返回值是不是有 warn/retry/fatal 这种多级语义?
- close/flush 阶段的错误有没有检查?
- 有没有依赖进程全局状态,比如 cwd、locale、环境变量?
- 测试有没有覆盖真实文件系统语义,而不是只测普通文件?
libarchive 的好处是帮你处理了 tar、zstd、pax、metadata 这些麻烦事。但它不是一个“传路径进去就完事”的库。按 bsdtar 的调用方式去理解它,少猜一点,代码会稳很多。

