Luigit
repositories / bugabinga.net

bugabinga.net

personal infrastructure for bugabinga!

owned by admin

services/luci/internal/buildctx/buildctx.go

Raw
// Package buildctx stages an untrusted checkout subtree for podman build.
package buildctx

import (
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"strings"

	"github.com/moby/patternmatcher"
)

const maxIgnoreBytes = 1 << 20

// Stage copies context into destination and Containerfile beside it. It never
// passes checkout paths to Podman, so ignored files and host-only metadata
// cannot become build input. Limits cover the separately staged Containerfile,
// the parsed .containerignore, and every non-directory context entry.
func Stage(checkout, containerfile, context, destination string, maxBytes int64, maxFiles int) (string, string, error) {
	limits := limits{maxBytes: maxBytes, maxFiles: maxFiles}
	containerfilePath, err := checkedPath(checkout, containerfile, false)
	if err != nil {
		return "", "", fmt.Errorf("containerfile: %w", err)
	}
	info, err := os.Stat(containerfilePath)
	if err != nil {
		return "", "", err
	}
	if !info.Mode().IsRegular() {
		return "", "", fmt.Errorf("containerfile is not a regular file")
	}
	contextPath, err := checkedPath(checkout, context, true)
	if err != nil {
		return "", "", fmt.Errorf("context: %w", err)
	}
	info, err = os.Stat(contextPath)
	if err != nil {
		return "", "", err
	}
	if !info.IsDir() {
		return "", "", fmt.Errorf("context is not a directory")
	}
	if err := os.MkdirAll(destination, 0o700); err != nil {
		return "", "", err
	}
	stagedContainerfile := filepath.Join(destination, "Containerfile")
	if err := copyRegular(containerfilePath, stagedContainerfile, &limits); err != nil {
		return "", "", err
	}
	ignore, err := readIgnore(filepath.Join(contextPath, ".containerignore"), &limits)
	if err != nil {
		return "", "", err
	}
	stagedContext := filepath.Join(destination, "context")
	if err := copyContext(contextPath, stagedContext, ignore, &limits); err != nil {
		return "", "", err
	}
	return stagedContainerfile, stagedContext, nil
}

type limits struct {
	maxBytes int64
	maxFiles int
	bytes    int64
	files    int
}

func (l *limits) add(size int64) error {
	if size < 0 || l.maxBytes > 0 && size > l.maxBytes-l.bytes {
		return fmt.Errorf("build context exceeds byte limit %d", l.maxBytes)
	}
	if l.maxFiles > 0 && l.files == l.maxFiles {
		return fmt.Errorf("build context exceeds file limit %d", l.maxFiles)
	}
	l.bytes += size
	l.files++
	return nil
}

func checkedPath(root, relative string, allowRoot bool) (string, error) {
	clean := filepath.Clean(relative)
	if strings.TrimSpace(relative) == "" || filepath.IsAbs(relative) || (!allowRoot && clean == ".") || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
		return "", fmt.Errorf("unsafe checkout-relative path %q", relative)
	}
	if clean == "." {
		return root, nil
	}
	current := root
	for _, part := range strings.Split(clean, string(filepath.Separator)) {
		if part == ".git" {
			return "", fmt.Errorf("path %q includes Git metadata", relative)
		}
		current = filepath.Join(current, part)
		info, err := os.Lstat(current)
		if err != nil {
			return "", err
		}
		if info.Mode()&os.ModeSymlink != 0 {
			return "", fmt.Errorf("path %q traverses symlink", relative)
		}
	}
	return current, nil
}

func readIgnore(path string, limits *limits) (*patternmatcher.PatternMatcher, error) {
	info, err := os.Lstat(path)
	if os.IsNotExist(err) {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
		return nil, fmt.Errorf(".containerignore is not a regular file")
	}
	if info.Size() > maxIgnoreBytes {
		return nil, fmt.Errorf(".containerignore exceeds parsing limit %d", maxIgnoreBytes)
	}
	if err := limits.add(info.Size()); err != nil {
		return nil, err
	}
	file, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer file.Close()
	data, err := io.ReadAll(io.LimitReader(file, maxIgnoreBytes+1))
	if err != nil {
		return nil, err
	}
	if len(data) > maxIgnoreBytes {
		return nil, fmt.Errorf(".containerignore exceeds parsing limit %d", maxIgnoreBytes)
	}
	patterns := make([]string, 0)
	for _, line := range strings.Split(string(data), "\n") {
		line = strings.TrimSpace(line)
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		patterns = append(patterns, filepath.ToSlash(line))
	}
	matcher, err := patternmatcher.New(patterns)
	if err != nil {
		return nil, fmt.Errorf("invalid .containerignore: %w", err)
	}
	return matcher, nil
}

func copyContext(source, destination string, ignore *patternmatcher.PatternMatcher, limits *limits) error {
	return filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error {
		if walkErr != nil {
			return walkErr
		}
		relative, err := filepath.Rel(source, path)
		if err != nil {
			return err
		}
		if relative == "." {
			return os.MkdirAll(destination, 0o700)
		}
		relative = filepath.ToSlash(relative)
		// A repository-root context legitimately contains .git. It is never
		// staging input, regardless of ignore rules or whether it is a file.
		if gitMetadata(relative) {
			if entry.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		// The ignore file was bounded and parsed separately. Podman receives an
		// already filtered context, so it has no reason to inspect this file.
		if relative == ".containerignore" {
			return nil
		}
		info, err := os.Lstat(path)
		if err != nil {
			return err
		}
		if !info.IsDir() && !info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0 {
			return fmt.Errorf("build context has unsupported file %q", relative)
		}
		if !info.IsDir() {
			if err := limits.add(info.Size()); err != nil {
				return err
			}
		}
		if ignore != nil {
			skip, err := ignore.MatchesOrParentMatches(relative)
			if err != nil {
				return fmt.Errorf("match .containerignore %q: %w", relative, err)
			}
			if skip {
				// Do not skip ignored directories: later negation rules may restore
				// descendants, as in Docker's native ignore semantics.
				return nil
			}
		}
		target := filepath.Join(destination, filepath.FromSlash(relative))
		switch {
		case info.IsDir():
			return os.MkdirAll(target, info.Mode().Perm())
		case info.Mode().IsRegular():
			return copyRegularCounted(path, target, info)
		case info.Mode()&os.ModeSymlink != 0:
			link, err := os.Readlink(path)
			if err != nil {
				return err
			}
			resolved, err := filepath.EvalSymlinks(path)
			if err != nil || filepath.IsAbs(link) || !within(source, resolved) {
				return fmt.Errorf("build context symlink %q escapes context", relative)
			}
			if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
				return err
			}
			return os.Symlink(link, target)
		default:
			return fmt.Errorf("build context has unsupported file %q", relative)
		}
	})
}

func gitMetadata(relative string) bool {
	for _, part := range strings.Split(relative, "/") {
		if part == ".git" {
			return true
		}
	}
	return false
}

func within(root, path string) bool {
	relative, err := filepath.Rel(root, path)
	return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}

func copyRegular(source, target string, limits *limits) error {
	info, err := os.Stat(source)
	if err != nil {
		return err
	}
	if err := limits.add(info.Size()); err != nil {
		return err
	}
	return copyRegularCounted(source, target, info)
}

func copyRegularCounted(source, target string, info fs.FileInfo) error {
	if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
		return err
	}
	input, err := os.Open(source)
	if err != nil {
		return err
	}
	defer input.Close()
	output, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm())
	if err != nil {
		return err
	}
	_, copyErr := io.Copy(output, input)
	closeErr := output.Close()
	if copyErr != nil {
		return copyErr
	}
	return closeErr
}