Skip to main content

在 Go 里用 libarchive 写一个接近 bsdtar 的归档器

·649 words·4 mins
IHEXON
Author
IHEXON
Do You Hear the People Sing ?

这篇只是记一下最近用 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

阅读顺序大概是:

  1. 先看 bsdtar.c 里怎么初始化默认值,比如 block size、locale、extract flags。
  2. 再看 write.c 里的 tar_mode_c -> write_archive -> write_hierarchy -> write_file
  3. 最后看 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-8

bsdtar 启动时会调用:

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。

所以代码结构要跟着这个状态机走:

  1. 扫描目录树时,对每个 entry 调 archive_entry_linkify()
  2. linkify 返回要写的 entry,才写 header 和 data。
  3. 扫描结束后,继续调用 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))

这时至少要保证:

  • prarchive_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 库,我大概会按这个顺序检查:

  1. 有没有先参考这个 C 库自己的 CLI 工具,而不是只看 API 名字?
  2. C runtime 有没有需要显式初始化的状态,比如 setlocale()
  3. C 对象的 new/open/close/free 有没有配对?
  4. fd 是借用还是转移所有权?Go 侧对象会不会提前 close 或被回收?
  5. 返回值是不是有 warn/retry/fatal 这种多级语义?
  6. close/flush 阶段的错误有没有检查?
  7. 有没有依赖进程全局状态,比如 cwd、locale、环境变量?
  8. 测试有没有覆盖真实文件系统语义,而不是只测普通文件?

libarchive 的好处是帮你处理了 tar、zstd、pax、metadata 这些麻烦事。但它不是一个“传路径进去就完事”的库。按 bsdtar 的调用方式去理解它,少猜一点,代码会稳很多。