// Copyright IBM Corp. 2018, 2025
// SPDX-License-Identifier: MPL-2.0

package unpackinfo

import (
	"archive/tar"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
	"time"
)

// UnpackInfo stores information about the file (or directory, or symlink) being
// unpacked. UnpackInfo ensures certain malicious tar files are not unpacked.
// The information can be used later to restore the original permissions
// and timestamps based on the type of entry the info represents.
type UnpackInfo struct {
	Path               string
	OriginalAccessTime time.Time
	OriginalModTime    time.Time
	OriginalMode       fs.FileMode
	Typeflag           byte
}

// NewUnpackInfo returns an UnpackInfo based on a destination root and a tar header.
// It will return an error if the header represents an illegal symlink extraction
// or if the entry type is not supported by go-slug.
func NewUnpackInfo(dst string, header *tar.Header) (UnpackInfo, error) {
	// Check for empty destination
	if len(dst) == 0 {
		return UnpackInfo{}, errors.New("empty destination is not allowed")
	}

	// Clean the destination path
	dst = filepath.Clean(dst)
	path := filepath.Clean(header.Name)

	path = filepath.Clean(filepath.Join(dst, path))
	target := path

	// Check for path traversal by ensuring the target is within the destination
	rel, err := filepath.Rel(dst, target)
	if err != nil {
		return UnpackInfo{}, errors.New("invalid filename, traversal with \"..\" outside of current directory")
	}

	rel = filepath.Clean(rel)

	rel_components := strings.Split(rel, string(os.PathSeparator))

	for _, component := range rel_components {
		if component == ".." {
			return UnpackInfo{}, fmt.Errorf("invalid filename, traversal with \"..\" outside of current directory")
		}
	}

	// Ensure the destination is not through any symlinks. This prevents
	// any files from being deployed through symlinks defined in the slug.
	// There are malicious cases where this could be used to escape the
	// slug's boundaries (zipslip), and any legitimate use is questionable
	// and likely indicates a hand-crafted tar file, which we are not in
	// the business of supporting here.
	//
	// The strategy is to Lstat each path  component from dst up to the
	// immediate parent directory of the file name in the tarball, checking
	// the mode on each to ensure we wouldn't be passing through any
	// symlinks.
	currentPath := dst // Start at the root of the unpacked tarball.
	components := strings.Split(filepath.Clean(header.Name), "/")

	for i := 0; i < len(components)-1; i++ {
		currentPath = filepath.Join(currentPath, components[i])
		fi, err := os.Lstat(currentPath)
		if os.IsNotExist(err) {
			// Parent directory structure is incomplete. Technically this
			// means from here upward cannot be a symlink, so we cancel the
			// remaining path tests.
			continue
		}
		if err != nil {
			return UnpackInfo{}, fmt.Errorf("failed to evaluate path %q: %w", header.Name, err)
		}
		if fi.Mode()&fs.ModeSymlink != 0 {
			return UnpackInfo{}, fmt.Errorf("cannot extract %q through symlink", header.Name)
		}
	}

	result := UnpackInfo{
		Path:               path,
		OriginalAccessTime: header.AccessTime,
		OriginalModTime:    header.ModTime,
		OriginalMode:       header.FileInfo().Mode(),
		Typeflag:           header.Typeflag,
	}

	if !result.IsDirectory() && !result.IsSymlink() && !result.IsRegular() && !result.IsTypeX() {
		return UnpackInfo{}, fmt.Errorf("failed creating %q, unsupported file type %c", path, result.Typeflag)
	}

	return result, nil
}

// IsSymlink describes whether the file being unpacked is a symlink
func (i UnpackInfo) IsSymlink() bool {
	return i.Typeflag == tar.TypeSymlink
}

// IsDirectory describes whether the file being unpacked is a directory
func (i UnpackInfo) IsDirectory() bool {
	return i.Typeflag == tar.TypeDir
}

// IsTypeX describes whether the file being unpacked is a special TypeXHeader that can
// be ignored by go-slug
func (i UnpackInfo) IsTypeX() bool {
	return i.Typeflag == tar.TypeXGlobalHeader || i.Typeflag == tar.TypeXHeader
}

// IsRegular describes whether the file being unpacked is a regular file
func (i UnpackInfo) IsRegular() bool {
	return i.Typeflag == tar.TypeReg
}

// RestoreInfo changes the file mode and timestamps for the given UnpackInfo data
func (i UnpackInfo) RestoreInfo() error {
	switch {
	case i.IsDirectory():
		return i.restoreDirectory()
	case i.IsSymlink():
		if CanMaintainSymlinkTimestamps() {
			return i.restoreSymlink()
		}
		return nil
	default: // Normal file
		return i.restoreNormal()
	}
}

// RestoreInfoWithRoot changes the file mode and timestamps for the given UnpackInfo data
// using os.Root for enhanced security to ensure operations stay within the destination
func (i UnpackInfo) RestoreInfoWithRoot(root *os.Root, dst string) error {
	// Calculate relative path from dst to i.Path for Root operations
	relPath, err := filepath.Rel(dst, i.Path)
	if err != nil {
		return fmt.Errorf("failed to get relative path: %w", err)
	}

	switch {
	case i.IsDirectory():
		return i.restoreDirectoryWithRoot(root, relPath)
	case i.IsSymlink():
		if CanMaintainSymlinkTimestamps() {
			return i.restoreSymlinkWithRoot(root, relPath)
		}
		return nil
	default: // Normal file
		return i.restoreNormalWithRoot(root, relPath)
	}
}

func (i UnpackInfo) restoreDirectory() error {
	if err := os.Chmod(i.Path, i.OriginalMode); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("failed setting permissions on directory %q: %w", i.Path, err)
	}

	if err := os.Chtimes(i.Path, i.OriginalAccessTime, i.OriginalModTime); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("failed setting times on directory %q: %w", i.Path, err)
	}
	return nil
}

func (i UnpackInfo) restoreSymlink() error {
	if err := i.Lchtimes(); err != nil {
		return fmt.Errorf("failed setting times on symlink %q: %w", i.Path, err)
	}
	return nil
}

func (i UnpackInfo) restoreNormal() error {
	if err := os.Chmod(i.Path, i.OriginalMode); err != nil {
		return fmt.Errorf("failed setting permissions on %q: %w", i.Path, err)
	}

	if err := os.Chtimes(i.Path, i.OriginalAccessTime, i.OriginalModTime); err != nil {
		return fmt.Errorf("failed setting times on %q: %w", i.Path, err)
	}
	return nil
}

func (i UnpackInfo) restoreDirectoryWithRoot(root *os.Root, relPath string) error {
	if err := root.Chmod(relPath, i.OriginalMode); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("failed setting permissions on directory %q: %w", i.Path, err)
	}

	if err := root.Chtimes(relPath, i.OriginalAccessTime, i.OriginalModTime); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("failed setting times on directory %q: %w", i.Path, err)
	}
	return nil
}

func (i UnpackInfo) restoreSymlinkWithRoot(root *os.Root, relPath string) error {
	// Note: Go 1.25's os.Root doesn't have Lchtimes, so we fall back to the original method
	// This is a limitation but still provides some security benefit for other operations
	if err := i.Lchtimes(); err != nil {
		return fmt.Errorf("failed setting times on symlink %q: %w", i.Path, err)
	}
	return nil
}

func (i UnpackInfo) restoreNormalWithRoot(root *os.Root, relPath string) error {
	if err := root.Chmod(relPath, i.OriginalMode); err != nil {
		return fmt.Errorf("failed setting permissions on %q: %w", i.Path, err)
	}

	if err := root.Chtimes(relPath, i.OriginalAccessTime, i.OriginalModTime); err != nil {
		return fmt.Errorf("failed setting times on %q: %w", i.Path, err)
	}
	return nil
}
