~nilium/mtar

ffbe98b8636e306d13765ba5281cd4c384713bf7 — Noel Cower 6 years ago
Initial commit

This program is a horrifying mess but I really wanted it for.. some
reason. Yeah. It should surprise nobody.

Change-Id: I5a2498a09c5dbbd379b37b71404d7982753b7f8a
4 files changed, 408 insertions(+), 0 deletions(-)

A .gitignore
A LICENSE.txt
A README.md
A mtar.go
A  => .gitignore +4 -0
@@ 1,4 @@
.*.sw[op]
*.tar
mtar
.DS_Store

A  => LICENSE.txt +23 -0
@@ 1,23 @@
Copyright 2018 Noel Cower

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice,
   this list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
   this list of conditions and the following disclaimer in the documentation
   and/or other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.

A  => README.md +20 -0
@@ 1,20 @@
mtar
====

[![GoDoc](https://godoc.org/go.spiff.io/mtar?status.svg)](https://godoc.org/go.spiff.io/mtar)

mtar is a simple-ish tool for creating tar files with arbitrary src-to-archive
path mappings and regexp filters.

You can download and install mtar using the go tool by running

    $ go get -u go.spiff.io/mtar

Usage text can be read at <https://godoc.org/go.spiff.io/mtar> or by running
`mtar -h`.

LICENSE
-------

mtar is licensed under the BSD two-clause license. This is readable at the top
of its source file(s) and in the LICENSE.txt file.

A  => mtar.go +361 -0
@@ 1,361 @@
// Copyright 2018 Noel Cower
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice,
//    this list of conditions and the following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice,
//    this list of conditions and the following disclaimer in the documentation
//    and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.

// Mtar is a simple tar program to create tar files with arbitrary path mappings from the source
// filesystem to tar paths. It only supports regular files, directories, and symlinks.
//
// Download and install with
//
//     go get -u go.spiff.io/mtar
//
// Usage:
//
//    mtar [-h|--help] [FILE|OPTION]
//
//    Writes a tar file to standard output.
//
//    FILE may be a regular filepath for a file, symlink, or directory. If
//    FILE contains a ':', the text after the colon is the path to write to
//    the tar file. For example, the following paths behave differently:
//
//      SRC
//          Add file SRC to the tar file as-is.
//      SRC:
//          Add file SRC to the tar file as-is.
//      SRC:DEST
//          Add file SRC as DEST to the tar file.
//
//    There is currently no SRC to accept standard input as a file (other
//    than, for example, using /dev/stdin).
//
//    In addition, options may be passed in the middle of file arguments to
//    control archive creation:
//
//      -h | --help
//        When passed as the first argument, print this usage text.
//      -Cdir | -C dir
//        Change to directory (relative to PWD at all times; -C. will reset
//        the current directory) for subsequent file additions.
//      -OREGEX | -O REGEX
//        Add a filter to reject output paths, after mapping, that do not
//        match the REGEX.
//      -oREGEX | -o REGEX
//        Select only output paths, after mapping, that match the REGEX.
//      -IREGEX | -I REGEX
//        Add a filter to reject input paths (as passed) that match the REGEX.
//      -iREGEX | -i REGEX
//        Add a filter to select only input paths that match the REGEX.
//      -Ri, -Ro, -R
//        Reset input, output, or all filters, respectively.
//
package main // import "go.spiff.io/mtar"

import (
	"archive/tar"
	"io"
	"log"
	"os"
	"os/user"
	"path"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
	"syscall"
)

type Args struct{ args []string }

type Matcher struct {
	rx   *regexp.Regexp
	want bool
}

func (m Matcher) matches(s string) bool {
	return m.rx.MatchString(s) == m.want
}

var startupDir string
var skipSrcGlobs []Matcher
var skipDestGlobs []Matcher

func (p *Args) Shift() (s string, ok bool) {
	if ok = len(p.args) > 0; ok {
		s, p.args = p.args[0], p.args[1:]
	}
	return
}

func usage() {
	io.WriteString(os.Stderr,
		`Usage: mtar [-h|--help] [FILE|OPTION]

Writes a tar file to standard output.

FILE may be a regular filepath for a file, symlink, or directory. If
FILE contains a ':', the text after the colon is the path to write to
the tar file. For example, the following paths behave differently:

  SRC
      Add file SRC to the tar file as-is.
  SRC:
      Add file SRC to the tar file as-is.
  SRC:DEST
      Add file SRC as DEST to the tar file.

There is currently no SRC to accept standard input as a file (other
than, for example, using /dev/stdin).

In addition, options may be passed in the middle of file arguments to
control archive creation:

  -h | --help
    When passed as the first argument, print this usage text.
  -Cdir | -C dir
    Change to directory (relative to PWD at all times; -C. will reset
    the current directory) for subsequent file additions.
  -OREGEX | -O REGEX
    Add a filter to reject output paths, after mapping, that do not
    match the REGEX.
  -oREGEX | -o REGEX
    Select only output paths, after mapping, that match the REGEX.
  -IREGEX | -I REGEX
    Add a filter to reject input paths (as passed) that match the REGEX.
  -iREGEX | -i REGEX
    Add a filter to select only input paths that match the REGEX.
  -Ri, -Ro, -R
    Reset input, output, or all filters, respectively.`+"\n")
}

func main() {
	log.SetFlags(0)
	log.SetPrefix("mtar: ")

	var err error
	startupDir, err = os.Getwd()
	failOnError("getwd", err)

	// Using some pretty weird CLI arguments here so incoming weird as hell arg loop ahead
	if len(os.Args) <= 1 || os.Args[1] == "-h" || os.Args[1] == "--help" {
		usage()
		os.Exit(2)
	}

	w := tar.NewWriter(os.Stdout)
	defer func() { failOnError("error writing output", w.Close()) }()
	argv := Args{args: os.Args[1:]}
	for s, ok := argv.Shift(); ok; s, ok = argv.Shift() {
		switch {
		// Filter flags
		case s == "-Ro": // reset output filters
			skipSrcGlobs = nil
		case s == "-Ri": // reset input filters
			skipSrcGlobs = nil
		case s == "-R": // reset all filters
			skipSrcGlobs, skipDestGlobs = nil, nil
		case s == "-i" || s == "-I": // filter input by regexp
			want := s[1] == 'i'
			if s, ok = argv.Shift(); !ok {
				log.Fatal("-i: missing regexp")
			}
			skipSrcGlobs = append(skipSrcGlobs, Matcher{rx: regexp.MustCompile(s), want: want})
		case strings.HasPrefix(s, "-I") || strings.HasPrefix(s, "-i"):
			want := s[1] == 'i'
			skipSrcGlobs = append(skipSrcGlobs, Matcher{rx: regexp.MustCompile(s[2:]), want: want})
		case s == "-o" || s == "-O": // filter output by regexp (after mapping)
			want := s[1] == 'o'
			if s, ok = argv.Shift(); !ok {
				log.Fatal("-O: missing regexp")
			}
			skipDestGlobs = append(skipDestGlobs, Matcher{rx: regexp.MustCompile(s), want: want})
		case strings.HasPrefix(s, "-O") || strings.HasPrefix(s, "-o"):
			want := s[1] == 'o'
			skipDestGlobs = append(skipDestGlobs, Matcher{rx: regexp.MustCompile(s[2:]), want: want})

		// Change dir
		case s == "-C": // cd
			if s, ok = argv.Shift(); !ok {
				log.Fatal("-C: missing directory")
			}
			failOnError("cd", changeDir(s))
			continue

		case strings.HasPrefix(s, "-C"): // cd
			failOnError("cd", changeDir(s[2:]))

		// Add files
		default:
			src, dest := s, ""
			switch idx := strings.IndexByte(src, ':'); idx {
			case -1: // no mapping -- use src as path
			case 0: // no src
				log.Fatalf("no source: %q", s)
			case len(src) - 1: // no dest -- use src path
				src = s[:idx]
			default: // path given
				src, dest = s[:idx], s[idx+1:]
			}

			addFile(w, src, dest, true)
		}
	}
}

func addFile(w *tar.Writer, src, dest string, allowRecursive bool) {
	if shouldSkip(skipSrcGlobs, src) {
		return
	}

	st, err := os.Lstat(src)
	failOnError("add file: stat error", err)
	if dest == "" {
		dest = src
	}

	dest = path.Clean(filepath.ToSlash(dest))
	if strings.HasPrefix(dest, "/") {
		dest = "." + dest
	} else if !strings.HasPrefix("./", dest) {
		dest = "./" + dest
	}
	if dest == ".." || strings.HasPrefix(dest, "../") {
		log.Fatal("add file: destination may not contain .. (", dest, ")")
	}

	hdr := &tar.Header{
		Name:     dest,
		Typeflag: tar.TypeReg,
		ModTime:  st.ModTime(),
		Mode:     int64(st.Mode().Perm()),
		Format:   tar.FormatPAX,
	}

	if uid, gid, ok := getUidGid(st); ok {
		hdr.Uid, err = strconv.Atoi(uid.Uid)
		hdr.Uname = uid.Username
		if err != nil {
			log.Fatalf("cannot parse uid (%q) for %s: %v", uid.Uid, src, err)
		}
		hdr.Gid, err = strconv.Atoi(gid.Gid)
		hdr.Gname = gid.Name
		if err != nil {
			log.Fatalf("cannot parse gid (%q) for %s: %v", gid.Gid, src, err)
		}
	}

	switch {
	case st.Mode().IsRegular():
		hdr.Size = st.Size()
	case st.IsDir():
		hdr.Typeflag = tar.TypeDir
		hdr.Name = dest + "/"
	case st.Mode()&os.ModeSymlink == os.ModeSymlink:
		hdr.Typeflag = tar.TypeSymlink
		hdr.Name = dest
		link, err := os.Readlink(src)
		failOnError("cannot resolve symlink", err)
		hdr.Linkname = link
	default:
		log.Print("skipping file: ", src, ": cannot add file with mode ", st.Mode().Perm())
		return
	}

	if shouldSkip(skipDestGlobs, hdr.Name) {
		return
	}

	failOnError("write header: "+hdr.Name, w.WriteHeader(hdr))

	if st.Mode().IsDir() {
		if allowRecursive {
			addRecursive(w, src, dest)
		}
		return
	}

	if !st.Mode().IsRegular() {
		return
	}

	file, err := os.Open(src)
	failOnError("read error: "+src, err)
	defer file.Close()
	n, err := io.Copy(w, file)
	failOnError("copy error: "+src, err)
	if n != hdr.Size {
		log.Fatalf("copy error: size mismatch for %s: wrote %d, want %d", src, n, hdr.Size)
	}
}

func addRecursive(w *tar.Writer, src, prefix string) {
	src = strings.TrimRight(src, "/")
	filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
		if filepath.Clean(p) == filepath.Clean(src) || shouldSkip(skipSrcGlobs, p) {
			return nil
		}
		dest := path.Join(prefix, strings.TrimPrefix(p, src))
		addFile(w, p, dest, false)
		return nil
	})
}

func changeDir(dir string) error {
	const pathsep = string(filepath.Separator)
	if !filepath.IsAbs(dir) {
		dir = filepath.Join(startupDir, dir)
	}
	return os.Chdir(dir)
}

func failOnError(prefix string, err error) {
	if err != nil {
		log.Fatalf("%s: %v", prefix, err)
	}
}

func shouldSkip(set []Matcher, s string) bool {
	for _, m := range set {
		if !m.matches(s) {
			return true
		}
	}
	return false
}

func getUidGid(f os.FileInfo) (*user.User, *user.Group, bool) {
	stat, ok := f.Sys().(*syscall.Stat_t)
	if !ok {
		return nil, nil, false
	}
	uid, gid := strconv.FormatUint(uint64(stat.Uid), 10), strconv.FormatUint(uint64(stat.Gid), 10)
	u, err := user.LookupId(uid)
	if err != nil {
		return nil, nil, false
	}
	g, err := user.LookupGroupId(gid)
	if err != nil {
		return nil, nil, false
	}
	return u, g, true
}