package signature

import (
	"bytes"
	"encoding/json"
	"errors"
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/opencontainers/go-digest"
	"github.com/santhosh-tekuri/jsonschema/v6"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"go.podman.io/image/v5/version"
)

func TestNewUntrustedSignature(t *testing.T) {
	timeBefore := time.Now()
	sig := newUntrustedSignature(TestImageManifestDigest, TestImageSignatureReference)
	assert.Equal(t, TestImageManifestDigest, sig.untrustedDockerManifestDigest)
	assert.Equal(t, TestImageSignatureReference, sig.untrustedDockerReference)
	require.NotNil(t, sig.untrustedCreatorID)
	assert.Equal(t, "atomic "+version.Version, *sig.untrustedCreatorID)
	require.NotNil(t, sig.untrustedTimestamp)
	timeAfter := time.Now()
	assert.True(t, timeBefore.Unix() <= *sig.untrustedTimestamp)
	assert.True(t, *sig.untrustedTimestamp <= timeAfter.Unix())
}

func TestMarshalJSON(t *testing.T) {
	const testDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

	// Empty string values
	s := newUntrustedSignature("", "_")
	_, err := s.MarshalJSON()
	assert.Error(t, err)
	s = newUntrustedSignature("_", "")
	_, err = s.MarshalJSON()
	assert.Error(t, err)

	// Success
	// Use intermediate variables for these values so that we can take their addresses.
	creatorID := "CREATOR"
	timestamp := int64(1484683104)
	for _, c := range []struct {
		input    untrustedSignature
		expected string
	}{
		{
			untrustedSignature{
				untrustedDockerManifestDigest: testDigest,
				untrustedDockerReference:      "reference#@!",
				untrustedCreatorID:            &creatorID,
				untrustedTimestamp:            &timestamp,
			},
			"{\"critical\":{\"identity\":{\"docker-reference\":\"reference#@!\"},\"image\":{\"docker-manifest-digest\":\"" + testDigest + "\"},\"type\":\"atomic container signature\"},\"optional\":{\"creator\":\"CREATOR\",\"timestamp\":1484683104}}",
		},
		{
			untrustedSignature{
				untrustedDockerManifestDigest: testDigest,
				untrustedDockerReference:      "reference#@!",
			},
			"{\"critical\":{\"identity\":{\"docker-reference\":\"reference#@!\"},\"image\":{\"docker-manifest-digest\":\"" + testDigest + "\"},\"type\":\"atomic container signature\"},\"optional\":{}}",
		},
	} {
		marshaled, err := c.input.MarshalJSON()
		require.NoError(t, err)
		assert.Equal(t, []byte(c.expected), marshaled)

		// Also call MarshalJSON through the JSON package.
		marshaled, err = json.Marshal(c.input)
		assert.NoError(t, err)
		assert.Equal(t, []byte(c.expected), marshaled)
	}
}

// Return the result of modifying validJSON with fn
func modifiedJSON(t *testing.T, validJSON []byte, modifyFn func(mSA)) []byte {
	var tmp mSA
	err := json.Unmarshal(validJSON, &tmp)
	require.NoError(t, err)

	modifyFn(tmp)

	modifiedJSON, err := json.Marshal(tmp)
	require.NoError(t, err)
	return modifiedJSON
}

// Verify that input can be unmarshaled as an untrustedSignature, and that it passes JSON schema validation, and return the unmarshaled untrustedSignature.
func successfullyUnmarshalUntrustedSignature(t *testing.T, schema *jsonschema.Schema, input []byte) untrustedSignature {
	inputString := string(input)

	var s untrustedSignature
	err := json.Unmarshal(input, &s)
	require.NoError(t, err, inputString)

	rawInput, err := jsonschema.UnmarshalJSON(bytes.NewReader(input))
	require.NoError(t, err, inputString)
	err = schema.Validate(rawInput)
	assert.NoError(t, err, inputString)

	return s
}

// Verify that input can't be unmarshaled as an untrusted signature, and that it fails JSON schema validation.
func assertUnmarshalUntrustedSignatureFails(t *testing.T, schema *jsonschema.Schema, input []byte) {
	inputString := string(input)

	var s untrustedSignature
	err := json.Unmarshal(input, &s)
	assert.Error(t, err, inputString)

	rawInput, err := jsonschema.UnmarshalJSON(bytes.NewReader(input))
	if err == nil {
		err := schema.Validate(rawInput)
		assert.Error(t, err, inputString)
	}
}

func TestUnmarshalJSON(t *testing.T) {
	const testDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

	// NOTE: The schema at schemaPath is NOT authoritative; docs/atomic-signature.json and the code is, rather!
	// The schemaPath references are not testing that the code follows the behavior declared by the schema,
	// they are testing that the schema follows the behavior of the code!
	schemaPath, err := filepath.Abs("../docs/atomic-signature-embedded-json.json")
	require.NoError(t, err)

	schema, err := jsonschema.NewCompiler().Compile("file://" + schemaPath)
	require.NoError(t, err)

	// Invalid input. Note that json.Unmarshal is guaranteed to validate input before calling our
	// UnmarshalJSON implementation; so test that first, then test our error handling for completeness.
	assertUnmarshalUntrustedSignatureFails(t, schema, []byte("&"))
	var s untrustedSignature
	err = s.UnmarshalJSON([]byte("&"))
	assert.Error(t, err)

	// Not an object
	assertUnmarshalUntrustedSignatureFails(t, schema, []byte("1"))

	// Start with a valid JSON.
	validSig := newUntrustedSignature(testDigest, "reference#@!")
	validJSON, err := validSig.MarshalJSON()
	require.NoError(t, err)

	// Success
	s = successfullyUnmarshalUntrustedSignature(t, schema, validJSON)
	assert.Equal(t, validSig, s)

	// Various ways to corrupt the JSON
	breakFns := []func(mSA){
		// A top-level field is missing
		func(v mSA) { delete(v, "critical") },
		func(v mSA) { delete(v, "optional") },
		// Extra top-level sub-object
		func(v mSA) { v["unexpected"] = 1 },
		// "critical" not an object
		func(v mSA) { v["critical"] = 1 },
		// "optional" not an object
		func(v mSA) { v["optional"] = 1 },
		// A field of "critical" is missing
		func(v mSA) { delete(x(v, "critical"), "type") },
		func(v mSA) { delete(x(v, "critical"), "image") },
		func(v mSA) { delete(x(v, "critical"), "identity") },
		// Extra field of "critical"
		func(v mSA) { x(v, "critical")["unexpected"] = 1 },
		// Invalid "type"
		func(v mSA) { x(v, "critical")["type"] = 1 },
		func(v mSA) { x(v, "critical")["type"] = "unexpected" },
		// Invalid "image" object
		func(v mSA) { x(v, "critical")["image"] = 1 },
		func(v mSA) { delete(x(v, "critical", "image"), "docker-manifest-digest") },
		func(v mSA) { x(v, "critical", "image")["unexpected"] = 1 },
		// Invalid "docker-manifest-digest"
		func(v mSA) { x(v, "critical", "image")["docker-manifest-digest"] = 1 },
		func(v mSA) { x(v, "critical", "image")["docker-manifest-digest"] = "sha256:../.." },
		// Invalid "identity" object
		func(v mSA) { x(v, "critical")["identity"] = 1 },
		func(v mSA) { delete(x(v, "critical", "identity"), "docker-reference") },
		func(v mSA) { x(v, "critical", "identity")["unexpected"] = 1 },
		// Invalid "docker-reference"
		func(v mSA) { x(v, "critical", "identity")["docker-reference"] = 1 },
		// Invalid "creator"
		func(v mSA) { x(v, "optional")["creator"] = 1 },
		// Invalid "timestamp"
		func(v mSA) { x(v, "optional")["timestamp"] = "unexpected" },
		func(v mSA) { x(v, "optional")["timestamp"] = 0.5 }, // Fractional input
	}
	for _, fn := range breakFns {
		testJSON := modifiedJSON(t, validJSON, fn)
		assertUnmarshalUntrustedSignatureFails(t, schema, testJSON)
	}

	// Modifications to unrecognized fields in "optional" are allowed and ignored
	allowedModificationFns := []func(mSA){
		// Add an optional field
		func(v mSA) { x(v, "optional")["unexpected"] = 1 },
	}
	for _, fn := range allowedModificationFns {
		testJSON := modifiedJSON(t, validJSON, fn)
		s := successfullyUnmarshalUntrustedSignature(t, schema, testJSON)
		assert.Equal(t, validSig, s)
	}

	// Optional fields can be missing
	validSig = untrustedSignature{
		untrustedDockerManifestDigest: testDigest,
		untrustedDockerReference:      "reference#@!",
		untrustedCreatorID:            nil,
		untrustedTimestamp:            nil,
	}
	validJSON, err = validSig.MarshalJSON()
	require.NoError(t, err)
	s = successfullyUnmarshalUntrustedSignature(t, schema, validJSON)
	assert.Equal(t, validSig, s)
}

func TestSign(t *testing.T) {
	const testDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

	mech, err := newGPGSigningMechanismInDirectory(testGPGHomeDirectory)
	require.NoError(t, err)
	defer mech.Close()

	if err := mech.SupportsSigning(); err != nil {
		t.Skipf("Signing not supported: %v", err)
	}

	sig := newUntrustedSignature(testDigest, "reference#@!")

	// Successful signing
	signature, err := sig.sign(mech, TestKeyFingerprint, "")
	require.NoError(t, err)

	verified, keyIdentity, err := verifyAndExtractSignature(mech, signature, signatureAcceptanceRules{
		acceptedKeyIdentities: []string{TestKeyFingerprint},
		validateSignedDockerReference: func(signedDockerReference string) error {
			if signedDockerReference != sig.untrustedDockerReference {
				return errors.New("Unexpected signedDockerReference")
			}
			return nil
		},
		validateSignedDockerManifestDigest: func(signedDockerManifestDigest digest.Digest) error {
			if signedDockerManifestDigest != sig.untrustedDockerManifestDigest {
				return errors.New("Unexpected signedDockerManifestDigest")
			}
			return nil
		},
	})
	require.NoError(t, err)
	assert.Equal(t, sig.untrustedDockerManifestDigest, verified.DockerManifestDigest)
	assert.Equal(t, sig.untrustedDockerReference, verified.DockerReference)
	assert.Equal(t, TestKeyFingerprint, keyIdentity)

	// Error creating blob to sign
	_, err = untrustedSignature{}.sign(mech, TestKeyFingerprint, "")
	assert.Error(t, err)

	// Error signing
	_, err = sig.sign(mech, "this fingerprint doesn't exist", "")
	assert.Error(t, err)
}

func TestVerifyAndExtractSignature(t *testing.T) {
	mech, err := newGPGSigningMechanismInDirectory(testGPGHomeDirectory)
	require.NoError(t, err)
	defer mech.Close()

	type tuple struct {
		signedDockerReference      string
		signedDockerManifestDigest digest.Digest
	}
	// setupRecording returns a signatureAcceptanceRules implementations for wanted, which also
	// records the values passed to rule callbacks into the returned triple, to validate
	// that we are passing the correct values to the rule callbacks.
	setupRecording := func(wanted tuple, acceptedKeyIdentities ...string) (*tuple, signatureAcceptanceRules) {
		recorded := tuple{}
		return &recorded, signatureAcceptanceRules{
			acceptedKeyIdentities: acceptedKeyIdentities,
			validateSignedDockerReference: func(signedDockerReference string) error {
				recorded.signedDockerReference = signedDockerReference
				if signedDockerReference != wanted.signedDockerReference {
					return errors.New("signedDockerReference mismatch")
				}
				return nil
			},
			validateSignedDockerManifestDigest: func(signedDockerManifestDigest digest.Digest) error {
				recorded.signedDockerManifestDigest = signedDockerManifestDigest
				if signedDockerManifestDigest != wanted.signedDockerManifestDigest {
					return errors.New("signedDockerManifestDigest mismatch")
				}
				return nil
			},
		}
	}

	signature, err := os.ReadFile("./fixtures/image.signature")
	require.NoError(t, err)
	signatureData := tuple{
		signedDockerReference:      TestImageSignatureReference,
		signedDockerManifestDigest: TestImageManifestDigest,
	}

	// Successful verification
	for _, acceptedIdentities := range [][]string{
		{TestKeyFingerprint},
		{TestKeyFingerprint, "some other fingerprint"},
		{"some other fingerprint", TestKeyFingerprint},
	} {
		recorded, recordingRules := setupRecording(signatureData, acceptedIdentities...)
		sig, keyIdentity, err := verifyAndExtractSignature(mech, signature, recordingRules)
		require.NoError(t, err)
		assert.Equal(t, TestImageSignatureReference, sig.DockerReference)
		assert.Equal(t, TestImageManifestDigest, sig.DockerManifestDigest)
		assert.Equal(t, TestKeyFingerprint, keyIdentity)
		assert.Equal(t, signatureData, *recorded)
	}
	// Successful verification using a subkey
	sig2, err := os.ReadFile("./fixtures/subkey.signature")
	require.NoError(t, err)
	sig2Data := tuple{
		signedDockerReference:      "testing/manifest:latest",
		signedDockerManifestDigest: TestImageManifestDigest,
	}
	recorded, recordingRules := setupRecording(sig2Data, TestKeyFingerprintPrimaryWithSubkey)
	sig, keyIdentity, err := verifyAndExtractSignature(mech, sig2, recordingRules)
	require.NoError(t, err)
	assert.Equal(t, sig2Data.signedDockerReference, sig.DockerReference)
	assert.Equal(t, sig2Data.signedDockerManifestDigest, sig.DockerManifestDigest)
	assert.Equal(t, TestKeyFingerprintPrimaryWithSubkey, keyIdentity)
	assert.Equal(t, sig2Data, *recorded)

	// For extra paranoia, test that we return a nil signature object and a "" key identity on error.

	// Completely invalid signature.
	recorded, recordingRules = setupRecording(signatureData, TestKeyFingerprint)
	sig, keyIdentity, err = verifyAndExtractSignature(mech, []byte{}, recordingRules)
	assert.Error(t, err)
	assert.Nil(t, sig)
	assert.Equal(t, "", keyIdentity)
	assert.Equal(t, tuple{}, *recorded)

	recorded, recordingRules = setupRecording(signatureData, TestKeyFingerprint)
	sig, keyIdentity, err = verifyAndExtractSignature(mech, []byte("invalid signature"), recordingRules)
	assert.Error(t, err)
	assert.Nil(t, sig)
	assert.Equal(t, "", keyIdentity)
	assert.Equal(t, tuple{}, *recorded)

	// No key accepted.
	recorded, recordingRules = setupRecording(signatureData /*,  nothing */)
	sig, keyIdentity, err = verifyAndExtractSignature(mech, signature, recordingRules)
	assert.Error(t, err)
	assert.Nil(t, sig)
	assert.Equal(t, "", keyIdentity)
	assert.Equal(t, tuple{}, *recorded)

	// Valid signature with a revoked subkey
	sig2, err = os.ReadFile("./fixtures/subkey-revoked.signature")
	require.NoError(t, err)
	recorded, recordingRules = setupRecording(sig2Data, TestKeyFingerprintPrimaryWithRevokedSubkey) // sig2Data describes subkey-revoked.signature as well.
	sig, keyIdentity, err = verifyAndExtractSignature(mech, sig2, recordingRules)
	assert.Error(t, err)
	assert.Nil(t, sig)
	assert.Equal(t, "", keyIdentity)
	assert.Equal(t, tuple{}, *recorded)

	// Valid signature of non-JSON: used acceptedKeyIdentities only
	invalidBlobSignature, err := os.ReadFile("./fixtures/invalid-blob.signature")
	require.NoError(t, err)
	recorded, recordingRules = setupRecording(signatureData, TestKeyFingerprint)
	sig, keyIdentity, err = verifyAndExtractSignature(mech, invalidBlobSignature, recordingRules)
	assert.Error(t, err)
	assert.Nil(t, sig)
	assert.Equal(t, "", keyIdentity)
	assert.Equal(t, tuple{}, *recorded)

	// Valid signature with a wrong key: used acceptedKeyIdentities only
	recorded, recordingRules = setupRecording(signatureData, "unexpected fingerprint")
	sig, keyIdentity, err = verifyAndExtractSignature(mech, signature, recordingRules)
	assert.Error(t, err)
	assert.Nil(t, sig)
	assert.Equal(t, "", keyIdentity)
	assert.Equal(t, tuple{}, *recorded)

	// Valid signature with a wrong manifest digest: used acceptedKeyIdentities, asked for signedDockerManifestDigest only
	recorded, recordingRules = setupRecording(tuple{
		signedDockerReference:      signatureData.signedDockerReference,
		signedDockerManifestDigest: "invalid digest",
	}, TestKeyFingerprint)
	sig, keyIdentity, err = verifyAndExtractSignature(mech, signature, recordingRules)
	assert.Error(t, err)
	assert.Nil(t, sig)
	assert.Equal(t, "", keyIdentity)
	assert.Equal(t, tuple{
		signedDockerManifestDigest: signatureData.signedDockerManifestDigest,
	}, *recorded)

	// Valid signature with a wrong image reference
	recorded, recordingRules = setupRecording(tuple{
		signedDockerReference:      "unexpected docker reference",
		signedDockerManifestDigest: signatureData.signedDockerManifestDigest,
	}, TestKeyFingerprint)
	sig, keyIdentity, err = verifyAndExtractSignature(mech, signature, recordingRules)
	assert.Error(t, err)
	assert.Nil(t, sig)
	assert.Equal(t, "", keyIdentity)
	assert.Equal(t, signatureData, *recorded)
}

func TestGetUntrustedSignatureInformationWithoutVerifying(t *testing.T) {
	signature, err := os.ReadFile("./fixtures/image.signature")
	require.NoError(t, err)
	// Successful parsing, all optional fields present
	info, err := GetUntrustedSignatureInformationWithoutVerifying(signature)
	require.NoError(t, err)
	assert.Equal(t, TestImageSignatureReference, info.UntrustedDockerReference)
	assert.Equal(t, TestImageManifestDigest, info.UntrustedDockerManifestDigest)
	assert.NotNil(t, info.UntrustedCreatorID)
	assert.Equal(t, "atomic ", *info.UntrustedCreatorID)
	assert.NotNil(t, info.UntrustedTimestamp)
	assert.Equal(t, time.Unix(1458239713, 0), *info.UntrustedTimestamp)
	assert.Equal(t, TestKeyShortID, info.UntrustedShortKeyIdentifier)
	// Successful parsing, no optional fields present
	signature, err = os.ReadFile("./fixtures/no-optional-fields.signature")
	require.NoError(t, err)
	// Successful parsing
	info, err = GetUntrustedSignatureInformationWithoutVerifying(signature)
	require.NoError(t, err)
	assert.Equal(t, TestImageSignatureReference, info.UntrustedDockerReference)
	assert.Equal(t, TestImageManifestDigest, info.UntrustedDockerManifestDigest)
	assert.Nil(t, info.UntrustedCreatorID)
	assert.Nil(t, info.UntrustedTimestamp)
	assert.Equal(t, TestKeyShortID, info.UntrustedShortKeyIdentifier)

	// Completely invalid signature.
	_, err = GetUntrustedSignatureInformationWithoutVerifying([]byte{})
	assert.Error(t, err)

	_, err = GetUntrustedSignatureInformationWithoutVerifying([]byte("invalid signature"))
	assert.Error(t, err)

	// Valid signature of non-JSON
	invalidBlobSignature, err := os.ReadFile("./fixtures/invalid-blob.signature")
	require.NoError(t, err)
	_, err = GetUntrustedSignatureInformationWithoutVerifying(invalidBlobSignature)
	assert.Error(t, err)
}
