280 lines
7.5 KiB
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)))
|
|
}
|