package dockerfile import ( "errors" "fmt" "os" "path/filepath" "strings" ) // resolveContextDir picks the directory the Docker build context will // be packed from, defensively. Returns an error rather than a directory // outside the cloned tree even if ContextPath contains a tricky // sequence — Validate already rejects ".." and leading "/", but // EvalSymlinks here is the second wall. // // ctx may be "" (use cloneRoot as-is) or a relative subpath like // "./api" or "services/api". func resolveContextDir(cloneRoot, ctx string) (string, error) { cloneRoot, err := filepath.Abs(cloneRoot) if err != nil { return "", fmt.Errorf("abs cloneRoot: %w", err) } if real, err := filepath.EvalSymlinks(cloneRoot); err == nil { cloneRoot = real } if ctx == "" || ctx == "." || ctx == "./" { return cloneRoot, nil } candidate := filepath.Join(cloneRoot, filepath.FromSlash(ctx)) candidate, err = filepath.Abs(candidate) if err != nil { return "", fmt.Errorf("abs candidate: %w", err) } // Resolve symlinks BEFORE the prefix check so a planted symlink // inside the clone cannot escape the build context. if real, err := filepath.EvalSymlinks(candidate); err == nil { candidate = real } if candidate != cloneRoot && !strings.HasPrefix(candidate, cloneRoot+string(filepath.Separator)) { return "", fmt.Errorf("context path %q escapes clone root", ctx) } info, err := os.Stat(candidate) if err != nil { return "", fmt.Errorf("stat context_path %q: %w", ctx, err) } if !info.IsDir() { return "", fmt.Errorf("context_path %q is not a directory", ctx) } return candidate, nil } // verifyDockerfileExists checks that the named Dockerfile is present in // the resolved context. Returns a focused error for the operator instead // of letting the daemon error out with a less obvious message later. // // dockerfilePath is the value from Config.DockerfilePath — relative to // the context dir, "Dockerfile" by default. func verifyDockerfileExists(contextDir, dockerfilePath string) error { if dockerfilePath == "" { dockerfilePath = "Dockerfile" } if strings.HasPrefix(dockerfilePath, "/") || strings.Contains(dockerfilePath, "..") { return fmt.Errorf("dockerfile_path %q must be relative and contain no '..'", dockerfilePath) } full := filepath.Join(contextDir, filepath.FromSlash(dockerfilePath)) info, err := os.Stat(full) if err != nil { if errors.Is(err, os.ErrNotExist) { return fmt.Errorf("Dockerfile not found at %s/%s", filepath.Base(contextDir), dockerfilePath) } return fmt.Errorf("stat Dockerfile %q: %w", dockerfilePath, err) } if info.IsDir() { return fmt.Errorf("dockerfile_path %q points at a directory, not a file", dockerfilePath) } return nil } // sanitizeError clamps an error string before it lands in // containers.extra_json (last_error) or echoes through an outbound // notification webhook. Mirrors the static-plugin helper of the same // name so both plugins agree on the surface area they expose to // operators. func sanitizeError(msg, accessToken string) string { return sanitizeErrorWithSecrets(msg, accessToken, nil) } // sanitizeErrorWithSecrets is the dockerfile-plugin-specific extension: // when capturing container build/runtime logs into last_error we ALSO // need to redact decrypted env-var values, because a malicious or // debug-laden Dockerfile can `RUN echo $SECRET` and land a runtime // secret in operator-readable state via /api/workloads/{id}/runtime-state. // // envKV is the same []string the docker client receives — entries shaped // "KEY=VALUE". We split on the first '=' and redact every non-empty // VALUE longer than 3 chars (shorter values produce too many false- // positive substring matches against words like "is" / "of"). func sanitizeErrorWithSecrets(msg, accessToken string, envKV []string) string { if msg == "" { return "" } if accessToken != "" { msg = strings.ReplaceAll(msg, accessToken, "[REDACTED]") } for _, kv := range envKV { eq := strings.IndexByte(kv, '=') if eq < 0 { continue } value := kv[eq+1:] if len(value) < 4 { continue } msg = strings.ReplaceAll(msg, value, "[REDACTED]") } msg = strings.Map(func(r rune) rune { switch r { case '\n', '\r', '\t': return ' ' } return r }, msg) const maxLen = 240 if len(msg) > maxLen { // Rune-aware truncation: walk back to the previous rune // boundary so multi-byte chars at the cap don't tear. cut := maxLen for cut > 0 && !isRuneStart(msg[cut]) { cut-- } msg = msg[:cut] + "…" } return msg } // isRuneStart reports whether b is a leading byte of a UTF-8 sequence. // Used to walk back from a byte-offset cut to a rune boundary. func isRuneStart(b byte) bool { return b&0xC0 != 0x80 }