/*
   Copyright The containerd Authors.

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.
*/

/*
   Copyright 2019 The Go Authors. All rights reserved.
   Use of this source code is governed by a BSD-style
   license that can be found in the LICENSE file.
*/

package estargz

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"crypto/sha256"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math/rand"
	"os"
	"path/filepath"
	"reflect"
	"sort"
	"strings"
	"testing"
	"time"

	"github.com/containerd/stargz-snapshotter/estargz/errorutil"
	"github.com/klauspost/compress/zstd"
	digest "github.com/opencontainers/go-digest"
)

func init() {
	rand.Seed(time.Now().UnixNano())
}

// TestingController is Compression with some helper methods necessary for testing.
type TestingController interface {
	Compression
	TestStreams(t *testing.T, b []byte, streams []int64)
	DiffIDOf(*testing.T, []byte) string
	String() string
}

// CompressionTestSuite tests this pkg with controllers can build valid eStargz blobs and parse them.
func CompressionTestSuite(t *testing.T, controllers ...TestingControllerFactory) {
	t.Run("testBuild", func(t *testing.T) { t.Parallel(); testBuild(t, controllers...) })
	t.Run("testDigestAndVerify", func(t *testing.T) { t.Parallel(); testDigestAndVerify(t, controllers...) })
	t.Run("testWriteAndOpen", func(t *testing.T) { t.Parallel(); testWriteAndOpen(t, controllers...) })
}

type TestingControllerFactory func() TestingController

const (
	uncompressedType int = iota
	gzipType
	zstdType
)

var srcCompressions = []int{
	uncompressedType,
	gzipType,
	zstdType,
}

var allowedPrefix = [4]string{"", "./", "/", "../"}

// testBuild tests the resulting stargz blob built by this pkg has the same
// contents as the normal stargz blob.
func testBuild(t *testing.T, controllers ...TestingControllerFactory) {
	tests := []struct {
		name         string
		chunkSize    int
		minChunkSize []int
		in           []tarEntry
	}{
		{
			name:      "regfiles and directories",
			chunkSize: 4,
			in: tarOf(
				file("foo", "test1"),
				dir("foo2/"),
				file("foo2/bar", "test2", xAttr(map[string]string{"test": "sample"})),
			),
		},
		{
			name:      "empty files",
			chunkSize: 4,
			in: tarOf(
				file("foo", "tttttt"),
				file("foo_empty", ""),
				file("foo2", "tttttt"),
				file("foo_empty2", ""),
				file("foo3", "tttttt"),
				file("foo_empty3", ""),
				file("foo4", "tttttt"),
				file("foo_empty4", ""),
				file("foo5", "tttttt"),
				file("foo_empty5", ""),
				file("foo6", "tttttt"),
			),
		},
		{
			name:         "various files",
			chunkSize:    4,
			minChunkSize: []int{0, 64000},
			in: tarOf(
				file("baz.txt", "bazbazbazbazbazbazbaz"),
				file("foo1.txt", "a"),
				file("bar/foo2.txt", "b"),
				file("foo3.txt", "c"),
				symlink("barlink", "test/bar.txt"),
				dir("test/"),
				dir("dev/"),
				blockdev("dev/testblock", 3, 4),
				fifo("dev/testfifo"),
				chardev("dev/testchar1", 5, 6),
				file("test/bar.txt", "testbartestbar", xAttr(map[string]string{"test2": "sample2"})),
				dir("test2/"),
				link("test2/bazlink", "baz.txt"),
				chardev("dev/testchar2", 1, 2),
			),
		},
		{
			name:      "no contents",
			chunkSize: 4,
			in: tarOf(
				file("baz.txt", ""),
				symlink("barlink", "test/bar.txt"),
				dir("test/"),
				dir("dev/"),
				blockdev("dev/testblock", 3, 4),
				fifo("dev/testfifo"),
				chardev("dev/testchar1", 5, 6),
				file("test/bar.txt", "", xAttr(map[string]string{"test2": "sample2"})),
				dir("test2/"),
				link("test2/bazlink", "baz.txt"),
				chardev("dev/testchar2", 1, 2),
			),
		},
	}
	for _, tt := range tests {
		if len(tt.minChunkSize) == 0 {
			tt.minChunkSize = []int{0}
		}
		for _, srcCompression := range srcCompressions {
			srcCompression := srcCompression
			for _, newCL := range controllers {
				newCL := newCL
				for _, srcTarFormat := range []tar.Format{tar.FormatUSTAR, tar.FormatPAX, tar.FormatGNU} {
					srcTarFormat := srcTarFormat
					for _, prefix := range allowedPrefix {
						prefix := prefix
						for _, minChunkSize := range tt.minChunkSize {
							minChunkSize := minChunkSize
							t.Run(tt.name+"-"+fmt.Sprintf("compression=%v,prefix=%q,src=%d,format=%s,minChunkSize=%d", newCL(), prefix, srcCompression, srcTarFormat, minChunkSize), func(t *testing.T) {
								tarBlob := buildTar(t, tt.in, prefix, srcTarFormat)
								// Test divideEntries()
								entries, err := sortEntries(tarBlob, nil, nil) // identical order
								if err != nil {
									t.Fatalf("failed to parse tar: %v", err)
								}
								var merged []*entry
								for _, part := range divideEntries(entries, 4) {
									merged = append(merged, part...)
								}
								if !reflect.DeepEqual(entries, merged) {
									for _, e := range entries {
										t.Logf("Original: %v", e.header)
									}
									for _, e := range merged {
										t.Logf("Merged: %v", e.header)
									}
									t.Errorf("divided entries couldn't be merged")
									return
								}

								// Prepare sample data
								cl1 := newCL()
								wantBuf := new(bytes.Buffer)
								sw := NewWriterWithCompressor(wantBuf, cl1)
								sw.MinChunkSize = minChunkSize
								sw.ChunkSize = tt.chunkSize
								if err := sw.AppendTar(tarBlob); err != nil {
									t.Fatalf("failed to append tar to want stargz: %v", err)
								}
								if _, err := sw.Close(); err != nil {
									t.Fatalf("failed to prepare want stargz: %v", err)
								}
								wantData := wantBuf.Bytes()
								want, err := Open(io.NewSectionReader(
									bytes.NewReader(wantData), 0, int64(len(wantData))),
									WithDecompressors(cl1),
								)
								if err != nil {
									t.Fatalf("failed to parse the want stargz: %v", err)
								}

								// Prepare testing data
								var opts []Option
								if minChunkSize > 0 {
									opts = append(opts, WithMinChunkSize(minChunkSize))
								}
								cl2 := newCL()
								rc, err := Build(compressBlob(t, tarBlob, srcCompression),
									append(opts, WithChunkSize(tt.chunkSize), WithCompression(cl2))...)
								if err != nil {
									t.Fatalf("failed to build stargz: %v", err)
								}
								defer rc.Close()
								gotBuf := new(bytes.Buffer)
								if _, err := io.Copy(gotBuf, rc); err != nil {
									t.Fatalf("failed to copy built stargz blob: %v", err)
								}
								gotData := gotBuf.Bytes()
								got, err := Open(io.NewSectionReader(
									bytes.NewReader(gotBuf.Bytes()), 0, int64(len(gotData))),
									WithDecompressors(cl2),
								)
								if err != nil {
									t.Fatalf("failed to parse the got stargz: %v", err)
								}

								// Check DiffID is properly calculated
								rc.Close()
								diffID := rc.DiffID()
								wantDiffID := cl2.DiffIDOf(t, gotData)
								if diffID.String() != wantDiffID {
									t.Errorf("DiffID = %q; want %q", diffID, wantDiffID)
								}

								// Compare as stargz
								if !isSameVersion(t, cl1, wantData, cl2, gotData) {
									t.Errorf("built stargz hasn't same json")
									return
								}
								if !isSameEntries(t, want, got) {
									t.Errorf("built stargz isn't same as the original")
									return
								}

								// Compare as tar.gz
								if !isSameTarGz(t, cl1, wantData, cl2, gotData) {
									t.Errorf("built stargz isn't same tar.gz")
									return
								}
							})
						}
					}
				}
			}
		}
	}
}

func isSameTarGz(t *testing.T, cla TestingController, a []byte, clb TestingController, b []byte) bool {
	aGz, err := cla.Reader(bytes.NewReader(a))
	if err != nil {
		t.Fatalf("failed to read A")
	}
	defer aGz.Close()
	bGz, err := clb.Reader(bytes.NewReader(b))
	if err != nil {
		t.Fatalf("failed to read B")
	}
	defer bGz.Close()

	// Same as tar's Next() method but ignores landmarks and TOCJSON file
	next := func(r *tar.Reader) (h *tar.Header, err error) {
		for {
			if h, err = r.Next(); err != nil {
				return
			}
			if h.Name != PrefetchLandmark &&
				h.Name != NoPrefetchLandmark &&
				h.Name != TOCTarName {
				return
			}
		}
	}

	aTar := tar.NewReader(aGz)
	bTar := tar.NewReader(bGz)
	for {
		// Fetch and parse next header.
		aH, aErr := next(aTar)
		bH, bErr := next(bTar)
		if aErr != nil || bErr != nil {
			if aErr == io.EOF && bErr == io.EOF {
				break
			}
			t.Fatalf("Failed to parse tar file: A: %v, B: %v", aErr, bErr)
		}
		if !reflect.DeepEqual(aH, bH) {
			t.Logf("different header (A = %v; B = %v)", aH, bH)
			return false

		}
		aFile, err := io.ReadAll(aTar)
		if err != nil {
			t.Fatal("failed to read tar payload of A")
		}
		bFile, err := io.ReadAll(bTar)
		if err != nil {
			t.Fatal("failed to read tar payload of B")
		}
		if !bytes.Equal(aFile, bFile) {
			t.Logf("different tar payload (A = %q; B = %q)", string(a), string(b))
			return false
		}
	}

	return true
}

func isSameVersion(t *testing.T, cla TestingController, a []byte, clb TestingController, b []byte) bool {
	aJTOC, _, err := parseStargz(io.NewSectionReader(bytes.NewReader(a), 0, int64(len(a))), cla)
	if err != nil {
		t.Fatalf("failed to parse A: %v", err)
	}
	bJTOC, _, err := parseStargz(io.NewSectionReader(bytes.NewReader(b), 0, int64(len(b))), clb)
	if err != nil {
		t.Fatalf("failed to parse B: %v", err)
	}
	t.Logf("A: TOCJSON: %v", dumpTOCJSON(t, aJTOC))
	t.Logf("B: TOCJSON: %v", dumpTOCJSON(t, bJTOC))
	return aJTOC.Version == bJTOC.Version
}

func isSameEntries(t *testing.T, a, b *Reader) bool {
	aroot, ok := a.Lookup("")
	if !ok {
		t.Fatalf("failed to get root of A")
	}
	broot, ok := b.Lookup("")
	if !ok {
		t.Fatalf("failed to get root of B")
	}
	aEntry := stargzEntry{aroot, a}
	bEntry := stargzEntry{broot, b}
	return contains(t, aEntry, bEntry) && contains(t, bEntry, aEntry)
}

func compressBlob(t *testing.T, src *io.SectionReader, srcCompression int) *io.SectionReader {
	buf := new(bytes.Buffer)
	var w io.WriteCloser
	var err error
	if srcCompression == gzipType {
		w = gzip.NewWriter(buf)
	} else if srcCompression == zstdType {
		w, err = zstd.NewWriter(buf)
		if err != nil {
			t.Fatalf("failed to init zstd writer: %v", err)
		}
	} else {
		return src
	}
	src.Seek(0, io.SeekStart)
	if _, err := io.Copy(w, src); err != nil {
		t.Fatalf("failed to compress source")
	}
	if err := w.Close(); err != nil {
		t.Fatalf("failed to finalize compress source")
	}
	data := buf.Bytes()
	return io.NewSectionReader(bytes.NewReader(data), 0, int64(len(data)))

}

type stargzEntry struct {
	e *TOCEntry
	r *Reader
}

// contains checks if all child entries in "b" are also contained in "a".
// This function also checks if the files/chunks contain the same contents among "a" and "b".
func contains(t *testing.T, a, b stargzEntry) bool {
	ae, ar := a.e, a.r
	be, br := b.e, b.r
	t.Logf("Comparing: %q vs %q", ae.Name, be.Name)
	if !equalEntry(ae, be) {
		t.Logf("%q != %q: entry: a: %v, b: %v", ae.Name, be.Name, ae, be)
		return false
	}
	if ae.Type == "dir" {
		t.Logf("Directory: %q vs %q: %v vs %v", ae.Name, be.Name,
			allChildrenName(ae), allChildrenName(be))
		iscontain := true
		ae.ForeachChild(func(aBaseName string, aChild *TOCEntry) bool {
			// Walk through all files on this stargz file.

			if aChild.Name == PrefetchLandmark ||
				aChild.Name == NoPrefetchLandmark {
				return true // Ignore landmarks
			}

			// Ignore a TOCEntry of "./" (formated as "" by stargz lib) on root directory
			// because this points to the root directory itself.
			if aChild.Name == "" && ae.Name == "" {
				return true
			}

			bChild, ok := be.LookupChild(aBaseName)
			if !ok {
				t.Logf("%q (base: %q): not found in b: %v",
					ae.Name, aBaseName, allChildrenName(be))
				iscontain = false
				return false
			}

			childcontain := contains(t, stargzEntry{aChild, a.r}, stargzEntry{bChild, b.r})
			if !childcontain {
				t.Logf("%q != %q: non-equal dir", ae.Name, be.Name)
				iscontain = false
				return false
			}
			return true
		})
		return iscontain
	} else if ae.Type == "reg" {
		af, err := ar.OpenFile(ae.Name)
		if err != nil {
			t.Fatalf("failed to open file %q on A: %v", ae.Name, err)
		}
		bf, err := br.OpenFile(be.Name)
		if err != nil {
			t.Fatalf("failed to open file %q on B: %v", be.Name, err)
		}

		var nr int64
		for nr < ae.Size {
			abytes, anext, aok := readOffset(t, af, nr, a)
			bbytes, bnext, bok := readOffset(t, bf, nr, b)
			if !aok && !bok {
				break
			} else if !(aok && bok) || anext != bnext {
				t.Logf("%q != %q (offset=%d): chunk existence a=%v vs b=%v, anext=%v vs bnext=%v",
					ae.Name, be.Name, nr, aok, bok, anext, bnext)
				return false
			}
			nr = anext
			if !bytes.Equal(abytes, bbytes) {
				t.Logf("%q != %q: different contents %v vs %v",
					ae.Name, be.Name, string(abytes), string(bbytes))
				return false
			}
		}
		return true
	}

	return true
}

func allChildrenName(e *TOCEntry) (children []string) {
	e.ForeachChild(func(baseName string, _ *TOCEntry) bool {
		children = append(children, baseName)
		return true
	})
	return
}

func equalEntry(a, b *TOCEntry) bool {
	// Here, we selectively compare fileds that we are interested in.
	return a.Name == b.Name &&
		a.Type == b.Type &&
		a.Size == b.Size &&
		a.ModTime3339 == b.ModTime3339 &&
		a.Stat().ModTime().Equal(b.Stat().ModTime()) && // modTime     time.Time
		a.LinkName == b.LinkName &&
		a.Mode == b.Mode &&
		a.UID == b.UID &&
		a.GID == b.GID &&
		a.Uname == b.Uname &&
		a.Gname == b.Gname &&
		(a.Offset >= 0) == (b.Offset >= 0) &&
		(a.NextOffset() > 0) == (b.NextOffset() > 0) &&
		a.DevMajor == b.DevMajor &&
		a.DevMinor == b.DevMinor &&
		a.NumLink == b.NumLink &&
		reflect.DeepEqual(a.Xattrs, b.Xattrs) &&
		// chunk-related infomations aren't compared in this function.
		// ChunkOffset int64 `json:"chunkOffset,omitempty"`
		// ChunkSize   int64 `json:"chunkSize,omitempty"`
		// children map[string]*TOCEntry
		a.Digest == b.Digest
}

func readOffset(t *testing.T, r *io.SectionReader, offset int64, e stargzEntry) ([]byte, int64, bool) {
	ce, ok := e.r.ChunkEntryForOffset(e.e.Name, offset)
	if !ok {
		return nil, 0, false
	}
	data := make([]byte, ce.ChunkSize)
	t.Logf("Offset: %v, NextOffset: %v", ce.Offset, ce.NextOffset())
	n, err := r.ReadAt(data, ce.ChunkOffset)
	if err != nil {
		t.Fatalf("failed to read file payload of %q (offset:%d,size:%d): %v",
			e.e.Name, ce.ChunkOffset, ce.ChunkSize, err)
	}
	if int64(n) != ce.ChunkSize {
		t.Fatalf("unexpected copied data size %d; want %d",
			n, ce.ChunkSize)
	}
	return data[:n], offset + ce.ChunkSize, true
}

func dumpTOCJSON(t *testing.T, tocJSON *JTOC) string {
	jtocData, err := json.Marshal(*tocJSON)
	if err != nil {
		t.Fatalf("failed to marshal TOC JSON: %v", err)
	}
	buf := new(bytes.Buffer)
	if _, err := io.Copy(buf, bytes.NewReader(jtocData)); err != nil {
		t.Fatalf("failed to read toc json blob: %v", err)
	}
	return buf.String()
}

const chunkSize = 3

// type check func(t *testing.T, sgzData []byte, tocDigest digest.Digest, dgstMap map[string]digest.Digest, compressionLevel int)
type check func(t *testing.T, sgzData []byte, tocDigest digest.Digest, dgstMap map[string]digest.Digest, controller TestingController, newController TestingControllerFactory)

// testDigestAndVerify runs specified checks against sample stargz blobs.
func testDigestAndVerify(t *testing.T, controllers ...TestingControllerFactory) {
	tests := []struct {
		name         string
		tarInit      func(t *testing.T, dgstMap map[string]digest.Digest) (blob []tarEntry)
		checks       []check
		minChunkSize []int
	}{
		{
			name: "no-regfile",
			tarInit: func(t *testing.T, dgstMap map[string]digest.Digest) (blob []tarEntry) {
				return tarOf(
					dir("test/"),
				)
			},
			checks: []check{
				checkStargzTOC,
				checkVerifyTOC,
				checkVerifyInvalidStargzFail(buildTar(t, tarOf(
					dir("test2/"), // modified
				), allowedPrefix[0])),
			},
		},
		{
			name: "small-files",
			tarInit: func(t *testing.T, dgstMap map[string]digest.Digest) (blob []tarEntry) {
				return tarOf(
					regDigest(t, "baz.txt", "", dgstMap),
					regDigest(t, "foo.txt", "a", dgstMap),
					dir("test/"),
					regDigest(t, "test/bar.txt", "bbb", dgstMap),
				)
			},
			minChunkSize: []int{0, 64000},
			checks: []check{
				checkStargzTOC,
				checkVerifyTOC,
				checkVerifyInvalidStargzFail(buildTar(t, tarOf(
					file("baz.txt", ""),
					file("foo.txt", "M"), // modified
					dir("test/"),
					file("test/bar.txt", "bbb"),
				), allowedPrefix[0])),
				// checkVerifyInvalidTOCEntryFail("foo.txt"), // TODO
				checkVerifyBrokenContentFail("foo.txt"),
			},
		},
		{
			name: "big-files",
			tarInit: func(t *testing.T, dgstMap map[string]digest.Digest) (blob []tarEntry) {
				return tarOf(
					regDigest(t, "baz.txt", "bazbazbazbazbazbazbaz", dgstMap),
					regDigest(t, "foo.txt", "a", dgstMap),
					dir("test/"),
					regDigest(t, "test/bar.txt", "testbartestbar", dgstMap),
				)
			},
			checks: []check{
				checkStargzTOC,
				checkVerifyTOC,
				checkVerifyInvalidStargzFail(buildTar(t, tarOf(
					file("baz.txt", "bazbazbazMMMbazbazbaz"), // modified
					file("foo.txt", "a"),
					dir("test/"),
					file("test/bar.txt", "testbartestbar"),
				), allowedPrefix[0])),
				checkVerifyInvalidTOCEntryFail("test/bar.txt"),
				checkVerifyBrokenContentFail("test/bar.txt"),
			},
		},
		{
			name:         "with-non-regfiles",
			minChunkSize: []int{0, 64000},
			tarInit: func(t *testing.T, dgstMap map[string]digest.Digest) (blob []tarEntry) {
				return tarOf(
					regDigest(t, "baz.txt", "bazbazbazbazbazbazbaz", dgstMap),
					regDigest(t, "foo.txt", "a", dgstMap),
					regDigest(t, "bar/foo2.txt", "b", dgstMap),
					regDigest(t, "foo3.txt", "c", dgstMap),
					symlink("barlink", "test/bar.txt"),
					dir("test/"),
					regDigest(t, "test/bar.txt", "testbartestbar", dgstMap),
					dir("test2/"),
					link("test2/bazlink", "baz.txt"),
				)
			},
			checks: []check{
				checkStargzTOC,
				checkVerifyTOC,
				checkVerifyInvalidStargzFail(buildTar(t, tarOf(
					file("baz.txt", "bazbazbazbazbazbazbaz"),
					file("foo.txt", "a"),
					file("bar/foo2.txt", "b"),
					file("foo3.txt", "c"),
					symlink("barlink", "test/bar.txt"),
					dir("test/"),
					file("test/bar.txt", "testbartestbar"),
					dir("test2/"),
					link("test2/bazlink", "foo.txt"), // modified
				), allowedPrefix[0])),
				checkVerifyInvalidTOCEntryFail("test/bar.txt"),
				checkVerifyBrokenContentFail("test/bar.txt"),
			},
		},
	}

	for _, tt := range tests {
		if len(tt.minChunkSize) == 0 {
			tt.minChunkSize = []int{0}
		}
		for _, srcCompression := range srcCompressions {
			srcCompression := srcCompression
			for _, newCL := range controllers {
				newCL := newCL
				for _, prefix := range allowedPrefix {
					prefix := prefix
					for _, srcTarFormat := range []tar.Format{tar.FormatUSTAR, tar.FormatPAX, tar.FormatGNU} {
						srcTarFormat := srcTarFormat
						for _, minChunkSize := range tt.minChunkSize {
							minChunkSize := minChunkSize
							t.Run(tt.name+"-"+fmt.Sprintf("compression=%v,prefix=%q,format=%s,minChunkSize=%d", newCL(), prefix, srcTarFormat, minChunkSize), func(t *testing.T) {
								// Get original tar file and chunk digests
								dgstMap := make(map[string]digest.Digest)
								tarBlob := buildTar(t, tt.tarInit(t, dgstMap), prefix, srcTarFormat)

								cl := newCL()
								rc, err := Build(compressBlob(t, tarBlob, srcCompression),
									WithChunkSize(chunkSize), WithCompression(cl))
								if err != nil {
									t.Fatalf("failed to convert stargz: %v", err)
								}
								tocDigest := rc.TOCDigest()
								defer rc.Close()
								buf := new(bytes.Buffer)
								if _, err := io.Copy(buf, rc); err != nil {
									t.Fatalf("failed to copy built stargz blob: %v", err)
								}
								newStargz := buf.Bytes()
								// NoPrefetchLandmark is added during `Bulid`, which is expected behaviour.
								dgstMap[chunkID(NoPrefetchLandmark, 0, int64(len([]byte{landmarkContents})))] = digest.FromBytes([]byte{landmarkContents})

								for _, check := range tt.checks {
									check(t, newStargz, tocDigest, dgstMap, cl, newCL)
								}
							})
						}
					}
				}
			}
		}
	}
}

// checkStargzTOC checks the TOC JSON of the passed stargz has the expected
// digest and contains valid chunks. It walks all entries in the stargz and
// checks all chunk digests stored to the TOC JSON match the actual contents.
func checkStargzTOC(t *testing.T, sgzData []byte, tocDigest digest.Digest, dgstMap map[string]digest.Digest, controller TestingController, newController TestingControllerFactory) {
	sgz, err := Open(
		io.NewSectionReader(bytes.NewReader(sgzData), 0, int64(len(sgzData))),
		WithDecompressors(controller),
	)
	if err != nil {
		t.Errorf("failed to parse converted stargz: %v", err)
		return
	}
	digestMapTOC, err := listDigests(io.NewSectionReader(
		bytes.NewReader(sgzData), 0, int64(len(sgzData))),
		controller,
	)
	if err != nil {
		t.Fatalf("failed to list digest: %v", err)
	}
	found := make(map[string]bool)
	for id := range dgstMap {
		found[id] = false
	}
	zr, err := controller.Reader(bytes.NewReader(sgzData))
	if err != nil {
		t.Fatalf("failed to decompress converted stargz: %v", err)
	}
	defer zr.Close()
	tr := tar.NewReader(zr)
	for {
		h, err := tr.Next()
		if err != nil {
			if err != io.EOF {
				t.Errorf("failed to read tar entry: %v", err)
				return
			}
			break
		}
		if h.Name == TOCTarName {
			// Check the digest of TOC JSON based on the actual contents
			// It's sure that TOC JSON exists in this archive because
			// Open succeeded.
			dgstr := digest.Canonical.Digester()
			if _, err := io.Copy(dgstr.Hash(), tr); err != nil {
				t.Fatalf("failed to calculate digest of TOC JSON: %v",
					err)
			}
			if dgstr.Digest() != tocDigest {
				t.Errorf("invalid TOC JSON %q; want %q", tocDigest, dgstr.Digest())
			}
			continue
		}
		if _, ok := sgz.Lookup(h.Name); !ok {
			t.Errorf("lost stargz entry %q in the converted TOC", h.Name)
			return
		}
		var n int64
		for n < h.Size {
			ce, ok := sgz.ChunkEntryForOffset(h.Name, n)
			if !ok {
				t.Errorf("lost chunk %q(offset=%d) in the converted TOC",
					h.Name, n)
				return
			}

			// Get the original digest to make sure the file contents are kept unchanged
			// from the original tar, during the whole conversion steps.
			id := chunkID(h.Name, n, ce.ChunkSize)
			want, ok := dgstMap[id]
			if !ok {
				t.Errorf("Unexpected chunk %q(offset=%d,size=%d): %v",
					h.Name, n, ce.ChunkSize, dgstMap)
				return
			}
			found[id] = true

			// Check the file contents
			dgstr := digest.Canonical.Digester()
			if _, err := io.CopyN(dgstr.Hash(), tr, ce.ChunkSize); err != nil {
				t.Fatalf("failed to calculate digest of %q (offset=%d,size=%d)",
					h.Name, n, ce.ChunkSize)
			}
			if want != dgstr.Digest() {
				t.Errorf("Invalid contents in converted stargz %q: %q; want %q",
					h.Name, dgstr.Digest(), want)
				return
			}

			// Check the digest stored in TOC JSON
			dgstTOC, ok := digestMapTOC[ce.Offset]
			if !ok {
				t.Errorf("digest of %q(offset=%d,size=%d,chunkOffset=%d) isn't registered",
					h.Name, ce.Offset, ce.ChunkSize, ce.ChunkOffset)
			}
			if want != dgstTOC {
				t.Errorf("Invalid digest in TOCEntry %q: %q; want %q",
					h.Name, dgstTOC, want)
				return
			}

			n += ce.ChunkSize
		}
	}

	for id, ok := range found {
		if !ok {
			t.Errorf("required chunk %q not found in the converted stargz: %v", id, found)
		}
	}
}

// checkVerifyTOC checks the verification works for the TOC JSON of the passed
// stargz. It walks all entries in the stargz and checks the verifications for
// all chunks work.
func checkVerifyTOC(t *testing.T, sgzData []byte, tocDigest digest.Digest, dgstMap map[string]digest.Digest, controller TestingController, newController TestingControllerFactory) {
	sgz, err := Open(
		io.NewSectionReader(bytes.NewReader(sgzData), 0, int64(len(sgzData))),
		WithDecompressors(controller),
	)
	if err != nil {
		t.Errorf("failed to parse converted stargz: %v", err)
		return
	}
	ev, err := sgz.VerifyTOC(tocDigest)
	if err != nil {
		t.Errorf("failed to verify stargz: %v", err)
		return
	}

	found := make(map[string]bool)
	for id := range dgstMap {
		found[id] = false
	}
	zr, err := controller.Reader(bytes.NewReader(sgzData))
	if err != nil {
		t.Fatalf("failed to decompress converted stargz: %v", err)
	}
	defer zr.Close()
	tr := tar.NewReader(zr)
	for {
		h, err := tr.Next()
		if err != nil {
			if err != io.EOF {
				t.Errorf("failed to read tar entry: %v", err)
				return
			}
			break
		}
		if h.Name == TOCTarName {
			continue
		}
		if _, ok := sgz.Lookup(h.Name); !ok {
			t.Errorf("lost stargz entry %q in the converted TOC", h.Name)
			return
		}
		var n int64
		for n < h.Size {
			ce, ok := sgz.ChunkEntryForOffset(h.Name, n)
			if !ok {
				t.Errorf("lost chunk %q(offset=%d) in the converted TOC",
					h.Name, n)
				return
			}

			v, err := ev.Verifier(ce)
			if err != nil {
				t.Errorf("failed to get verifier for %q(offset=%d)", h.Name, n)
			}

			found[chunkID(h.Name, n, ce.ChunkSize)] = true

			// Check the file contents
			if _, err := io.CopyN(v, tr, ce.ChunkSize); err != nil {
				t.Fatalf("failed to get chunk of %q (offset=%d,size=%d)",
					h.Name, n, ce.ChunkSize)
			}
			if !v.Verified() {
				t.Errorf("Invalid contents in converted stargz %q (should be succeeded)",
					h.Name)
				return
			}
			n += ce.ChunkSize
		}
	}

	for id, ok := range found {
		if !ok {
			t.Errorf("required chunk %q not found in the converted stargz: %v", id, found)
		}
	}
}

// checkVerifyInvalidTOCEntryFail checks if misconfigured TOC JSON can be
// detected during the verification and the verification returns an error.
func checkVerifyInvalidTOCEntryFail(filename string) check {
	return func(t *testing.T, sgzData []byte, tocDigest digest.Digest, dgstMap map[string]digest.Digest, controller TestingController, newController TestingControllerFactory) {
		funcs := map[string]rewriteFunc{
			"lost digest in a entry": func(t *testing.T, toc *JTOC, sgz *io.SectionReader) {
				var found bool
				for _, e := range toc.Entries {
					if cleanEntryName(e.Name) == filename {
						if e.Type != "reg" && e.Type != "chunk" {
							t.Fatalf("entry %q to break must be regfile or chunk", filename)
						}
						if e.ChunkDigest == "" {
							t.Fatalf("entry %q is already invalid", filename)
						}
						e.ChunkDigest = ""
						found = true
					}
				}
				if !found {
					t.Fatalf("rewrite target not found")
				}
			},
			"duplicated entry offset": func(t *testing.T, toc *JTOC, sgz *io.SectionReader) {
				var (
					sampleEntry *TOCEntry
					targetEntry *TOCEntry
				)
				for _, e := range toc.Entries {
					if e.Type == "reg" || e.Type == "chunk" {
						if cleanEntryName(e.Name) == filename {
							targetEntry = e
						} else {
							sampleEntry = e
						}
					}
				}
				if sampleEntry == nil {
					t.Fatalf("TOC must contain at least one regfile or chunk entry other than the rewrite target")
				}
				if targetEntry == nil {
					t.Fatalf("rewrite target not found")
				}
				targetEntry.Offset = sampleEntry.Offset
			},
		}

		for name, rFunc := range funcs {
			t.Run(name, func(t *testing.T) {
				newSgz, newTocDigest := rewriteTOCJSON(t, io.NewSectionReader(bytes.NewReader(sgzData), 0, int64(len(sgzData))), rFunc, controller)
				buf := new(bytes.Buffer)
				if _, err := io.Copy(buf, newSgz); err != nil {
					t.Fatalf("failed to get converted stargz")
				}
				isgz := buf.Bytes()

				sgz, err := Open(
					io.NewSectionReader(bytes.NewReader(isgz), 0, int64(len(isgz))),
					WithDecompressors(controller),
				)
				if err != nil {
					t.Fatalf("failed to parse converted stargz: %v", err)
					return
				}
				_, err = sgz.VerifyTOC(newTocDigest)
				if err == nil {
					t.Errorf("must fail for invalid TOC")
					return
				}
			})
		}
	}
}

// checkVerifyInvalidStargzFail checks if the verification detects that the
// given stargz file doesn't match to the expected digest and returns error.
func checkVerifyInvalidStargzFail(invalid *io.SectionReader) check {
	return func(t *testing.T, sgzData []byte, tocDigest digest.Digest, dgstMap map[string]digest.Digest, controller TestingController, newController TestingControllerFactory) {
		cl := newController()
		rc, err := Build(invalid, WithChunkSize(chunkSize), WithCompression(cl))
		if err != nil {
			t.Fatalf("failed to convert stargz: %v", err)
		}
		defer rc.Close()
		buf := new(bytes.Buffer)
		if _, err := io.Copy(buf, rc); err != nil {
			t.Fatalf("failed to copy built stargz blob: %v", err)
		}
		mStargz := buf.Bytes()

		sgz, err := Open(
			io.NewSectionReader(bytes.NewReader(mStargz), 0, int64(len(mStargz))),
			WithDecompressors(cl),
		)
		if err != nil {
			t.Fatalf("failed to parse converted stargz: %v", err)
			return
		}
		_, err = sgz.VerifyTOC(tocDigest)
		if err == nil {
			t.Errorf("must fail for invalid TOC")
			return
		}
	}
}

// checkVerifyBrokenContentFail checks if the verifier detects broken contents
// that doesn't match to the expected digest and returns error.
func checkVerifyBrokenContentFail(filename string) check {
	return func(t *testing.T, sgzData []byte, tocDigest digest.Digest, dgstMap map[string]digest.Digest, controller TestingController, newController TestingControllerFactory) {
		// Parse stargz file
		sgz, err := Open(
			io.NewSectionReader(bytes.NewReader(sgzData), 0, int64(len(sgzData))),
			WithDecompressors(controller),
		)
		if err != nil {
			t.Fatalf("failed to parse converted stargz: %v", err)
			return
		}
		ev, err := sgz.VerifyTOC(tocDigest)
		if err != nil {
			t.Fatalf("failed to verify stargz: %v", err)
			return
		}

		// Open the target file
		sr, err := sgz.OpenFile(filename)
		if err != nil {
			t.Fatalf("failed to open file %q", filename)
		}
		ce, ok := sgz.ChunkEntryForOffset(filename, 0)
		if !ok {
			t.Fatalf("lost chunk %q(offset=%d) in the converted TOC", filename, 0)
			return
		}
		if ce.ChunkSize == 0 {
			t.Fatalf("file mustn't be empty")
			return
		}
		data := make([]byte, ce.ChunkSize)
		if _, err := sr.ReadAt(data, ce.ChunkOffset); err != nil {
			t.Errorf("failed to get data of a chunk of %q(offset=%q)",
				filename, ce.ChunkOffset)
		}

		// Check the broken chunk (must fail)
		v, err := ev.Verifier(ce)
		if err != nil {
			t.Fatalf("failed to get verifier for %q", filename)
		}
		broken := append([]byte{^data[0]}, data[1:]...)
		if _, err := io.CopyN(v, bytes.NewReader(broken), ce.ChunkSize); err != nil {
			t.Fatalf("failed to get chunk of %q (offset=%d,size=%d)",
				filename, ce.ChunkOffset, ce.ChunkSize)
		}
		if v.Verified() {
			t.Errorf("verification must fail for broken file chunk %q(org:%q,broken:%q)",
				filename, data, broken)
		}
	}
}

func chunkID(name string, offset, size int64) string {
	return fmt.Sprintf("%s-%d-%d", cleanEntryName(name), offset, size)
}

type rewriteFunc func(t *testing.T, toc *JTOC, sgz *io.SectionReader)

func rewriteTOCJSON(t *testing.T, sgz *io.SectionReader, rewrite rewriteFunc, controller TestingController) (newSgz io.Reader, tocDigest digest.Digest) {
	decodedJTOC, jtocOffset, err := parseStargz(sgz, controller)
	if err != nil {
		t.Fatalf("failed to extract TOC JSON: %v", err)
	}

	rewrite(t, decodedJTOC, sgz)

	tocFooter, tocDigest, err := tocAndFooter(controller, decodedJTOC, jtocOffset)
	if err != nil {
		t.Fatalf("failed to create toc and footer: %v", err)
	}

	// Reconstruct stargz file with the modified TOC JSON
	if _, err := sgz.Seek(0, io.SeekStart); err != nil {
		t.Fatalf("failed to reset the seek position of stargz: %v", err)
	}
	return io.MultiReader(
		io.LimitReader(sgz, jtocOffset), // Original stargz (before TOC JSON)
		tocFooter,                       // Rewritten TOC and footer
	), tocDigest
}

func listDigests(sgz *io.SectionReader, controller TestingController) (map[int64]digest.Digest, error) {
	decodedJTOC, _, err := parseStargz(sgz, controller)
	if err != nil {
		return nil, err
	}
	digestMap := make(map[int64]digest.Digest)
	for _, e := range decodedJTOC.Entries {
		if e.Type == "reg" || e.Type == "chunk" {
			if e.Type == "reg" && e.Size == 0 {
				continue // ignores empty file
			}
			if e.ChunkDigest == "" {
				return nil, fmt.Errorf("ChunkDigest of %q(off=%d) not found in TOC JSON",
					e.Name, e.Offset)
			}
			d, err := digest.Parse(e.ChunkDigest)
			if err != nil {
				return nil, err
			}
			digestMap[e.Offset] = d
		}
	}
	return digestMap, nil
}

func parseStargz(sgz *io.SectionReader, controller TestingController) (decodedJTOC *JTOC, jtocOffset int64, err error) {
	fSize := controller.FooterSize()
	footer := make([]byte, fSize)
	if _, err := sgz.ReadAt(footer, sgz.Size()-fSize); err != nil {
		return nil, 0, fmt.Errorf("error reading footer: %w", err)
	}
	_, tocOffset, _, err := controller.ParseFooter(footer[positive(int64(len(footer))-fSize):])
	if err != nil {
		return nil, 0, fmt.Errorf("failed to parse footer: %w", err)
	}

	// Decode the TOC JSON
	var tocReader io.Reader
	if tocOffset >= 0 {
		tocReader = io.NewSectionReader(sgz, tocOffset, sgz.Size()-tocOffset-fSize)
	}
	decodedJTOC, _, err = controller.ParseTOC(tocReader)
	if err != nil {
		return nil, 0, fmt.Errorf("failed to parse TOC: %w", err)
	}
	return decodedJTOC, tocOffset, nil
}

func testWriteAndOpen(t *testing.T, controllers ...TestingControllerFactory) {
	const content = "Some contents"
	invalidUtf8 := "\xff\xfe\xfd"

	xAttrFile := xAttr{"foo": "bar", "invalid-utf8": invalidUtf8}
	sampleOwner := owner{uid: 50, gid: 100}

	data64KB := randomContents(64000)

	tests := []struct {
		name         string
		chunkSize    int
		minChunkSize int
		in           []tarEntry
		want         []stargzCheck
		wantNumGz    int // expected number of streams

		wantNumGzLossLess  int // expected number of streams (> 0) in lossless mode if it's different from wantNumGz
		wantFailOnLossLess bool
		wantTOCVersion     int // default = 1
	}{
		{
			name:      "empty",
			in:        tarOf(),
			wantNumGz: 2, // (empty tar) + TOC + footer
			want: checks(
				numTOCEntries(0),
			),
		},
		{
			name: "1dir_1empty_file",
			in: tarOf(
				dir("foo/"),
				file("foo/bar.txt", ""),
			),
			wantNumGz: 3, // dir, TOC, footer
			want: checks(
				numTOCEntries(2),
				hasDir("foo/"),
				hasFileLen("foo/bar.txt", 0),
				entryHasChildren("foo", "bar.txt"),
				hasFileDigest("foo/bar.txt", digestFor("")),
			),
		},
		{
			name: "1dir_1file",
			in: tarOf(
				dir("foo/"),
				file("foo/bar.txt", content, xAttrFile),
			),
			wantNumGz: 4, // var dir, foo.txt alone, TOC, footer
			want: checks(
				numTOCEntries(2),
				hasDir("foo/"),
				hasFileLen("foo/bar.txt", len(content)),
				hasFileDigest("foo/bar.txt", digestFor(content)),
				hasFileContentsRange("foo/bar.txt", 0, content),
				hasFileContentsRange("foo/bar.txt", 1, content[1:]),
				entryHasChildren("", "foo"),
				entryHasChildren("foo", "bar.txt"),
				hasFileXattrs("foo/bar.txt", "foo", "bar"),
				hasFileXattrs("foo/bar.txt", "invalid-utf8", invalidUtf8),
			),
		},
		{
			name: "2meta_2file",
			in: tarOf(
				dir("bar/", sampleOwner),
				dir("foo/", sampleOwner),
				file("foo/bar.txt", content, sampleOwner),
			),
			wantNumGz: 4, // both dirs, foo.txt alone, TOC, footer
			want: checks(
				numTOCEntries(3),
				hasDir("bar/"),
				hasDir("foo/"),
				hasFileLen("foo/bar.txt", len(content)),
				entryHasChildren("", "bar", "foo"),
				entryHasChildren("foo", "bar.txt"),
				hasChunkEntries("foo/bar.txt", 1),
				hasEntryOwner("bar/", sampleOwner),
				hasEntryOwner("foo/", sampleOwner),
				hasEntryOwner("foo/bar.txt", sampleOwner),
			),
		},
		{
			name: "3dir",
			in: tarOf(
				dir("bar/"),
				dir("foo/"),
				dir("foo/bar/"),
			),
			wantNumGz: 3, // 3 dirs, TOC, footer
			want: checks(
				hasDirLinkCount("bar/", 2),
				hasDirLinkCount("foo/", 3),
				hasDirLinkCount("foo/bar/", 2),
			),
		},
		{
			name: "symlink",
			in: tarOf(
				dir("foo/"),
				symlink("foo/bar", "../../x"),
			),
			wantNumGz: 3, // metas + TOC + footer
			want: checks(
				numTOCEntries(2),
				hasSymlink("foo/bar", "../../x"),
				entryHasChildren("", "foo"),
				entryHasChildren("foo", "bar"),
			),
		},
		{
			name:      "chunked_file",
			chunkSize: 4,
			in: tarOf(
				dir("foo/"),
				file("foo/big.txt", "This "+"is s"+"uch "+"a bi"+"g fi"+"le"),
			),
			wantNumGz: 9, // dir + big.txt(6 chunks) + TOC + footer
			want: checks(
				numTOCEntries(7), // 1 for foo dir, 6 for the foo/big.txt file
				hasDir("foo/"),
				hasFileLen("foo/big.txt", len("This is such a big file")),
				hasFileDigest("foo/big.txt", digestFor("This is such a big file")),
				hasFileContentsRange("foo/big.txt", 0, "This is such a big file"),
				hasFileContentsRange("foo/big.txt", 1, "his is such a big file"),
				hasFileContentsRange("foo/big.txt", 2, "is is such a big file"),
				hasFileContentsRange("foo/big.txt", 3, "s is such a big file"),
				hasFileContentsRange("foo/big.txt", 4, " is such a big file"),
				hasFileContentsRange("foo/big.txt", 5, "is such a big file"),
				hasFileContentsRange("foo/big.txt", 6, "s such a big file"),
				hasFileContentsRange("foo/big.txt", 7, " such a big file"),
				hasFileContentsRange("foo/big.txt", 8, "such a big file"),
				hasFileContentsRange("foo/big.txt", 9, "uch a big file"),
				hasFileContentsRange("foo/big.txt", 10, "ch a big file"),
				hasFileContentsRange("foo/big.txt", 11, "h a big file"),
				hasFileContentsRange("foo/big.txt", 12, " a big file"),
				hasFileContentsRange("foo/big.txt", len("This is such a big file")-1, ""),
				hasChunkEntries("foo/big.txt", 6),
			),
		},
		{
			name: "recursive",
			in: tarOf(
				dir("/", sampleOwner),
				dir("bar/", sampleOwner),
				dir("foo/", sampleOwner),
				file("foo/bar.txt", content, sampleOwner),
			),
			wantNumGz: 4, // dirs, bar.txt alone, TOC, footer
			want: checks(
				maxDepth(2), // 0: root directory, 1: "foo/", 2: "bar.txt"
			),
		},
		{
			name: "block_char_fifo",
			in: tarOf(
				tarEntryFunc(func(w *tar.Writer, prefix string, format tar.Format) error {
					return w.WriteHeader(&tar.Header{
						Name:     prefix + "b",
						Typeflag: tar.TypeBlock,
						Devmajor: 123,
						Devminor: 456,
						Format:   format,
					})
				}),
				tarEntryFunc(func(w *tar.Writer, prefix string, format tar.Format) error {
					return w.WriteHeader(&tar.Header{
						Name:     prefix + "c",
						Typeflag: tar.TypeChar,
						Devmajor: 111,
						Devminor: 222,
						Format:   format,
					})
				}),
				tarEntryFunc(func(w *tar.Writer, prefix string, format tar.Format) error {
					return w.WriteHeader(&tar.Header{
						Name:     prefix + "f",
						Typeflag: tar.TypeFifo,
						Format:   format,
					})
				}),
			),
			wantNumGz: 3,
			want: checks(
				lookupMatch("b", &TOCEntry{Name: "b", Type: "block", DevMajor: 123, DevMinor: 456, NumLink: 1}),
				lookupMatch("c", &TOCEntry{Name: "c", Type: "char", DevMajor: 111, DevMinor: 222, NumLink: 1}),
				lookupMatch("f", &TOCEntry{Name: "f", Type: "fifo", NumLink: 1}),
			),
		},
		{
			name: "modes",
			in: tarOf(
				dir("foo1/", 0755|os.ModeDir|os.ModeSetgid),
				file("foo1/bar1", content, 0700|os.ModeSetuid),
				file("foo1/bar2", content, 0755|os.ModeSetgid),
				dir("foo2/", 0755|os.ModeDir|os.ModeSticky),
				file("foo2/bar3", content, 0755|os.ModeSticky),
				dir("foo3/", 0755|os.ModeDir),
				file("foo3/bar4", content, os.FileMode(0700)),
				file("foo3/bar5", content, os.FileMode(0755)),
			),
			wantNumGz: 8, // dir, bar1 alone, bar2 alone + dir, bar3 alone + dir, bar4 alone, bar5 alone, TOC, footer
			want: checks(
				hasMode("foo1/", 0755|os.ModeDir|os.ModeSetgid),
				hasMode("foo1/bar1", 0700|os.ModeSetuid),
				hasMode("foo1/bar2", 0755|os.ModeSetgid),
				hasMode("foo2/", 0755|os.ModeDir|os.ModeSticky),
				hasMode("foo2/bar3", 0755|os.ModeSticky),
				hasMode("foo3/", 0755|os.ModeDir),
				hasMode("foo3/bar4", os.FileMode(0700)),
				hasMode("foo3/bar5", os.FileMode(0755)),
			),
		},
		{
			name: "lossy",
			in: tarOf(
				dir("bar/", sampleOwner),
				dir("foo/", sampleOwner),
				file("foo/bar.txt", content, sampleOwner),
				file(TOCTarName, "dummy"), // ignored by the writer. (lossless write returns error)
			),
			wantNumGz: 4, // both dirs, foo.txt alone, TOC, footer
			want: checks(
				numTOCEntries(3),
				hasDir("bar/"),
				hasDir("foo/"),
				hasFileLen("foo/bar.txt", len(content)),
				entryHasChildren("", "bar", "foo"),
				entryHasChildren("foo", "bar.txt"),
				hasChunkEntries("foo/bar.txt", 1),
				hasEntryOwner("bar/", sampleOwner),
				hasEntryOwner("foo/", sampleOwner),
				hasEntryOwner("foo/bar.txt", sampleOwner),
			),
			wantFailOnLossLess: true,
		},
		{
			name: "hardlink should be replaced to the destination entry",
			in: tarOf(
				dir("foo/"),
				file("foo/foo1", "test"),
				link("foolink", "foo/foo1"),
			),
			wantNumGz: 4, // dir, foo1 + link, TOC, footer
			want: checks(
				mustSameEntry("foo/foo1", "foolink"),
			),
		},
		{
			name:         "several_files_in_chunk",
			minChunkSize: 8000,
			in: tarOf(
				dir("foo/"),
				file("foo/foo1", data64KB),
				file("foo2", "bb"),
				file("foo22", "ccc"),
				dir("bar/"),
				file("bar/bar.txt", "aaa"),
				file("foo3", data64KB),
			),
			// NOTE: we assume that the compressed "data64KB" is still larger than 8KB
			wantNumGz: 4, // dir+foo1, foo2+foo22+dir+bar.txt+foo3, TOC, footer
			want: checks(
				numTOCEntries(7), // dir, foo1, foo2, foo22, dir, bar.txt, foo3
				hasDir("foo/"),
				hasDir("bar/"),
				hasFileLen("foo/foo1", len(data64KB)),
				hasFileLen("foo2", len("bb")),
				hasFileLen("foo22", len("ccc")),
				hasFileLen("bar/bar.txt", len("aaa")),
				hasFileLen("foo3", len(data64KB)),
				hasFileDigest("foo/foo1", digestFor(data64KB)),
				hasFileDigest("foo2", digestFor("bb")),
				hasFileDigest("foo22", digestFor("ccc")),
				hasFileDigest("bar/bar.txt", digestFor("aaa")),
				hasFileDigest("foo3", digestFor(data64KB)),
				hasFileContentsWithPreRead("foo22", 0, "ccc", chunkInfo{"foo2", "bb"}, chunkInfo{"bar/bar.txt", "aaa"}, chunkInfo{"foo3", data64KB}),
				hasFileContentsRange("foo/foo1", 0, data64KB),
				hasFileContentsRange("foo2", 0, "bb"),
				hasFileContentsRange("foo2", 1, "b"),
				hasFileContentsRange("foo22", 0, "ccc"),
				hasFileContentsRange("foo22", 1, "cc"),
				hasFileContentsRange("foo22", 2, "c"),
				hasFileContentsRange("bar/bar.txt", 0, "aaa"),
				hasFileContentsRange("bar/bar.txt", 1, "aa"),
				hasFileContentsRange("bar/bar.txt", 2, "a"),
				hasFileContentsRange("foo3", 0, data64KB),
				hasFileContentsRange("foo3", 1, data64KB[1:]),
				hasFileContentsRange("foo3", 2, data64KB[2:]),
				hasFileContentsRange("foo3", len(data64KB)/2, data64KB[len(data64KB)/2:]),
				hasFileContentsRange("foo3", len(data64KB)-1, data64KB[len(data64KB)-1:]),
			),
		},
		{
			name:         "several_files_in_chunk_chunked",
			minChunkSize: 8000,
			chunkSize:    32000,
			in: tarOf(
				dir("foo/"),
				file("foo/foo1", data64KB),
				file("foo2", "bb"),
				dir("bar/"),
				file("foo3", data64KB),
			),
			// NOTE: we assume that the compressed chunk of "data64KB" is still larger than 8KB
			wantNumGz: 6, // dir+foo1(1), foo1(2), foo2+dir+foo3(1), foo3(2), TOC, footer
			want: checks(
				numTOCEntries(7), // dir, foo1(2 chunks), foo2, dir, foo3(2 chunks)
				hasDir("foo/"),
				hasDir("bar/"),
				hasFileLen("foo/foo1", len(data64KB)),
				hasFileLen("foo2", len("bb")),
				hasFileLen("foo3", len(data64KB)),
				hasFileDigest("foo/foo1", digestFor(data64KB)),
				hasFileDigest("foo2", digestFor("bb")),
				hasFileDigest("foo3", digestFor(data64KB)),
				hasFileContentsWithPreRead("foo2", 0, "bb", chunkInfo{"foo3", data64KB[:32000]}),
				hasFileContentsRange("foo/foo1", 0, data64KB),
				hasFileContentsRange("foo/foo1", 1, data64KB[1:]),
				hasFileContentsRange("foo/foo1", 2, data64KB[2:]),
				hasFileContentsRange("foo/foo1", len(data64KB)/2, data64KB[len(data64KB)/2:]),
				hasFileContentsRange("foo/foo1", len(data64KB)-1, data64KB[len(data64KB)-1:]),
				hasFileContentsRange("foo2", 0, "bb"),
				hasFileContentsRange("foo2", 1, "b"),
				hasFileContentsRange("foo3", 0, data64KB),
				hasFileContentsRange("foo3", 1, data64KB[1:]),
				hasFileContentsRange("foo3", 2, data64KB[2:]),
				hasFileContentsRange("foo3", len(data64KB)/2, data64KB[len(data64KB)/2:]),
				hasFileContentsRange("foo3", len(data64KB)-1, data64KB[len(data64KB)-1:]),
			),
		},
	}

	for _, tt := range tests {
		for _, newCL := range controllers {
			newCL := newCL
			for _, prefix := range allowedPrefix {
				prefix := prefix
				for _, srcTarFormat := range []tar.Format{tar.FormatUSTAR, tar.FormatPAX, tar.FormatGNU} {
					srcTarFormat := srcTarFormat
					for _, lossless := range []bool{true, false} {
						t.Run(tt.name+"-"+fmt.Sprintf("compression=%v,prefix=%q,lossless=%v,format=%s", newCL(), prefix, lossless, srcTarFormat), func(t *testing.T) {
							var tr io.Reader = buildTar(t, tt.in, prefix, srcTarFormat)
							origTarDgstr := digest.Canonical.Digester()
							tr = io.TeeReader(tr, origTarDgstr.Hash())
							var stargzBuf bytes.Buffer
							cl1 := newCL()
							w := NewWriterWithCompressor(&stargzBuf, cl1)
							w.ChunkSize = tt.chunkSize
							w.MinChunkSize = tt.minChunkSize
							if lossless {
								err := w.AppendTarLossLess(tr)
								if tt.wantFailOnLossLess {
									if err != nil {
										return // expected to fail
									}
									t.Fatalf("Append wanted to fail on lossless")
								}
								if err != nil {
									t.Fatalf("Append(lossless): %v", err)
								}
							} else {
								if err := w.AppendTar(tr); err != nil {
									t.Fatalf("Append: %v", err)
								}
							}
							if _, err := w.Close(); err != nil {
								t.Fatalf("Writer.Close: %v", err)
							}
							b := stargzBuf.Bytes()

							if lossless {
								// Check if the result blob reserves original tar metadata
								rc, err := Unpack(io.NewSectionReader(bytes.NewReader(b), 0, int64(len(b))), cl1)
								if err != nil {
									t.Errorf("failed to decompress blob: %v", err)
									return
								}
								defer rc.Close()
								resultDgstr := digest.Canonical.Digester()
								if _, err := io.Copy(resultDgstr.Hash(), rc); err != nil {
									t.Errorf("failed to read result decompressed blob: %v", err)
									return
								}
								if resultDgstr.Digest() != origTarDgstr.Digest() {
									t.Errorf("lossy compression occurred: digest=%v; want %v",
										resultDgstr.Digest(), origTarDgstr.Digest())
									return
								}
							}

							diffID := w.DiffID()
							wantDiffID := cl1.DiffIDOf(t, b)
							if diffID != wantDiffID {
								t.Errorf("DiffID = %q; want %q", diffID, wantDiffID)
							}

							telemetry, checkCalled := newCalledTelemetry()
							sr := io.NewSectionReader(bytes.NewReader(b), 0, int64(len(b)))
							r, err := Open(
								sr,
								WithDecompressors(cl1),
								WithTelemetry(telemetry),
							)
							if err != nil {
								t.Fatalf("stargz.Open: %v", err)
							}
							wantTOCVersion := 1
							if tt.wantTOCVersion > 0 {
								wantTOCVersion = tt.wantTOCVersion
							}
							if r.toc.Version != wantTOCVersion {
								t.Fatalf("invalid TOC Version %d; wanted %d", r.toc.Version, wantTOCVersion)
							}

							footerSize := cl1.FooterSize()
							footerOffset := sr.Size() - footerSize
							footer := make([]byte, footerSize)
							if _, err := sr.ReadAt(footer, footerOffset); err != nil {
								t.Errorf("failed to read footer: %v", err)
							}
							_, tocOffset, _, err := cl1.ParseFooter(footer)
							if err != nil {
								t.Errorf("failed to parse footer: %v", err)
							}
							if err := checkCalled(tocOffset >= 0); err != nil {
								t.Errorf("telemetry failure: %v", err)
							}

							wantNumGz := tt.wantNumGz
							if lossless && tt.wantNumGzLossLess > 0 {
								wantNumGz = tt.wantNumGzLossLess
							}
							streamOffsets := []int64{0}
							prevOffset := int64(-1)
							streams := 0
							for _, e := range r.toc.Entries {
								if e.Offset > prevOffset {
									streamOffsets = append(streamOffsets, e.Offset)
									prevOffset = e.Offset
									streams++
								}
							}
							streams++ // TOC
							if tocOffset >= 0 {
								// toc is in the blob
								streamOffsets = append(streamOffsets, tocOffset)
							}
							streams++ // footer
							streamOffsets = append(streamOffsets, footerOffset)
							if streams != wantNumGz {
								t.Errorf("number of streams in TOC = %d; want %d", streams, wantNumGz)
							}

							t.Logf("testing streams: %+v", streamOffsets)
							cl1.TestStreams(t, b, streamOffsets)

							for _, want := range tt.want {
								want.check(t, r)
							}
						})
					}
				}
			}
		}
	}
}

type chunkInfo struct {
	name string
	data string
}

func newCalledTelemetry() (telemetry *Telemetry, check func(needsGetTOC bool) error) {
	var getFooterLatencyCalled bool
	var getTocLatencyCalled bool
	var deserializeTocLatencyCalled bool
	return &Telemetry{
			func(time.Time) { getFooterLatencyCalled = true },
			func(time.Time) { getTocLatencyCalled = true },
			func(time.Time) { deserializeTocLatencyCalled = true },
		}, func(needsGetTOC bool) error {
			var allErr []error
			if !getFooterLatencyCalled {
				allErr = append(allErr, fmt.Errorf("metrics GetFooterLatency isn't called"))
			}
			if needsGetTOC {
				if !getTocLatencyCalled {
					allErr = append(allErr, fmt.Errorf("metrics GetTocLatency isn't called"))
				}
			}
			if !deserializeTocLatencyCalled {
				allErr = append(allErr, fmt.Errorf("metrics DeserializeTocLatency isn't called"))
			}
			return errorutil.Aggregate(allErr)
		}
}

func digestFor(content string) string {
	sum := sha256.Sum256([]byte(content))
	return fmt.Sprintf("sha256:%x", sum)
}

type numTOCEntries int

func (n numTOCEntries) check(t *testing.T, r *Reader) {
	if r.toc == nil {
		t.Fatal("nil TOC")
	}
	if got, want := len(r.toc.Entries), int(n); got != want {
		t.Errorf("got %d TOC entries; want %d", got, want)
	}
	t.Logf("got TOC entries:")
	for i, ent := range r.toc.Entries {
		entj, _ := json.Marshal(ent)
		t.Logf("  [%d]: %s\n", i, entj)
	}
	if t.Failed() {
		t.FailNow()
	}
}

func checks(s ...stargzCheck) []stargzCheck { return s }

type stargzCheck interface {
	check(t *testing.T, r *Reader)
}

type stargzCheckFn func(*testing.T, *Reader)

func (f stargzCheckFn) check(t *testing.T, r *Reader) { f(t, r) }

func maxDepth(max int) stargzCheck {
	return stargzCheckFn(func(t *testing.T, r *Reader) {
		e, ok := r.Lookup("")
		if !ok {
			t.Fatal("root directory not found")
		}
		d, err := getMaxDepth(t, e, 0, 10*max)
		if err != nil {
			t.Errorf("failed to get max depth (wanted %d): %v", max, err)
			return
		}
		if d != max {
			t.Errorf("invalid depth %d; want %d", d, max)
			return
		}
	})
}

func getMaxDepth(t *testing.T, e *TOCEntry, current, limit int) (max int, rErr error) {
	if current > limit {
		return -1, fmt.Errorf("walkMaxDepth: exceeds limit: current:%d > limit:%d",
			current, limit)
	}
	max = current
	e.ForeachChild(func(baseName string, ent *TOCEntry) bool {
		t.Logf("%q(basename:%q) is child of %q\n", ent.Name, baseName, e.Name)
		d, err := getMaxDepth(t, ent, current+1, limit)
		if err != nil {
			rErr = err
			return false
		}
		if d > max {
			max = d
		}
		return true
	})
	return
}

func hasFileLen(file string, wantLen int) stargzCheck {
	return stargzCheckFn(func(t *testing.T, r *Reader) {
		for _, ent := range r.toc.Entries {
			if ent.Name == file {
				if ent.Type != "reg" {
					t.Errorf("file type of %q is %q; want \"reg\"", file, ent.Type)
				} else if ent.Size != int64(wantLen) {
					t.Errorf("file size of %q = %d; want %d", file, ent.Size, wantLen)
				}
				return
			}
		}
		t.Errorf("file %q not found", file)
	})
}

func hasFileXattrs(file, name, value string) stargzCheck {
	return stargzCheckFn(func(t *testing.T, r *Reader) {
		for _, ent := range r.toc.Entries {
			if ent.Name == file {
				if ent.Type != "reg" {
					t.Errorf("file type of %q is %q; want \"reg\"", file, ent.Type)
				}
				if ent.Xattrs == nil {
					t.Errorf("file %q has no xattrs", file)
					return
				}
				valueFound, found := ent.Xattrs[name]
				if !found {
					t.Errorf("file %q has no xattr %q", file, name)
					return
				}
				if string(valueFound) != value {
					t.Errorf("file %q has xattr %q with value %q instead of %q", file, name, valueFound, value)
				}

				return
			}
		}
		t.Errorf("file %q not found", file)
	})
}

func hasFileDigest(file string, digest string) stargzCheck {
	return stargzCheckFn(func(t *testing.T, r *Reader) {
		ent, ok := r.Lookup(file)
		if !ok {
			t.Fatalf("didn't find TOCEntry for file %q", file)
		}
		if ent.Digest != digest {
			t.Fatalf("Digest(%q) = %q, want %q", file, ent.Digest, digest)
		}
	})
}

func hasFileContentsWithPreRead(file string, offset int, want string, extra ...chunkInfo) stargzCheck {
	return stargzCheckFn(func(t *testing.T, r *Reader) {
		extraMap := make(map[string]chunkInfo)
		for _, e := range extra {
			extraMap[e.name] = e
		}
		var extraNames []string
		for n := range extraMap {
			extraNames = append(extraNames, n)
		}
		f, err := r.OpenFileWithPreReader(file, func(e *TOCEntry, cr io.Reader) error {
			t.Logf("On %q: got preread of %q", file, e.Name)
			ex, ok := extraMap[e.Name]
			if !ok {
				t.Fatalf("fail on %q: unexpected entry %q: %+v, %+v", file, e.Name, e, extraNames)
			}
			got, err := io.ReadAll(cr)
			if err != nil {
				t.Fatalf("fail on %q: failed to read %q: %v", file, e.Name, err)
			}
			if ex.data != string(got) {
				t.Fatalf("fail on %q: unexpected contents of %q: len=%d; want=%d", file, e.Name, len(got), len(ex.data))
			}
			delete(extraMap, e.Name)
			return nil
		})
		if err != nil {
			t.Fatal(err)
		}
		got := make([]byte, len(want))
		n, err := f.ReadAt(got, int64(offset))
		if err != nil {
			t.Fatalf("ReadAt(len %d, offset %d, size %d) = %v, %v", len(got), offset, f.Size(), n, err)
		}
		if string(got) != want {
			t.Fatalf("ReadAt(len %d, offset %d) = %q, want %q", len(got), offset, viewContent(got), viewContent([]byte(want)))
		}
		if len(extraMap) != 0 {
			var exNames []string
			for _, ex := range extraMap {
				exNames = append(exNames, ex.name)
			}
			t.Fatalf("fail on %q: some entries aren't read: %+v", file, exNames)
		}
	})
}

func hasFileContentsRange(file string, offset int, want string) stargzCheck {
	return stargzCheckFn(func(t *testing.T, r *Reader) {
		f, err := r.OpenFile(file)
		if err != nil {
			t.Fatal(err)
		}
		got := make([]byte, len(want))
		n, err := f.ReadAt(got, int64(offset))
		if err != nil {
			t.Fatalf("ReadAt(len %d, offset %d) = %v, %v", len(got), offset, n, err)
		}
		if string(got) != want {
			t.Fatalf("ReadAt(len %d, offset %d) = %q, want %q", len(got), offset, viewContent(got), viewContent([]byte(want)))
		}
	})
}

func hasChunkEntries(file string, wantChunks int) stargzCheck {
	return stargzCheckFn(func(t *testing.T, r *Reader) {
		ent, ok := r.Lookup(file)
		if !ok {
			t.Fatalf("no file for %q", file)
		}
		if ent.Type != "reg" {
			t.Fatalf("file %q has unexpected type %q; want reg", file, ent.Type)
		}
		chunks := r.getChunks(ent)
		if len(chunks) != wantChunks {
			t.Errorf("len(r.getChunks(%q)) = %d; want %d", file, len(chunks), wantChunks)
			return
		}
		f := chunks[0]

		var gotChunks []*TOCEntry
		var last *TOCEntry
		for off := int64(0); off < f.Size; off++ {
			e, ok := r.ChunkEntryForOffset(file, off)
			if !ok {
				t.Errorf("no ChunkEntryForOffset at %d", off)
				return
			}
			if last != e {
				gotChunks = append(gotChunks, e)
				last = e
			}
		}
		if !reflect.DeepEqual(chunks, gotChunks) {
			t.Errorf("gotChunks=%d, want=%d; contents mismatch", len(gotChunks), wantChunks)
		}

		// And verify the NextOffset
		for i := 0; i < len(gotChunks)-1; i++ {
			ci := gotChunks[i]
			cnext := gotChunks[i+1]
			if ci.NextOffset() != cnext.Offset {
				t.Errorf("chunk %d NextOffset %d != next chunk's Offset of %d", i, ci.NextOffset(), cnext.Offset)
			}
		}
	})
}

func entryHasChildren(dir string, want ...string) stargzCheck {
	return stargzCheckFn(func(t *testing.T, r *Reader) {
		want := append([]string(nil), want...)
		var got []string
		ent, ok := r.Lookup(dir)
		if !ok {
			t.Fatalf("didn't find TOCEntry for dir node %q", dir)
		}
		for baseName := range ent.children {
			got = append(got, baseName)
		}
		sort.Strings(got)
		sort.Strings(want)
		if !reflect.DeepEqual(got, want) {
			t.Errorf("children of %q = %q; want %q", dir, got, want)
		}
	})
}

func hasDir(file string) stargzCheck {
	return stargzCheckFn(func(t *testing.T, r *Reader) {
		for _, ent := range r.toc.Entries {
			if ent.Name == cleanEntryName(file) {
				if ent.Type != "dir" {
					t.Errorf("file type of %q is %q; want \"dir\"", file, ent.Type)
				}
				return
			}
		}
		t.Errorf("directory %q not found", file)
	})
}

func hasDirLinkCount(file string, count int) stargzCheck {
	return stargzCheckFn(func(t *testing.T, r *Reader) {
		for _, ent := range r.toc.Entries {
			if ent.Name == cleanEntryName(file) {
				if ent.Type != "dir" {
					t.Errorf("file type of %q is %q; want \"dir\"", file, ent.Type)
					return
				}
				if ent.NumLink != count {
					t.Errorf("link count of %q = %d; want %d", file, ent.NumLink, count)
				}
				return
			}
		}
		t.Errorf("directory %q not found", file)
	})
}

func hasMode(file string, mode os.FileMode) stargzCheck {
	return stargzCheckFn(func(t *testing.T, r *Reader) {
		for _, ent := range r.toc.Entries {
			if ent.Name == cleanEntryName(file) {
				if ent.Stat().Mode() != mode {
					t.Errorf("invalid mode: got %v; want %v", ent.Stat().Mode(), mode)
					return
				}
				return
			}
		}
		t.Errorf("file %q not found", file)
	})
}

func hasSymlink(file, target string) stargzCheck {
	return stargzCheckFn(func(t *testing.T, r *Reader) {
		for _, ent := range r.toc.Entries {
			if ent.Name == file {
				if ent.Type != "symlink" {
					t.Errorf("file type of %q is %q; want \"symlink\"", file, ent.Type)
				} else if ent.LinkName != target {
					t.Errorf("link target of symlink %q is %q; want %q", file, ent.LinkName, target)
				}
				return
			}
		}
		t.Errorf("symlink %q not found", file)
	})
}

func lookupMatch(name string, want *TOCEntry) stargzCheck {
	return stargzCheckFn(func(t *testing.T, r *Reader) {
		e, ok := r.Lookup(name)
		if !ok {
			t.Fatalf("failed to Lookup entry %q", name)
		}
		if !reflect.DeepEqual(e, want) {
			t.Errorf("entry %q mismatch.\n got: %+v\nwant: %+v\n", name, e, want)
		}

	})
}

func hasEntryOwner(entry string, owner owner) stargzCheck {
	return stargzCheckFn(func(t *testing.T, r *Reader) {
		ent, ok := r.Lookup(strings.TrimSuffix(entry, "/"))
		if !ok {
			t.Errorf("entry %q not found", entry)
			return
		}
		if ent.UID != owner.uid || ent.GID != owner.gid {
			t.Errorf("entry %q has invalid owner (uid:%d, gid:%d) instead of (uid:%d, gid:%d)", entry, ent.UID, ent.GID, owner.uid, owner.gid)
			return
		}
	})
}

func mustSameEntry(files ...string) stargzCheck {
	return stargzCheckFn(func(t *testing.T, r *Reader) {
		var first *TOCEntry
		for _, f := range files {
			if first == nil {
				var ok bool
				first, ok = r.Lookup(f)
				if !ok {
					t.Errorf("unknown first file on Lookup: %q", f)
					return
				}
			}

			// Test Lookup
			e, ok := r.Lookup(f)
			if !ok {
				t.Errorf("unknown file on Lookup: %q", f)
				return
			}
			if e != first {
				t.Errorf("Lookup: %+v(%p) != %+v(%p)", e, e, first, first)
				return
			}

			// Test LookupChild
			pe, ok := r.Lookup(filepath.Dir(filepath.Clean(f)))
			if !ok {
				t.Errorf("failed to get parent of %q", f)
				return
			}
			e, ok = pe.LookupChild(filepath.Base(filepath.Clean(f)))
			if !ok {
				t.Errorf("failed to get %q as the child of %+v", f, pe)
				return
			}
			if e != first {
				t.Errorf("LookupChild: %+v(%p) != %+v(%p)", e, e, first, first)
				return
			}

			// Test ForeachChild
			pe.ForeachChild(func(baseName string, e *TOCEntry) bool {
				if baseName == filepath.Base(filepath.Clean(f)) {
					if e != first {
						t.Errorf("ForeachChild: %+v(%p) != %+v(%p)", e, e, first, first)
						return false
					}
				}
				return true
			})
		}
	})
}

func viewContent(c []byte) string {
	if len(c) < 100 {
		return string(c)
	}
	return string(c[:50]) + "...(omit)..." + string(c[50:100])
}

func tarOf(s ...tarEntry) []tarEntry { return s }

type tarEntry interface {
	appendTar(tw *tar.Writer, prefix string, format tar.Format) error
}

type tarEntryFunc func(*tar.Writer, string, tar.Format) error

func (f tarEntryFunc) appendTar(tw *tar.Writer, prefix string, format tar.Format) error {
	return f(tw, prefix, format)
}

func buildTar(t *testing.T, ents []tarEntry, prefix string, opts ...interface{}) *io.SectionReader {
	format := tar.FormatUnknown
	for _, opt := range opts {
		switch v := opt.(type) {
		case tar.Format:
			format = v
		default:
			panic(fmt.Errorf("unsupported opt for buildTar: %v", opt))
		}
	}
	buf := new(bytes.Buffer)
	tw := tar.NewWriter(buf)
	for _, ent := range ents {
		if err := ent.appendTar(tw, prefix, format); err != nil {
			t.Fatalf("building input tar: %v", err)
		}
	}
	if err := tw.Close(); err != nil {
		t.Errorf("closing write of input tar: %v", err)
	}
	data := append(buf.Bytes(), make([]byte, 100)...) // append empty bytes at the tail to see lossless works
	return io.NewSectionReader(bytes.NewReader(data), 0, int64(len(data)))
}

func dir(name string, opts ...interface{}) tarEntry {
	return tarEntryFunc(func(tw *tar.Writer, prefix string, format tar.Format) error {
		var o owner
		mode := os.FileMode(0755)
		for _, opt := range opts {
			switch v := opt.(type) {
			case owner:
				o = v
			case os.FileMode:
				mode = v
			default:
				return errors.New("unsupported opt")
			}
		}
		if !strings.HasSuffix(name, "/") {
			panic(fmt.Sprintf("missing trailing slash in dir %q ", name))
		}
		tm, err := fileModeToTarMode(mode)
		if err != nil {
			return err
		}
		return tw.WriteHeader(&tar.Header{
			Typeflag: tar.TypeDir,
			Name:     prefix + name,
			Mode:     tm,
			Uid:      o.uid,
			Gid:      o.gid,
			Format:   format,
		})
	})
}

// xAttr are extended attributes to set on test files created with the file func.
type xAttr map[string]string

// owner is owner ot set on test files and directories with the file and dir functions.
type owner struct {
	uid int
	gid int
}

func file(name, contents string, opts ...interface{}) tarEntry {
	return tarEntryFunc(func(tw *tar.Writer, prefix string, format tar.Format) error {
		var xattrs xAttr
		var o owner
		mode := os.FileMode(0644)
		for _, opt := range opts {
			switch v := opt.(type) {
			case xAttr:
				xattrs = v
			case owner:
				o = v
			case os.FileMode:
				mode = v
			default:
				return errors.New("unsupported opt")
			}
		}
		if strings.HasSuffix(name, "/") {
			return fmt.Errorf("bogus trailing slash in file %q", name)
		}
		tm, err := fileModeToTarMode(mode)
		if err != nil {
			return err
		}
		if len(xattrs) > 0 {
			format = tar.FormatPAX // only PAX supports xattrs
		}
		if err := tw.WriteHeader(&tar.Header{
			Typeflag: tar.TypeReg,
			Name:     prefix + name,
			Mode:     tm,
			Xattrs:   xattrs,
			Size:     int64(len(contents)),
			Uid:      o.uid,
			Gid:      o.gid,
			Format:   format,
		}); err != nil {
			return err
		}
		_, err = io.WriteString(tw, contents)
		return err
	})
}

func symlink(name, target string) tarEntry {
	return tarEntryFunc(func(tw *tar.Writer, prefix string, format tar.Format) error {
		return tw.WriteHeader(&tar.Header{
			Typeflag: tar.TypeSymlink,
			Name:     prefix + name,
			Linkname: target,
			Mode:     0644,
			Format:   format,
		})
	})
}

func link(name string, linkname string) tarEntry {
	now := time.Now()
	return tarEntryFunc(func(w *tar.Writer, prefix string, format tar.Format) error {
		return w.WriteHeader(&tar.Header{
			Typeflag: tar.TypeLink,
			Name:     prefix + name,
			Linkname: linkname,
			ModTime:  now,
			Format:   format,
		})
	})
}

func chardev(name string, major, minor int64) tarEntry {
	now := time.Now()
	return tarEntryFunc(func(w *tar.Writer, prefix string, format tar.Format) error {
		return w.WriteHeader(&tar.Header{
			Typeflag: tar.TypeChar,
			Name:     prefix + name,
			Devmajor: major,
			Devminor: minor,
			ModTime:  now,
			Format:   format,
		})
	})
}

func blockdev(name string, major, minor int64) tarEntry {
	now := time.Now()
	return tarEntryFunc(func(w *tar.Writer, prefix string, format tar.Format) error {
		return w.WriteHeader(&tar.Header{
			Typeflag: tar.TypeBlock,
			Name:     prefix + name,
			Devmajor: major,
			Devminor: minor,
			ModTime:  now,
			Format:   format,
		})
	})
}
func fifo(name string) tarEntry {
	now := time.Now()
	return tarEntryFunc(func(w *tar.Writer, prefix string, format tar.Format) error {
		return w.WriteHeader(&tar.Header{
			Typeflag: tar.TypeFifo,
			Name:     prefix + name,
			ModTime:  now,
			Format:   format,
		})
	})
}

func prefetchLandmark() tarEntry {
	return tarEntryFunc(func(w *tar.Writer, prefix string, format tar.Format) error {
		if err := w.WriteHeader(&tar.Header{
			Name:     PrefetchLandmark,
			Typeflag: tar.TypeReg,
			Size:     int64(len([]byte{landmarkContents})),
			Format:   format,
		}); err != nil {
			return err
		}
		contents := []byte{landmarkContents}
		if _, err := io.CopyN(w, bytes.NewReader(contents), int64(len(contents))); err != nil {
			return err
		}
		return nil
	})
}

func noPrefetchLandmark() tarEntry {
	return tarEntryFunc(func(w *tar.Writer, prefix string, format tar.Format) error {
		if err := w.WriteHeader(&tar.Header{
			Name:     NoPrefetchLandmark,
			Typeflag: tar.TypeReg,
			Size:     int64(len([]byte{landmarkContents})),
			Format:   format,
		}); err != nil {
			return err
		}
		contents := []byte{landmarkContents}
		if _, err := io.CopyN(w, bytes.NewReader(contents), int64(len(contents))); err != nil {
			return err
		}
		return nil
	})
}

func regDigest(t *testing.T, name string, contentStr string, digestMap map[string]digest.Digest) tarEntry {
	if digestMap == nil {
		t.Fatalf("digest map mustn't be nil")
	}
	content := []byte(contentStr)

	var n int64
	for n < int64(len(content)) {
		size := int64(chunkSize)
		remain := int64(len(content)) - n
		if remain < size {
			size = remain
		}
		dgstr := digest.Canonical.Digester()
		if _, err := io.CopyN(dgstr.Hash(), bytes.NewReader(content[n:n+size]), size); err != nil {
			t.Fatalf("failed to calculate digest of %q (name=%q,offset=%d,size=%d)",
				string(content[n:n+size]), name, n, size)
		}
		digestMap[chunkID(name, n, size)] = dgstr.Digest()
		n += size
	}

	return tarEntryFunc(func(w *tar.Writer, prefix string, format tar.Format) error {
		if err := w.WriteHeader(&tar.Header{
			Typeflag: tar.TypeReg,
			Name:     prefix + name,
			Size:     int64(len(content)),
			Format:   format,
		}); err != nil {
			return err
		}
		if _, err := io.CopyN(w, bytes.NewReader(content), int64(len(content))); err != nil {
			return err
		}
		return nil
	})
}

var runes = []rune("1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")

func randomContents(n int) string {
	b := make([]rune, n)
	for i := range b {
		b[i] = runes[rand.Intn(len(runes))]
	}
	return string(b)
}

func fileModeToTarMode(mode os.FileMode) (int64, error) {
	h, err := tar.FileInfoHeader(fileInfoOnlyMode(mode), "")
	if err != nil {
		return 0, err
	}
	return h.Mode, nil
}

// fileInfoOnlyMode is os.FileMode that populates only file mode.
type fileInfoOnlyMode os.FileMode

func (f fileInfoOnlyMode) Name() string       { return "" }
func (f fileInfoOnlyMode) Size() int64        { return 0 }
func (f fileInfoOnlyMode) Mode() os.FileMode  { return os.FileMode(f) }
func (f fileInfoOnlyMode) ModTime() time.Time { return time.Now() }
func (f fileInfoOnlyMode) IsDir() bool        { return os.FileMode(f).IsDir() }
func (f fileInfoOnlyMode) Sys() interface{}   { return nil }

func CheckGzipHasStreams(t *testing.T, b []byte, streams []int64) {
	if len(streams) == 0 {
		return // nop
	}

	wants := map[int64]struct{}{}
	for _, s := range streams {
		wants[s] = struct{}{}
	}

	len0 := len(b)
	br := bytes.NewReader(b)
	zr := new(gzip.Reader)
	t.Logf("got gzip streams:")
	numStreams := 0
	for {
		zoff := len0 - br.Len()
		if err := zr.Reset(br); err != nil {
			if err == io.EOF {
				return
			}
			t.Fatalf("countStreams(gzip), Reset: %v", err)
		}
		zr.Multistream(false)
		n, err := io.Copy(io.Discard, zr)
		if err != nil {
			t.Fatalf("countStreams(gzip), Copy: %v", err)
		}
		var extra string
		if len(zr.Header.Extra) > 0 {
			extra = fmt.Sprintf("; extra=%q", zr.Header.Extra)
		}
		t.Logf("  [%d] at %d in stargz, uncompressed length %d%s", numStreams, zoff, n, extra)
		delete(wants, int64(zoff))
		numStreams++
	}
}

func GzipDiffIDOf(t *testing.T, b []byte) string {
	h := sha256.New()
	zr, err := gzip.NewReader(bytes.NewReader(b))
	if err != nil {
		t.Fatalf("diffIDOf(gzip): %v", err)
	}
	defer zr.Close()
	if _, err := io.Copy(h, zr); err != nil {
		t.Fatalf("diffIDOf(gzip).Copy: %v", err)
	}
	return fmt.Sprintf("sha256:%x", h.Sum(nil))
}
