// SPDX-License-Identifier: Apache-2.0
/*
 * umoci: Umoci Modifies Open Containers' Images
 * Copyright (C) 2016-2025 SUSE LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package layer

import (
	"bytes"
	"context"
	"encoding/base64"
	"os"
	"path/filepath"
	"testing"

	"github.com/opencontainers/go-digest"
	"github.com/opencontainers/image-spec/specs-go"
	ispec "github.com/opencontainers/image-spec/specs-go/v1"
	rspec "github.com/opencontainers/runtime-spec/specs-go"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/opencontainers/umoci/internal"
	"github.com/opencontainers/umoci/oci/cas/dir"
	"github.com/opencontainers/umoci/oci/casext"
	"github.com/opencontainers/umoci/oci/casext/blobcompress"
)

func mustDecodeString(s string) []byte {
	b, err := base64.StdEncoding.DecodeString(s)
	if err != nil {
		panic(err)
	}
	return b
}

func makeImage(t *testing.T) (string, ispec.Manifest, casext.Engine) { //nolint:unparam
	ctx := context.Background()
	// These layers were manually generated using GNU tar + GNU gzip.
	// XXX: In future we should also add libarchive tar archives.
	layers := []struct {
		base64 string
		digest digest.Digest
	}{
		{
			base64: `
H4sIAAsoz1kAA+3XvW7CMBAH8Mx9Cj+Bcz7bZxjYO3brWEXBCCS+lBiJvn2dVColAUpUEop6v8UR
jrGj6P7RyfQl2z/7bOqLUloNJpXJrUFExtRj1BxBaUyUVqQJtSWVgAJnVSL2Nz/JCbsyZEU8yhB7
/UEaxCosVn6iLJAzI3RaIhEhGjJPcTZrzhLUs85Vs/n5tfd+MnYNmfa/R1XjztpqVM5+1r065EGd
Bcf1rxxSImz/RzvUf/6+nceLC/fFhLzwP81wexCylf/Bl+Fttlj6m+3RKf+1ju8freP8H0Qr/62S
qA2hInCt/NcAcgRYvyfbzv+jtfd+MnYN2UO9N32r/5P5r9A16j9exfwfpCb/ef6/zjci36xDsVmW
Isy92GZlOP5ltgu7wkvRvrXwpV+H9nrJxf8oznz/p4sLpdBV9/4Pjebv/yC69X8/fP9HJMdYrR2P
LUfAQ5Bf9d5fI1j3f+A69H/aGuD+jzHGGGOMMcYYY4wxxn7jA5XNY6oAKAAA`,
			digest: digest.NewDigestFromHex(digest.SHA256.String(), "e489a16a8ca0d682394867ad8a8183f0a47cbad80b3134a83412a6796ad9242a"),
		},
		{
			base64: `
H4sIAJ4oz1kAA+3Wu27CMBQG4Mw8xSldK8d3p0OHbu3WN6hCYhELCMh2Bbx9HTogwkWtBLSo51uM
dBLsSPl/heRv5erFlrX1gWimHnOSnRtNtJSbNemvlAmeMcG00FxyajLKqNE0g9XZT3LAR4ilT0e5
xl5/kKAwi25mn5ii2shCKUaKgmshBOWDNC13p5xIvply0U2r4/f+9pOh7yD55ffoMm6U6lZm1Ffu
2bYPNl2wm39muMxAXf5o2/xX60WTfpy4LjXkif/pl9uNIHv9H22I76HybhFJaM6xx8/7XyhjsP+v
4WD/m+JY/2tBKOumRqj9/teUCCXTVAvs/9tALpD3vhRxear/mZC9/Kd3KH3/XSWT/7z/7+/ykWvz
0AwGtmrmMHyNsCwDlDDybtxEqObTGupyDa6F54V30wco2xpiY6GazqtJgKX1FkL0buLacRo4H61t
yRAbACGEEEIIIYQQQgghhBBCCKEr+wTE0sQyACgAAA==`,
			digest: digest.NewDigestFromHex(digest.SHA256.String(), "39f100ed000b187ba74b3132cc207c63ad1765adaeb783aa7f242f1f7b6f5ea2"),
		},
	}

	root := t.TempDir()

	// Create our image.
	image := filepath.Join(root, "image")
	if err := dir.Create(image); err != nil {
		t.Fatal(err)
	}
	engine, err := dir.Open(image)
	require.NoError(t, err)
	engineExt := casext.NewEngine(engine)

	// Set up the CAS and an image from the above layers.
	layerDigests := make([]digest.Digest, 0, len(layers))
	layerDescriptors := make([]ispec.Descriptor, 0, len(layers))
	for _, layer := range layers {
		// Since we already have the digests we don't need to jump through the
		// hoops of decompressing our already-compressed blobs above to get the
		// DiffIDs.
		layerReader := bytes.NewBuffer(mustDecodeString(layer.base64))
		layerDigest, layerSize, err := engineExt.PutBlob(ctx, layerReader)
		require.NoError(t, err)

		layerDigests = append(layerDigests, layer.digest)
		layerDescriptors = append(layerDescriptors, ispec.Descriptor{
			MediaType: ispec.MediaTypeImageLayerGzip,
			Digest:    layerDigest,
			Size:      layerSize,
		})
	}

	// Create the config.
	config := ispec.Image{
		OS: "linux",
		RootFS: ispec.RootFS{
			Type:    "layers",
			DiffIDs: layerDigests,
		},
	}
	configDigest, configSize, err := engineExt.PutBlobJSON(ctx, config)
	if err != nil {
		t.Fatal(err)
	}
	configDescriptor := ispec.Descriptor{
		MediaType: ispec.MediaTypeImageConfig,
		Digest:    configDigest,
		Size:      configSize,
	}

	// Create the manifest.
	manifest := ispec.Manifest{
		Versioned: specs.Versioned{
			SchemaVersion: 2,
		},
		MediaType: ispec.MediaTypeImageManifest,
		Config:    configDescriptor,
		Layers:    layerDescriptors,
	}

	return root, manifest, engineExt
}

// Ensure that "custom layers" generated by other programs (such as a manual
// tar+gzip) are still correctly handled by us (this used to not work because
// that "archive/tar" parser doesn't consume the whole tar stream if it detects
// that there is no more metadata it is interested in in the tar stream).
func TestUnpackManifestCustomLayer(t *testing.T) {
	ctx := context.Background()

	_, manifest, engineExt := makeImage(t)
	bundle := t.TempDir()

	var called bool
	afterLayerCallback := func(ispec.Manifest, ispec.Descriptor) error {
		called = true
		return nil
	}

	// Unpack (we map both root and the uid/gid in the archives to the current user).
	unpackOptions := &UnpackOptions{
		OnDiskFormat: DirRootfs{
			MapOptions: MapOptions{
				UIDMappings: []rspec.LinuxIDMapping{
					{HostID: uint32(os.Geteuid()), ContainerID: 0, Size: 1},
					{HostID: uint32(os.Geteuid()), ContainerID: 1000, Size: 1},
				},
				GIDMappings: []rspec.LinuxIDMapping{
					{HostID: uint32(os.Getegid()), ContainerID: 0, Size: 1},
					{HostID: uint32(os.Getegid()), ContainerID: 100, Size: 1},
				},
				Rootless: os.Geteuid() != 0,
			},
		},
		AfterLayerUnpack: afterLayerCallback,
	}
	err := UnpackManifest(ctx, engineExt, bundle, manifest, unpackOptions)
	require.NoError(t, err, "UnpackManifest")
	assert.True(t, called, "UnpackManifest callback should have been called")
}

func TestUnpackStartFromDescriptor(t *testing.T) {
	ctx := context.Background()

	_, manifest, engineExt := makeImage(t)
	bundle := t.TempDir()

	// Unpack (we map both root and the uid/gid in the archives to the current user).
	unpackOptions := &UnpackOptions{
		OnDiskFormat: DirRootfs{
			MapOptions: MapOptions{
				UIDMappings: []rspec.LinuxIDMapping{
					{HostID: uint32(os.Geteuid()), ContainerID: 0, Size: 1},
					{HostID: uint32(os.Geteuid()), ContainerID: 1000, Size: 1},
				},
				GIDMappings: []rspec.LinuxIDMapping{
					{HostID: uint32(os.Getegid()), ContainerID: 0, Size: 1},
					{HostID: uint32(os.Getegid()), ContainerID: 100, Size: 1},
				},
				Rootless: os.Geteuid() != 0,
			},
		},
		// Skip the first layer.
		StartFrom: manifest.Layers[1],
	}
	err := UnpackManifest(ctx, engineExt, bundle, manifest, unpackOptions)
	require.NoError(t, err, "UnpackManifest")

	_, err = os.Stat(filepath.Join(bundle, "rootfs/test_file"))
	assert.ErrorIs(t, err, os.ErrNotExist, "test file should not be present")
}

// TODO: Temporary until <https://github.com/opencontainers/umoci/issues/574>
// is resolved.
func TestUnpackUnimplementedOverlayfs(t *testing.T) {
	ctx := context.Background()

	_, manifest, engineExt := makeImage(t)

	// Unpacking with non-DirRootfs should fail.
	unpackOptions := &UnpackOptions{
		OnDiskFormat: OverlayfsRootfs{
			MapOptions: MapOptions{
				UIDMappings: []rspec.LinuxIDMapping{
					{HostID: uint32(os.Geteuid()), ContainerID: 0, Size: 1},
					{HostID: uint32(os.Geteuid()), ContainerID: 1000, Size: 1},
				},
				GIDMappings: []rspec.LinuxIDMapping{
					{HostID: uint32(os.Getegid()), ContainerID: 0, Size: 1},
					{HostID: uint32(os.Getegid()), ContainerID: 100, Size: 1},
				},
				Rootless: os.Geteuid() != 0,
			},
		},
	}

	bundle := t.TempDir()
	err := UnpackManifest(ctx, engineExt, bundle, manifest, unpackOptions)
	require.ErrorIs(t, err, internal.ErrUnimplemented, "UnpackManifest with OverlayfsRootfs")

	rootfs := t.TempDir()
	err = UnpackRootfs(ctx, engineExt, rootfs, manifest, unpackOptions)
	require.ErrorIs(t, err, internal.ErrUnimplemented, "UnpackRootfs with OverlayfsRootfs")
}

func TestLayerCompressionCheck(t *testing.T) {
	for _, test := range []struct {
		name, mediaType   string
		expectedIsLayer   bool
		expectedAlgorithm blobcompress.Algorithm
	}{
		{"layer.v1.tar", ispec.MediaTypeImageLayer, true, blobcompress.Noop},
		{"layer.v1.tar+gzip", ispec.MediaTypeImageLayerGzip, true, blobcompress.Gzip},
		{"layer.v1.tar+zstd", ispec.MediaTypeImageLayer + "+zstd", true, blobcompress.Zstd},
		{"layer.v1.tar+invalid", ispec.MediaTypeImageLayer + "+invalid", true, nil},
		{"layer.nondistributable.v1.tar", ispec.MediaTypeImageLayerNonDistributable, true, blobcompress.Noop},
		{"layer.nondistributable.v1.tar+gzip", ispec.MediaTypeImageLayerNonDistributableGzip, true, blobcompress.Gzip},
		{"layer.nondistributable.v1.tar+zstd", ispec.MediaTypeImageLayerNonDistributable + "+zstd", true, blobcompress.Zstd},
		{"layer.nondistributable.v1.tar+invalid", ispec.MediaTypeImageLayerNonDistributable + "+invalid", true, nil},
		{"application/json", "application/json", false, nil},
		{"application/gzip", "application/gzip", false, nil},
	} {
		test := test // copy iterator
		t.Run(test.name, func(t *testing.T) {
			assert.Equalf(t, test.expectedIsLayer, isLayerType(test.mediaType), "isLayerType(%q) should be %v", test.mediaType, test.expectedIsLayer)

			if test.expectedIsLayer {
				gotAlgoName, gotAlgo, err := getLayerCompressAlgorithm(test.mediaType)
				if test.expectedAlgorithm == nil {
					assert.Errorf(t, err, "getLayerCompressAlgorithm(%q) should be unsupported", test.mediaType)
				} else {
					require.NoErrorf(t, err, "getLayerCompressAlgorithm(%q) should succeed", test.mediaType)
					assert.Equalf(t, gotAlgoName, test.expectedAlgorithm.MediaTypeSuffix(), "getLayerCompressAlgorithm(%q) should be %v", test.mediaType, test.expectedAlgorithm)
					assert.Equalf(t, test.expectedAlgorithm, gotAlgo, "getLayerCompressAlgorithm(%q) should be %v", test.mediaType, test.expectedAlgorithm)
				}
			}
		})
	}
}
