package build import ( "encoding/json" "fmt" "os" "path" "path/filepath" "sort" "strings" "werkdock/internal/rootpath" ) // copyStep executes one COPY instruction: files from the build context // on the host into the work tree. No sandbox is involved — the tree is // a plain host directory, and the build is the only writer. // // The paths are resolved inside the step's writable directory the way // the sandbox would see them (rootpath.Resolve), so a symlink in the // image cannot make a COPY write outside the tree. func (b *builder) copyStep(step Step, instruction Instruction) error { sources, dest, err := parseCopy(instruction.Args) if err != nil { return err } if !path.IsAbs(dest) { workdir := b.config.WorkingDir if workdir == "" { workdir = "/" } // Keep a trailing slash: it is what says "this is a directory". trailing := strings.HasSuffix(dest, "/") dest = path.Join(workdir, dest) if trailing { dest += "/" } } matched, err := resolveSources(b.opts.Context, sources) if err != nil { return err } destIsDir := strings.HasSuffix(dest, "/") || len(matched) > 1 if !destIsDir && len(matched) == 1 { if fi, err := os.Stat(matched[0]); err == nil && fi.IsDir() { destIsDir = true } } for _, source := range matched { fi, err := os.Stat(source) if err != nil { return err } target := dest if fi.IsDir() { // docker copies a directory's CONTENTS, not the directory. host, err := rootpath.Resolve(step.Writable(), target) if err != nil { return err } if err := os.MkdirAll(host, 0o755); err != nil { return err } if err := copyTree(source, host); err != nil { return err } continue } if destIsDir { target = path.Join(target, filepath.Base(source)) } host, err := rootpath.Resolve(step.Writable(), target) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(host), 0o755); err != nil { return err } if err := copyFile(source, host, fi.Mode().Perm()); err != nil { return err } } return nil } // parseCopy splits COPY's arguments into sources and destination. The // JSON array form (for paths with spaces) is accepted as docker's is. // The flags are refused with their reason rather than ignored. func parseCopy(args string) ([]string, string, error) { fields, err := copyFields(args) if err != nil { return nil, "", err } var paths []string for _, field := range fields { if !strings.HasPrefix(field, "--") { paths = append(paths, field) continue } name, _, _ := strings.Cut(strings.TrimPrefix(field, "--"), "=") switch name { case "from": return nil, "", fmt.Errorf("COPY --from is multi-stage, which is stage 3 of RFC 0006") case "chown", "chmod": return nil, "", fmt.Errorf("COPY --%s is not supported: the sandbox maps one uid, and ownership is "+ "dropped when an image is unpacked anyway (RFC 0005)", name) default: return nil, "", fmt.Errorf("COPY --%s is not supported", name) } } if len(paths) < 2 { return nil, "", fmt.Errorf("COPY needs at least one source and a destination") } return paths[:len(paths)-1], paths[len(paths)-1], nil } // copyFields reads COPY's arguments: docker's JSON array form, or // whitespace-separated words. func copyFields(args string) ([]string, error) { if !strings.HasPrefix(args, "[") { return strings.Fields(args), nil } var fields []string if err := json.Unmarshal([]byte(args), &fields); err != nil { return nil, fmt.Errorf("the JSON form of COPY must be an array of strings: %w", err) } return fields, nil } // resolveSources maps COPY's sources — relative to the build context, // globs allowed — to host paths, and refuses anything outside the // context: a Dockerfile may not read the host beyond what was handed // to the build, not even through a symlink in the context, e.g. // leak -> ~/.ssh/id_rsa. A symlink within the context counts as its // target, as docker copies it. func resolveSources(context string, sources []string) ([]string, error) { // the walk below follows symlinks, so the check compares the real paths realContext, err := filepath.EvalSymlinks(context) if err != nil { return nil, fmt.Errorf("the build context %s: %w", context, err) } var result []string for _, source := range sources { if path.IsAbs(source) { return nil, fmt.Errorf("COPY source %s must be relative to the build context", source) } candidate := filepath.Join(context, filepath.FromSlash(source)) if !within(context, candidate) { return nil, fmt.Errorf("COPY source %s leaves the build context", source) } matches, err := filepath.Glob(candidate) if err != nil { return nil, fmt.Errorf("COPY source %s: %w", source, err) } if len(matches) == 0 { return nil, fmt.Errorf("COPY source %s: no such file or directory in the build context", source) } sort.Strings(matches) for _, match := range matches { real, err := filepath.EvalSymlinks(match) if err != nil { return nil, fmt.Errorf("COPY source %s: %w", source, err) } if !within(context, match) || !within(realContext, real) { return nil, fmt.Errorf("COPY source %s leaves the build context", source) } // the name stays the link's, as docker names the copy result = append(result, match) } } return result, nil } func within(root, candidate string) bool { root = filepath.Clean(root) candidate = filepath.Clean(candidate) return candidate == root || strings.HasPrefix(candidate, root+string(filepath.Separator)) }