// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

//go:build integration_api

package api

import (
	"bufio"
	"bytes"
	"context"
	"io"
	"time"

	"github.com/cosi-project/runtime/pkg/resource"
	"github.com/cosi-project/runtime/pkg/resource/rtestutils"
	"github.com/cosi-project/runtime/pkg/safe"
	"github.com/siderolabs/gen/xslices"
	"github.com/siderolabs/go-pointer"
	"github.com/stretchr/testify/assert"

	"github.com/siderolabs/talos/internal/integration/base"
	"github.com/siderolabs/talos/pkg/machinery/client"
	"github.com/siderolabs/talos/pkg/machinery/config/machine"
	blockcfg "github.com/siderolabs/talos/pkg/machinery/config/types/block"
	"github.com/siderolabs/talos/pkg/machinery/constants"
	"github.com/siderolabs/talos/pkg/machinery/resources/block"
	runtimeres "github.com/siderolabs/talos/pkg/machinery/resources/runtime"
)

// TrustedBootSuite verifies Talos is securebooted.
type TrustedBootSuite struct {
	base.K8sSuite

	ctx       context.Context //nolint:containedctx
	ctxCancel context.CancelFunc
}

// SuiteName ...
func (suite *TrustedBootSuite) SuiteName() string {
	return "api.TrustedBootSuite"
}

// SetupTest ...
func (suite *TrustedBootSuite) SetupTest() {
	if !suite.TrustedBoot {
		suite.T().Skip("skipping since talos.trustedboot is false")
	}

	// make sure API calls have timeout
	suite.ctx, suite.ctxCancel = context.WithTimeout(context.Background(), 10*time.Minute)
}

// TearDownTest ...
func (suite *TrustedBootSuite) TearDownTest() {
	if suite.ctxCancel != nil {
		suite.ctxCancel()
	}
}

// TestTrustedBootState verifies that the system is booted in secure boot mode
// and that the disks are encrypted.
func (suite *TrustedBootSuite) TestTrustedBootState() {
	node := suite.RandomDiscoveredNodeInternalIP()
	ctx := client.WithNode(suite.ctx, node)

	rtestutils.AssertResources(ctx, suite.T(), suite.Client.COSI, []resource.ID{runtimeres.SecurityStateID},
		func(r *runtimeres.SecurityState, asrt *assert.Assertions) {
			asrt.True(r.TypedSpec().SecureBoot)
		},
	)

	rtestutils.AssertResources(ctx, suite.T(), suite.Client.COSI,
		[]resource.ID{constants.StatePartitionLabel, constants.EphemeralPartitionLabel},
		func(r *block.VolumeStatus, asrt *assert.Assertions) {
			asrt.Equal(block.VolumePhaseReady, r.TypedSpec().Phase)
			asrt.Equal(block.EncryptionProviderLUKS2, r.TypedSpec().EncryptionProvider)
		},
	)

	dmesgStream, err := suite.Client.Dmesg(
		suite.ctx,
		false,
		false,
	)
	suite.Require().NoError(err)

	logReader, err := client.ReadStream(dmesgStream)
	suite.Require().NoError(err)

	var dmesg bytes.Buffer

	_, err = io.Copy(bufio.NewWriter(&dmesg), logReader)
	suite.Require().NoError(err)

	suite.Require().Contains(dmesg.String(), "Secure boot enabled")
}

// TestEncryptionConfigRotate verifies that the encryption supports locking and unlocking to different PCRs.
func (suite *TrustedBootSuite) TestEncryptionConfigRotate() {
	node := suite.RandomDiscoveredNodeInternalIP(machine.TypeWorker)
	suite.ClearConnectionRefused(suite.ctx, node)

	nodeCtx := client.WithNode(suite.ctx, node)

	provider, err := suite.ReadConfigFromNode(nodeCtx)
	suite.Require().NoError(err)

	ephemeralCfg, _ := provider.Volumes().ByName(constants.EphemeralPartitionLabel)
	encryption := ephemeralCfg.Encryption()

	suite.Require().NotNil(encryption, "encryption config must be set for EPHEMERAL in trustedboot test")

	suite.WaitForBootDone(suite.ctx)

	suite.T().Logf("testing encryption key rotation on node %s", node)

	cfg, ok := encryption.(blockcfg.EncryptionSpec)
	suite.Require().True(ok, "expected blockcfg.EncryptionSpec, got %T", encryption)

	// when we start the test we do not know the current encryption provider in use
	// so let's read the volumestatus to get the information about the slot in use and whether lockToState is set
	volumeStatus, err := safe.StateGetByID[*block.VolumeStatus](nodeCtx, suite.Client.COSI, constants.EphemeralPartitionLabel)
	suite.Require().NoError(err)

	existingEncryptionSlotInUse := *volumeStatus.TypedSpec().EncryptionSlot

	existing := xslices.Filter(cfg.EncryptionKeys, func(key blockcfg.EncryptionKey) bool {
		return key.Slot() == existingEncryptionSlotInUse
	})[0]

	var (
		existingPCRs       []int
		expectedPubKeyPCRs []int
	)

	if existing.TPM() != nil {
		existingPCRs = existing.TPM().PCRs()
		expectedPubKeyPCRs = existing.TPM().PubKeyPCRs()
	}

	nextSlot := existing.Slot() + 1

	for _, test := range []struct {
		keys []blockcfg.EncryptionKey

		expectedPCRs          []int
		expectedPubKeyPCRs    []int
		expectedLockedToState bool
	}{
		// for the initial set, let's add a new TPM based key with no PCR options specified
		// in this case after reboot the new slot will be added for the TPM key and the expected PCRs
		// and lockToState status will be the same as the existing key
		{
			keys: []blockcfg.EncryptionKey{
				existing,
				{
					KeyTPM: &blockcfg.EncryptionKeyTPM{
						// TPMCheckSecurebootStatusOnEnroll: pointer.To(true),
					},
					KeySlot:        nextSlot,
					KeyLockToSTATE: pointer.To(true),
				},
			},

			expectedPCRs:          existingPCRs,
			expectedPubKeyPCRs:    expectedPubKeyPCRs,
			expectedLockedToState: existing.LockToSTATE(),
		},
		// now remove the existing key and add a new TPM based key with no PCRs specified
		// in this case after a reboot we should have default TPM based encryption values
		// i.e. PCR is SecureBootStatePCR and lockToState is true
		{
			keys: []blockcfg.EncryptionKey{
				{
					KeyTPM: &blockcfg.EncryptionKeyTPM{
						// TPMCheckSecurebootStatusOnEnroll: pointer.To(true),
					},
					KeySlot:        nextSlot,
					KeyLockToSTATE: pointer.To(true),
				},
			},

			expectedPCRs:          []int{constants.SecureBootStatePCR},
			expectedPubKeyPCRs:    []int{constants.UKIPCR},
			expectedLockedToState: true,
		},
		// now keep the previous TPM based key with no PCRs specified and add a new key with PCRs set
		// to empty and lockToState set to false, after reboot we should have default TPM based encryption values
		// i.e. PCR is SecureBootStatePCR and lockToState is true
		{
			keys: []blockcfg.EncryptionKey{
				{
					KeyTPM: &blockcfg.EncryptionKeyTPM{
						// TPMCheckSecurebootStatusOnEnroll: pointer.To(true),
					},
					KeySlot:        nextSlot,
					KeyLockToSTATE: pointer.To(true),
				},
				{
					KeyTPM: &blockcfg.EncryptionKeyTPM{
						// TPMCheckSecurebootStatusOnEnroll: pointer.To(true),
						TPMOptions: &blockcfg.EncryptionKeyTPMOptions{
							PCRs: []int{},
						},
					},
					KeySlot:        nextSlot + 1,
					KeyLockToSTATE: pointer.To(false),
				},
			},

			expectedPCRs:          []int{constants.SecureBootStatePCR},
			expectedPubKeyPCRs:    []int{constants.UKIPCR},
			expectedLockedToState: true,
		},
		// now only keep the TPM key with PCRs set to empty and lockToState set to false
		// in this case after a reboot we should have no PCRs and lockToState is false
		{
			keys: []blockcfg.EncryptionKey{
				{
					KeyTPM: &blockcfg.EncryptionKeyTPM{
						// TPMCheckSecurebootStatusOnEnroll: pointer.To(true),
						TPMOptions: &blockcfg.EncryptionKeyTPMOptions{
							PCRs: []int{},
						},
					},
					KeySlot:        nextSlot + 1,
					KeyLockToSTATE: pointer.To(false),
				},
			},

			expectedPCRs:          nil,
			expectedPubKeyPCRs:    []int{constants.UKIPCR},
			expectedLockedToState: false,
		},
		// now keep the previous TPM based key with PCRs set to empty and lockToState set to false
		// and add a new key with PCRs set to [0, SecureBootStatePCR] and lockToState set to true
		// in this case after a reboot we should have no PCRs and lockToState is false
		{
			keys: []blockcfg.EncryptionKey{
				{
					KeyTPM: &blockcfg.EncryptionKeyTPM{
						// TPMCheckSecurebootStatusOnEnroll: pointer.To(true),
						TPMOptions: &blockcfg.EncryptionKeyTPMOptions{
							PCRs: []int{},
						},
					},
					KeySlot:        nextSlot + 1,
					KeyLockToSTATE: pointer.To(false),
				},
				{
					KeyTPM: &blockcfg.EncryptionKeyTPM{
						// TPMCheckSecurebootStatusOnEnroll: pointer.To(true),
						TPMOptions: &blockcfg.EncryptionKeyTPMOptions{
							PCRs: []int{0, constants.SecureBootStatePCR},
						},
					},
					KeySlot:        nextSlot + 2,
					KeyLockToSTATE: pointer.To(true),
				},
			},

			expectedPCRs:          nil,
			expectedPubKeyPCRs:    []int{constants.UKIPCR},
			expectedLockedToState: false,
		},
		// now only keep the TPM key with PCRs set to [0, SecureBootStatePCR] and lockToState set to true
		// in this case after a reboot we should have PCRs set to [0, SecureBootStatePCR] and lockToState is true
		{
			keys: []blockcfg.EncryptionKey{
				{
					KeyTPM: &blockcfg.EncryptionKeyTPM{
						// TPMCheckSecurebootStatusOnEnroll: pointer.To(true),
						TPMOptions: &blockcfg.EncryptionKeyTPMOptions{
							PCRs: []int{0, constants.SecureBootStatePCR},
						},
					},
					KeySlot:        nextSlot + 2,
					KeyLockToSTATE: pointer.To(true),
				},
			},

			expectedPCRs:          []int{0, constants.SecureBootStatePCR},
			expectedPubKeyPCRs:    []int{constants.UKIPCR},
			expectedLockedToState: true,
		},
	} {
		suite.T().Logf("applying encryption keys %s on node %s", toJSONString(suite.T(), test.keys), node)

		// prepare a patch to apply, first removing existing keys
		removeKeysPatch := map[string]any{
			"apiVersion": "v1alpha1",
			"kind":       "VolumeConfig",
			"name":       constants.EphemeralPartitionLabel,
			"encryption": map[string]any{
				"keys": map[string]any{
					"$patch": "delete",
				},
			},
		}

		newEphemeralCfg := blockcfg.NewVolumeConfigV1Alpha1()
		newEphemeralCfg.MetaName = constants.EphemeralPartitionLabel
		newEphemeralCfg.EncryptionSpec.EncryptionKeys = test.keys

		// right now, patching encryption keys doesn't reboot and doesn't rotate the secrets either
		suite.PatchMachineConfig(nodeCtx, removeKeysPatch, newEphemeralCfg)

		suite.AssertRebooted(
			suite.ctx, node,
			func(nodeCtx context.Context) error {
				return base.IgnoreGRPCUnavailable(suite.Client.Reboot(nodeCtx))
			}, assertRebootedRebootTimeout,
			suite.CleanupFailedPods,
		)

		suite.ClearConnectionRefused(suite.ctx, node)

		suite.WaitForBootDone(suite.ctx)

		rtestutils.AssertResource(nodeCtx, suite.T(), suite.Client.COSI, constants.EphemeralPartitionLabel, func(r *block.VolumeStatus, asrt *assert.Assertions) {
			asrt.Equal(test.expectedPCRs, r.TypedSpec().TPMEncryptionOptions.PCRs)
			asrt.Equal(test.expectedPubKeyPCRs, r.TypedSpec().TPMEncryptionOptions.PubKeyPCRs)
			asrt.Equal(test.expectedLockedToState, r.TypedSpec().EncryptionLockedToState)
		})
	}
}

func init() {
	allSuites = append(allSuites, &TrustedBootSuite{})
}
