package archiver

import (
	"bytes"
	"fmt"
	"io/ioutil"
	"os"
	"path/filepath"
	"testing"
	"time"
)

func TestWithin(t *testing.T) {
	for i, tc := range []struct {
		path1, path2 string
		expect       bool
	}{
		{
			path1:  "/foo",
			path2:  "/foo/bar",
			expect: true,
		},
		{
			path1:  "/foo",
			path2:  "/foobar/asdf",
			expect: false,
		},
		{
			path1:  "/foobar/",
			path2:  "/foobar/asdf",
			expect: true,
		},
		{
			path1:  "/foobar/asdf",
			path2:  "/foobar",
			expect: false,
		},
		{
			path1:  "/foobar/asdf",
			path2:  "/foobar/",
			expect: false,
		},
		{
			path1:  "/",
			path2:  "/asdf",
			expect: true,
		},
		{
			path1:  "/asdf",
			path2:  "/asdf",
			expect: true,
		},
		{
			path1:  "/",
			path2:  "/",
			expect: true,
		},
		{
			path1:  "/foo/bar/daa",
			path2:  "/foo",
			expect: false,
		},
		{
			path1:  "/foo/",
			path2:  "/foo/bar/daa",
			expect: true,
		},
	} {
		actual := within(tc.path1, tc.path2)
		if actual != tc.expect {
			t.Errorf("Test %d: [%s %s] Expected %t but got %t", i, tc.path1, tc.path2, tc.expect, actual)
		}
	}
}

func TestMultipleTopLevels(t *testing.T) {
	for i, tc := range []struct {
		set    []string
		expect bool
	}{
		{
			set:    []string{},
			expect: false,
		},
		{
			set:    []string{"/foo"},
			expect: false,
		},
		{
			set:    []string{"/foo", "/foo/bar"},
			expect: false,
		},
		{
			set:    []string{"/foo", "/bar"},
			expect: true,
		},
		{
			set:    []string{"/foo", "/foobar"},
			expect: true,
		},
		{
			set:    []string{"foo", "foo/bar"},
			expect: false,
		},
		{
			set:    []string{"foo", "/foo/bar"},
			expect: false,
		},
		{
			set:    []string{"../foo", "foo/bar"},
			expect: true,
		},
		{
			set:    []string{`C:\foo\bar`, `C:\foo\bar\zee`},
			expect: false,
		},
		{
			set:    []string{`C:\`, `C:\foo\bar`},
			expect: false,
		},
		{
			set:    []string{`D:\foo`, `E:\foo`},
			expect: true,
		},
		{
			set:    []string{`D:\foo`, `D:\foo\bar`, `C:\foo`},
			expect: true,
		},
		{
			set:    []string{"/foo", "/", "/bar"},
			expect: true,
		},
	} {
		actual := multipleTopLevels(tc.set)
		if actual != tc.expect {
			t.Errorf("Test %d: %v: Expected %t but got %t", i, tc.set, tc.expect, actual)
		}
	}
}

func TestMakeNameInArchive(t *testing.T) {
	for i, tc := range []struct {
		sourceInfo fakeFileInfo
		source     string // a file path explicitly listed by the user to include in the archive
		baseDir    string // the base or root directory or path within the archive which contains all other files
		fpath      string // the file path being walked; if source is a directory, this will be a child path
		expect     string
	}{
		{
			sourceInfo: fakeFileInfo{isDir: false},
			source:     "foo.txt",
			baseDir:    "",
			fpath:      "foo.txt",
			expect:     "foo.txt",
		},
		{
			sourceInfo: fakeFileInfo{isDir: false},
			source:     "foo.txt",
			baseDir:    "base",
			fpath:      "foo.txt",
			expect:     "base/foo.txt",
		},
		{
			sourceInfo: fakeFileInfo{isDir: false},
			source:     "foo/bar.txt",
			baseDir:    "",
			fpath:      "foo/bar.txt",
			expect:     "bar.txt",
		},
		{
			sourceInfo: fakeFileInfo{isDir: false},
			source:     "foo/bar.txt",
			baseDir:    "base",
			fpath:      "foo/bar.txt",
			expect:     "base/bar.txt",
		},
		{
			sourceInfo: fakeFileInfo{isDir: true},
			source:     "foo/bar",
			baseDir:    "base",
			fpath:      "foo/bar",
			expect:     "base/bar",
		},
		{
			sourceInfo: fakeFileInfo{isDir: false},
			source:     "/absolute/path.txt",
			baseDir:    "",
			fpath:      "/absolute/path.txt",
			expect:     "path.txt",
		},
		{
			sourceInfo: fakeFileInfo{isDir: false},
			source:     "/absolute/sub/path.txt",
			baseDir:    "",
			fpath:      "/absolute/sub/path.txt",
			expect:     "path.txt",
		},
		{
			sourceInfo: fakeFileInfo{isDir: false},
			source:     "/absolute/sub/path.txt",
			baseDir:    "base",
			fpath:      "/absolute/sub/path.txt",
			expect:     "base/path.txt",
		},
		{
			sourceInfo: fakeFileInfo{isDir: false},
			source:     "sub/path.txt",
			baseDir:    "base/subbase",
			fpath:      "sub/path.txt",
			expect:     "base/subbase/path.txt",
		},
		{
			sourceInfo: fakeFileInfo{isDir: true},
			source:     "sub/dir",
			baseDir:    "base/subbase",
			fpath:      "sub/dir/path.txt",
			expect:     "base/subbase/dir/path.txt",
		},
		{
			sourceInfo: fakeFileInfo{isDir: true},
			source:     "sub/dir",
			baseDir:    "base/subbase",
			fpath:      "sub/dir/sub2/sub3/path.txt",
			expect:     "base/subbase/dir/sub2/sub3/path.txt",
		},
		{
			sourceInfo: fakeFileInfo{isDir: true},
			source:     `/absolute/dir`,
			baseDir:    "base",
			fpath:      `/absolute/dir/sub1/sub2/file.txt`,
			expect:     "base/dir/sub1/sub2/file.txt",
		},
	} {
		actual, err := makeNameInArchive(tc.sourceInfo, tc.source, tc.baseDir, tc.fpath)
		if err != nil {
			t.Errorf("Test %d: Got error: %v", i, err)
		}
		if actual != tc.expect {
			t.Errorf("Test %d: Expected '%s' but got '%s'", i, tc.expect, actual)
		}
	}
}

// TODO: We need a new .rar file since we moved the test corpus into the testdata/corpus subfolder.
/*
func TestRarUnarchive(t *testing.T) {
	au := DefaultRar
	auStr := fmt.Sprintf("%s", au)

	tmp, err := ioutil.TempDir("", "archiver_test")
	if err != nil {
		t.Fatalf("[%s] %v", auStr, err)
	}
	defer os.RemoveAll(tmp)

	dest := filepath.Join(tmp, "extraction_test_"+auStr)
	os.Mkdir(dest, 0755)

	file := "testdata/sample.rar"
	err = au.Unarchive(file, dest)
	if err != nil {
		t.Fatalf("[%s] extracting archive [%s -> %s]: didn't expect an error, but got: %v", auStr, file, dest, err)
	}

	// Check that what was extracted is what was compressed
	// Extracting links isn't implemented yet (in github.com/nwaples/rardecode lib there are no methods to get symlink info)
	// Files access modes may differs on different machines, we are comparing extracted(as archive host) and local git clone
	symmetricTest(t, auStr, dest, false, false)
}
*/

func TestArchiveUnarchive(t *testing.T) {
	for _, af := range archiveFormats {
		au, ok := af.(archiverUnarchiver)
		if !ok {
			t.Errorf("%s (%T): not an Archiver and Unarchiver", af, af)
			continue
		}
		testArchiveUnarchive(t, au)
	}
}

func TestArchiveUnarchiveWithFolderPermissions(t *testing.T) {
	dir := "testdata/corpus/proverbs/extra"
	currentPerms, err := os.Stat(dir)
	if err != nil {
		t.Fatalf("%v", err)
	}
	err = os.Chmod(dir, 0700)
	if err != nil {
		t.Fatalf("%v", err)
	}

	defer func() {
		err := os.Chmod(dir, currentPerms.Mode())
		if err != nil {
			t.Fatalf("%v", err)
		}
	}()

	TestArchiveUnarchive(t)
}

func testArchiveUnarchive(t *testing.T, au archiverUnarchiver) {
	auStr := fmt.Sprintf("%s", au)

	tmp, err := ioutil.TempDir("", "archiver_test")
	if err != nil {
		t.Fatalf("[%s] %v", auStr, err)
	}
	defer os.RemoveAll(tmp)

	// Test creating archive
	outfile := filepath.Join(tmp, "archiver_test."+auStr)
	err = au.Archive([]string{"testdata/corpus"}, outfile)
	if err != nil {
		t.Fatalf("[%s] making archive: didn't expect an error, but got: %v", auStr, err)
	}

	// Test format matching (TODO: Make this its own test, out of band with the archive/unarchive tests)
	//testMatching(t, au, outfile) // TODO: Disabled until we can finish implementing this for compressed tar formats

	// Test extracting archive
	dest := filepath.Join(tmp, "extraction_test_"+auStr)
	_ = os.Mkdir(dest, 0755)
	err = au.Unarchive(outfile, dest)
	if err != nil {
		t.Fatalf("[%s] extracting archive [%s -> %s]: didn't expect an error, but got: %v", auStr, outfile, dest, err)
	}

	// Check that what was extracted is what was compressed
	symmetricTest(t, auStr, dest, true, true)
}

/*
// testMatching tests that au can match the format of archiveFile.
func testMatching(t *testing.T, au archiverUnarchiver, archiveFile string) {
	m, ok := au.(Matcher)
	if !ok {
		t.Logf("[NOTICE] %T (%s) is not a Matcher", au, au)
		return
	}

	file, err := os.Open(archiveFile)
	if err != nil {
		t.Fatalf("[%s] opening file for matching: %v", au, err)
	}
	defer file.Close()

	tmpBuf := make([]byte, 2048)
	io.ReadFull(file, tmpBuf)

	matched, err := m.Match(file)
	if err != nil {
		t.Fatalf("%s (%T): testing matching: got error, expected none: %v", m, m, err)
	}
	if !matched {
		t.Fatalf("%s (%T): format should have matched, but didn't", m, m)
	}
}
*/

// symmetricTest compares the contents of a destination directory to the contents
// of the test corpus and tests that they are equal.
func symmetricTest(t *testing.T, formatName, dest string, testSymlinks, testModes bool) {
	var expectedFileCount int
	_ = filepath.Walk("testdata/corpus", func(fpath string, info os.FileInfo, err error) error {
		if testSymlinks || (info.Mode()&os.ModeSymlink) == 0 {
			expectedFileCount++
		}
		return nil
	})

	// If outputs equals inputs, we're good; traverse output files
	// and compare file names, file contents, and file count.
	var actualFileCount int
	_ = filepath.Walk(dest, func(fpath string, info os.FileInfo, _ error) error {
		if fpath == dest {
			return nil
		}
		if testSymlinks || (info.Mode()&os.ModeSymlink) == 0 {
			actualFileCount++
		}

		origPath, err := filepath.Rel(dest, fpath)
		if err != nil {
			t.Fatalf("[%s] %s: Error inducing original file path: %v", formatName, fpath, err)
		}
		origPath = filepath.Join("testdata", origPath)

		expectedFileInfo, err := os.Lstat(origPath)
		if err != nil {
			t.Fatalf("[%s] %s: Error obtaining original file info: %v", formatName, fpath, err)
		}
		if !testSymlinks && (expectedFileInfo.Mode()&os.ModeSymlink) != 0 {
			return nil
		}
		actualFileInfo, err := os.Lstat(fpath)
		if err != nil {
			t.Fatalf("[%s] %s: Error obtaining actual file info: %v", formatName, fpath, err)
		}

		if testModes && actualFileInfo.Mode() != expectedFileInfo.Mode() {
			t.Fatalf("[%s] %s: File mode differed between on disk and compressed", formatName,
				expectedFileInfo.Mode().String()+" : "+actualFileInfo.Mode().String())
		}

		if info.IsDir() {
			// stat dir instead of read file
			_, err = os.Stat(origPath)
			if err != nil {
				t.Fatalf("[%s] %s: Couldn't stat original directory (%s): %v", formatName,
					fpath, origPath, err)
			}
			return nil
		}

		if (actualFileInfo.Mode() & os.ModeSymlink) != 0 {
			expectedLinkTarget, err := os.Readlink(origPath)
			if err != nil {
				t.Fatalf("[%s] %s: Couldn't read original symlink target: %v", formatName, origPath, err)
			}
			actualLinkTarget, err := os.Readlink(fpath)
			if err != nil {
				t.Fatalf("[%s] %s: Couldn't read actual symlink target: %v", formatName, fpath, err)
			}
			if expectedLinkTarget != actualLinkTarget {
				t.Fatalf("[%s] %s: Symlink targets differed between on disk and compressed", formatName, origPath)
			}
			return nil
		}

		expected, err := ioutil.ReadFile(origPath)
		if err != nil {
			t.Fatalf("[%s] %s: Couldn't open original file (%s) from disk: %v", formatName,
				fpath, origPath, err)
		}
		actual, err := ioutil.ReadFile(fpath)
		if err != nil {
			t.Fatalf("[%s] %s: Couldn't open new file from disk: %v", formatName, fpath, err)
		}

		if !bytes.Equal(expected, actual) {
			t.Fatalf("[%s] %s: File contents differed between on disk and compressed", formatName, origPath)
		}

		return nil
	})

	if got, want := actualFileCount, expectedFileCount; got != want {
		t.Fatalf("[%s] Expected %d resulting files, got %d", formatName, want, got)
	}
}

func TestUnarchiveWithStripComponents(t *testing.T) {
	testArchives := []string{
		"testdata/sample.rar",
		"testdata/testarchives/evilarchives/evil.zip",
		"testdata/testarchives/evilarchives/evil.tar",
		"testdata/testarchives/evilarchives/evil.tar.gz",
		"testdata/testarchives/evilarchives/evil.tar.bz2",
	}

	to := "testdata/testarchives/destarchives/"

	for _, archiveName := range testArchives {
		f, err := ByExtension(archiveName)

		if err != nil {
			t.Error(err)
		}

		var target string

		switch v := f.(type) {
		case *Rar:
			v.OverwriteExisting = false
			v.ImplicitTopLevelFolder = false
			v.StripComponents = 1
			target = "quote1.txt"
		case *Zip:
		case *Tar:
			v.OverwriteExisting = false
			v.ImplicitTopLevelFolder = false
			v.StripComponents = 1
			target = "safefile"
		case *TarGz:
		case *TarBz2:
			v.Tar.OverwriteExisting = false
			v.Tar.ImplicitTopLevelFolder = false
			v.Tar.StripComponents = 1
			target = "safefile"
		}

		u := f.(Unarchiver)

		if err := u.Unarchive(archiveName, to); err != nil {
			fmt.Println(err)
		}

		if _, err := os.Stat(filepath.Join(to, target)); os.IsNotExist(err) {
			t.Errorf("file is incorrectly extracted: %s", target)
		}

		os.RemoveAll(to)
	}
}

// test at runtime if the CheckFilename function is behaving properly for the archive formats
func TestSafeExtraction(t *testing.T) {

	testArchives := []string{
		"testdata/testarchives/evilarchives/evil.zip",
		"testdata/testarchives/evilarchives/evil.tar",
		"testdata/testarchives/evilarchives/evil.tar.gz",
		"testdata/testarchives/evilarchives/evil.tar.bz2",
	}

	for _, archiveName := range testArchives {

		expected := true // 'evilfile' should not be extracted outside of destination directory and 'safefile' should be extracted anyway in the destination folder anyway

		if _, err := os.Stat(archiveName); os.IsNotExist(err) {
			t.Errorf("archive not found")
		}

		actual := CheckFilenames(archiveName)

		if actual != expected {
			t.Errorf("CheckFilename is misbehaving for archive format type %s", filepath.Ext(archiveName))
		}
	}
}

func CheckFilenames(archiveName string) bool {

	evilNotExtracted := false // by default we cannot assume that the path traversal filename is mitigated by CheckFilename
	safeExtracted := false    // by default we cannot assume that a benign file can be extracted successfully

	// clean the destination folder after this test
	defer os.RemoveAll("testdata/testarchives/destarchives/")

	err := Unarchive(archiveName, "testdata/testarchives/destarchives/")
	if err != nil {
		fmt.Println(err)
	}

	// is 'evilfile' prevented to be extracted outside of the destination folder?
	if _, err := os.Stat("testdata/testarchives/evilfile"); os.IsNotExist(err) {
		evilNotExtracted = true
	}
	// is 'safefile' safely extracted without errors inside the destination path?
	if _, err := os.Stat("testdata/testarchives/destarchives/safedir/safefile"); !os.IsNotExist(err) {
		safeExtracted = true
	}

	return evilNotExtracted && safeExtracted
}

var archiveFormats = []interface{}{
	DefaultZip,
	DefaultTar,
	DefaultTarBrotli,
	DefaultTarBz2,
	DefaultTarGz,
	DefaultTarLz4,
	DefaultTarSz,
	DefaultTarXz,
	DefaultTarZstd,
}

type archiverUnarchiver interface {
	Archiver
	Unarchiver
}

type fakeFileInfo struct {
	name    string
	size    int64
	mode    os.FileMode
	modTime time.Time
	isDir   bool
	sys     interface{}
}

func (ffi fakeFileInfo) Name() string       { return ffi.name }
func (ffi fakeFileInfo) Size() int64        { return ffi.size }
func (ffi fakeFileInfo) Mode() os.FileMode  { return ffi.mode }
func (ffi fakeFileInfo) ModTime() time.Time { return ffi.modTime }
func (ffi fakeFileInfo) IsDir() bool        { return ffi.isDir }
func (ffi fakeFileInfo) Sys() interface{}   { return ffi.sys }
