/*
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 "github.com/ProtonMail/go-crypto/openpgp/errors" //nolint
	"github.com/ProtonMail/go-crypto/openpgp/packet"           //nolint
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"sigs.k8s.io/yaml"

	"helm.sh/helm/v4/pkg/chart/v2/loader"
)

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"

	// testMixedKeyring points to a keyring containing RSA and ed25519 keys.
	testMixedKeyring = "testdata/helm-mixed-keyring.pub"

	// 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
`

// loadChartMetadataForSigning is a test helper that loads chart metadata and marshals it to YAML bytes
func loadChartMetadataForSigning(t *testing.T, chartPath string) []byte {
	t.Helper()

	chart, err := loader.LoadFile(chartPath)
	if err != nil {
		t.Fatal(err)
	}

	metadataBytes, err := yaml.Marshal(chart.Metadata)
	if err != nil {
		t.Fatal(err)
	}

	return metadataBytes
}

func TestMessageBlock(t *testing.T) {
	metadataBytes := loadChartMetadataForSigning(t, testChartfile)

	// Read the chart file data
	archiveData, err := os.ReadFile(testChartfile)
	if err != nil {
		t.Fatal(err)
	}

	out, err := messageBlock(archiveData, filepath.Base(testChartfile), metadataBytes)
	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) {
	sc, err := parseMessageBlock([]byte(testMessageBlock))
	if err != nil {
		t.Fatal(err)
	}

	// parseMessageBlock only returns checksums, not metadata (like upstream)

	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)
	}

	metadataBytes := loadChartMetadataForSigning(t, testChartfile)

	// Read the chart file data
	archiveData, err := os.ReadFile(testChartfile)
	if err != nil {
		t.Fatal(err)
	}

	sig, err := signer.ClearSign(archiveData, filepath.Base(testChartfile), metadataBytes)
	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)
	}
}

func TestMixedKeyringRSASigningAndVerification(t *testing.T) {
	signer, err := NewFromFiles(testKeyfile, testMixedKeyring)
	require.NoError(t, err)

	require.NotEmpty(t, signer.KeyRing, "expected signer keyring to be loaded")

	hasEdDSA := false
	for _, entity := range signer.KeyRing {
		if entity.PrimaryKey != nil && entity.PrimaryKey.PubKeyAlgo == packet.PubKeyAlgoEdDSA {
			hasEdDSA = true
			break
		}

		for _, subkey := range entity.Subkeys {
			if subkey.PublicKey != nil && subkey.PublicKey.PubKeyAlgo == packet.PubKeyAlgoEdDSA {
				hasEdDSA = true
				break
			}
		}

		if hasEdDSA {
			break
		}
	}

	assert.True(t, hasEdDSA, "expected %s to include an Ed25519 public key", testMixedKeyring)

	require.NotNil(t, signer.Entity, "expected signer entity to be loaded")
	require.NotNil(t, signer.Entity.PrivateKey, "expected signer private key to be loaded")
	assert.Equal(t, packet.PubKeyAlgoRSA, signer.Entity.PrivateKey.PubKeyAlgo, "expected RSA key")

	metadataBytes := loadChartMetadataForSigning(t, testChartfile)

	archiveData, err := os.ReadFile(testChartfile)
	require.NoError(t, err)

	sig, err := signer.ClearSign(archiveData, filepath.Base(testChartfile), metadataBytes)
	require.NoError(t, err, "failed to sign chart")

	verification, err := signer.Verify(archiveData, []byte(sig), filepath.Base(testChartfile))
	require.NoError(t, err, "failed to verify chart signature")

	require.NotNil(t, verification.SignedBy, "expected verification to include signer")
	require.NotNil(t, verification.SignedBy.PrimaryKey, "expected verification to include signer primary key")
	assert.Equal(t, packet.PubKeyAlgoRSA, verification.SignedBy.PrimaryKey.PubKeyAlgo, "expected verification to report RSA key")

	_, ok := verification.SignedBy.Identities[testKeyName]
	assert.True(t, ok, "expected verification to be signed by %q", testKeyName)
}

// 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{}

	metadataBytes := loadChartMetadataForSigning(t, testChartfile)

	// Read the chart file data
	archiveData, err := os.ReadFile(testChartfile)
	if err != nil {
		t.Fatal(err)
	}

	sig, err := signer.ClearSign(archiveData, filepath.Base(testChartfile), metadataBytes)
	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 TestVerify(t *testing.T) {
	signer, err := NewFromFiles(testKeyfile, testPubfile)
	if err != nil {
		t.Fatal(err)
	}

	// Read the chart file data
	archiveData, err := os.ReadFile(testChartfile)
	if err != nil {
		t.Fatal(err)
	}

	// Read the signature file data
	sigData, err := os.ReadFile(testSigBlock)
	if err != nil {
		t.Fatal(err)
	}

	if ver, err := signer.Verify(archiveData, sigData, filepath.Base(testChartfile)); 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)
	}

	// Read the tampered signature file data
	tamperedSigData, err := os.ReadFile(testTamperedSigBlock)
	if err != nil {
		t.Fatal(err)
	}

	if _, err = signer.Verify(archiveData, tamperedSigData, filepath.Base(testChartfile)); 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
}
