package fsutil

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/binary"
	"hash"
	"io"
	gofs "io/fs"
	"os"
	"path/filepath"
	"runtime"
	"sync"
	"testing"
	"time"

	"github.com/opencontainers/go-digest"
	"github.com/pkg/errors"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/tonistiigi/fsutil/types"
	"golang.org/x/sync/errgroup"
)

type receiveTestFunc func(context.Context, Stream, string, ReceiveOpt) error

func forEachReceiveDiskWriter(t *testing.T, fn func(*testing.T, receiveTestFunc)) {
	t.Helper()

	receivers := []struct {
		name    string
		receive receiveTestFunc
	}{
		{
			name:    "DiskWriter",
			receive: Receive,
		},
	}

	receivers = append(receivers, struct {
		name    string
		receive receiveTestFunc
	}{
		name: "RootDiskWriter",
		receive: func(ctx context.Context, conn Stream, dest string, opt ReceiveOpt) error {
			osroot, err := os.OpenRoot(dest)
			if err != nil {
				return errors.WithStack(err)
			}
			destRoot := NewRoot(osroot)

			err = ReceiveRoot(ctx, conn, destRoot, opt)
			if closeErr := destRoot.Close(); err == nil {
				err = closeErr
			}
			return err
		},
	})

	for _, receiver := range receivers {
		t.Run(receiver.name, func(t *testing.T) {
			fn(t, receiver.receive)
		})
	}
}

func TestSendError(t *testing.T) {
	forEachReceiveDiskWriter(t, func(t *testing.T, receive receiveTestFunc) {
		fs := &testErrFS{err: errors.New("foo bar")}

		dest := t.TempDir()

		ts := newNotificationBuffer()
		chs := &changes{fn: ts.HandleChange}

		ctx, cancel := context.WithCancel(context.Background())
		defer cancel()
		var eg errgroup.Group
		s1, s2 := sockPairProto(ctx)

		eg.Go(func() error {
			defer s1.(*fakeConnProto).closeSend()
			err := Send(ctx, s1, fs, nil)
			assert.Contains(t, err.Error(), "foo bar")
			return err
		})
		eg.Go(func() error {
			err := receive(ctx, s2, dest, ReceiveOpt{
				NotifyHashed:  chs.HandleChange,
				ContentHasher: simpleSHA256Hasher,
			})
			assert.Contains(t, err.Error(), "error from sender:")
			assert.Contains(t, err.Error(), "foo bar")
			return err
		})

		errCh := make(chan error, 1)
		go func() {
			errCh <- eg.Wait()
		}()
		select {
		case <-time.After(15 * time.Second):
			cancel()
			t.Fatal("timeout")
		case err := <-errCh:
			assert.Contains(t, err.Error(), "foo bar")
		}
	})
}

func TestCopyWithSubDir(t *testing.T) {
	forEachReceiveDiskWriter(t, func(t *testing.T, receive receiveTestFunc) {
		requiresRoot(t)

		d, err := tmpDir(changeStream([]string{
			"ADD foo dir",
			"ADD foo/bar file data1",
		}))
		assert.NoError(t, err)
		defer os.RemoveAll(d)
		fs, err := NewFS(d)
		assert.NoError(t, err)

		dest := t.TempDir()

		eg, ctx := errgroup.WithContext(context.Background())
		s1, s2 := sockPairProto(ctx)

		eg.Go(func() error {
			defer s1.(*fakeConnProto).closeSend()
			subdir, err := SubDirFS([]Dir{{FS: fs, Stat: &types.Stat{Path: "sub", Mode: uint32(os.ModeDir | 0755)}}})
			if err != nil {
				return err
			}
			return Send(ctx, s1, subdir, nil)
		})
		eg.Go(func() error {
			return receive(ctx, s2, dest, ReceiveOpt{})
		})

		err = eg.Wait()
		assert.NoError(t, err)

		dt, err := os.ReadFile(filepath.Join(dest, "sub/foo/bar"))
		assert.NoError(t, err)
		assert.Equal(t, "data1", string(dt))
	})
}

func TestCopyDirectoryTimestamps(t *testing.T) {
	forEachReceiveDiskWriter(t, func(t *testing.T, receive receiveTestFunc) {
		d, err := tmpDir(changeStream([]string{
			"ADD foo dir",
			"ADD foo/bar dir",
			"ADD foo/bar/baz file data1",
		}))
		assert.NoError(t, err)
		defer os.RemoveAll(d)
		fs, err := NewFS(d)
		assert.NoError(t, err)

		timestamp := time.Unix(0, 0)
		require.NoError(t, os.Chtimes(filepath.Join(d, "foo"), timestamp, timestamp))
		require.NoError(t, os.Chtimes(filepath.Join(d, "foo", "bar"), timestamp, timestamp))

		dest := t.TempDir()

		eg, ctx := errgroup.WithContext(context.Background())
		s1, s2 := sockPairProto(ctx)

		eg.Go(func() error {
			defer s1.(*fakeConnProto).closeSend()
			return Send(ctx, s1, fs, nil)
		})
		eg.Go(func() error {
			return receive(ctx, s2, dest, ReceiveOpt{})
		})

		err = eg.Wait()
		assert.NoError(t, err)

		dt, err := os.ReadFile(filepath.Join(dest, "foo", "bar", "baz"))
		assert.NoError(t, err)
		assert.Equal(t, "data1", string(dt))

		stat, err := os.Stat(filepath.Join(dest, "foo"))
		require.NoError(t, err)
		assert.Equal(t, timestamp, stat.ModTime())

		stat, err = os.Stat(filepath.Join(dest, "foo", "bar"))
		require.NoError(t, err)
		assert.Equal(t, timestamp, stat.ModTime())
	})
}

func TestCopySwitchDirToFile(t *testing.T) {
	forEachReceiveDiskWriter(t, func(t *testing.T, receive receiveTestFunc) {
		d, err := tmpDir(changeStream([]string{
			"ADD foo file data1",
		}))
		assert.NoError(t, err)
		defer os.RemoveAll(d)

		dest, err := tmpDir(changeStream([]string{
			"ADD foo dir",
			"ADD foo/bar file data2",
		}))
		assert.NoError(t, err)
		defer os.RemoveAll(dest)

		copy := func(src, dest string) (*changes, error) {
			ts := newNotificationBuffer()
			chs := &changes{fn: ts.HandleChange}

			eg, ctx := errgroup.WithContext(context.Background())
			s1, s2 := sockPairProto(ctx)

			fs, err := NewFS(src)
			if err != nil {
				return nil, err
			}
			fs, err = NewFilterFS(fs, &FilterOpt{
				Map: func(_ string, s *types.Stat) MapResult {
					s.Uid = 0
					s.Gid = 0
					return MapResultKeep
				},
			})
			if err != nil {
				return nil, err
			}

			eg.Go(func() error {
				defer s1.(*fakeConnProto).closeSend()
				return Send(ctx, s1, fs, nil)
			})
			eg.Go(func() error {
				return receive(ctx, s2, dest, ReceiveOpt{
					NotifyHashed:  chs.HandleChange,
					ContentHasher: simpleSHA256Hasher,
					Filter: func(_ string, s *types.Stat) bool {
						if runtime.GOOS != "windows" {
							// On Windows, Getuid() and Getgid() always return -1
							// See: https://pkg.go.dev/os#Getgid
							// See: https://pkg.go.dev/os#Geteuid
							s.Uid = uint32(os.Getuid())
							s.Gid = uint32(os.Getgid())
						}
						return true
					},
				})
			})

			if err := eg.Wait(); err != nil {
				return nil, err
			}

			return chs, nil
		}

		chs, err := copy(d, dest)
		require.NoError(t, err)

		k, ok := chs.c["foo"]
		require.True(t, ok)
		require.Equal(t, k, ChangeKindAdd)
		require.Equal(t, len(chs.c), 1)

		b := &bytes.Buffer{}
		err = Walk(context.Background(), dest, nil, bufWalk(b))
		assert.NoError(t, err)

		assert.Equal(t, `file foo
`, b.String())
	})
}

func TestHardlinkFilter(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("hardlink stat linknames are not populated on windows")
	}

	forEachReceiveDiskWriter(t, func(t *testing.T, receive receiveTestFunc) {
		d, err := tmpDir(changeStream([]string{
			"ADD bar file data1",
			"ADD foo file >bar",
			"ADD foo2 file >bar",
		}))
		assert.NoError(t, err)
		defer os.RemoveAll(d)

		fs, err := NewFS(d)
		assert.NoError(t, err)
		fs, err = NewFilterFS(fs, &FilterOpt{})
		assert.NoError(t, err)
		fs, err = NewFilterFS(fs, &FilterOpt{
			IncludePatterns: []string{"foo*"},
			Map: func(_ string, s *types.Stat) MapResult {
				s.Uid = 0
				s.Gid = 0
				return MapResultKeep
			},
		})
		assert.NoError(t, err)

		dest := t.TempDir()

		eg, ctx := errgroup.WithContext(context.Background())
		s1, s2 := sockPairProto(ctx)

		eg.Go(func() error {
			defer s1.(*fakeConnProto).closeSend()
			return Send(ctx, s1, fs, nil)
		})
		eg.Go(func() error {
			return receive(ctx, s2, dest, ReceiveOpt{
				Filter: func(p string, s *types.Stat) bool {
					if p == "foo2" {
						require.Equal(t, "foo", s.Linkname)
					}
					if runtime.GOOS != "windows" {
						// On Windows, Getuid() and Getgid() always return -1
						// See: https://pkg.go.dev/os#Getgid
						// See: https://pkg.go.dev/os#Geteuid
						s.Uid = uint32(os.Getuid())
						s.Gid = uint32(os.Getgid())
					}
					return true
				},
			})
		})
		assert.NoError(t, eg.Wait())

		dt, err := os.ReadFile(filepath.Join(dest, "foo"))
		assert.NoError(t, err)
		assert.Equal(t, "data1", string(dt))

		st1, err := os.Stat(filepath.Join(dest, "foo"))
		assert.NoError(t, err)

		st2, err := os.Stat(filepath.Join(dest, "foo2"))
		assert.NoError(t, err)

		assert.True(t, os.SameFile(st1, st2))
	})
}

func TestCopySimple(t *testing.T) {
	forEachReceiveDiskWriter(t, testCopySimple)
}

func testCopySimple(t *testing.T, receive receiveTestFunc) {
	d, err := tmpDir(changeStream([]string{
		"ADD foo file data1",
		"ADD foo2 file dat2",
		"ADD zzz dir",
		"ADD zzz/aa file data3",
		"ADD zzz/bb dir",
		"ADD zzz/bb/cc dir",
		"ADD zzz/bb/cc/dd symlink ../../",
		"ADD zzz.aa zzdata",
	}))
	assert.NoError(t, err)
	defer os.RemoveAll(d)
	fs, err := NewFS(d)
	assert.NoError(t, err)
	fs, err = NewFilterFS(fs, &FilterOpt{
		Map: func(_ string, s *types.Stat) MapResult {
			s.Uid = 0
			s.Gid = 0
			return MapResultKeep
		},
	})
	assert.NoError(t, err)

	dest := t.TempDir()

	ts := newNotificationBuffer()
	chs := &changes{fn: ts.HandleChange}

	eg, ctx := errgroup.WithContext(context.Background())
	s1, s2 := sockPairProto(ctx)

	tm := time.Now().Truncate(time.Hour)

	var processCbWasCalled bool
	progressCb := func(size int, last bool) {
		processCbWasCalled = true
	}

	eg.Go(func() error {
		defer s1.(*fakeConnProto).closeSend()
		return Send(ctx, s1, fs, progressCb)
	})
	eg.Go(func() error {
		return receive(ctx, s2, dest, ReceiveOpt{
			NotifyHashed:  chs.HandleChange,
			ContentHasher: simpleSHA256Hasher,
			Filter: func(p string, s *types.Stat) bool {
				if runtime.GOOS != "windows" {
					// On Windows, Getuid() and Getgid() always return -1
					// See: https://pkg.go.dev/os#Getgid
					// See: https://pkg.go.dev/os#Geteuid
					s.Uid = uint32(os.Getuid())
					s.Gid = uint32(os.Getgid())
				}
				s.ModTime = tm.UnixNano()
				return true
			},
		})
	})

	assert.NoError(t, eg.Wait())
	assert.True(t, processCbWasCalled)

	b := &bytes.Buffer{}
	err = Walk(context.Background(), dest, nil, bufWalk(b))
	assert.NoError(t, err)

	if runtime.GOOS == "windows" {
		assert.Equal(t, `file foo
file foo2
dir zzz
file zzz\aa
dir zzz\bb
dir zzz\bb\cc
symlink:..\..\ zzz\bb\cc\dd
file zzz.aa
`, b.String())
	} else {
		assert.Equal(t, `file foo
file foo2
dir zzz
file zzz/aa
dir zzz/bb
dir zzz/bb/cc
symlink:../../ zzz/bb/cc/dd
file zzz.aa
`, b.String())
	}

	dt, err := os.ReadFile(filepath.Join(dest, "zzz/aa"))
	assert.NoError(t, err)
	assert.Equal(t, "data3", string(dt))

	dt, err = os.ReadFile(filepath.Join(dest, "foo2"))
	assert.NoError(t, err)
	assert.Equal(t, "dat2", string(dt))

	fi, err := os.Stat(filepath.Join(dest, "foo2"))
	require.NoError(t, err)
	assert.Equal(t, tm, fi.ModTime())

	h, ok := ts.Hash(filepath.FromSlash("zzz/aa"))
	assert.True(t, ok)
	if runtime.GOOS == "windows" {
		assert.Equal(t, digest.Digest("sha256:5da6b6a222dca8d9260384da15378d4389f7e16943e812e08c39759b8514b456"), h)
	} else {
		assert.Equal(t, digest.Digest("sha256:99b6ef96ee0572b5b3a4eb28f00b715d820bfd73836e59cc1565e241f4d1bb2f"), h)
	}

	h, ok = ts.Hash("foo2")
	assert.True(t, ok)
	if runtime.GOOS == "windows" {
		assert.Equal(t, digest.Digest("sha256:cd83620e5308f6ddb9953a82b2c7450832eac78521dbf067d2882318cabc1311"), h)
	} else {
		assert.Equal(t, digest.Digest("sha256:dd2529f7749ba45ea55de3b2e10086d6494cc45a94e57650c2882a6a14b4ff32"), h)
	}

	h, ok = ts.Hash(filepath.FromSlash("zzz/bb/cc/dd"))
	assert.True(t, ok)
	if runtime.GOOS == "windows" {
		assert.Equal(t, digest.Digest("sha256:47dc68d117ae85dc688103d6ba2cee54caabbbcf606e54ca62fda6a3d9deae19"), h)
	} else {
		assert.Equal(t, digest.Digest("sha256:eca07e8f2d09bd574ea2496312e6de1685ef15b8e6a49a534ed9e722bcac8adc"), h)
	}

	k, ok := chs.c[filepath.FromSlash("zzz/aa")]
	assert.Equal(t, true, ok)
	assert.Equal(t, ChangeKindAdd, k)

	err = os.WriteFile(filepath.Join(d, "zzz/bb/cc/foo"), []byte("data5"), 0600)
	assert.NoError(t, err)

	err = os.RemoveAll(filepath.Join(d, "foo2"))
	assert.NoError(t, err)

	chs = &changes{fn: ts.HandleChange}

	eg, ctx = errgroup.WithContext(context.Background())
	s1, s2 = sockPairProto(ctx)

	eg.Go(func() error {
		defer s1.(*fakeConnProto).closeSend()
		return Send(ctx, s1, fs, nil)
	})
	eg.Go(func() error {
		return receive(ctx, s2, dest, ReceiveOpt{
			NotifyHashed:  chs.HandleChange,
			ContentHasher: simpleSHA256Hasher,
			Filter: func(_ string, s *types.Stat) bool {
				if runtime.GOOS != "windows" {
					// On Windows, Getuid() and Getgid() always return -1
					// See: https://pkg.go.dev/os#Getgid
					// See: https://pkg.go.dev/os#Geteuid
					s.Uid = uint32(os.Getuid())
					s.Gid = uint32(os.Getgid())
				}
				s.ModTime = tm.UnixNano()
				return true
			},
		})
	})

	assert.NoError(t, eg.Wait())

	b = &bytes.Buffer{}
	err = Walk(context.Background(), dest, nil, bufWalk(b))
	assert.NoError(t, err)

	if runtime.GOOS == "windows" {
		assert.Equal(t, `file foo
dir zzz
file zzz\aa
dir zzz\bb
dir zzz\bb\cc
symlink:..\..\ zzz\bb\cc\dd
file zzz\bb\cc\foo
file zzz.aa
`, b.String())
	} else {
		assert.Equal(t, `file foo
dir zzz
file zzz/aa
dir zzz/bb
dir zzz/bb/cc
symlink:../../ zzz/bb/cc/dd
file zzz/bb/cc/foo
file zzz.aa
`, b.String())
	}

	dt, err = os.ReadFile(filepath.Join(dest, "zzz/bb/cc/foo"))
	assert.NoError(t, err)
	assert.Equal(t, "data5", string(dt))

	h, ok = ts.Hash(filepath.FromSlash("zzz/bb/cc/dd"))
	assert.True(t, ok)
	if runtime.GOOS == "windows" {
		assert.Equal(t, digest.Digest("sha256:47dc68d117ae85dc688103d6ba2cee54caabbbcf606e54ca62fda6a3d9deae19"), h)
	} else {
		assert.Equal(t, digest.Digest("sha256:eca07e8f2d09bd574ea2496312e6de1685ef15b8e6a49a534ed9e722bcac8adc"), h)
	}

	h, ok = ts.Hash(filepath.FromSlash("zzz/bb/cc/foo"))
	assert.True(t, ok)
	if runtime.GOOS == "windows" {
		assert.Equal(t, digest.Digest("sha256:9184a7db8d056ee43838613279db9a7ab02272e50d5e20d253393521bb34aa46"), h)
	} else {
		assert.Equal(t, digest.Digest("sha256:cd14a931fc2e123ded338093f2864b173eecdee578bba6ec24d0724272326c3a"), h)
	}

	_, ok = ts.Hash("foo2")
	assert.False(t, ok)

	k, ok = chs.c["foo2"]
	assert.Equal(t, true, ok)
	assert.Equal(t, ChangeKindDelete, k)

	k, ok = chs.c[filepath.FromSlash("zzz/bb/cc/foo")]
	assert.Equal(t, true, ok)
	assert.Equal(t, ChangeKindAdd, k)

	_, ok = chs.c[filepath.FromSlash("zzz/aa")]
	assert.Equal(t, false, ok)

	_, ok = chs.c["zzz.aa"]
	assert.Equal(t, false, ok)
}

func TestCopyMetadataOnly(t *testing.T) {
	forEachReceiveDiskWriter(t, testCopyMetadataOnly)
}

func testCopyMetadataOnly(t *testing.T, receive receiveTestFunc) {
	d, err := tmpDir(changeStream([]string{
		"ADD foo file data1",
		"ADD foo2 file dat2",
		"ADD zzz dir",
		"ADD zzz/aa file data3",
	}))
	assert.NoError(t, err)
	defer os.RemoveAll(d)
	fs, err := NewFS(d)
	assert.NoError(t, err)
	fs, err = NewFilterFS(fs, &FilterOpt{
		Map: func(_ string, s *types.Stat) MapResult {
			s.Uid = 0
			s.Gid = 0
			return MapResultKeep
		},
	})
	assert.NoError(t, err)

	dest := t.TempDir()

	ts := newNotificationBuffer()
	chs := &changes{fn: ts.HandleChange}

	eg, ctx := errgroup.WithContext(context.Background())
	s1, s2 := sockPairProto(ctx)

	tm := time.Now().Truncate(time.Hour)

	var processCbWasCalled bool
	progressCb := func(size int, last bool) {
		processCbWasCalled = true
	}

	eg.Go(func() error {
		defer s1.(*fakeConnProto).closeSend()
		return Send(ctx, s1, fs, progressCb)
	})
	eg.Go(func() error {
		return receive(ctx, s2, dest, ReceiveOpt{
			NotifyHashed:  chs.HandleChange,
			ContentHasher: simpleSHA256Hasher,
			Filter: func(p string, s *types.Stat) bool {
				if runtime.GOOS != "windows" {
					// On Windows, Getuid() and Getgid() always return -1
					// See: https://pkg.go.dev/os#Getgid
					// See: https://pkg.go.dev/os#Geteuid
					s.Uid = uint32(os.Getuid())
					s.Gid = uint32(os.Getgid())
				}
				s.ModTime = tm.UnixNano()
				return true
			},
			MetadataOnly: func(p string, s *types.Stat) bool {
				return p == "foo2"
			},
		})
	})

	assert.NoError(t, eg.Wait())
	assert.True(t, processCbWasCalled)

	b := &bytes.Buffer{}
	err = Walk(context.Background(), dest, nil, bufWalk(b))
	assert.NoError(t, err)

	assert.Equal(t, `file .fsutil-metadata
file foo2
`, b.String())

	dt, err := os.ReadFile(filepath.Join(dest, "foo2"))
	assert.NoError(t, err)
	assert.Equal(t, "dat2", string(dt))

	dt, err = os.ReadFile(filepath.Join(dest, ".fsutil-metadata"))
	assert.NoError(t, err)

	files := parseFSMetadata(t, dt)
	assert.Equal(t, 4, len(files))

	assert.Equal(t, "foo", files[0].Path)
	assert.Equal(t, int64(5), files[0].Size)
	assert.Equal(t, "foo2", files[1].Path)
	assert.Equal(t, int64(4), files[1].Size)
	assert.Equal(t, "zzz", files[2].Path)
	assert.Equal(t, int64(0), files[2].Size)
	assert.True(t, (&StatInfo{&files[2]}).IsDir())
	assert.Equal(t, "zzz/aa", files[3].Path)
	assert.Equal(t, int64(5), files[3].Size)
}

func parseFSMetadata(t *testing.T, dt []byte) []types.Stat {
	var m []types.Stat
	for len(dt) > 0 {
		var s types.Stat
		n := binary.LittleEndian.Uint32(dt[:4])
		dt = dt[4:]
		err := s.Unmarshal(dt[:n])
		assert.NoError(t, err)
		m = append(m, *s.CloneVT())
		dt = dt[n:]
	}
	return m
}

func sockPairProto(ctx context.Context) (Stream, Stream) {
	c1 := make(chan []byte, 32)
	c2 := make(chan []byte, 32)
	return &fakeConnProto{ctx, c1, c2}, &fakeConnProto{ctx, c2, c1}
}

type fakeConnProto struct {
	ctx      context.Context
	recvChan chan []byte
	sendChan chan []byte
}

func (fc *fakeConnProto) Context() context.Context {
	return fc.ctx
}

func (fc *fakeConnProto) RecvMsg(m any) error {
	p, ok := m.(*types.Packet)
	if !ok {
		return errors.Errorf("invalid msg: %#v", m)
	}
	select {
	case <-fc.ctx.Done():
		return fc.ctx.Err()
	case dt, ok := <-fc.recvChan:
		if !ok {
			return io.EOF
		}
		return p.Unmarshal(dt)
	}
}

func (fc *fakeConnProto) SendMsg(m any) error {
	p, ok := m.(*types.Packet)
	if !ok {
		return errors.Errorf("invalid msg: %#v", m)
	}
	dt, err := p.Marshal()
	if err != nil {
		return err
	}
	select {
	case <-fc.ctx.Done():
		return fc.ctx.Err()
	case fc.sendChan <- dt:
		return nil
	}
}

func (fc *fakeConnProto) closeSend() {
	close(fc.sendChan)
}

type changes struct {
	c  map[string]ChangeKind
	fn ChangeFunc
	mu sync.Mutex
}

func (c *changes) HandleChange(kind ChangeKind, p string, fi os.FileInfo, err error) error {
	c.mu.Lock()
	if c.c == nil {
		c.c = make(map[string]ChangeKind)
	}
	c.c[p] = kind
	c.mu.Unlock()
	return c.fn(kind, p, fi, err)
}

func simpleSHA256Hasher(s *types.Stat) (hash.Hash, error) {
	h := sha256.New()
	ss := s.Clone()
	ss.ModTime = 0
	// Unlike Linux, on FreeBSD's stat() call returns -1 in st_rdev for regular files
	ss.Devminor = 0
	ss.Devmajor = 0
	if runtime.GOOS == "windows" {
		// On Windows, Getuid() and Getgid() always return -1
		// See: https://pkg.go.dev/os#Getgid
		// See: https://pkg.go.dev/os#Geteuid
		ss.Uid = 0
		ss.Gid = 0
	}

	if os.FileMode(ss.Mode)&os.ModeSymlink != 0 && runtime.GOOS != "windows" {
		ss.Mode = ss.Mode | 0777
	}

	dt, err := ss.Marshal()
	if err != nil {
		return nil, err
	}
	h.Write(dt)
	return h, nil
}

type testErrFS struct {
	err error
}

func (e *testErrFS) Walk(ctx context.Context, p string, fn gofs.WalkDirFunc) error {
	return errors.Wrap(e.err, "invalid walk")
}

func (e *testErrFS) Open(p string) (io.ReadCloser, error) {
	return nil, errors.Wrap(e.err, "invalid open")
}
