Scert/backend/internal/releasearchive/archive.go

280 lines
7.5 KiB
Go

package releasearchive
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type Format string
const (
FormatTarGz Format = "tar.gz"
FormatZip Format = "zip"
)
type Options struct {
Source string
Destination string
Format Format
Executables []string
}
type entry struct {
path string
relative string
info fs.FileInfo
}
var archiveTime = time.Unix(0, 0).UTC()
func Create(options Options) error {
_, destination, executables, entries, err := prepare(options)
if err != nil {
return err
}
if err := ensureExecutableEntries(executables, entries); err != nil {
return err
}
file, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
if errors.Is(err, os.ErrExist) {
return fmt.Errorf("发布归档已存在,拒绝覆盖:%s", destination)
}
return fmt.Errorf("创建发布归档失败:%w", err)
}
completed := false
defer func() {
_ = file.Close()
if !completed {
_ = os.Remove(destination)
}
}()
switch options.Format {
case FormatTarGz:
err = writeTarGz(file, entries, executables)
case FormatZip:
err = writeZip(file, entries, executables)
default:
err = fmt.Errorf("不支持的发布归档格式:%s", options.Format)
}
if err != nil {
return err
}
if err := file.Sync(); err != nil {
return fmt.Errorf("同步发布归档失败:%w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("关闭发布归档失败:%w", err)
}
completed = true
return nil
}
func prepare(options Options) (string, string, map[string]struct{}, []entry, error) {
if options.Source == "" || options.Destination == "" {
return "", "", nil, nil, errors.New("发布源目录和归档路径不能为空")
}
source, err := filepath.Abs(options.Source)
if err != nil {
return "", "", nil, nil, fmt.Errorf("解析发布源目录失败:%w", err)
}
destination, err := filepath.Abs(options.Destination)
if err != nil {
return "", "", nil, nil, fmt.Errorf("解析发布归档路径失败:%w", err)
}
info, err := os.Lstat(source)
if err != nil {
return "", "", nil, nil, fmt.Errorf("检查发布源目录失败:%w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return "", "", nil, nil, errors.New("发布源路径必须是普通目录")
}
if inside(source, destination) {
return "", "", nil, nil, errors.New("发布归档不能位于发布源目录内")
}
if _, err := os.Lstat(destination); err == nil {
return "", "", nil, nil, fmt.Errorf("发布归档已存在,拒绝覆盖:%s", destination)
} else if !errors.Is(err, os.ErrNotExist) {
return "", "", nil, nil, fmt.Errorf("检查发布归档失败:%w", err)
}
executables := make(map[string]struct{}, len(options.Executables))
for _, name := range options.Executables {
normalized, err := normalizeRelative(name)
if err != nil {
return "", "", nil, nil, fmt.Errorf("可执行文件路径无效:%w", err)
}
executables[normalized] = struct{}{}
}
var entries []entry
err = filepath.WalkDir(source, func(path string, item fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == source {
return nil
}
if item.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("发布目录不能包含符号链接:%s", path)
}
info, err := item.Info()
if err != nil {
return err
}
if !info.IsDir() && !info.Mode().IsRegular() {
return fmt.Errorf("发布目录包含不支持的文件类型:%s", path)
}
relative, err := filepath.Rel(source, path)
if err != nil {
return err
}
entries = append(entries, entry{path: path, relative: filepath.ToSlash(relative), info: info})
return nil
})
if err != nil {
return "", "", nil, nil, fmt.Errorf("检查发布目录失败:%w", err)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].relative < entries[j].relative })
return source, destination, executables, entries, nil
}
func ensureExecutableEntries(executables map[string]struct{}, entries []entry) error {
for executable := range executables {
found := false
for _, item := range entries {
if item.relative == executable && item.info.Mode().IsRegular() {
found = true
break
}
}
if !found {
return fmt.Errorf("发布目录缺少可执行文件:%s", executable)
}
}
return nil
}
func writeTarGz(output io.Writer, entries []entry, executables map[string]struct{}) error {
gzipWriter := gzip.NewWriter(output)
gzipWriter.Header.ModTime = archiveTime
gzipWriter.Header.OS = 255
tarWriter := tar.NewWriter(gzipWriter)
for _, item := range entries {
header, err := tar.FileInfoHeader(item.info, "")
if err != nil {
return fmt.Errorf("创建 tar 文件头失败:%w", err)
}
header.Name = item.relative
header.Uid = 0
header.Gid = 0
header.Uname = "root"
header.Gname = "root"
header.ModTime = archiveTime
header.AccessTime = archiveTime
header.ChangeTime = archiveTime
header.Mode = archiveMode(item, executables)
if item.info.IsDir() && !strings.HasSuffix(header.Name, "/") {
header.Name += "/"
}
if err := tarWriter.WriteHeader(header); err != nil {
return fmt.Errorf("写入 tar 文件头失败:%w", err)
}
if item.info.Mode().IsRegular() {
if err := copyEntry(tarWriter, item.path); err != nil {
return err
}
}
}
if err := tarWriter.Close(); err != nil {
return fmt.Errorf("关闭 tar 归档失败:%w", err)
}
if err := gzipWriter.Close(); err != nil {
return fmt.Errorf("关闭 gzip 归档失败:%w", err)
}
return nil
}
func writeZip(output io.Writer, entries []entry, executables map[string]struct{}) error {
zipWriter := zip.NewWriter(output)
for _, item := range entries {
header, err := zip.FileInfoHeader(item.info)
if err != nil {
return fmt.Errorf("创建 zip 文件头失败:%w", err)
}
header.Name = item.relative
header.Modified = archiveTime
header.SetMode(fs.FileMode(archiveMode(item, executables)))
if item.info.IsDir() && !strings.HasSuffix(header.Name, "/") {
header.Name += "/"
}
if item.info.Mode().IsRegular() {
header.Method = zip.Deflate
}
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return fmt.Errorf("写入 zip 文件头失败:%w", err)
}
if item.info.Mode().IsRegular() {
if err := copyEntry(writer, item.path); err != nil {
return err
}
}
}
if err := zipWriter.Close(); err != nil {
return fmt.Errorf("关闭 zip 归档失败:%w", err)
}
return nil
}
func copyEntry(destination io.Writer, path string) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("打开发布文件失败:%w", err)
}
defer file.Close()
if _, err := io.Copy(destination, file); err != nil {
return fmt.Errorf("写入发布文件失败:%w", err)
}
return nil
}
func archiveMode(item entry, executables map[string]struct{}) int64 {
if item.info.IsDir() {
return 0o755
}
if _, ok := executables[item.relative]; ok {
return 0o755
}
return 0o644
}
func normalizeRelative(path string) (string, error) {
path = filepath.ToSlash(filepath.Clean(path))
if path == "." || path == "" || strings.HasPrefix(path, "/") || path == ".." || strings.HasPrefix(path, "../") {
return "", errors.New("路径必须位于发布目录内")
}
return path, nil
}
func inside(parent, child string) bool {
relative, err := filepath.Rel(parent, child)
if err != nil {
return false
}
return relative == "." || (relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)))
}