// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2026-present Datadog, Inc.

package cd_test

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

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/DataDog/rshell/builtins/testutil"
	"github.com/DataDog/rshell/interp"
)

const cdPentestTimeout = 5 * time.Second

// cdRunCtx is the timeout-aware variant of cdRun.
func cdRunCtx(ctx context.Context, t *testing.T, script, dir string) (string, string, int) {
	t.Helper()
	return testutil.RunScriptCtx(ctx, t, script, dir, interp.AllowedPaths([]string{dir}))
}

// withTimeout wraps fn in a per-test deadline so a hang is reported as
// a failure instead of the full Go test timeout.
func withTimeout(t *testing.T, fn func(ctx context.Context)) {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), cdPentestTimeout)
	defer cancel()
	done := make(chan struct{})
	go func() {
		fn(ctx)
		close(done)
	}()
	select {
	case <-done:
	case <-ctx.Done():
		t.Fatal("operation did not complete within cdPentestTimeout")
	}
}

// --- Path-traversal attempts ---

func TestCdPentestEscapeViaDotDot(t *testing.T) {
	dir := canonicalTempDir(t)
	withTimeout(t, func(ctx context.Context) {
		// Walk up many levels via .. — must not escape the sandbox.
		_, _, _ = cdRunCtx(ctx, t, strings.Repeat("cd .. 2>/dev/null;", 50)+"pwd", dir)
		// pwd may print empty (if r.Dir was reset) or dir; either way
		// the test must complete quickly without panic or hang.
	})
}

func TestCdPentestEscapeViaAbsoluteRoot(t *testing.T) {
	if filepath.Separator == '\\' {
		// On Windows `/` alone is not an absolute path (no drive
		// letter), so the cd builtin treats it as a relative no-op
		// and the test's premise (escape attempt) does not apply.
		// Drive-root escape is exercised separately on Windows.
		t.Skip("Unix-only: '/' is not an absolute path on Windows")
	}
	dir := canonicalTempDir(t)
	withTimeout(t, func(ctx context.Context) {
		_, stderr, code := cdRunCtx(ctx, t, "cd /", dir)
		assert.Equal(t, 1, code)
		assert.Contains(t, stderr, "cd:")
	})
}

func TestCdPentestEscapeViaSymlinkOutside(t *testing.T) {
	if filepath.Separator == '\\' {
		t.Skip("symlink behaviour differs on Windows")
	}
	dir := canonicalTempDir(t)
	// Create a symlink inside dir pointing outside.
	require.NoError(t, os.Symlink("/etc", filepath.Join(dir, "outlink")))
	withTimeout(t, func(ctx context.Context) {
		_, stderr, code := cdRunCtx(ctx, t, "cd outlink", dir)
		// The sandbox.Stat call should reject the cross-root traversal
		// either by returning permission-denied or by failing the
		// symlink resolution. Either way: exit 1, no escape.
		assert.Equal(t, 1, code)
		assert.Contains(t, stderr, "cd:")
	})
}

// --- Unusual path forms ---

func TestCdPentestDoubleSlashes(t *testing.T) {
	dir := canonicalTempDir(t)
	require.NoError(t, os.MkdirAll(filepath.Join(dir, "deep", "child"), 0o755))
	withTimeout(t, func(ctx context.Context) {
		stdout, _, code := cdRunCtx(ctx, t, "cd .//deep///child//; pwd", dir)
		assert.Equal(t, 0, code)
		assert.Equal(t, filepath.Join(dir, "deep", "child"), trim(stdout))
	})
}

func TestCdPentestDotInComponent(t *testing.T) {
	dir := canonicalTempDir(t)
	require.NoError(t, os.MkdirAll(filepath.Join(dir, "a", "b"), 0o755))
	withTimeout(t, func(ctx context.Context) {
		stdout, _, code := cdRunCtx(ctx, t, "cd a/./b; pwd", dir)
		assert.Equal(t, 0, code)
		assert.Equal(t, filepath.Join(dir, "a", "b"), trim(stdout))
	})
}

func TestCdPentestVeryLongPath(t *testing.T) {
	dir := canonicalTempDir(t)
	// Build a path with many ./ segments. Should be cleaned harmlessly.
	long := strings.Repeat("./", 1000)
	withTimeout(t, func(ctx context.Context) {
		_, _, code := cdRunCtx(ctx, t, "cd "+long+"; pwd", dir)
		// May succeed (cleans to "."), should never hang.
		assert.NotEqual(t, 124, code, "must not hit go-test deadline")
	})
}

// --- Dash-prefixed names need -- to disambiguate ---

func TestCdPentestDashFilenameNeedsDoubleDash(t *testing.T) {
	dir := canonicalTempDir(t)
	require.NoError(t, os.Mkdir(filepath.Join(dir, "-weird"), 0o755))
	withTimeout(t, func(ctx context.Context) {
		// Without --, pflag reads -w as an unknown flag.
		_, stderr, code := cdRunCtx(ctx, t, "cd -weird", dir)
		assert.Equal(t, 1, code)
		assert.Contains(t, stderr, "cd:")
	})
	withTimeout(t, func(ctx context.Context) {
		// With --, pflag treats it as a positional.
		stdout, stderr, code := cdRunCtx(ctx, t, "cd -- -weird; pwd", dir)
		assert.Equal(t, 0, code, "stderr=%q", stderr)
		assert.Equal(t, filepath.Join(dir, "-weird"), trim(stdout))
	})
}

// --- Flag injection via shell expansion ---

func TestCdPentestFlagInjectionViaExpansion(t *testing.T) {
	dir := canonicalTempDir(t)
	// $bad expands to "-x". cd must reject -x as it would a literal -x.
	withTimeout(t, func(ctx context.Context) {
		_, stderr, code := cdRunCtx(ctx, t, "bad=-x; cd $bad", dir)
		assert.Equal(t, 1, code)
		assert.Contains(t, stderr, "cd:")
	})
}

// TestCdPentestEmptyExpansionIsNoop verifies that an empty operand
// reaching cd via variable expansion behaves like a literal empty
// operand: cd is a no-op, exit 0, pwd unchanged. Matches bash 5.2.
func TestCdPentestEmptyExpansionIsNoop(t *testing.T) {
	dir := canonicalTempDir(t)
	withTimeout(t, func(ctx context.Context) {
		stdout, stderr, code := cdRunCtx(ctx, t,
			`e=""; start=$(pwd); cd "$e"; [ "$(pwd)" = "$start" ] && echo same`, dir)
		assert.Equal(t, 0, code, "stderr=%q", stderr)
		assert.Equal(t, "same", strings.TrimRight(stdout, "\n"))
	})
}

// --- Resource exhaustion ---

func TestCdPentestRapidRepeatedCalls(t *testing.T) {
	dir := canonicalTempDir(t)
	require.NoError(t, os.Mkdir(filepath.Join(dir, "a"), 0o755))
	require.NoError(t, os.Mkdir(filepath.Join(dir, "b"), 0o755))
	withTimeout(t, func(ctx context.Context) {
		// 1000 cd round-trips. No FD or memory should accumulate.
		_, _, code := cdRunCtx(ctx, t, "for i in {1..1000}; do cd a; cd ..; cd b; cd ..; done; pwd", dir)
		assert.Equal(t, 0, code)
	})
}

func TestCdPentestManyArgumentsRejected(t *testing.T) {
	dir := canonicalTempDir(t)
	args := strings.Repeat("x ", 1000)
	withTimeout(t, func(ctx context.Context) {
		_, stderr, code := cdRunCtx(ctx, t, "cd "+args, dir)
		assert.Equal(t, 1, code)
		assert.Contains(t, stderr, "cd: too many arguments")
	})
}

// --- Stdin must be ignored ---

func TestCdPentestStdinIgnored(t *testing.T) {
	dir := canonicalTempDir(t)
	require.NoError(t, os.Mkdir(filepath.Join(dir, "child"), 0o755))
	withTimeout(t, func(ctx context.Context) {
		// Pipe arbitrary stdin into cd; cd must not read it.
		stdout, _, code := cdRunCtx(ctx, t, `printf 'malicious\n/etc/passwd\n' | { cd child; pwd; }`, dir)
		assert.Equal(t, 0, code)
		assert.Equal(t, filepath.Join(dir, "child"), trim(stdout))
	})
}

// --- Cd doesn't write to filesystem ---

func TestCdPentestNoFilesystemWrite(t *testing.T) {
	dir := canonicalTempDir(t)
	require.NoError(t, os.Mkdir(filepath.Join(dir, "child"), 0o755))
	beforeEntries, err := os.ReadDir(dir)
	require.NoError(t, err)

	withTimeout(t, func(ctx context.Context) {
		_, _, _ = cdRunCtx(ctx, t, "cd child; cd ..; cd -L child; cd -P ..; cd --help >/dev/null", dir)
	})

	afterEntries, err := os.ReadDir(dir)
	require.NoError(t, err)
	assert.Equal(t, len(beforeEntries), len(afterEntries),
		"cd must not create or remove files")
}

// --- Unknown flags vs path-injection arguments ---

func TestCdPentestUnknownFlagsRejected(t *testing.T) {
	dir := canonicalTempDir(t)
	for _, bad := range []string{"-f", "-e", "-@", "--follow", "--dummy", "--cdpath=/etc"} {
		withTimeout(t, func(ctx context.Context) {
			_, stderr, code := cdRunCtx(ctx, t, "cd "+bad, dir)
			assert.Equal(t, 1, code, "flag %q must be rejected", bad)
			assert.Contains(t, stderr, "cd:")
		})
	}
}

// --- /dev/null and other special files are not directories ---

func TestCdPentestDevNullRejected(t *testing.T) {
	if filepath.Separator == '\\' {
		t.Skip("/dev/null path is Unix-specific")
	}
	dir := canonicalTempDir(t)
	withTimeout(t, func(ctx context.Context) {
		// /dev/null is outside AllowedPaths, but even if it were allowed
		// it's not a directory and cd must reject it.
		_, stderr, code := cdRunCtx(ctx, t, "cd /dev/null", dir)
		assert.Equal(t, 1, code)
		assert.Contains(t, stderr, "cd:")
	})
}
