/*
Copyright The Helm 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 provenance

import (
	"crypto"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"testing"

	pgperrors "golang.org/x/crypto/openpgp/errors" //nolint
)

const (
	// testKeyFile is the secret key.
	// Generating keys should be done with `gpg --gen-key`. The current key
	// was generated to match Go's defaults (RSA/RSA 2048). It has no pass
	// phrase. Use `gpg --export-secret-keys helm-test` to export the secret.
	testKeyfile = "testdata/helm-test-key.secret"

	// testPasswordKeyfile is a keyfile with a password.
	testPasswordKeyfile = "testdata/helm-password-key.secret"

	// testPubfile is the public key file.
	// Use `gpg --export helm-test` to export the public key.
	testPubfile = "testdata/helm-test-key.pub"

	// Generated name for the PGP key in testKeyFile.
	testKeyName = `Helm Testing (This key should only be used for testing. DO NOT TRUST.) <helm-testing@helm.sh>`

	testPasswordKeyName = `password key (fake) <fake@helm.sh>`

	testChartfile = "testdata/hashtest-1.2.3.tgz"

	// testSigBlock points to a signature generated by an external tool.
	// This file was generated with GnuPG:
	// gpg --clearsign -u helm-test --openpgp testdata/msgblock.yaml
	testSigBlock = "testdata/msgblock.yaml.asc"

	// testTamperedSigBlock is a tampered copy of msgblock.yaml.asc
	testTamperedSigBlock = "testdata/msgblock.yaml.tampered"

	// testSumfile points to a SHA256 sum generated by an external tool.
	// We always want to validate against an external tool's representation to
	// verify that we haven't done something stupid. This file was generated
	// with shasum.
	// shasum -a 256 hashtest-1.2.3.tgz > testdata/hashtest.sha256
	testSumfile = "testdata/hashtest.sha256"
)

// testMessageBlock represents the expected message block for the testdata/hashtest chart.
const testMessageBlock = `apiVersion: v1
description: Test chart versioning
name: hashtest
version: 1.2.3

...
files:
  hashtest-1.2.3.tgz: sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888
`

func TestMessageBlock(t *testing.T) {
	out, err := messageBlock(testChartfile)
	if err != nil {
		t.Fatal(err)
	}
	got := out.String()

	if got != testMessageBlock {
		t.Errorf("Expected:\n%q\nGot\n%q\n", testMessageBlock, got)
	}
}

func TestParseMessageBlock(t *testing.T) {
	md, sc, err := parseMessageBlock([]byte(testMessageBlock))
	if err != nil {
		t.Fatal(err)
	}

	if md.Name != "hashtest" {
		t.Errorf("Expected name %q, got %q", "hashtest", md.Name)
	}

	if lsc := len(sc.Files); lsc != 1 {
		t.Errorf("Expected 1 file, got %d", lsc)
	}

	if hash, ok := sc.Files["hashtest-1.2.3.tgz"]; !ok {
		t.Errorf("hashtest file not found in Files")
	} else if hash != "sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888" {
		t.Errorf("Unexpected hash: %q", hash)
	}
}

func TestLoadKey(t *testing.T) {
	k, err := loadKey(testKeyfile)
	if err != nil {
		t.Fatal(err)
	}

	if _, ok := k.Identities[testKeyName]; !ok {
		t.Errorf("Expected to load a key for user %q", testKeyName)
	}
}

func TestLoadKeyRing(t *testing.T) {
	k, err := loadKeyRing(testPubfile)
	if err != nil {
		t.Fatal(err)
	}

	if len(k) > 1 {
		t.Errorf("Expected 1, got %d", len(k))
	}

	for _, e := range k {
		if ii, ok := e.Identities[testKeyName]; !ok {
			t.Errorf("Expected %s in %v", testKeyName, ii)
		}
	}
}

func TestDigest(t *testing.T) {
	f, err := os.Open(testChartfile)
	if err != nil {
		t.Fatal(err)
	}
	defer f.Close()

	hash, err := Digest(f)
	if err != nil {
		t.Fatal(err)
	}

	sig, err := readSumFile(testSumfile)
	if err != nil {
		t.Fatal(err)
	}

	if !strings.Contains(sig, hash) {
		t.Errorf("Expected %s to be in %s", hash, sig)
	}
}

func TestNewFromFiles(t *testing.T) {
	s, err := NewFromFiles(testKeyfile, testPubfile)
	if err != nil {
		t.Fatal(err)
	}

	if _, ok := s.Entity.Identities[testKeyName]; !ok {
		t.Errorf("Expected to load a key for user %q", testKeyName)
	}
}

func TestDigestFile(t *testing.T) {
	hash, err := DigestFile(testChartfile)
	if err != nil {
		t.Fatal(err)
	}

	sig, err := readSumFile(testSumfile)
	if err != nil {
		t.Fatal(err)
	}

	if !strings.Contains(sig, hash) {
		t.Errorf("Expected %s to be in %s", hash, sig)
	}
}

func TestDecryptKey(t *testing.T) {
	k, err := NewFromKeyring(testPasswordKeyfile, testPasswordKeyName)
	if err != nil {
		t.Fatal(err)
	}

	if !k.Entity.PrivateKey.Encrypted {
		t.Fatal("Key is not encrypted")
	}

	// We give this a simple callback that returns the password.
	if err := k.DecryptKey(func(_ string) ([]byte, error) {
		return []byte("secret"), nil
	}); err != nil {
		t.Fatal(err)
	}

	// Re-read the key (since we already unlocked it)
	k, err = NewFromKeyring(testPasswordKeyfile, testPasswordKeyName)
	if err != nil {
		t.Fatal(err)
	}
	// Now we give it a bogus password.
	if err := k.DecryptKey(func(_ string) ([]byte, error) {
		return []byte("secrets_and_lies"), nil
	}); err == nil {
		t.Fatal("Expected an error when giving a bogus passphrase")
	}
}

func TestClearSign(t *testing.T) {
	signer, err := NewFromFiles(testKeyfile, testPubfile)
	if err != nil {
		t.Fatal(err)
	}

	sig, err := signer.ClearSign(testChartfile)
	if err != nil {
		t.Fatal(err)
	}
	t.Logf("Sig:\n%s", sig)

	if !strings.Contains(sig, testMessageBlock) {
		t.Errorf("expected message block to be in sig: %s", sig)
	}
}

// failSigner always fails to sign and returns an error
type failSigner struct{}

func (s failSigner) Public() crypto.PublicKey {
	return nil
}

func (s failSigner) Sign(_ io.Reader, _ []byte, _ crypto.SignerOpts) ([]byte, error) {
	return nil, fmt.Errorf("always fails")
}

func TestClearSignError(t *testing.T) {
	signer, err := NewFromFiles(testKeyfile, testPubfile)
	if err != nil {
		t.Fatal(err)
	}

	// ensure that signing always fails
	signer.Entity.PrivateKey.PrivateKey = failSigner{}

	sig, err := signer.ClearSign(testChartfile)
	if err == nil {
		t.Fatal("didn't get an error from ClearSign but expected one")
	}

	if sig != "" {
		t.Fatalf("expected an empty signature after failed ClearSign but got %q", sig)
	}
}

func TestDecodeSignature(t *testing.T) {
	// Unlike other tests, this does a round-trip test, ensuring that a signature
	// generated by the library can also be verified by the library.

	signer, err := NewFromFiles(testKeyfile, testPubfile)
	if err != nil {
		t.Fatal(err)
	}

	sig, err := signer.ClearSign(testChartfile)
	if err != nil {
		t.Fatal(err)
	}

	f, err := os.CreateTemp("", "helm-test-sig-")
	if err != nil {
		t.Fatal(err)
	}

	tname := f.Name()
	defer func() {
		os.Remove(tname)
	}()
	f.WriteString(sig)
	f.Close()

	sig2, err := signer.decodeSignature(tname)
	if err != nil {
		t.Fatal(err)
	}

	by, err := signer.verifySignature(sig2)
	if err != nil {
		t.Fatal(err)
	}

	if _, ok := by.Identities[testKeyName]; !ok {
		t.Errorf("Expected identity %q", testKeyName)
	}
}

func TestVerify(t *testing.T) {
	signer, err := NewFromFiles(testKeyfile, testPubfile)
	if err != nil {
		t.Fatal(err)
	}

	if ver, err := signer.Verify(testChartfile, testSigBlock); err != nil {
		t.Errorf("Failed to pass verify. Err: %s", err)
	} else if len(ver.FileHash) == 0 {
		t.Error("Verification is missing hash.")
	} else if ver.SignedBy == nil {
		t.Error("No SignedBy field")
	} else if ver.FileName != filepath.Base(testChartfile) {
		t.Errorf("FileName is unexpectedly %q", ver.FileName)
	}

	if _, err = signer.Verify(testChartfile, testTamperedSigBlock); err == nil {
		t.Errorf("Expected %s to fail.", testTamperedSigBlock)
	}

	switch err.(type) {
	case pgperrors.SignatureError:
		t.Logf("Tampered sig block error: %s (%T)", err, err)
	default:
		t.Errorf("Expected invalid signature error, got %q (%T)", err, err)
	}
}

// readSumFile reads a file containing a sum generated by the UNIX shasum tool.
func readSumFile(sumfile string) (string, error) {
	data, err := os.ReadFile(sumfile)
	if err != nil {
		return "", err
	}

	sig := string(data)
	parts := strings.SplitN(sig, " ", 2)
	return parts[0], nil
}
