package client

import (
	"bytes"
	"context"
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/moby/buildkit/client/llb"
	"github.com/moby/buildkit/frontend/gateway/client"
	"github.com/moby/buildkit/session"
	"github.com/moby/buildkit/session/secrets/secretsprovider"
	"github.com/moby/buildkit/session/sshforward/sshprovider"
	"github.com/moby/buildkit/solver/pb"
	"github.com/moby/buildkit/util/iohelper"
	utilsystem "github.com/moby/buildkit/util/system"
	"github.com/moby/buildkit/util/testutil/integration"
	"github.com/pkg/errors"
	"github.com/stretchr/testify/require"
	"github.com/tonistiigi/fsutil"
	"golang.org/x/crypto/ssh/agent"
)

// testClientGatewayContainerMounts is testing mounts derived from various
// llb.States
func testClientGatewayContainerMounts(t *testing.T, sb integration.Sandbox) {
	requiresLinux(t)

	ctx := sb.Context()

	c, err := New(ctx, sb.Address())
	require.NoError(t, err)
	defer c.Close()

	tmpdir := integration.Tmpdir(t)

	err = os.WriteFile(filepath.Join(tmpdir.Name, "local-file"), []byte("local"), 0o644)
	require.NoError(t, err)

	a := agent.NewKeyring()
	sockPath, err := makeSSHAgentSock(t, a)
	require.NoError(t, err)

	ssh, err := sshprovider.NewSSHAgentProvider([]sshprovider.AgentConfig{{
		ID:    t.Name(),
		Paths: []string{sockPath},
	}})
	require.NoError(t, err)

	product := "buildkit_test"

	b := func(ctx context.Context, c client.Client) (*client.Result, error) {
		mounts := map[string]llb.State{
			"/": llb.Image("busybox:latest").Run(
				llb.Shlex("touch /root-file /cached/cache-file"),
				llb.AddMount("/cached", llb.Scratch(), llb.AsPersistentCacheDir(t.Name(), llb.CacheMountShared)),
			).Root(),
			"/foo": llb.Image("busybox:latest").Run(
				llb.Shlex("touch foo-file"),
				llb.Dir("/tmp"),
				llb.AddMount("/tmp", llb.Scratch()),
			).GetMount("/tmp"),
			"/local": llb.Local("mylocal"),
			// TODO How do we get a results.Ref for a cache mount, tmpfs mount
		}

		containerMounts := []client.Mount{
			{
				Dest:      "/",
				MountType: pb.MountType_BIND,
			},
			{
				Dest:      "/foo",
				MountType: pb.MountType_BIND,
			},
			{
				Dest:      "/local",
				MountType: pb.MountType_BIND,
			},
			{
				Dest:      "/cached",
				MountType: pb.MountType_CACHE,
				CacheOpt: &pb.CacheOpt{
					ID:      t.Name(),
					Sharing: pb.CacheSharingOpt_SHARED,
				},
			},
			{
				Dest:      "/tmpfs",
				MountType: pb.MountType_TMPFS,
			},
			{
				Dest:      "/run/secrets/mysecret",
				MountType: pb.MountType_SECRET,
				SecretOpt: &pb.SecretOpt{
					ID: "/run/secrets/mysecret",
				},
			},
			{
				Dest:      sockPath,
				MountType: pb.MountType_SSH,
				SSHOpt: &pb.SSHOpt{
					ID: t.Name(),
				},
			},
		}

		// Fill in mount references.
		for i, m := range containerMounts {
			st := mounts[m.Dest]

			def, err := st.Marshal(ctx)
			if err != nil {
				return nil, errors.Wrap(err, "failed to marshal state")
			}

			r, err := c.Solve(ctx, client.SolveRequest{
				Definition: def.ToPB(),
			})
			if err != nil {
				return nil, errors.Wrap(err, "failed to solve")
			}
			containerMounts[i].Ref = r.Ref
		}

		ctr, err := c.NewContainer(ctx, client.NewContainerRequest{Mounts: containerMounts})
		if err != nil {
			return nil, err
		}

		pid1, err := ctr.Start(ctx, client.StartRequest{
			Args:   []string{"sleep", "10"},
			Stderr: os.Stderr,
		})
		require.NoError(t, err)
		defer pid1.Wait()

		files := []struct {
			index int
			path  string
			data  []byte
		}{
			{0, "/root-file", []byte(nil)},
			{1, "/foo-file", []byte(nil)},
			{2, "/local-file", []byte(`local`)},
			{3, "/cache-file", []byte(nil)},
		}
		for _, file := range files {
			cpath := containerMounts[file.index].Dest + file.path
			pid, err := ctr.Start(ctx, client.StartRequest{
				Args: []string{"test", "-f", cpath},
			})
			require.NoError(t, err, "cannot start container to check for file: %s", cpath)
			err = pid.Wait()
			require.NoError(t, err, "process for checking file failed: %s", cpath)

			_, err = ctr.StatFile(ctx, client.StatContainerRequest{
				StatRequest: client.StatRequest{
					Path: file.path,
				},
				MountIndex: file.index,
			})
			require.NoError(t, err, "stat file for %q on mount %d failed", file.path, file.index)

			b, err := ctr.ReadFile(ctx, client.ReadContainerRequest{
				ReadRequest: client.ReadRequest{
					Filename: file.path,
				},
				MountIndex: file.index,
			})
			require.NoError(t, err, "read file for %q on mount %d failed", file.path, file.index)
			require.Equal(t, file.data, b)
		}

		pid, err := ctr.Start(ctx, client.StartRequest{
			Args: []string{"test", "-f", "/root-file"},
		})
		require.NoError(t, err)
		err = pid.Wait()
		require.NoError(t, err)

		pid, err = ctr.Start(ctx, client.StartRequest{
			Args: []string{"test", "-f", "/foo/foo-file"},
		})
		require.NoError(t, err)
		err = pid.Wait()
		require.NoError(t, err)

		pid, err = ctr.Start(ctx, client.StartRequest{
			Args: []string{"test", "-f", "/local/local-file"},
		})
		require.NoError(t, err)
		err = pid.Wait()
		require.NoError(t, err)

		pid, err = ctr.Start(ctx, client.StartRequest{
			Args: []string{"test", "-f", "/cached/cache-file"},
		})
		require.NoError(t, err)
		err = pid.Wait()
		require.NoError(t, err)

		pid, err = ctr.Start(ctx, client.StartRequest{
			Args: []string{"test", "-w", "/tmpfs"},
		})
		require.NoError(t, err)
		err = pid.Wait()
		require.NoError(t, err)

		secretOutput := bytes.NewBuffer(nil)
		pid, err = ctr.Start(ctx, client.StartRequest{
			Args:   []string{"cat", "/run/secrets/mysecret"},
			Stdout: &iohelper.NopWriteCloser{Writer: secretOutput},
		})
		require.NoError(t, err)
		err = pid.Wait()
		require.NoError(t, err)
		require.Equal(t, "foo-secret", secretOutput.String())

		pid, err = ctr.Start(ctx, client.StartRequest{
			Args: []string{"test", "-S", sockPath},
		})
		require.NoError(t, err)
		err = pid.Wait()
		require.NoError(t, err)

		return &client.Result{}, ctr.Release(ctx)
	}

	_, err = c.Build(ctx, SolveOpt{
		LocalMounts: map[string]fsutil.FS{
			"mylocal": tmpdir,
		},
		Session: []session.Attachable{
			ssh,
			secretsprovider.FromMap(map[string][]byte{
				"/run/secrets/mysecret": []byte("foo-secret"),
			}),
		},
	}, product, b, nil)
	require.Error(t, err)
	require.Contains(t, err.Error(), context.Canceled.Error())

	checkAllReleasable(t, c, sb, true)
}

// testClientGatewayContainerPlatformPATH is testing the correct default PATH
// gets set for the requested platform
func testClientGatewayContainerPlatformPATH(t *testing.T, sb integration.Sandbox) {
	requiresLinux(t)
	ctx := sb.Context()

	c, err := New(ctx, sb.Address())
	require.NoError(t, err)
	defer c.Close()

	product := "buildkit_test"
	b := func(ctx context.Context, c client.Client) (*client.Result, error) {
		st := llb.Image("busybox:latest")
		def, err := st.Marshal(ctx)
		require.NoError(t, err)
		r, err := c.Solve(ctx, client.SolveRequest{
			Definition: def.ToPB(),
		})
		require.NoError(t, err)

		tests := []struct {
			Name     string
			Platform *pb.Platform
			Expected string
		}{{
			"default path",
			nil,
			utilsystem.DefaultPathEnvUnix,
		}, {
			"linux path",
			&pb.Platform{OS: "linux"},
			utilsystem.DefaultPathEnvUnix,
		}, {
			"windows path",
			&pb.Platform{OS: "windows"},
			utilsystem.DefaultPathEnvWindows,
		}}

		for _, tt := range tests {
			t.Run(tt.Name, func(t *testing.T) {
				ctr, err := c.NewContainer(ctx, client.NewContainerRequest{
					Mounts: []client.Mount{{
						Dest:      "/",
						MountType: pb.MountType_BIND,
						Ref:       r.Ref,
					}},
					Platform: tt.Platform,
				})
				require.NoError(t, err)
				output := bytes.NewBuffer(nil)
				pid1, err := ctr.Start(ctx, client.StartRequest{
					Args:   []string{"/bin/sh", "-c", "echo -n $PATH"},
					Stdout: &iohelper.NopWriteCloser{Writer: output},
				})
				require.NoError(t, err)

				err = pid1.Wait()
				require.NoError(t, err)
				require.Equal(t, tt.Expected, output.String())
				err = ctr.Release(ctx)
				require.NoError(t, err)
			})
		}
		return &client.Result{}, err
	}

	_, err = c.Build(ctx, SolveOpt{}, product, b, nil)
	require.NoError(t, err)
	checkAllReleasable(t, c, sb, true)
}

func testClientGatewayContainerSecretEnv(t *testing.T, sb integration.Sandbox) {
	requiresLinux(t)

	ctx := sb.Context()

	c, err := New(ctx, sb.Address())
	require.NoError(t, err)
	defer c.Close()

	product := "buildkit_test"

	b := func(ctx context.Context, c client.Client) (*client.Result, error) {
		mounts := map[string]llb.State{
			"/": llb.Image("busybox:latest"),
		}

		var containerMounts []client.Mount
		for mountpoint, st := range mounts {
			def, err := st.Marshal(ctx)
			if err != nil {
				return nil, errors.Wrap(err, "failed to marshal state")
			}

			r, err := c.Solve(ctx, client.SolveRequest{
				Definition: def.ToPB(),
			})
			if err != nil {
				return nil, errors.Wrap(err, "failed to solve")
			}
			containerMounts = append(containerMounts, client.Mount{
				Dest:      mountpoint,
				MountType: pb.MountType_BIND,
				Ref:       r.Ref,
			})
		}

		ctr, err := c.NewContainer(ctx, client.NewContainerRequest{Mounts: containerMounts})
		if err != nil {
			return nil, err
		}

		pid, err := ctr.Start(ctx, client.StartRequest{
			Args: []string{"sh", "-c", "test $SOME_SECRET = foo-secret"},
			SecretEnv: []*pb.SecretEnv{
				{
					ID:   "sekrit",
					Name: "SOME_SECRET",
				},
			},
		})
		require.NoError(t, err)
		err = pid.Wait()
		require.NoError(t, err)

		return &client.Result{}, ctr.Release(ctx)
	}

	_, err = c.Build(ctx, SolveOpt{
		Session: []session.Attachable{
			secretsprovider.FromMap(map[string][]byte{
				"sekrit": []byte("foo-secret"),
			}),
		},
	}, product, b, nil)
	require.NoError(t, err)

	checkAllReleasable(t, c, sb, true)
}

// testClientGatewayContainerReadFileSpecial checks that ReadFile only reads
// regular files. A container can mknod a device node or mkfifo with the default
// capability set; reading the former daemon-side would resolve its rdev against
// the host device table, and the latter would block the daemon in open(2).
func testClientGatewayContainerReadFileSpecial(t *testing.T, sb integration.Sandbox) {
	requiresLinux(t)

	ctx := sb.Context()
	c, err := New(ctx, sb.Address())
	require.NoError(t, err)
	defer c.Close()

	product := "buildkit_test"

	// a user namespace cannot mknod a device node, so the device case is only
	// reachable, and only testable, on a rootful worker
	withDevice := !sb.Rootless()

	b := func(ctx context.Context, c client.Client) (*client.Result, error) {
		setup := `mkfifo /fifo && printf 0123456789 > /regular`
		if withDevice {
			// 5,0 is /dev/tty, which fails open with ENXIO when the daemon has
			// no controlling terminal. Rejecting it as a non-regular file
			// therefore shows the driver was never entered, which a device
			// like /dev/null could not distinguish.
			setup = `mknod /dev-node c 5 0 && ` + setup
		}
		st := llb.Image("busybox:latest").Run(
			llb.Shlexf(`sh -c %q`, setup),
		).Root()

		def, err := st.Marshal(ctx)
		if err != nil {
			return nil, err
		}
		r, err := c.Solve(ctx, client.SolveRequest{Definition: def.ToPB()})
		if err != nil {
			return nil, err
		}

		ctr, err := c.NewContainer(ctx, client.NewContainerRequest{
			Mounts: []client.Mount{{
				Dest:      "/",
				MountType: pb.MountType_BIND,
				Ref:       r.Ref,
			}},
		})
		if err != nil {
			return nil, err
		}
		defer ctr.Release(context.WithoutCancel(ctx))

		read := func(name string, rng *client.FileRange) ([]byte, error) {
			return ctr.ReadFile(ctx, client.ReadContainerRequest{
				ReadRequest: client.ReadRequest{Filename: name, Range: rng},
				MountIndex:  0,
			})
		}

		if withDevice {
			_, err = read("/dev-node", nil)
			require.ErrorContains(t, err, "not a regular file")
		}

		// Bound this one. Against vulnerable code the daemon blocks in open(2)
		// on a fifo with no writer and never answers, so an unbounded call would
		// hang the suite instead of failing it.
		fifoCtx, cancel := context.WithTimeoutCause(ctx, 30*time.Second,
			errors.New("fifo read did not return"))
		defer cancel()
		_, err = ctr.ReadFile(fifoCtx, client.ReadContainerRequest{
			ReadRequest: client.ReadRequest{Filename: "/fifo"},
			MountIndex:  0,
		})
		require.ErrorContains(t, err, "not a regular file")

		dt, err := read("/regular", nil)
		require.NoError(t, err)
		require.Equal(t, []byte("0123456789"), dt)

		dt, err = read("/regular", &client.FileRange{Offset: 2, Length: 3})
		require.NoError(t, err)
		require.Equal(t, []byte("234"), dt)

		// a negative range must not reach ReadAt, whose error names the
		// daemon-side path
		for _, rng := range []client.FileRange{{Offset: -1, Length: 3}, {Offset: 0, Length: -1}} {
			_, err = read("/regular", &rng)
			require.ErrorContains(t, err, "invalid range")
		}

		_, err = read("/missing", nil)
		require.ErrorContains(t, err, "/missing")
		require.NotContains(t, err.Error(), "/tmp/", "daemon-side path leaked")

		return client.NewResult(), nil
	}

	_, err = c.Build(ctx, SolveOpt{}, product, b, nil)
	require.NoError(t, err)
}
