// 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 }