// Copyright Istio 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.

package wasm

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"fmt"
	"io"
	"net/http/httptest"
	"net/url"
	"reflect"
	"strings"
	"testing"

	"github.com/google/go-containerregistry/pkg/authn"
	"github.com/google/go-containerregistry/pkg/crane"
	"github.com/google/go-containerregistry/pkg/name"
	"github.com/google/go-containerregistry/pkg/registry"
	v1 "github.com/google/go-containerregistry/pkg/v1"
	"github.com/google/go-containerregistry/pkg/v1/empty"
	"github.com/google/go-containerregistry/pkg/v1/mutate"
	"github.com/google/go-containerregistry/pkg/v1/partial"
	"github.com/google/go-containerregistry/pkg/v1/random"
	"github.com/google/go-containerregistry/pkg/v1/remote"
	"github.com/google/go-containerregistry/pkg/v1/types"
)

func TestImageFetcherOption_useDefaultKeyChain(t *testing.T) {
	cases := []struct {
		name string
		opt  ImageFetcherOption
		exp  bool
	}{
		{name: "default key chain", exp: true},
		{name: "use secret config", opt: ImageFetcherOption{PullSecret: []byte("secret")}},
		{name: "missing secret", opt: ImageFetcherOption{}, exp: true},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			actual := c.opt.useDefaultKeyChain()
			if actual != c.exp {
				t.Errorf("useDefaultKeyChain got %v want %v", actual, c.exp)
			}
		})
	}
}

func TestImageFetcher_Fetch(t *testing.T) {
	// Fetcher with anonymous auth.
	fetcher := ImageFetcher{fetchOpts: []remote.Option{remote.WithAuth(authn.Anonymous)}}

	// Set up a fake registry.
	s := httptest.NewServer(registry.New())
	defer s.Close()
	u, err := url.Parse(s.URL)
	if err != nil {
		t.Fatal(err)
	}

	t.Run("docker image", func(t *testing.T) {
		ref := fmt.Sprintf("%s/test/valid/docker", u.Host)
		exp := "this is wasm plugin"

		// Create docker layer.
		l, err := newMockLayer(types.DockerLayer,
			map[string][]byte{"plugin.wasm": []byte(exp)})
		if err != nil {
			t.Fatal(err)
		}
		img, err := mutate.Append(empty.Image, mutate.Addendum{Layer: l})
		if err != nil {
			t.Fatal(err)
		}

		// Set manifest type.
		manifest, err := img.Manifest()
		if err != nil {
			t.Fatal(err)
		}
		manifest.MediaType = types.DockerManifestSchema2

		// Push image to the registry.
		err = crane.Push(img, ref)
		if err != nil {
			t.Fatal(err)
		}

		// Fetch docker image with digest
		d, err := img.Digest()
		if err != nil {
			t.Fatal(err)
		}

		// Fetch OCI image.
		binaryFetcher, actualDiget, err := fetcher.PrepareFetch(ref)
		if err != nil {
			t.Fatal(err)
		}
		actual, err := binaryFetcher()
		if err != nil {
			t.Fatal(err)
		}
		if string(actual) != exp {
			t.Errorf("ImageFetcher.binaryFetcher got %s, but want '%s'", string(actual), exp)
		}
		if actualDiget != d.Hex {
			t.Errorf("ImageFetcher.binaryFetcher got digest %s, but want '%s'", actualDiget, d.Hex)
		}
	})

	t.Run("OCI standard", func(t *testing.T) {
		ref := fmt.Sprintf("%s/test/valid/oci_standard", u.Host)
		exp := "this is wasm plugin"

		// Create OCI compressed layer.
		l, err := newMockLayer(types.OCILayer,
			map[string][]byte{"plugin.wasm": []byte(exp)})
		if err != nil {
			t.Fatal(err)
		}
		img, err := mutate.Append(empty.Image, mutate.Addendum{Layer: l})
		if err != nil {
			t.Fatal(err)
		}
		img = mutate.MediaType(img, types.OCIManifestSchema1)

		// Push image to the registry.
		err = crane.Push(img, ref)
		if err != nil {
			t.Fatal(err)
		}

		// Fetch OCI image with digest
		d, err := img.Digest()
		if err != nil {
			t.Fatal(err)
		}

		// Fetch OCI image.
		binaryFetcher, actualDiget, err := fetcher.PrepareFetch(ref)
		if err != nil {
			t.Fatal(err)
		}
		actual, err := binaryFetcher()
		if err != nil {
			t.Fatal(err)
		}
		if string(actual) != exp {
			t.Errorf("ImageFetcher.binaryFetcher got %s, but want '%s'", string(actual), exp)
		}
		if actualDiget != d.Hex {
			t.Errorf("ImageFetcher.binaryFetcher got digest %s, but want '%s'", actualDiget, d.Hex)
		}
	})

	t.Run("OCI artifact", func(t *testing.T) {
		ref := fmt.Sprintf("%s/test/valid/oci_artifact", u.Host)

		// Create the image with custom media types.
		wasmLayer, err := random.Layer(1000, "application/vnd.module.wasm.content.layer.v1+wasm")
		if err != nil {
			t.Fatal(err)
		}
		configLayer, err := random.Layer(1000, "application/vnd.module.wasm.config.v1+json")
		if err != nil {
			t.Fatal(err)
		}
		img, err := mutate.Append(empty.Image, mutate.Addendum{Layer: wasmLayer}, mutate.Addendum{Layer: configLayer})
		if err != nil {
			t.Fatal(err)
		}
		img = mutate.MediaType(img, types.OCIManifestSchema1)

		// Push image to the registry.
		err = crane.Push(img, ref)
		if err != nil {
			t.Fatal(err)
		}

		// Retrieve the wanted image content.
		wantReader, err := wasmLayer.Compressed()
		if err != nil {
			t.Fatal(err)
		}
		defer wantReader.Close()

		want, err := io.ReadAll(wantReader)
		if err != nil {
			t.Fatal(err)
		}

		// Fetch OCI image with digest
		d, err := img.Digest()
		if err != nil {
			t.Fatal(err)
		}

		// Fetch OCI image.
		binaryFetcher, actualDiget, err := fetcher.PrepareFetch(ref)
		if err != nil {
			t.Fatal(err)
		}
		actual, err := binaryFetcher()
		if err != nil {
			t.Fatal(err)
		}
		if !bytes.Equal(actual, want) {
			t.Errorf("ImageFetcher.binaryFetcher got %s, but want '%s'", string(actual), string(want))
		}
		if actualDiget != d.Hex {
			t.Errorf("ImageFetcher.binaryFetcher got digest %s, but want '%s'", actualDiget, d.Hex)
		}
	})

	t.Run("invalid image", func(t *testing.T) {
		ref := fmt.Sprintf("%s/test/invalid", u.Host)

		l, err := newMockLayer(types.OCIUncompressedLayer, map[string][]byte{"not-wasm.txt": []byte("a")})
		if err != nil {
			t.Fatal(err)
		}
		img, err := mutate.Append(empty.Image, mutate.Addendum{Layer: l})
		if err != nil {
			t.Fatal(err)
		}
		img = mutate.MediaType(img, types.OCIManifestSchema1)

		// Push image to the registry.
		err = crane.Push(img, ref)
		if err != nil {
			t.Fatal(err)
		}

		// Try to fetch.
		binaryFetcher, _, err := fetcher.PrepareFetch(ref)
		if err != nil {
			t.Fatal(err)
		}
		actual, err := binaryFetcher()
		if actual != nil {
			t.Errorf("ImageFetcher.binaryFetcher got %s, but want nil", string(actual))
		}

		expErr := `the given image is in invalid format as an OCI image: 2 errors occurred:
	* could not parse as compat variant: invalid media type application/vnd.oci.image.layer.v1.tar (expect application/vnd.oci.image.layer.v1.tar+gzip)
	* could not parse as oci variant: number of layers must be 2 but got 1`
		if actual := strings.TrimSpace(err.Error()); actual != expErr {
			t.Errorf("ImageFetcher.binaryFetcher get unexpected error '%v', but want '%v'", actual, expErr)
		}
	})
}

func TestExtractDockerImage(t *testing.T) {
	t.Run("no layers", func(t *testing.T) {
		_, err := extractDockerImage(empty.Image)
		if err == nil || err.Error() != "number of layers must be greater than zero" {
			t.Fatal("extractDockerImage should fail due to empty image")
		}
	})

	t.Run("valid layers", func(t *testing.T) {
		previousLayer, err := newMockLayer(types.DockerLayer, nil)
		if err != nil {
			t.Fatal(err)
		}

		exp := "this is wasm binary"
		lastLayer, err := newMockLayer(types.DockerLayer, map[string][]byte{
			"plugin.wasm": []byte(exp),
		})
		if err != nil {
			t.Fatal(err)
		}

		tCases := map[string]int{
			"one layer":           0,
			"more than one layer": 1,
		}

		for name, numberOfPreviousLayers := range tCases {
			t.Run(name, func(t *testing.T) {
				img := empty.Image
				for i := 0; i < numberOfPreviousLayers; i++ {
					img, err = mutate.Append(img, mutate.Addendum{Layer: previousLayer})
					if err != nil {
						t.Fatal(err)
					}
				}

				img, err = mutate.Append(img, mutate.Addendum{Layer: lastLayer})
				if err != nil {
					t.Fatal(err)
				}
				actual, err := extractDockerImage(img)
				if err != nil {
					t.Fatalf("extractDockerImage failed: %v", err)
				}

				if string(actual) != exp {
					t.Fatalf("got %s, but want %s", string(actual), exp)
				}
			})
		}
	})

	t.Run("invalid media type", func(t *testing.T) {
		l, err := newMockLayer(types.DockerPluginConfig, nil)
		if err != nil {
			t.Fatal(err)
		}
		img, err := mutate.Append(empty.Image, mutate.Addendum{Layer: l})
		if err != nil {
			t.Fatal(err)
		}
		_, err = extractDockerImage(img)
		if err == nil || !strings.Contains(err.Error(), "invalid media type") {
			t.Fatal("extractDockerImage should fail due to invalid media type")
		}
	})
}

func TestExtractOCIStandardImage(t *testing.T) {
	t.Run("no layers", func(t *testing.T) {
		_, err := extractOCIStandardImage(empty.Image)
		if err == nil || err.Error() != "number of layers must be greater than zero" {
			t.Fatal("extractDockerImage should fail due to empty image")
		}
	})

	t.Run("valid layers", func(t *testing.T) {
		previousLayer, err := newMockLayer(types.DockerLayer, nil)
		if err != nil {
			t.Fatal(err)
		}

		exp := "this is wasm binary"
		lastLayer, err := newMockLayer(types.OCILayer, map[string][]byte{
			"plugin.wasm": []byte(exp),
		})
		if err != nil {
			t.Fatal(err)
		}

		tCases := map[string]int{
			"one layer":           0,
			"more than one layer": 1,
		}

		for name, numberOfPreviousLayers := range tCases {
			t.Run(name, func(t *testing.T) {
				img := empty.Image
				for i := 0; i < numberOfPreviousLayers; i++ {
					img, err = mutate.Append(img, mutate.Addendum{Layer: previousLayer})
					if err != nil {
						t.Fatal(err)
					}
				}

				img, err = mutate.Append(img, mutate.Addendum{Layer: lastLayer})
				if err != nil {
					t.Fatal(err)
				}
				actual, err := extractOCIStandardImage(img)
				if err != nil {
					t.Fatalf("extractOCIStandardImage failed: %v", err)
				}

				if string(actual) != exp {
					t.Fatalf("got %s, but want %s", string(actual), exp)
				}
			})
		}
	})

	t.Run("invalid media type", func(t *testing.T) {
		l, err := newMockLayer(types.DockerLayer, nil)
		if err != nil {
			t.Fatal(err)
		}
		img, err := mutate.Append(empty.Image, mutate.Addendum{Layer: l})
		if err != nil {
			t.Fatal(err)
		}
		_, err = extractOCIStandardImage(img)
		if err == nil || !strings.Contains(err.Error(), "invalid media type") {
			t.Fatal("extractOCIStandardImage should fail due to invalid media type")
		}
	})
}

func newMockLayer(mediaType types.MediaType, contents map[string][]byte) (v1.Layer, error) {
	var b bytes.Buffer
	hasher := sha256.New()
	mw := io.MultiWriter(&b, hasher)
	tw := tar.NewWriter(mw)
	defer tw.Close()

	for filename, content := range contents {
		if err := tw.WriteHeader(&tar.Header{
			Name:     filename,
			Size:     int64(len(content)),
			Typeflag: tar.TypeReg,
		}); err != nil {
			return nil, err
		}
		if _, err := io.CopyN(tw, bytes.NewReader(content), int64(len(content))); err != nil {
			return nil, err
		}
	}
	return partial.UncompressedToLayer(
		&mockLayer{
			raw: b.Bytes(),
			diffID: v1.Hash{
				Algorithm: "sha256",
				Hex:       hex.EncodeToString(hasher.Sum(make([]byte, 0, hasher.Size()))),
			},
			mediaType: mediaType,
		},
	)
}

type mockLayer struct {
	raw       []byte
	diffID    v1.Hash
	mediaType types.MediaType
}

func (r *mockLayer) DiffID() (v1.Hash, error) { return v1.Hash{}, nil }
func (r *mockLayer) Uncompressed() (io.ReadCloser, error) {
	return io.NopCloser(bytes.NewBuffer(r.raw)), nil
}
func (r *mockLayer) MediaType() (types.MediaType, error) { return r.mediaType, nil }

func TestExtractOCIArtifactImage(t *testing.T) {
	t.Run("valid", func(t *testing.T) {
		// Create the image with custom media types.
		wasmLayer, err := random.Layer(1000, "application/vnd.module.wasm.content.layer.v1+wasm")
		if err != nil {
			t.Fatal(err)
		}
		configLayer, err := random.Layer(1000, "application/vnd.module.wasm.config.v1+json")
		if err != nil {
			t.Fatal(err)
		}
		img, err := mutate.Append(empty.Image, mutate.Addendum{Layer: wasmLayer}, mutate.Addendum{Layer: configLayer})
		if err != nil {
			t.Fatal(err)
		}

		// Extract the binary.
		actual, err := extractOCIArtifactImage(img)
		if err != nil {
			t.Fatalf("extractOCIArtifactImage failed: %v", err)
		}

		// Retrieve the wanted image content.
		wantReader, err := wasmLayer.Compressed()
		if err != nil {
			t.Fatal(err)
		}
		defer wantReader.Close()
		want, err := io.ReadAll(wantReader)
		if err != nil {
			t.Fatal(err)
		}

		if !bytes.Equal(actual, want) {
			t.Errorf("extractOCIArtifactImage got %s, but want '%s'", string(actual), string(want))
		}
	})

	t.Run("invalid number of layers", func(t *testing.T) {
		l, err := random.Layer(1000, "application/vnd.module.wasm.content.layer.v1+wasm")
		if err != nil {
			t.Fatal(err)
		}
		img, err := mutate.Append(empty.Image, mutate.Addendum{Layer: l})
		if err != nil {
			t.Fatal(err)
		}
		_, err = extractOCIArtifactImage(img)
		if err == nil || !strings.Contains(err.Error(), "number of layers must be") {
			t.Fatal("extractOCIArtifactImage should fail due to invalid number of layers")
		}
	})

	t.Run("invalid media types", func(t *testing.T) {
		// Create the image with invalid media types.
		layer, err := random.Layer(1000, "aaa")
		if err != nil {
			t.Fatal(err)
		}
		img, err := mutate.Append(empty.Image, mutate.Addendum{Layer: layer}, mutate.Addendum{Layer: layer})
		if err != nil {
			t.Fatal(err)
		}

		_, err = extractOCIArtifactImage(img)
		if err == nil || !strings.Contains(err.Error(),
			"could not find the layer of type application/vnd.module.wasm.content.layer.v1+wasm") {
			t.Fatal("extractOCIArtifactImage should fail due to invalid number of layers")
		}
	})
}

func TestExtractWasmPluginBinary(t *testing.T) {
	t.Run("ok", func(t *testing.T) {
		buf := bytes.NewBuffer(nil)
		gz := gzip.NewWriter(buf)
		tw := tar.NewWriter(gz)

		exp := "hello"
		if err := tw.WriteHeader(&tar.Header{
			Name: "plugin.wasm",
			Size: int64(len(exp)),
		}); err != nil {
			t.Fatal(err)
		}

		if _, err := io.WriteString(tw, exp); err != nil {
			t.Fatal(err)
		}

		tw.Close()
		gz.Close()

		actual, err := extractWasmPluginBinary(buf)
		if err != nil {
			t.Errorf("extractWasmPluginBinary failed: %v", err)
		}

		if string(actual) != exp {
			t.Errorf("extractWasmPluginBinary got %v, but want %v", string(actual), exp)
		}
	})

	t.Run("ok with relative path prefix", func(t *testing.T) {
		buf := bytes.NewBuffer(nil)
		gz := gzip.NewWriter(buf)
		tw := tar.NewWriter(gz)

		exp := "hello"
		if err := tw.WriteHeader(&tar.Header{
			Name: "./plugin.wasm",
			Size: int64(len(exp)),
		}); err != nil {
			t.Fatal(err)
		}

		if _, err := io.WriteString(tw, exp); err != nil {
			t.Fatal(err)
		}

		tw.Close()
		gz.Close()

		actual, err := extractWasmPluginBinary(buf)
		if err != nil {
			t.Errorf("extractWasmPluginBinary failed: %v", err)
		}

		if string(actual) != exp {
			t.Errorf("extractWasmPluginBinary got %v, but want %v", string(actual), exp)
		}
	})

	t.Run("not found", func(t *testing.T) {
		buf := bytes.NewBuffer(nil)
		gz := gzip.NewWriter(buf)
		tw := tar.NewWriter(gz)
		if err := tw.WriteHeader(&tar.Header{
			Name: "non-wasm.txt",
			Size: int64(1),
		}); err != nil {
			t.Fatal(err)
		}
		if _, err := tw.Write([]byte{1}); err != nil {
			t.Fatal(err)
		}
		tw.Close()
		gz.Close()
		_, err := extractWasmPluginBinary(buf)
		if err == nil || !strings.Contains(err.Error(), "not found") {
			t.Errorf("extractWasmPluginBinary must fail with not found")
		}
	})
}

func TestWasmKeyChain(t *testing.T) {
	dockerjson := fmt.Sprintf(`{"auths": {"test.io": {"auth": %q}}}`, encode("foo", "bar"))
	keyChain := wasmKeyChain{data: []byte(dockerjson)}
	testRegistry, _ := name.NewRegistry("test.io", name.WeakValidation)
	keyChain.Resolve(testRegistry)
	auth, err := keyChain.Resolve(testRegistry)
	if err != nil {
		t.Fatalf("Resolve() = %v", err)
	}
	got, err := auth.Authorization()
	if err != nil {
		t.Fatal(err)
	}
	want := &authn.AuthConfig{
		Username: "foo",
		Password: "bar",
	}
	if !reflect.DeepEqual(got, want) {
		t.Errorf("got %+v, want %+v", got, want)
	}
}

func encode(user, pass string) string {
	delimited := fmt.Sprintf("%s:%s", user, pass)
	return base64.StdEncoding.EncodeToString([]byte(delimited))
}

func TestValidateAllRealms(t *testing.T) {
	tests := []struct {
		name      string
		wwwAuth   string
		expectErr bool
	}{
		{
			name:      "valid https realm",
			wwwAuth:   `Bearer realm="https://auth.example.com/token",service="registry.example.com"`,
			expectErr: false,
		},
		{
			name:      "valid http realm",
			wwwAuth:   `Bearer realm="http://auth.example.com/token",service="registry.example.com"`,
			expectErr: false,
		},
		{
			name:      "cloud metadata SSRF",
			wwwAuth:   `Bearer realm="http://169.254.169.254/latest/meta-data/",service="evil.com"`,
			expectErr: true,
		},
		{
			name:      "link-local IP",
			wwwAuth:   `Bearer realm="http://169.254.1.1/foo",service="evil.com"`,
			expectErr: true,
		},
		{
			name:      "gcp metadata SSRF",
			wwwAuth:   `Bearer realm="http://metadata.google.internal/computeMetadata/v1/",service="evil.com"`,
			expectErr: true,
		},
		{
			name:      "localhost SSRF",
			wwwAuth:   `Bearer realm="http://localhost:8080/admin",service="evil.com"`,
			expectErr: true,
		},
		{
			name:      "loopback SSRF",
			wwwAuth:   `Bearer realm="http://127.0.0.1/api",service="evil.com"`,
			expectErr: true,
		},
		{
			name:      "loopback range",
			wwwAuth:   `Bearer realm="http://127.0.0.5/api",service="evil.com"`,
			expectErr: true,
		},
		{
			name:      "file scheme",
			wwwAuth:   `Bearer realm="file:///etc/passwd",service="evil.com"`,
			expectErr: true,
		},
		{
			name:      "gopher scheme",
			wwwAuth:   `Bearer realm="gopher://internal:9000/_GET",service="evil.com"`,
			expectErr: true,
		},
		{
			name:      "dict scheme",
			wwwAuth:   `Bearer realm="dict://redis:6379/INFO",service="evil.com"`,
			expectErr: true,
		},
		{
			name:      "private IP 10.x",
			wwwAuth:   `Bearer realm="http://10.0.0.1/token",service="evil.com"`,
			expectErr: true,
		},
		{
			name:      "private IP 192.168",
			wwwAuth:   `Bearer realm="http://192.168.1.1/token",service="evil.com"`,
			expectErr: true,
		},
		{
			name:      "private IP 172.16-31",
			wwwAuth:   `Bearer realm="http://172.16.0.1/token",service="evil.com"`,
			expectErr: true,
		},
		{
			name:      "ipv6 loopback",
			wwwAuth:   `Bearer realm="http://[::1]/token",service="evil.com"`,
			expectErr: true,
		},
		{
			name:      "ipv6 link-local",
			wwwAuth:   `Bearer realm="http://[fe80::1]/token",service="evil.com"`,
			expectErr: true,
		},
		{
			name:      "aws ipv6 metadata",
			wwwAuth:   `Bearer realm="http://[fd00:ec2::254]/latest/meta-data/",service="evil.com"`,
			expectErr: true,
		},
		{
			name:      "ipv6 unique local",
			wwwAuth:   `Bearer realm="http://[fc00::1]/token",service="evil.com"`,
			expectErr: true,
		},
		{
			name:      "no realm param",
			wwwAuth:   `Bearer service="registry.example.com"`,
			expectErr: false,
		},
		{
			name:      "basic auth no realm",
			wwwAuth:   `Basic charset="UTF-8"`,
			expectErr: false,
		},
		// multiple realms in one header - safe first, malicious second
		{
			name:      "multiple challenges safe then malicious",
			wwwAuth:   `Bearer realm="https://safe.com/token", Basic realm="http://169.254.169.254/"`,
			expectErr: true,
		},
		// multiple realms - malicious first
		{
			name:      "multiple challenges malicious first",
			wwwAuth:   `Basic realm="http://localhost/", Bearer realm="https://safe.com/token"`,
			expectErr: true,
		},
		// both realms safe
		{
			name:      "multiple challenges both safe",
			wwwAuth:   `Bearer realm="https://auth.example.com/", Basic realm="https://other.example.com/"`,
			expectErr: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := validateAllRealms(tt.wwwAuth)
			if (err != nil) != tt.expectErr {
				t.Errorf("validateAllRealms() error = %v, expectErr %v", err, tt.expectErr)
			}
		})
	}
}
