356 lines
8.4 KiB
Go
356 lines
8.4 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"bytes"
|
|
"compress/bzip2"
|
|
"compress/gzip"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// Single-file compression that Go does not carry in its standard library. Each
|
|
// entry lists the tools that can expand it, in order of preference; the first
|
|
// one found wins. All of them write to stdout with the flags given.
|
|
var external = map[string]struct {
|
|
tools []string
|
|
args []string
|
|
}{
|
|
"xz": {[]string{"xz", "unxz"}, []string{"-dc"}},
|
|
"lzma": {[]string{"xz", "unxz", "lzma"}, []string{"-dc"}},
|
|
"zst": {[]string{"zstd", "unzstd"}, []string{"-dcq"}},
|
|
"lz4": {[]string{"lz4", "unlz4"}, []string{"-dcq"}},
|
|
}
|
|
|
|
type decomp struct {
|
|
prog string
|
|
args []string
|
|
}
|
|
|
|
// decompressor reports how to expand ext, or nil if this machine cannot.
|
|
// gz and bz2 are handled in-process and need no helper at all.
|
|
func decompressor(ext string) *decomp {
|
|
switch ext {
|
|
case "gz", "bz2":
|
|
return &decomp{} // stdlib, no external program
|
|
}
|
|
e, ok := external[ext]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
for _, t := range e.tools {
|
|
if p, err := exec.LookPath(t); err == nil {
|
|
return &decomp{prog: p, args: e.args}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sevenZip() string {
|
|
for _, t := range []string{"7zz", "7z", "7za"} {
|
|
if p, err := exec.LookPath(t); err == nil {
|
|
return p
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// reader wraps f in whatever ext needs, shelling out only when the standard
|
|
// library has no codec for it.
|
|
func decompressReader(ext, path string, f *os.File) (io.Reader, func() error, error) {
|
|
nop := func() error { return nil }
|
|
switch ext {
|
|
case "gz":
|
|
zr, err := gzip.NewReader(f)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return zr, zr.Close, nil
|
|
case "bz2":
|
|
return bzip2.NewReader(f), nop, nil
|
|
}
|
|
|
|
d := decompressor(ext)
|
|
if d == nil {
|
|
return nil, nil, fmt.Errorf("cannot unpack .%s - install %s or pick another asset with --asset/--pattern",
|
|
ext, strings.Join(external[ext].tools, "/"))
|
|
}
|
|
cmd := exec.Command(d.prog, append(append([]string{}, d.args...), path)...)
|
|
cmd.Stdin = nil
|
|
cmd.Stderr = os.Stderr
|
|
out, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return out, func() error {
|
|
io.Copy(io.Discard, out) // drain, or the child gets EPIPE
|
|
if err := cmd.Wait(); err != nil {
|
|
return fmt.Errorf("failed to decompress %s (%s: %w)", filepath.Base(path), filepath.Base(d.prog), err)
|
|
}
|
|
return nil
|
|
}, nil
|
|
}
|
|
|
|
// extractIfArchive unpacks into a fresh directory below tmpdir and returns its
|
|
// path, or "" if the asset is a bare binary (then the download is the binary).
|
|
func extractIfArchive(file, tmpdir string) (string, error) {
|
|
lc := strings.ToLower(filepath.Base(file))
|
|
out := filepath.Join(tmpdir, "x")
|
|
|
|
switch {
|
|
case tarballRe.MatchString(lc):
|
|
if err := os.Mkdir(out, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
return out, untar(file, out, tarCodec(lc))
|
|
|
|
case strings.HasSuffix(lc, ".zip"):
|
|
if err := os.Mkdir(out, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
return out, unzip(file, out)
|
|
|
|
case strings.HasSuffix(lc, ".7z"):
|
|
z := sevenZip()
|
|
if z == "" {
|
|
return "", fmt.Errorf("7z not found, but needed for %s", lc)
|
|
}
|
|
if err := os.Mkdir(out, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
cmd := exec.Command(z, "x", "-y", "-o"+out, file)
|
|
cmd.Stdout, cmd.Stderr = io.Discard, os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return "", fmt.Errorf("failed to extract %s: %w", lc, err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// A single compressed file, e.g. restic_0.19.1_darwin_arm64.bz2 - the
|
|
// binary itself, just squeezed.
|
|
if m := singleRe.FindStringSubmatch(lc); m != nil {
|
|
ext := m[1]
|
|
if err := os.Mkdir(out, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
stem := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
|
|
plain := filepath.Join(out, stem)
|
|
if err := decompressFile(ext, file, plain); err != nil {
|
|
return "", err
|
|
}
|
|
return out, nil
|
|
}
|
|
return "", nil // bare binary
|
|
}
|
|
|
|
// tarCodec maps a tarball suffix to the compression wrapped around the tar.
|
|
func tarCodec(lc string) string {
|
|
switch {
|
|
case strings.HasSuffix(lc, ".tar.gz"), strings.HasSuffix(lc, ".tgz"):
|
|
return "gz"
|
|
case strings.HasSuffix(lc, ".tar.bz2"), strings.HasSuffix(lc, ".tbz"):
|
|
return "bz2"
|
|
case strings.HasSuffix(lc, ".tar.xz"), strings.HasSuffix(lc, ".txz"):
|
|
return "xz"
|
|
case strings.HasSuffix(lc, ".tar.zst"), strings.HasSuffix(lc, ".tzst"):
|
|
return "zst"
|
|
}
|
|
return "" // plain .tar
|
|
}
|
|
|
|
func decompressFile(ext, src, dst string) error {
|
|
f, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
r, closer, err := decompressReader(ext, src, f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
if _, err := io.Copy(out, r); err != nil {
|
|
return err
|
|
}
|
|
return closer()
|
|
}
|
|
|
|
func untar(file, dest, codec string) error {
|
|
f, err := os.Open(file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
var r io.Reader = f
|
|
closer := func() error { return nil }
|
|
if codec != "" {
|
|
r, closer, err = decompressReader(codec, file, f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
tr := tar.NewReader(r)
|
|
for {
|
|
h, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("failed to extract %s: %w", filepath.Base(file), err)
|
|
}
|
|
path, ok := safeJoin(dest, h.Name)
|
|
if !ok {
|
|
verbose("skipping %s (path escapes the archive)", h.Name)
|
|
continue
|
|
}
|
|
switch h.Typeflag {
|
|
case tar.TypeDir:
|
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
return err
|
|
}
|
|
case tar.TypeReg:
|
|
if err := writeFileFrom(path, tr, os.FileMode(h.Mode).Perm()); err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
// Symlinks and devices are never the binary we are after.
|
|
verbose("skipping %s (not a regular file)", h.Name)
|
|
}
|
|
}
|
|
return closer()
|
|
}
|
|
|
|
func unzip(file, dest string) error {
|
|
zr, err := zip.OpenReader(file)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to extract %s: %w", filepath.Base(file), err)
|
|
}
|
|
defer zr.Close()
|
|
|
|
for _, f := range zr.File {
|
|
path, ok := safeJoin(dest, f.Name)
|
|
if !ok {
|
|
verbose("skipping %s (path escapes the archive)", f.Name)
|
|
continue
|
|
}
|
|
if f.FileInfo().IsDir() {
|
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if !f.Mode().IsRegular() {
|
|
continue
|
|
}
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = writeFileFrom(path, rc, f.Mode().Perm())
|
|
rc.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// safeJoin keeps an archive from writing outside its own directory.
|
|
func safeJoin(dest, name string) (string, bool) {
|
|
clean := filepath.Clean(filepath.FromSlash(name))
|
|
if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) {
|
|
return "", false
|
|
}
|
|
return filepath.Join(dest, clean), true
|
|
}
|
|
|
|
func writeFileFrom(path string, r io.Reader, mode os.FileMode) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if mode == 0 {
|
|
mode = 0o644
|
|
}
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
_, err = io.Copy(f, r)
|
|
return err
|
|
}
|
|
|
|
// findInTree looks for a file called name; failing that, for the single
|
|
// executable in the tree.
|
|
func findInTree(root, name string) string {
|
|
var files []string
|
|
filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
|
if err == nil && d.Type().IsRegular() {
|
|
files = append(files, p)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
for _, p := range files {
|
|
if b := filepath.Base(p); b == name || b == name+".exe" {
|
|
return p
|
|
}
|
|
}
|
|
var execs []string
|
|
for _, p := range files {
|
|
if looksExecutable(p) {
|
|
execs = append(execs, p)
|
|
}
|
|
}
|
|
if len(execs) == 1 {
|
|
return execs[0]
|
|
}
|
|
verbose("archive contains %d files, %d of them executable", len(files), len(execs))
|
|
return ""
|
|
}
|
|
|
|
// Magic bytes beat guessing by file size: ELF, Mach-O (incl. fat), PE, script.
|
|
func looksExecutable(path string) bool {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer f.Close()
|
|
|
|
m := make([]byte, 4)
|
|
n, _ := io.ReadFull(f, m)
|
|
if n < 2 {
|
|
return false
|
|
}
|
|
m = m[:n]
|
|
switch {
|
|
case bytes.HasPrefix(m, []byte("\x7fELF")):
|
|
return true
|
|
case bytes.HasPrefix(m, []byte{0xcf, 0xfa, 0xed, 0xfe}), // Mach-O 64
|
|
bytes.HasPrefix(m, []byte{0xce, 0xfa, 0xed, 0xfe}), // Mach-O 32
|
|
bytes.HasPrefix(m, []byte{0xca, 0xfe, 0xba, 0xbe}), // fat
|
|
bytes.HasPrefix(m, []byte{0xbe, 0xba, 0xfe, 0xca}):
|
|
return true
|
|
case bytes.HasPrefix(m, []byte("MZ")):
|
|
return true
|
|
case bytes.HasPrefix(m, []byte("#!")):
|
|
return true
|
|
}
|
|
return false
|
|
}
|