package docker

import (
	"bufio"
	"bytes"
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/containers/image/v5/internal/useragent"
	"github.com/containers/image/v5/types"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestDockerCertDir(t *testing.T) {
	const nondefaultFullPath = "/this/is/not/the/default/full/path"
	const nondefaultPerHostDir = "/this/is/not/the/default/certs.d"
	const variableReference = "$HOME"
	const rootPrefix = "/root/prefix"
	const registryHostPort = "thishostdefinitelydoesnotexist:5000"

	systemPerHostResult := filepath.Join(perHostCertDirs[len(perHostCertDirs)-1].path, registryHostPort)
	for _, c := range []struct {
		sys      *types.SystemContext
		expected string
	}{
		// The common case
		{nil, systemPerHostResult},
		// There is a context, but it does not override the path.
		{&types.SystemContext{}, systemPerHostResult},
		// Full path overridden
		{&types.SystemContext{DockerCertPath: nondefaultFullPath}, nondefaultFullPath},
		// Per-host path overridden
		{
			&types.SystemContext{DockerPerHostCertDirPath: nondefaultPerHostDir},
			filepath.Join(nondefaultPerHostDir, registryHostPort),
		},
		// Both overridden
		{
			&types.SystemContext{
				DockerCertPath:           nondefaultFullPath,
				DockerPerHostCertDirPath: nondefaultPerHostDir,
			},
			nondefaultFullPath,
		},
		// Root overridden
		{
			&types.SystemContext{RootForImplicitAbsolutePaths: rootPrefix},
			filepath.Join(rootPrefix, systemPerHostResult),
		},
		// Root and path overrides present simultaneously,
		{
			&types.SystemContext{
				DockerCertPath:               nondefaultFullPath,
				RootForImplicitAbsolutePaths: rootPrefix,
			},
			nondefaultFullPath,
		},
		{
			&types.SystemContext{
				DockerPerHostCertDirPath:     nondefaultPerHostDir,
				RootForImplicitAbsolutePaths: rootPrefix,
			},
			filepath.Join(nondefaultPerHostDir, registryHostPort),
		},
		// … and everything at once
		{
			&types.SystemContext{
				DockerCertPath:               nondefaultFullPath,
				DockerPerHostCertDirPath:     nondefaultPerHostDir,
				RootForImplicitAbsolutePaths: rootPrefix,
			},
			nondefaultFullPath,
		},
		// No environment expansion happens in the overridden paths
		{&types.SystemContext{DockerCertPath: variableReference}, variableReference},
		{
			&types.SystemContext{DockerPerHostCertDirPath: variableReference},
			filepath.Join(variableReference, registryHostPort),
		},
	} {
		path, err := dockerCertDir(c.sys, registryHostPort)
		require.Equal(t, nil, err)
		assert.Equal(t, c.expected, path)
	}
}

// testTokenHTTPResponse creates just enough of a *http.Response to work with newBearerTokenFromHTTPResponseBody.
func testTokenHTTPResponse(t *testing.T, body string) *http.Response {
	requestURL, err := url.Parse("https://example.com/token")
	require.NoError(t, err)
	return &http.Response{
		Body: io.NopCloser(bytes.NewReader([]byte(body))),
		Request: &http.Request{
			Method: "",
			URL:    requestURL,
		},
	}
}

func TestNewBearerTokenFromHTTPResponseBody(t *testing.T) {
	for _, c := range []struct {
		input    string
		expected *bearerToken // or nil if a failure is expected
	}{
		{ // Invalid JSON
			input:    "IAmNotJson",
			expected: nil,
		},
		{ // "token"
			input:    `{"token":"IAmAToken","expires_in":100,"issued_at":"2018-01-01T10:00:02+00:00"}`,
			expected: &bearerToken{token: "IAmAToken", expirationTime: time.Unix(1514800802+100, 0)},
		},
		{ // "access_token"
			input:    `{"access_token":"IAmAToken","expires_in":100,"issued_at":"2018-01-01T10:00:02+00:00"}`,
			expected: &bearerToken{token: "IAmAToken", expirationTime: time.Unix(1514800802+100, 0)},
		},
		{ // Small expiry
			input:    `{"token":"IAmAToken","expires_in":1,"issued_at":"2018-01-01T10:00:02+00:00"}`,
			expected: &bearerToken{token: "IAmAToken", expirationTime: time.Unix(1514800802+60, 0)},
		},
	} {
		token, err := newBearerTokenFromHTTPResponseBody(testTokenHTTPResponse(t, c.input))
		if c.expected == nil {
			assert.Error(t, err, c.input)
		} else {
			require.NoError(t, err, c.input)
			assert.Equal(t, c.expected.token, token.token, c.input)
			assert.True(t, c.expected.expirationTime.Equal(token.expirationTime),
				"expected [%s] to equal [%s], it did not", token.expirationTime, c.expected.expirationTime)
		}
	}
}

func TestNewBearerTokenFromHTTPResponseBodyIssuedAtZero(t *testing.T) {
	zeroTime := time.Time{}.Format(time.RFC3339)
	now := time.Now()
	tokenBlob := fmt.Sprintf(`{"token":"IAmAToken","expires_in":100,"issued_at":"%s"}`, zeroTime)
	token, err := newBearerTokenFromHTTPResponseBody(testTokenHTTPResponse(t, tokenBlob))
	require.NoError(t, err)
	expectedExpiration := now.Add(time.Duration(100) * time.Second)
	require.False(t, token.expirationTime.Before(expectedExpiration),
		"expected [%s] not to be before [%s]", token.expirationTime, expectedExpiration)
}

func TestUserAgent(t *testing.T) {
	const sentinelUA = "sentinel/1.0"

	var expectedUA string
	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		got := r.Header.Get("User-Agent")
		assert.Equal(t, expectedUA, got)
		w.WriteHeader(http.StatusOK)
	}))
	defer s.Close()

	for _, tc := range []struct {
		sys      *types.SystemContext
		expected string
	}{
		// Can't both test nil and set DockerInsecureSkipTLSVerify :(
		// {nil, defaultUA},
		{&types.SystemContext{}, useragent.DefaultUserAgent},
		{&types.SystemContext{DockerRegistryUserAgent: sentinelUA}, sentinelUA},
	} {
		// For this test against localhost, we don't care.
		tc.sys.DockerInsecureSkipTLSVerify = types.OptionalBoolTrue

		registry := strings.TrimPrefix(s.URL, "http://")

		expectedUA = tc.expected
		if err := CheckAuth(context.Background(), tc.sys, "", "", registry); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
	}
}

var registrySuseComResp = http.Response{
	Status:     "401 Unauthorized",
	StatusCode: http.StatusUnauthorized,
	Proto:      "HTTP/1.1",
	ProtoMajor: 1,
	ProtoMinor: 1,
	Header: map[string][]string{
		"Content-Length":                  {"145"},
		"Content-Type":                    {"application/json"},
		"Date":                            {"Fri, 26 Aug 2022 08:03:13 GMT"},
		"Docker-Distribution-Api-Version": {"registry/2.0"},
		// "Www-Authenticate":                {`Bearer realm="https://registry.suse.com/auth",service="SUSE Linux Docker Registry",scope="registry:catalog:*",error="insufficient_scope"`},
		"X-Content-Type-Options": {"nosniff"},
	},
	Request: nil,
}

func TestNeedsRetryOnInsuficientScope(t *testing.T) {
	resp := registrySuseComResp
	resp.Header["Www-Authenticate"] = []string{
		`Bearer realm="https://registry.suse.com/auth",service="SUSE Linux Docker Registry",scope="registry:catalog:*",error="insufficient_scope"`,
	}
	expectedScope := authScope{
		resourceType: "registry",
		remoteName:   "catalog",
		actions:      "*",
	}

	needsRetry, scope := needsRetryWithUpdatedScope(&resp)

	if !needsRetry {
		t.Fatal("Expected needing to retry")
	}

	if expectedScope != *scope {
		t.Fatalf("Got an invalid scope, expected '%q' but got '%q'", expectedScope, *scope)
	}
}

func TestNeedsRetryNoRetryWhenNoAuthHeader(t *testing.T) {
	resp := registrySuseComResp
	delete(resp.Header, "Www-Authenticate")

	needsRetry, _ := needsRetryWithUpdatedScope(&resp)

	if needsRetry {
		t.Fatal("Expected no need to retry, as no Authentication headers are present")
	}
}

func TestNeedsRetryNoRetryWhenNoBearerAuthHeader(t *testing.T) {
	resp := registrySuseComResp
	resp.Header["Www-Authenticate"] = []string{
		`OAuth2 realm="https://registry.suse.com/auth",service="SUSE Linux Docker Registry",scope="registry:catalog:*"`,
	}

	needsRetry, _ := needsRetryWithUpdatedScope(&resp)

	if needsRetry {
		t.Fatal("Expected no need to retry, as no bearer authentication header is present")
	}
}

func TestNeedsRetryNoRetryWhenNoErrorInBearer(t *testing.T) {
	resp := registrySuseComResp
	resp.Header["Www-Authenticate"] = []string{
		`Bearer realm="https://registry.suse.com/auth",service="SUSE Linux Docker Registry",scope="registry:catalog:*"`,
	}

	needsRetry, _ := needsRetryWithUpdatedScope(&resp)

	if needsRetry {
		t.Fatal("Expected no need to retry, as no insufficient error is present in the authentication header")
	}
}

func TestNeedsRetryNoRetryWhenInvalidErrorInBearer(t *testing.T) {
	resp := registrySuseComResp
	resp.Header["Www-Authenticate"] = []string{
		`Bearer realm="https://registry.suse.com/auth",service="SUSE Linux Docker Registry",scope="registry:catalog:*,error="random_error"`,
	}

	needsRetry, _ := needsRetryWithUpdatedScope(&resp)

	if needsRetry {
		t.Fatal("Expected no need to retry, as no insufficient_error is present in the authentication header")
	}
}

func TestNeedsRetryNoRetryWhenInvalidScope(t *testing.T) {
	resp := registrySuseComResp
	resp.Header["Www-Authenticate"] = []string{
		`Bearer realm="https://registry.suse.com/auth",service="SUSE Linux Docker Registry",scope="foo:bar",error="insufficient_scope"`,
	}

	needsRetry, _ := needsRetryWithUpdatedScope(&resp)

	if needsRetry {
		t.Fatal("Expected no need to retry, as no insufficient_error is present in the authentication header")
	}
}

func TestNeedsNoRetry(t *testing.T) {
	resp := http.Response{
		Status:     "200 OK",
		StatusCode: http.StatusOK,
		Proto:      "HTTP/1.1",
		ProtoMajor: 1,
		ProtoMinor: 1,
		Header: map[string][]string{"Apptime": {"D=49722"},
			"Content-Length":                  {"1683"},
			"Content-Type":                    {"application/json; charset=utf-8"},
			"Date":                            {"Fri, 26 Aug 2022 09:00:21 GMT"},
			"Docker-Distribution-Api-Version": {"registry/2.0"},
			"Link":                            {`</v2/_catalog?last=f35%2Fs2i-base&n=100>; rel="next"`},
			"Referrer-Policy":                 {"same-origin"},
			"Server":                          {"Apache"},
			"Strict-Transport-Security":       {"max-age=31536000; includeSubDomains; preload"},
			"Vary":                            {"Accept"},
			"X-Content-Type-Options":          {"nosniff"},
			"X-Fedora-Proxyserver":            {"proxy10.iad2.fedoraproject.org"},
			"X-Fedora-Requestid":              {"YwiLpHEhLsbSTugJblBF8QAAAEI"},
			"X-Frame-Options":                 {"SAMEORIGIN"},
			"X-Xss-Protection":                {"1; mode=block"},
		},
	}

	needsRetry, _ := needsRetryWithUpdatedScope(&resp)
	if needsRetry {
		t.Fatal("Got the need to retry, but none should be required")
	}
}

func TestParseRegistryWarningHeader(t *testing.T) {
	for _, c := range []struct{ header, expected string }{
		{"completely invalid", ""},
		{`299 - "trivial"`, "trivial"},
		{`100 - "not-299"`, ""},
		{`299 localhost "warn-agent set"`, ""},
		{`299 - "no-terminating-quote`, ""},
		{"299 - \"\x01 control\"", ""},
		{"299 - \"\\\x01 escaped control\"", ""},
		{"299 - \"e\\scaped\"", "escaped"},
		{"299 - \"non-UTF8 \xA1\xA2\"", "non-UTF8 \xA1\xA2"},
		{"299 - \"non-UTF8 escaped \\\xA1\\\xA2\"", "non-UTF8 escaped \xA1\xA2"},
		{"299 - \"UTF8 žluťoučký\"", "UTF8 žluťoučký"},
		{"299 - \"UTF8 \\\xC5\\\xBEluťoučký\"", "UTF8 žluťoučký"},
		{`299 - "unterminated`, ""},
		{`299 - "warning" "some-date"`, ""},
	} {
		res := parseRegistryWarningHeader(c.header)
		assert.Equal(t, c.expected, res, c.header)
	}
}

func TestGetBlobSize(t *testing.T) {
	for _, c := range []struct {
		headers  []string
		expected int64 // -1 if error expected
	}{
		{[]string{}, -1},
		{[]string{"0"}, 0},
		{[]string{"1"}, 1},
		{[]string{"0777"}, 777},  // Not interpreted as octal
		{[]string{"x"}, -1},      // Not a number: Go's response reader rejects such responses.
		{[]string{"1", "2"}, -1}, // Ambiguous: Go's response reader rejects such responses.
		{[]string{""}, -1},       // Empty header: Go's response reader rejects such responses.
		{[]string{"-1"}, -1},     // Negative: Go's response reader rejects such responses.
	} {
		var buf bytes.Buffer
		buf.WriteString("HTTP/1.1 200 OK\r\n")
		for _, v := range c.headers {
			buf.WriteString("Content-Length: " + v + "\r\n")
		}
		buf.WriteString("\r\n")
		resp, err := http.ReadResponse(bufio.NewReader(&buf), nil)
		if err != nil {
			assert.Equal(t, int64(-1), c.expected)
		} else {
			res, err := getBlobSize(resp)
			if c.expected == -1 {
				assert.Error(t, err, c.headers)
			} else {
				require.NoError(t, err, c.headers)
				assert.Equal(t, c.expected, res)
			}
		}
	}
}

func TestIsManifestUnknownError(t *testing.T) {
	// Mostly a smoke test; we can add more registries here if they need special handling.

	for _, c := range []struct{ name, response string }{
		{
			name: "docker.io when a tag in an _existing repo_ is not found",
			response: "HTTP/1.1 404 Not Found\r\n" +
				"Connection: close\r\n" +
				"Content-Length: 109\r\n" +
				"Content-Type: application/json\r\n" +
				"Date: Thu, 12 Aug 2021 20:51:32 GMT\r\n" +
				"Docker-Distribution-Api-Version: registry/2.0\r\n" +
				"Ratelimit-Limit: 100;w=21600\r\n" +
				"Ratelimit-Remaining: 100;w=21600\r\n" +
				"Strict-Transport-Security: max-age=31536000\r\n" +
				"\r\n" +
				"{\"errors\":[{\"code\":\"MANIFEST_UNKNOWN\",\"message\":\"manifest unknown\",\"detail\":{\"Tag\":\"this-does-not-exist\"}}]}\n",
		},
		{
			name: "registry.redhat.io/v2/this-does-not-exist/manifests/latest",
			response: "HTTP/1.1 404 Not Found\r\n" +
				"Connection: close\r\n" +
				"Content-Length: 53\r\n" +
				"Cache-Control: max-age=0, no-cache, no-store\r\n" +
				"Content-Type: application/json\r\n" +
				"Date: Thu, 13 Oct 2022 18:15:15 GMT\r\n" +
				"Expires: Thu, 13 Oct 2022 18:15:15 GMT\r\n" +
				"Pragma: no-cache\r\n" +
				"Server: Apache\r\n" +
				"Strict-Transport-Security: max-age=63072000; includeSubdomains; preload\r\n" +
				"X-Hostname: crane-tbr06.cran-001.prod.iad2.dc.redhat.com\r\n" +
				"\r\n" +
				"{\"errors\": [{\"code\": \"404\", \"message\": \"Not Found\"}]}\r\n",
		},
		{
			name: "registry.redhat.io/v2/rhosp15-rhel8/openstack-cron/manifests/sha256-8df5e60c42668706ac108b59c559b9187fa2de7e4e262e2967e3e9da35d5a8d7.sig",
			response: "HTTP/1.1 404 Not Found\r\n" +
				"Connection: close\r\n" +
				"Content-Length: 10\r\n" +
				"Accept-Ranges: bytes\r\n" +
				"Date: Thu, 13 Oct 2022 18:13:53 GMT\r\n" +
				"Server: AkamaiNetStorage\r\n" +
				"X-Docker-Size: -1\r\n" +
				"\r\n" +
				"Not found\r\n",
		},
		{
			name: "Harbor v2.10.2",
			response: "HTTP/1.1 404 Not Found\r\n" +
				"Content-Length: 153\r\n" +
				"Connection: keep-alive\r\n" +
				"Content-Type: application/json; charset=utf-8\r\n" +
				"Date: Wed, 08 May 2024 08:14:59 GMT\r\n" +
				"Server: nginx\r\n" +
				"Set-Cookie: sid=f617c257877837614ada2561513d6827; Path=/; HttpOnly\r\n" +
				"X-Request-Id: 1b151fb1-c943-4190-a9ce-5156ed5e3200\r\n" +
				"\r\n" +
				"{\"errors\":[{\"code\":\"NOT_FOUND\",\"message\":\"artifact test/alpine:sha256-443205b0cfcc78444321d56a2fe273f06e27b2c72b5058f8d7e975997d45b015.sig not found\"}]}\n",
		},
	} {
		resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader([]byte(c.response))), nil)
		require.NoError(t, err, c.name)
		defer resp.Body.Close()
		err = fmt.Errorf("wrapped: %w", registryHTTPResponseToError(resp))

		res := isManifestUnknownError(err)
		assert.True(t, res, "%s: %#v", c.name, err)
	}
}
