veyron/services/mgmt: Implement optional packages

This change implements optional packages in the mgmt services. The
binary repository now saves the media type and encoding of the objects,
e.g type=application/x-tar,encoding=gzip, or type=application/zip.

The node manager uses this information to download and install the
packages requested in the application envelopes.

TODO: Test the node manager implementation. This will require either using the
real binaryd, or significantly improving the mock binaryd.

Change-Id: Idc7d0ee7663ccd1816a271289de8f61403d8dcce
diff --git a/services/mgmt/lib/binary/impl.go b/services/mgmt/lib/binary/impl.go
index a1bedb8..07bedd9 100644
--- a/services/mgmt/lib/binary/impl.go
+++ b/services/mgmt/lib/binary/impl.go
@@ -18,6 +18,8 @@
 	"veyron.io/veyron/veyron2/services/mgmt/repository"
 	"veyron.io/veyron/veyron2/verror"
 	"veyron.io/veyron/veyron2/vlog"
+
+	"veyron.io/veyron/veyron/services/mgmt/lib/packages"
 )
 
 var (
@@ -41,17 +43,16 @@
 	return nil
 }
 
-func download(ctx context.T, w io.WriteSeeker, von string) error {
+func download(ctx context.T, w io.WriteSeeker, von string) (repository.MediaInfo, error) {
 	client := repository.BinaryClient(von)
-	// TODO(rthellend): Use the media type.
-	parts, _, err := client.Stat(ctx)
+	parts, mediaInfo, err := client.Stat(ctx)
 	if err != nil {
 		vlog.Errorf("Stat() failed: %v", err)
-		return err
+		return repository.MediaInfo{}, err
 	}
 	for _, part := range parts {
 		if part.Checksum == binary.MissingChecksum {
-			return errNotExist
+			return repository.MediaInfo{}, errNotExist
 		}
 	}
 	offset, whence := int64(0), 0
@@ -102,33 +103,34 @@
 			success = true
 		}
 		if !success {
-			return errOperationFailed
+			return repository.MediaInfo{}, errOperationFailed
 		}
 		offset += part.Size
 	}
-	return nil
+	return mediaInfo, nil
 }
 
-func Download(ctx context.T, von string) ([]byte, error) {
+func Download(ctx context.T, von string) ([]byte, repository.MediaInfo, error) {
 	dir, prefix := "", ""
 	file, err := ioutil.TempFile(dir, prefix)
 	if err != nil {
 		vlog.Errorf("TempFile(%v, %v) failed: %v", dir, prefix, err)
-		return nil, errOperationFailed
+		return nil, repository.MediaInfo{}, errOperationFailed
 	}
 	defer os.Remove(file.Name())
 	defer file.Close()
 	ctx, cancel := ctx.WithTimeout(time.Minute)
 	defer cancel()
-	if err := download(ctx, file, von); err != nil {
-		return nil, errOperationFailed
+	mediaInfo, err := download(ctx, file, von)
+	if err != nil {
+		return nil, repository.MediaInfo{}, errOperationFailed
 	}
 	bytes, err := ioutil.ReadFile(file.Name())
 	if err != nil {
 		vlog.Errorf("ReadFile(%v) failed: %v", file.Name(), err)
-		return nil, errOperationFailed
+		return nil, repository.MediaInfo{}, errOperationFailed
 	}
-	return bytes, nil
+	return bytes, mediaInfo, nil
 }
 
 func DownloadToFile(ctx context.T, von, path string) error {
@@ -141,13 +143,14 @@
 	defer file.Close()
 	ctx, cancel := ctx.WithTimeout(time.Minute)
 	defer cancel()
-	if err := download(ctx, file, von); err != nil {
+	mediaInfo, err := download(ctx, file, von)
+	if err != nil {
 		if err := os.Remove(file.Name()); err != nil {
 			vlog.Errorf("Remove(%v) failed: %v", file.Name(), err)
 		}
 		return errOperationFailed
 	}
-	perm := os.FileMode(0700)
+	perm := os.FileMode(0600)
 	if err := file.Chmod(perm); err != nil {
 		vlog.Errorf("Chmod(%v) failed: %v", perm, err)
 		if err := os.Remove(file.Name()); err != nil {
@@ -162,10 +165,17 @@
 		}
 		return errOperationFailed
 	}
+	if err := packages.SaveMediaInfo(path, mediaInfo); err != nil {
+		vlog.Errorf("packages.SaveMediaInfo(%v, %v) failed: %v", path, mediaInfo, err)
+		if err := os.Remove(path); err != nil {
+			vlog.Errorf("Remove(%v) failed: %v", path, err)
+		}
+		return errOperationFailed
+	}
 	return nil
 }
 
-func upload(ctx context.T, r io.ReadSeeker, von string) error {
+func upload(ctx context.T, r io.ReadSeeker, mediaInfo repository.MediaInfo, von string) error {
 	client := repository.BinaryClient(von)
 	offset, whence := int64(0), 2
 	size, err := r.Seek(offset, whence)
@@ -174,9 +184,7 @@
 		return errOperationFailed
 	}
 	nparts := (size-1)/partSize + 1
-	// TODO(rthellend): Determine the actual media type.
-	mediaType := "application/octet-stream"
-	if err := client.Create(ctx, int32(nparts), mediaType); err != nil {
+	if err := client.Create(ctx, int32(nparts), mediaInfo); err != nil {
 		vlog.Errorf("Create() failed: %v", err)
 		return err
 	}
@@ -258,11 +266,11 @@
 	return nil
 }
 
-func Upload(ctx context.T, von string, data []byte) error {
+func Upload(ctx context.T, von string, data []byte, mediaInfo repository.MediaInfo) error {
 	buffer := bytes.NewReader(data)
 	ctx, cancel := ctx.WithTimeout(time.Minute)
 	defer cancel()
-	return upload(ctx, buffer, von)
+	return upload(ctx, buffer, mediaInfo, von)
 }
 
 func UploadFromFile(ctx context.T, von, path string) error {
@@ -274,5 +282,6 @@
 	}
 	ctx, cancel := ctx.WithTimeout(time.Minute)
 	defer cancel()
-	return upload(ctx, file, von)
+	mediaInfo := packages.MediaInfoForFileName(path)
+	return upload(ctx, file, mediaInfo, von)
 }
diff --git a/services/mgmt/lib/binary/impl_test.go b/services/mgmt/lib/binary/impl_test.go
index ce66250..ca0a76f 100644
--- a/services/mgmt/lib/binary/impl_test.go
+++ b/services/mgmt/lib/binary/impl_test.go
@@ -5,11 +5,13 @@
 	"io/ioutil"
 	"os"
 	"path/filepath"
+	"reflect"
 	"testing"
 
 	"veyron.io/veyron/veyron2"
 	"veyron.io/veyron/veyron2/naming"
 	"veyron.io/veyron/veyron2/rt"
+	"veyron.io/veyron/veyron2/services/mgmt/repository"
 	"veyron.io/veyron/veyron2/vlog"
 
 	"veyron.io/veyron/veyron/lib/testutil"
@@ -85,21 +87,25 @@
 	von, cleanup := setupRepository(t)
 	defer cleanup()
 	data := testutil.RandomBytes(testutil.Rand.Intn(10 << 20))
-	if err := Upload(runtime.NewContext(), von, data); err != nil {
+	mediaInfo := repository.MediaInfo{Type: "application/octet-stream"}
+	if err := Upload(runtime.NewContext(), von, data, mediaInfo); err != nil {
 		t.Fatalf("Upload(%v) failed: %v", von, err)
 	}
-	output, err := Download(runtime.NewContext(), von)
+	output, outInfo, err := Download(runtime.NewContext(), von)
 	if err != nil {
 		t.Fatalf("Download(%v) failed: %v", von, err)
 	}
 	if bytes.Compare(data, output) != 0 {
-		t.Fatalf("Data mismatch:\nexpected %v %v\ngot %v %v", len(data), data[:100], len(output), output[:100])
+		t.Errorf("Data mismatch:\nexpected %v %v\ngot %v %v", len(data), data[:100], len(output), output[:100])
 	}
 	if err := Delete(runtime.NewContext(), von); err != nil {
-		t.Fatalf("Delete(%v) failed: %v", von, err)
+		t.Errorf("Delete(%v) failed: %v", von, err)
 	}
-	if _, err := Download(runtime.NewContext(), von); err == nil {
-		t.Fatalf("Download(%v) did not fail", von)
+	if _, _, err := Download(runtime.NewContext(), von); err == nil {
+		t.Errorf("Download(%v) did not fail", von)
+	}
+	if !reflect.DeepEqual(mediaInfo, outInfo) {
+		t.Errorf("unexpected media info: expected %v, got %v", mediaInfo, outInfo)
 	}
 }
 
@@ -117,11 +123,15 @@
 	}
 	defer os.Remove(src.Name())
 	defer src.Close()
-	dst, err := ioutil.TempFile(dir, prefix)
+	dstdir, err := ioutil.TempDir(dir, prefix)
 	if err != nil {
-		t.Fatalf("TempFile(%v, %v) failed: %v", dir, prefix, err)
+		t.Fatalf("TempDir(%v, %v) failed: %v", dir, prefix, err)
 	}
-	defer os.Remove(dst.Name())
+	defer os.RemoveAll(dstdir)
+	dst, err := ioutil.TempFile(dstdir, prefix)
+	if err != nil {
+		t.Fatalf("TempFile(%v, %v) failed: %v", dstdir, prefix, err)
+	}
 	defer dst.Close()
 	if _, err := src.Write(data); err != nil {
 		t.Fatalf("Write() failed: %v", err)
@@ -134,12 +144,19 @@
 	}
 	output, err := ioutil.ReadFile(dst.Name())
 	if err != nil {
-		t.Fatalf("ReadFile(%v) failed: %v", dst.Name(), err)
+		t.Errorf("ReadFile(%v) failed: %v", dst.Name(), err)
 	}
 	if bytes.Compare(data, output) != 0 {
-		t.Fatalf("Data mismatch:\nexpected %v %v\ngot %v %v", len(data), data[:100], len(output), output[:100])
+		t.Errorf("Data mismatch:\nexpected %v %v\ngot %v %v", len(data), data[:100], len(output), output[:100])
+	}
+	jMediaInfo, err := ioutil.ReadFile(dst.Name() + ".__info")
+	if err != nil {
+		t.Errorf("ReadFile(%v) failed: %v", dst.Name()+".__info", err)
+	}
+	if expected := `{"Type":"application/octet-stream","Encoding":""}`; string(jMediaInfo) != expected {
+		t.Errorf("unexpected media info: expected %q, got %q", expected, string(jMediaInfo))
 	}
 	if err := Delete(runtime.NewContext(), von); err != nil {
-		t.Fatalf("Delete(%v) failed: %v", von, err)
+		t.Errorf("Delete(%v) failed: %v", von, err)
 	}
 }
diff --git a/services/mgmt/lib/packages/packages.go b/services/mgmt/lib/packages/packages.go
new file mode 100644
index 0000000..57f9dde
--- /dev/null
+++ b/services/mgmt/lib/packages/packages.go
@@ -0,0 +1,192 @@
+// Package packages provides functionality to install ZIP and TAR packages.
+package packages
+
+import (
+	"archive/tar"
+	"archive/zip"
+	"compress/bzip2"
+	"compress/gzip"
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"veyron.io/veyron/veyron2/services/mgmt/repository"
+)
+
+const defaultType = "application/octet-stream"
+
+var typemap = map[string]repository.MediaInfo{
+	".zip":     repository.MediaInfo{Type: "application/zip"},
+	".tar":     repository.MediaInfo{Type: "application/x-tar"},
+	".tgz":     repository.MediaInfo{Type: "application/x-tar", Encoding: "gzip"},
+	".tar.gz":  repository.MediaInfo{Type: "application/x-tar", Encoding: "gzip"},
+	".tbz2":    repository.MediaInfo{Type: "application/x-tar", Encoding: "bzip2"},
+	".tb2":     repository.MediaInfo{Type: "application/x-tar", Encoding: "bzip2"},
+	".tbz":     repository.MediaInfo{Type: "application/x-tar", Encoding: "bzip2"},
+	".tar.bz2": repository.MediaInfo{Type: "application/x-tar", Encoding: "bzip2"},
+}
+
+// MediaInfoForFileName returns the MediaInfo based on the file's extension.
+func MediaInfoForFileName(fileName string) repository.MediaInfo {
+	fileName = strings.ToLower(fileName)
+	for k, v := range typemap {
+		if strings.HasSuffix(fileName, k) {
+			return v
+		}
+	}
+	return repository.MediaInfo{Type: defaultType}
+}
+
+// Install installs a package in the given directory. If the package is a TAR or
+// ZIP archive, its content is extracted in the destination directory.
+// Otherwise, the package file itself is copied to the destination directory.
+func Install(pkgFile, dir string) error {
+	mediaInfo, err := LoadMediaInfo(pkgFile)
+	if err != nil {
+		return err
+	}
+	switch mediaInfo.Type {
+	case "application/x-tar":
+		return extractTar(pkgFile, mediaInfo, dir)
+	case "application/zip":
+		return extractZip(pkgFile, dir)
+	default:
+		return fmt.Errorf("unsupported media type: %v", mediaInfo.Type)
+	}
+}
+
+// LoadMediaInfo returns the MediaInfo for the given package file.
+func LoadMediaInfo(pkgFile string) (repository.MediaInfo, error) {
+	jInfo, err := ioutil.ReadFile(pkgFile + ".__info")
+	if err != nil {
+		return repository.MediaInfo{}, err
+	}
+	var info repository.MediaInfo
+	if err := json.Unmarshal(jInfo, &info); err != nil {
+		return repository.MediaInfo{}, err
+	}
+	return info, nil
+}
+
+// SaveMediaInfo saves the media info for a package.
+func SaveMediaInfo(pkgFile string, mediaInfo repository.MediaInfo) error {
+	jInfo, err := json.Marshal(mediaInfo)
+	if err != nil {
+		return err
+	}
+	infoFile := pkgFile + ".__info"
+	if err := ioutil.WriteFile(infoFile, jInfo, os.FileMode(0600)); err != nil {
+		return err
+	}
+	return nil
+}
+
+func extractZip(zipFile, installDir string) error {
+	zr, err := zip.OpenReader(zipFile)
+	if err != nil {
+		return err
+	}
+	for _, file := range zr.File {
+		fi := file.FileInfo()
+		name := filepath.Join(installDir, file.Name)
+		if !strings.HasPrefix(name, installDir) {
+			return fmt.Errorf("failed to extract file %q outside of install directory", file.Name)
+		}
+		if fi.IsDir() {
+			if err := os.MkdirAll(name, os.FileMode(fi.Mode()&0700)); err != nil && !os.IsExist(err) {
+				return err
+			}
+			continue
+		}
+		in, err := file.Open()
+		if err != nil {
+			return err
+		}
+		parentName := filepath.Dir(name)
+		if err := os.MkdirAll(parentName, os.FileMode(0700)); err != nil {
+			return err
+		}
+		out, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY, os.FileMode(fi.Mode()&0700))
+		if err != nil {
+			in.Close()
+			return err
+		}
+		nbytes, err := io.Copy(out, in)
+		in.Close()
+		out.Close()
+		if err != nil {
+			return err
+		}
+		if nbytes != fi.Size() {
+			return fmt.Errorf("file size doesn't match for %q: %d != %d", fi.Name(), nbytes, fi.Size())
+		}
+	}
+	return nil
+}
+
+func extractTar(pkgFile string, mediaInfo repository.MediaInfo, installDir string) error {
+	f, err := os.Open(pkgFile)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+
+	var reader io.Reader
+	switch enc := mediaInfo.Encoding; enc {
+	case "":
+		reader = f
+	case "gzip":
+		var err error
+		if reader, err = gzip.NewReader(f); err != nil {
+			return err
+		}
+	case "bzip2":
+		reader = bzip2.NewReader(f)
+	default:
+		return fmt.Errorf("unsupported encoding: %q", enc)
+	}
+
+	tr := tar.NewReader(reader)
+	for {
+		hdr, err := tr.Next()
+		if err == io.EOF {
+			return nil
+		}
+		if err != nil {
+			return err
+		}
+		name := filepath.Join(installDir, hdr.Name)
+		if !strings.HasPrefix(name, installDir) {
+			return fmt.Errorf("failed to extract file %q outside of install directory", hdr.Name)
+		}
+		// Regular file
+		if hdr.Typeflag == tar.TypeReg {
+			out, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY, os.FileMode(hdr.Mode&0700))
+			if err != nil {
+				return err
+			}
+			nbytes, err := io.Copy(out, tr)
+			out.Close()
+			if err != nil {
+				return err
+			}
+			if nbytes != hdr.Size {
+				return fmt.Errorf("file size doesn't match for %q: %d != %d", hdr.Name, nbytes, hdr.Size)
+			}
+			continue
+		}
+		// Directory
+		if hdr.Typeflag == tar.TypeDir {
+			if err := os.Mkdir(name, os.FileMode(hdr.Mode&0700)); err != nil && !os.IsExist(err) {
+				return err
+			}
+			continue
+		}
+		// Skip unsupported types
+		// TODO(rthellend): Consider adding support for Symlink.
+	}
+}
diff --git a/services/mgmt/lib/packages/packages_test.go b/services/mgmt/lib/packages/packages_test.go
new file mode 100644
index 0000000..8182b44
--- /dev/null
+++ b/services/mgmt/lib/packages/packages_test.go
@@ -0,0 +1,242 @@
+package packages_test
+
+import (
+	"archive/tar"
+	"archive/zip"
+	"compress/gzip"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"reflect"
+	"sort"
+	"testing"
+
+	"veyron.io/veyron/veyron2/services/mgmt/repository"
+
+	"veyron.io/veyron/veyron/services/mgmt/lib/packages"
+)
+
+func TestInstall(t *testing.T) {
+	workdir, err := ioutil.TempDir("", "packages-test-")
+	if err != nil {
+		t.Fatalf("ioutil.TempDir failed: %v", err)
+	}
+	defer os.RemoveAll(workdir)
+	srcdir := filepath.Join(workdir, "src")
+	dstdir := filepath.Join(workdir, "dst")
+	createFiles(t, srcdir)
+
+	zipfile := filepath.Join(workdir, "archivezip")
+	tarfile := filepath.Join(workdir, "archivetar")
+	tgzfile := filepath.Join(workdir, "archivetgz")
+
+	makeZip(t, zipfile, srcdir)
+	makeTar(t, tarfile, srcdir)
+	doGzip(t, tarfile, tgzfile)
+
+	binfile := filepath.Join(workdir, "binfile")
+	ioutil.WriteFile(binfile, []byte("This is a binary file"), os.FileMode(0644))
+	ioutil.WriteFile(binfile+".__info", []byte(`{"type":"application/octet-stream"}`), os.FileMode(0644))
+
+	expected := []string{
+		"a perm:700",
+		"a/b perm:700",
+		"a/b/xyzzy.txt perm:600",
+		"a/bar.txt perm:600",
+		"a/foo.txt perm:600",
+	}
+	for _, file := range []string{zipfile, tarfile, tgzfile} {
+		setupDstDir(t, dstdir)
+		if err := packages.Install(file, dstdir); err != nil {
+			t.Errorf("packages.Install failed for %q: %v", file, err)
+		}
+		files := scanDir(dstdir)
+		if !reflect.DeepEqual(files, expected) {
+			t.Errorf("unexpected result for %q: Got %q, want %q", file, files, expected)
+		}
+	}
+
+	setupDstDir(t, dstdir)
+	if err := packages.Install(binfile, dstdir); err == nil {
+		t.Errorf("expected packages.Install to fail %q", binfile)
+	}
+}
+
+func TestMediaInfo(t *testing.T) {
+	testcases := []struct {
+		filename string
+		expected repository.MediaInfo
+	}{
+		{"foo.zip", repository.MediaInfo{Type: "application/zip"}},
+		{"foo.ZIP", repository.MediaInfo{Type: "application/zip"}},
+		{"foo.tar", repository.MediaInfo{Type: "application/x-tar"}},
+		{"foo.TAR", repository.MediaInfo{Type: "application/x-tar"}},
+		{"foo.tgz", repository.MediaInfo{Type: "application/x-tar", Encoding: "gzip"}},
+		{"FOO.TAR.GZ", repository.MediaInfo{Type: "application/x-tar", Encoding: "gzip"}},
+		{"foo.tbz2", repository.MediaInfo{Type: "application/x-tar", Encoding: "bzip2"}},
+		{"foo.tar.bz2", repository.MediaInfo{Type: "application/x-tar", Encoding: "bzip2"}},
+		{"foo", repository.MediaInfo{Type: "application/octet-stream"}},
+	}
+	for _, tc := range testcases {
+		if got := packages.MediaInfoForFileName(tc.filename); !reflect.DeepEqual(got, tc.expected) {
+			t.Errorf("unexpected result for %q: Got %v, want %v", tc.filename, got, tc.expected)
+		}
+	}
+}
+
+func createFiles(t *testing.T, dir string) {
+	if err := os.Mkdir(dir, os.FileMode(0755)); err != nil {
+		t.Fatalf("os.Mkdir(%q) failed: %v", dir, err)
+	}
+	dirs := []string{"a", "a/b"}
+	for _, d := range dirs {
+		fullname := filepath.Join(dir, d)
+		if err := os.Mkdir(fullname, os.FileMode(0755)); err != nil {
+			t.Fatalf("os.Mkdir(%q) failed: %v", fullname, err)
+		}
+	}
+	files := []string{"a/foo.txt", "a/bar.txt", "a/b/xyzzy.txt"}
+	for _, f := range files {
+		fullname := filepath.Join(dir, f)
+		if err := ioutil.WriteFile(fullname, []byte(f), os.FileMode(0644)); err != nil {
+			t.Fatalf("ioutil.WriteFile(%q) failed: %v", fullname, err)
+		}
+	}
+}
+
+func makeZip(t *testing.T, zipfile, dir string) {
+	z, err := os.OpenFile(zipfile, os.O_CREATE|os.O_WRONLY, os.FileMode(0644))
+	if err != nil {
+		t.Fatalf("os.OpenFile(%q) failed: %v", zipfile, err)
+	}
+	defer z.Close()
+	w := zip.NewWriter(z)
+	filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			t.Fatalf("Walk(%q) error: %v", dir, err)
+		}
+		if dir == path {
+			return nil
+		}
+		fh, err := zip.FileInfoHeader(info)
+		if err != nil {
+			t.Fatalf("FileInfoHeader failed: %v", err)
+		}
+		fh.Name, _ = filepath.Rel(dir, path)
+		hdr, err := w.CreateHeader(fh)
+		if err != nil {
+			t.Fatalf("w.CreateHeader failed: %v", err)
+		}
+		if !info.IsDir() {
+			content, err := ioutil.ReadFile(path)
+			if err != nil {
+				t.Fatalf("ioutil.ReadFile(%q) failed: %v", path, err)
+			}
+			if _, err = hdr.Write(content); err != nil {
+				t.Fatalf("hdr.Write(%q) failed: %v", content, err)
+			}
+		}
+		return nil
+	})
+	if err := w.Close(); err != nil {
+		t.Fatalf("w.Close() failed: %v", err)
+	}
+	if err := ioutil.WriteFile(zipfile+".__info", []byte(`{"type":"application/zip"}`), os.FileMode(0644)); err != nil {
+		t.Fatalf("ioutil.WriteFile() failed: %v", err)
+	}
+}
+
+func makeTar(t *testing.T, tarfile, dir string) {
+	tf, err := os.OpenFile(tarfile, os.O_CREATE|os.O_WRONLY, os.FileMode(0644))
+	if err != nil {
+		t.Fatalf("os.OpenFile(%q) failed: %v", tarfile, err)
+	}
+	defer tf.Close()
+
+	tw := tar.NewWriter(tf)
+	filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			t.Fatalf("Walk(%q) error: %v", dir, err)
+		}
+		if dir == path {
+			return nil
+		}
+		hdr, err := tar.FileInfoHeader(info, "")
+		if err != nil {
+			t.Fatalf("tar.FileInfoHeader failed: %v", err)
+		}
+		hdr.Name, _ = filepath.Rel(dir, path)
+		if err := tw.WriteHeader(hdr); err != nil {
+			t.Fatalf("tw.WriteHeader failed: %v", err)
+		}
+		if !info.IsDir() {
+			content, err := ioutil.ReadFile(path)
+			if err != nil {
+				t.Fatalf("ioutil.ReadFile(%q) failed: %v", path, err)
+			}
+			if _, err := tw.Write(content); err != nil {
+				t.Fatalf("tw.Write failed: %v", err)
+			}
+		}
+		return nil
+	})
+	if err := tw.Close(); err != nil {
+		t.Fatalf("tw.Close failed: %v", err)
+	}
+	if err := ioutil.WriteFile(tarfile+".__info", []byte(`{"type":"application/x-tar"}`), os.FileMode(0644)); err != nil {
+		t.Fatalf("ioutil.WriteFile() failed: %v", err)
+	}
+}
+
+func doGzip(t *testing.T, infile, outfile string) {
+	in, err := os.Open(infile)
+	if err != nil {
+		t.Fatalf("os.Open(%q) failed: %v", infile, err)
+	}
+	defer in.Close()
+	out, err := os.OpenFile(outfile, os.O_CREATE|os.O_WRONLY, os.FileMode(0644))
+	if err != nil {
+		t.Fatalf("os.OpenFile(%q) failed: %v", outfile, err)
+	}
+	defer out.Close()
+	writer := gzip.NewWriter(out)
+	defer writer.Close()
+	if _, err := io.Copy(writer, in); err != nil {
+		t.Fatalf("io.Copy() failed: %v", err)
+	}
+
+	info, err := packages.LoadMediaInfo(infile)
+	if err != nil {
+		t.Fatalf("LoadMediaInfo(%q) failed: %v", infile, err)
+	}
+	info.Encoding = "gzip"
+	if err := packages.SaveMediaInfo(outfile, info); err != nil {
+		t.Fatalf("SaveMediaInfo(%v) failed: %v", outfile, err)
+	}
+}
+
+func scanDir(root string) []string {
+	files := []string{}
+	filepath.Walk(root, func(path string, info os.FileInfo, _ error) error {
+		if root == path {
+			return nil
+		}
+		rel, _ := filepath.Rel(root, path)
+		perm := info.Mode() & 0700
+		files = append(files, fmt.Sprintf("%s perm:%o", rel, perm))
+		return nil
+	})
+	sort.Strings(files)
+	return files
+}
+
+func setupDstDir(t *testing.T, dst string) {
+	if err := os.RemoveAll(dst); err != nil {
+		t.Fatalf("os.RemoveAll(%q) failed: %v", dst, err)
+	}
+	if err := os.Mkdir(dst, os.FileMode(0755)); err != nil {
+		t.Fatalf("os.Mkdir(%q) failed: %v", dst, err)
+	}
+}