Luigit
repositories / bugabinga.net

bugabinga.net

personal infrastructure for bugabinga!

owned by admin

services/luci/internal/publish/publish.go

Raw
package publish

import (
	"context"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"

	"golang.org/x/sys/unix"
)

type Request struct {
	Adapter   string
	Image     string
	From      string
	To        string
	Workspace string
}

type Executor interface {
	Publish(context.Context, Request, io.Writer) error
}

type Service struct {
	RegistryAuthFile string
	PkgRoot          string
	SiteRoot         string
	Run              func(context.Context, []string, []string, io.Writer) error
}

const maxRegistryArchiveBytes int64 = 10 << 30

var registryReference = regexp.MustCompile(`^(?:localhost|[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]+)?)/[a-z0-9]+(?:[._-][a-z0-9]+)*(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[a-f0-9]{64})?$`)
var manifestDigest = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)

var renamePublish = os.Rename
var renameExchange = func(oldPath, newPath string) error {
	return unix.Renameat2(unix.AT_FDCWD, oldPath, unix.AT_FDCWD, newPath, unix.RENAME_EXCHANGE)
}

func (s Service) Publish(ctx context.Context, request Request, output io.Writer) error {
	switch request.Adapter {
	case "registry":
		if s.RegistryAuthFile == "" {
			return fmt.Errorf("publish registry requires LUCI_PUBLISH_REGISTRY_AUTH_FILE")
		}
		if request.Image != "" {
			return fmt.Errorf("publish registry image is no longer supported; write an OCI archive in the job and use from")
		}
		if request.From == "" {
			return fmt.Errorf("publish registry requires from")
		}
		if !registryReference.MatchString(request.To) {
			return fmt.Errorf("publish registry requires a docker registry target, not %q", request.To)
		}
		archive, cleanup, err := stageRegistryArchive(ctx, request.Workspace, request.From)
		if err != nil {
			return err
		}
		defer cleanup()
		digestFile, err := os.CreateTemp(filepath.Dir(archive), "digest-")
		if err != nil {
			return err
		}
		digestPath := digestFile.Name()
		if err := digestFile.Close(); err != nil {
			_ = os.Remove(digestPath)
			return err
		}
		defer os.Remove(digestPath)
		run := s.Run
		if run == nil {
			run = runSkopeo
		}
		if err := run(ctx, []string{"copy", "--authfile", s.RegistryAuthFile, "--digestfile", digestPath, "oci-archive:" + archive, "docker://" + request.To}, os.Environ(), output); err != nil {
			return err
		}
		digest, err := os.ReadFile(digestPath)
		if err != nil {
			return fmt.Errorf("read published registry digest: %w", err)
		}
		value := strings.TrimSpace(string(digest))
		if !manifestDigest.MatchString(value) {
			return fmt.Errorf("invalid published registry digest %q", value)
		}
		reference, err := canonicalRegistryReference(request.To, value)
		if err != nil {
			return err
		}
		_, err = fmt.Fprintf(output, "[luci] registry-published destination=%s digest=%s reference=%s\n", request.To, value, reference)
		return err
	case "pkg":
		return publishFiles(ctx, s.PkgRoot, "LUCI_PUBLISH_PKG_ROOT", request)
	case "site":
		return publishFiles(ctx, s.SiteRoot, "LUCI_PUBLISH_SITE_ROOT", request)
	default:
		return fmt.Errorf("unsupported publish adapter %q", request.Adapter)
	}
}

func canonicalRegistryReference(destination, digest string) (string, error) {
	repository, pinned, hasPinned := strings.Cut(destination, "@")
	if hasPinned && pinned != digest {
		return "", fmt.Errorf("published registry digest %s conflicts with pinned destination %s", digest, destination)
	}
	if separator := strings.LastIndex(repository, "/"); strings.LastIndex(repository, ":") > separator {
		repository = repository[:strings.LastIndex(repository, ":")]
	}
	return repository + "@" + digest, nil
}

func runSkopeo(ctx context.Context, args, env []string, output io.Writer) error {
	command := exec.CommandContext(ctx, "skopeo", args...)
	command.Env = env
	command.Stdout = output
	command.Stderr = output
	return command.Run()
}

func stageRegistryArchive(ctx context.Context, workspace, relative string) (string, func(), error) {
	if err := ctx.Err(); err != nil {
		return "", nil, err
	}
	source, err := safeSource(workspace, relative)
	if err != nil {
		return "", nil, err
	}
	inputFD, err := unix.Open(source, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
	if err != nil {
		return "", nil, fmt.Errorf("open registry archive: %w", err)
	}
	input := os.NewFile(uintptr(inputFD), source)
	var stat unix.Stat_t
	if err := unix.Fstat(inputFD, &stat); err != nil {
		_ = input.Close()
		return "", nil, err
	}
	if stat.Mode&unix.S_IFMT != unix.S_IFREG {
		_ = input.Close()
		return "", nil, fmt.Errorf("registry archive must be a regular file")
	}
	if stat.Size <= 0 || stat.Size > maxRegistryArchiveBytes {
		_ = input.Close()
		return "", nil, fmt.Errorf("registry archive size %d exceeds allowed range", stat.Size)
	}
	var fs unix.Statfs_t
	if err := unix.Statfs(workspace, &fs); err != nil {
		_ = input.Close()
		return "", nil, err
	}
	if fs.Bsize <= 0 || (uint64(stat.Size)+uint64(fs.Bsize)-1)/uint64(fs.Bsize) > uint64(fs.Bavail) {
		_ = input.Close()
		return "", nil, fmt.Errorf("insufficient workspace space to stage registry archive")
	}
	stage, err := os.MkdirTemp(workspace, ".luci-registry-")
	if err != nil {
		_ = input.Close()
		return "", nil, err
	}
	archive := filepath.Join(stage, "archive")
	output, err := os.OpenFile(archive, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
	if err == nil {
		_, err = io.Copy(output, contextReader{ctx: ctx, reader: input})
		closeErr := output.Close()
		if err == nil {
			err = closeErr
		}
	}
	inputErr := input.Close()
	if err == nil {
		err = inputErr
	}
	if err != nil {
		_ = os.RemoveAll(stage)
		return "", nil, err
	}
	return archive, func() { _ = os.RemoveAll(stage) }, nil
}

func publishFiles(ctx context.Context, root, envName string, request Request) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	if root == "" {
		return fmt.Errorf("publish %s requires %s", request.Adapter, envName)
	}
	if request.From == "" || request.Image != "" {
		return fmt.Errorf("publish %s requires from", request.Adapter)
	}
	source, err := safeSource(request.Workspace, request.From)
	if err != nil {
		return err
	}
	if err := rejectSymlinks(source); err != nil {
		return err
	}
	target, parent, err := safeTarget(root, request.To)
	if err != nil {
		return err
	}
	if err := os.MkdirAll(parent, 0o755); err != nil {
		return err
	}
	if err := safeTargetParent(root, request.To); err != nil {
		return err
	}
	stage, err := os.MkdirTemp(parent, ".luci-publish-")
	if err != nil {
		return err
	}
	cleanupStage := true
	defer func() {
		if cleanupStage {
			_ = os.RemoveAll(stage)
		}
	}()
	staged := filepath.Join(stage, "value")
	if err := copyTree(ctx, source, staged); err != nil {
		return err
	}
	if err := ctx.Err(); err != nil {
		return err
	}
	lock, err := lockPublishParent(parent)
	if err != nil {
		return err
	}
	defer lock.Close()
	info, err := os.Lstat(target)
	if err == nil {
		if info.Mode()&os.ModeSymlink != 0 {
			return fmt.Errorf("publish target is symlink %s", target)
		}
		if err := renameExchange(staged, target); err != nil {
			return fmt.Errorf("publish replacement requires atomic rename exchange: %w", err)
		}
		return nil
	}
	if !os.IsNotExist(err) {
		return err
	}
	if err := renamePublish(staged, target); err != nil {
		return err
	}
	return nil
}

func safeJoin(root, relative string) (string, error) {
	clean := filepath.Clean(relative)
	if strings.TrimSpace(root) == "" || strings.TrimSpace(relative) == "" || clean == "." || filepath.IsAbs(relative) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
		return "", fmt.Errorf("unsafe publish path %q", relative)
	}
	return filepath.Join(root, clean), nil
}

func safeSource(root, relative string) (string, error) {
	path, err := safeJoin(root, relative)
	if err != nil {
		return "", err
	}
	current := root
	for _, part := range strings.Split(filepath.Clean(relative), string(filepath.Separator)) {
		current = filepath.Join(current, part)
		info, err := os.Lstat(current)
		if err != nil {
			return "", err
		}
		if info.Mode()&os.ModeSymlink != 0 {
			return "", fmt.Errorf("publish source traverses symlink %s", current)
		}
	}
	return path, nil
}

func safeTarget(root, relative string) (string, string, error) {
	path, err := safeJoin(root, relative)
	if err != nil {
		return "", "", err
	}
	if err := safeTargetParent(root, relative); err != nil && !os.IsNotExist(err) {
		return "", "", err
	}
	return path, filepath.Dir(path), nil
}

func safeTargetParent(root, relative string) error {
	current := root
	parts := strings.Split(filepath.Clean(relative), string(filepath.Separator))
	for _, part := range append([]string{"."}, parts[:len(parts)-1]...) {
		if part != "." {
			current = filepath.Join(current, part)
		}
		info, err := os.Lstat(current)
		if err != nil {
			return err
		}
		if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
			return fmt.Errorf("publish target traverses unsafe parent %s", current)
		}
	}
	return nil
}

func lockPublishParent(parent string) (*os.File, error) {
	path := filepath.Join(parent, ".luci-publish.lock")
	fd, err := unix.Open(path, unix.O_CREAT|unix.O_RDWR|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0o644)
	if err != nil {
		return nil, err
	}
	file := os.NewFile(uintptr(fd), path)
	var stat unix.Stat_t
	if err := unix.Fstat(fd, &stat); err != nil {
		_ = file.Close()
		return nil, err
	}
	if stat.Mode&unix.S_IFMT != unix.S_IFREG {
		_ = file.Close()
		return nil, fmt.Errorf("publish lock is not a regular file %s", path)
	}
	if err := unix.Flock(fd, unix.LOCK_EX); err != nil {
		_ = file.Close()
		return nil, err
	}
	return file, nil
}

func rejectSymlinks(path string) error {
	info, err := os.Lstat(path)
	if err != nil {
		return err
	}
	if info.Mode()&os.ModeSymlink != 0 {
		return fmt.Errorf("publish source contains symlink %s", path)
	}
	if !info.IsDir() {
		if !info.Mode().IsRegular() {
			return fmt.Errorf("unsupported publish source %s", path)
		}
		return nil
	}
	entries, err := os.ReadDir(path)
	if err != nil {
		return err
	}
	for _, entry := range entries {
		if err := rejectSymlinks(filepath.Join(path, entry.Name())); err != nil {
			return err
		}
	}
	return nil
}

func copyTree(ctx context.Context, source, target string) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	info, err := os.Stat(source)
	if err != nil {
		return err
	}
	if info.IsDir() {
		if err := os.MkdirAll(target, info.Mode().Perm()); err != nil {
			return err
		}
		entries, err := os.ReadDir(source)
		if err != nil {
			return err
		}
		for _, entry := range entries {
			if err := copyTree(ctx, filepath.Join(source, entry.Name()), filepath.Join(target, entry.Name())); err != nil {
				return err
			}
		}
		return nil
	}
	if !info.Mode().IsRegular() {
		return fmt.Errorf("unsupported publish source %s", source)
	}
	if err := os.MkdirAll(filepath.Dir(target), 0o755); 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_EXCL|os.O_WRONLY, info.Mode().Perm())
	if err != nil {
		return err
	}
	_, copyErr := io.Copy(output, contextReader{ctx: ctx, reader: input})
	closeErr := output.Close()
	if copyErr != nil {
		return copyErr
	}
	return closeErr
}

type contextReader struct {
	ctx    context.Context
	reader io.Reader
}

func (r contextReader) Read(data []byte) (int, error) {
	if err := r.ctx.Err(); err != nil {
		return 0, err
	}
	return r.reader.Read(data)
}