Golang archive/zip 问题排查小记

2021年给开发商做了一个移动端版本体验,近期开发商反馈当上传的ipa文件比较大超过4G的时候上传失败。

背景

移动端版本体验的技术架构如下图,,蓝盾插件请求版本体验后端,后端临时缓存文件并解析ipa和apk文件,获取包的相关信息(包名,版本号,图标等),然后上传至cos存储。服务端使用的golang的版本是go1.16

问题分析

插件侧报错

用户反馈插件执行报错 “zip: not a valid zip file”(插件将ipa或者apk包上传至版本体验后端)

服务端解析包报错

通过排查后端服务日志,问题比较清晰,就是后端解析包的时候报错。解析ipa包的相关代码如下所示,整体逻辑比较简单,通过 archive/zip 读取ipa文件,通过正则找到 plistAppIcon 文件,然后分别通过 plistiospng 解析
得到相关信息。

func ParseIpa(readerAt io.ReaderAt, size int64) (*App, error) {
log.Info("[upload] file size : ", size)
var reInfoPlist = regexp.MustCompile(`Payload/[^/]+/Info\.plist`)

reader, err := zip.NewReader(readerAt, size)
if err != nil {
log.Error("[upload] zip new reader failed, err: ", err.Error())
return nil, err
}

var plistFile, iosIconFile *zip.File
for _, f := range reader.File {
log.Info("[upload] reader file: ", f.Name)
switch {
case reInfoPlist.MatchString(f.Name):
plistFile = f
case strings.Contains(f.Name, "AppIcon60x60"):
iosIconFile = f
}
}
log.Info("[upload] reader plist file: ", plistFile.Name)
log.Info("[upload] reader icon file: ", iosIconFile.Name)
app, err := parseIpaFile(plistFile)
if err != nil {
// NOTE: ignore error
log.Error("[upload] parse ipa failed, err: ", err.Error())
}
log.Info("[upload] parse ipa success, err: ", app)
icon, err := parseIpaIcon(iosIconFile)
if err != nil {
// NOTE: ignore error
log.Error("[upload] parse ipa icon failed, err: ", err.Error())
}
app.Size = size
app.Icon = icon
return app, nil
}

上述的报错:“zip: not a valid zip file” 定位是 archive/zip 库抛出的。

源码阅读(archive/zip)

通过查阅源码,发现该错误就是常量 ErrFormatNewReader 方法调用了 init 方法,而 init 方法在循环读取文件头部 readDirectoryHeader 时会判断错误类型,如果是 ErrFormat 会将错误抛出。

var (
ErrFormat = errors.New("zip: not a valid zip file")
)
func NewReader(r io.ReaderAt, size int64) (*Reader, error) {
if size < 0 {
return nil, errors.New("zip: size cannot be negative")
}
zr := new(Reader)
var err error
if err = zr.init(r, size); err != nil && err != ErrInsecurePath {
return nil, err
}
return zr, err
}

func (r *Reader) init(rdr io.ReaderAt, size int64) error {
end, baseOffset, err := readDirectoryEnd(rdr, size)
if err != nil {
return err
}
r.r = rdr
r.baseOffset = baseOffset
// Since the number of directory records is not validated, it is not
// safe to preallocate r.File without first checking that the specified
// number of files is reasonable, since a malformed archive may
// indicate it contains up to 1 << 128 - 1 files. Since each file has a
// header which will be _at least_ 30 bytes we can safely preallocate
// if (data size / 30) >= end.directoryRecords.
if end.directorySize < uint64(size) && (uint64(size)-end.directorySize)/30 >= end.directoryRecords {
r.File = make([]*File, 0, end.directoryRecords)
}
r.Comment = end.comment
rs := io.NewSectionReader(rdr, 0, size)
if _, err = rs.Seek(r.baseOffset+int64(end.directoryOffset), io.SeekStart); err != nil {
return err
}
buf := bufio.NewReader(rs)

// The count of files inside a zip is truncated to fit in a uint16.
// Gloss over this by reading headers until we encounter
// a bad one, and then only report an ErrFormat or UnexpectedEOF if
// the file count modulo 65536 is incorrect.
for {
f := &File{zip: r, zipr: rdr}
err = readDirectoryHeader(f, buf)
if err == ErrFormat || err == io.ErrUnexpectedEOF {
break
}
if err != nil {
return err
}
f.headerOffset += r.baseOffset
r.File = append(r.File, f)
}
if uint16(len(r.File)) != uint16(end.directoryRecords) { // only compare 16 bits here
// Return the readDirectoryHeader error if we read
// the wrong number of directory entries.
return err
}
if zipinsecurepath.Value() == "0" {
for _, f := range r.File {
if f.Name == "" {
// Zip permits an empty file name field.
continue
}
// The zip specification states that names must use forward slashes,
// so consider any backslashes in the name insecure.
if !filepath.IsLocal(f.Name) || strings.Contains(f.Name, `\`) {
zipinsecurepath.IncNonDefault()
return ErrInsecurePath
}
}
}
return nil
}

4. 本地复现

本地复现该问题的时候发现解析正常,但是打成镜像容器部署会报错,通过对比我发现本地使用的go的版本是 go1.21 ,而镜像使用的构建镜像是 go1.16 。挨个查阅 golang 的 release 最终定位到是 go1.19 的新特性导致的差异。

相关改动代码如下:

// The count of files inside a zip is truncated to fit in a uint16.
// Gloss over this by reading headers until we encounter
// a bad one, and then only report an ErrFormat or UnexpectedEOF if
// the file count modulo 65536 is incorrect.
for {
f := &File{zip: z, zipr: r}
err = readDirectoryHeader(f, buf)

// For compatibility with other zip programs,
// if we have a non-zero base offset and can't read
// the first directory header, try again with a zero
// base offset.
if err == ErrFormat && z.baseOffset != 0 && len(z.File) == 0 {
z.baseOffset = 0
if _, err = rs.Seek(int64(end.directoryOffset), io.SeekStart); err != nil {
return err
}
buf.Reset(rs)
continue
}

if err == ErrFormat || err == io.ErrUnexpectedEOF {
break
}
if err != nil {
return err
}
f.headerOffset += z.baseOffset
z.File = append(z.File, f)
}

新增逻辑解读:如果在读取第一个目录头时遇到 ErrFormat 错误,并且基偏移量不为零,则尝试使用零基偏移量重新读取目录头。如果重新读取目录头仍然失败,则返回错误。

NOTE: 不同操作系统或 ZIP 工具创建的 ZIP 文件时,可能会遇到不同的实现和约定。这可能导致基偏移量的计算方式不同,从而导致错误的值。

解决方案

升级golang构建的基础镜像,从1.16 -> 1.22,重新构建新的服务镜像更新服务,这样基本满足了用户的需求。

# builder
FROM golang:1.22 AS builder
COPY . /src/
RUN cd /src && go mod tidy
RUN cd /src && go build -ldflags '-linkmode "external" --extldflags "-static"' main.go

# runtime
FROM alpine:3.14
LABEL maintainer="blazehu"
WORKDIR /ipapk
COPY --from=builder /src/main /ipapk
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT /docker-entrypoint.sh

参考资料