package exec

import (
	"context"
	"encoding/json"
	"errors"
	"os"
	"testing"
	"time"

	spec "github.com/opencontainers/runtime-spec/specs-go"
	"github.com/stretchr/testify/assert"
)

func TestRuntimeConfigFilter(t *testing.T) {
	var discardedParsingDestination map[string]any
	unexpectedEndOfJSONInput := json.Unmarshal([]byte("{\n"), &discardedParsingDestination) // this should force the error
	fileMode := os.FileMode(0o600)
	rootUint32 := uint32(0)
	binUser := int(1)
	for _, tt := range []struct {
		name                   string
		contextTimeout         time.Duration
		hooks                  []spec.Hook
		input                  *spec.Spec
		expected               *spec.Spec
		expectedHookError      string
		expectedRunError       error
		expectedRunErrorString string
	}{
		{
			name: "no-op",
			hooks: []spec.Hook{
				{
					Path: path,
					Args: []string{"sh", "-c", "cat"},
				},
			},
			input: &spec.Spec{
				Version: "1.0.0",
				Root: &spec.Root{
					Path: "rootfs",
				},
			},
			expected: &spec.Spec{
				Version: "1.0.0",
				Root: &spec.Root{
					Path: "rootfs",
				},
			},
		},
		{
			name: "device injection",
			hooks: []spec.Hook{
				{
					Path: path,
					Args: []string{"sh", "-c", `sed 's|\("gid":0}\)|\1,{"path": "/dev/sda","type":"b","major":8,"minor":0,"fileMode":384,"uid":0,"gid":0}|'`},
				},
			},
			input: &spec.Spec{
				Version: "1.0.0",
				Root: &spec.Root{
					Path: "rootfs",
				},
				Linux: &spec.Linux{
					Devices: []spec.LinuxDevice{
						{
							Path:     "/dev/fuse",
							Type:     "c",
							Major:    10,
							Minor:    229,
							FileMode: &fileMode,
							UID:      &rootUint32,
							GID:      &rootUint32,
						},
					},
				},
			},
			expected: &spec.Spec{
				Version: "1.0.0",
				Root: &spec.Root{
					Path: "rootfs",
				},
				Linux: &spec.Linux{
					Devices: []spec.LinuxDevice{
						{
							Path:     "/dev/fuse",
							Type:     "c",
							Major:    10,
							Minor:    229,
							FileMode: &fileMode,
							UID:      &rootUint32,
							GID:      &rootUint32,
						},
						{
							Path:     "/dev/sda",
							Type:     "b",
							Major:    8,
							Minor:    0,
							FileMode: &fileMode,
							UID:      &rootUint32,
							GID:      &rootUint32,
						},
					},
				},
			},
		},
		{
			name: "chaining",
			hooks: []spec.Hook{
				{
					Path: path,
					Args: []string{"sh", "-c", `sed 's|\("gid":0}\)|\1,{"path": "/dev/sda","type":"b","major":8,"minor":0,"fileMode":384,"uid":0,"gid":0}|'`},
				},
				{
					Path: path,
					Args: []string{"sh", "-c", `sed 's|/dev/sda|/dev/sdb|'`},
				},
			},
			input: &spec.Spec{
				Version: "1.0.0",
				Root: &spec.Root{
					Path: "rootfs",
				},
				Linux: &spec.Linux{
					Devices: []spec.LinuxDevice{
						{
							Path:     "/dev/fuse",
							Type:     "c",
							Major:    10,
							Minor:    229,
							FileMode: &fileMode,
							UID:      &rootUint32,
							GID:      &rootUint32,
						},
					},
				},
			},
			expected: &spec.Spec{
				Version: "1.0.0",
				Root: &spec.Root{
					Path: "rootfs",
				},
				Linux: &spec.Linux{
					Devices: []spec.LinuxDevice{
						{
							Path:     "/dev/fuse",
							Type:     "c",
							Major:    10,
							Minor:    229,
							FileMode: &fileMode,
							UID:      &rootUint32,
							GID:      &rootUint32,
						},
						{
							Path:     "/dev/sdb",
							Type:     "b",
							Major:    8,
							Minor:    0,
							FileMode: &fileMode,
							UID:      &rootUint32,
							GID:      &rootUint32,
						},
					},
				},
			},
		},
		{
			name:           "context timeout",
			contextTimeout: time.Duration(1) * time.Second,
			hooks: []spec.Hook{
				{
					Path: path,
					Args: []string{"sh", "-c", "sleep 2"},
				},
			},
			input: &spec.Spec{
				Version: "1.0.0",
				Root: &spec.Root{
					Path: "rootfs",
				},
			},
			expected: &spec.Spec{
				Version: "1.0.0",
				Root: &spec.Root{
					Path: "rootfs",
				},
			},
			expectedHookError: "^executing \\[sh -c sleep 2]: signal: killed$",
			expectedRunError:  context.DeadlineExceeded,
		},
		{
			name: "hook timeout",
			hooks: []spec.Hook{
				{
					Path:    path,
					Args:    []string{"sh", "-c", "sleep 2"},
					Timeout: &binUser,
				},
			},
			input: &spec.Spec{
				Version: "1.0.0",
				Root: &spec.Root{
					Path: "rootfs",
				},
			},
			expected: &spec.Spec{
				Version: "1.0.0",
				Root: &spec.Root{
					Path: "rootfs",
				},
			},
			expectedHookError: "^executing \\[sh -c sleep 2]: signal: killed$",
			expectedRunError:  context.DeadlineExceeded,
		},
		{
			name: "invalid JSON",
			hooks: []spec.Hook{
				{
					Path: path,
					Args: []string{"sh", "-c", "echo '{'"},
				},
			},
			input: &spec.Spec{
				Version: "1.0.0",
				Root: &spec.Root{
					Path: "rootfs",
				},
			},
			expected: &spec.Spec{
				Version: "1.0.0",
				Root: &spec.Root{
					Path: "rootfs",
				},
			},
			expectedRunErrorString: unexpectedEndOfJSONInput.Error(),
		},
	} {
		test := tt
		t.Run(test.name, func(t *testing.T) {
			ctx := context.Background()
			if test.contextTimeout > 0 {
				var cancel context.CancelFunc
				ctx, cancel = context.WithTimeout(ctx, test.contextTimeout)
				defer cancel()
			}
			hookErr, err := RuntimeConfigFilterWithOptions(ctx, RuntimeConfigFilterOptions{Hooks: test.hooks, Config: test.input, PostKillTimeout: DefaultPostKillTimeout})
			if test.expectedRunErrorString != "" {
				// We have to compare the error strings in that case because
				// errors.Is works differently.
				assert.Contains(t, err.Error(), test.expectedRunErrorString)
			} else {
				assert.True(t, errors.Is(err, test.expectedRunError))
			}
			if test.expectedHookError == "" {
				if hookErr != nil {
					t.Fatal(hookErr)
				}
			} else {
				assert.Regexp(t, test.expectedHookError, hookErr.Error())
			}
			assert.Equal(t, test.expected, test.input)
		})
	}
}
