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
}