OpenCode의 일반 Chat 요청을 GPT provider가 거부한 뒤 재시도 가능한 오류로 왜곡해 벤치가 장시간 정체됐다. 선택된 protocol profile에 맞춰 출력 토큰 필드를 정규화하고 upstream 400을 비재시도 validation 오류로 유지한다.
555 lines
20 KiB
Go
555 lines
20 KiB
Go
package openai
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
)
|
|
|
|
// workspaceEncodedPayload is the deterministic, self-contained payload the Edge
|
|
// produces for caller execution. The Edge never executes it and never inspects
|
|
// the workspace; it only produces it from the compiled binding and the issued
|
|
// tool call.
|
|
type workspaceEncodedPayload struct {
|
|
fingerprint string
|
|
alternative string
|
|
operation workspaceOperationKind
|
|
mode workspaceBindingMode
|
|
toolName string
|
|
// publicCallID is the IOP-issued tool call id; providerCallID is the
|
|
// provider-native id. Both are carried into receipt correlation.
|
|
publicCallID string
|
|
providerCallID string
|
|
// safePath is the lexically normalized, containment-checked relative path.
|
|
safePath string
|
|
// structuredArgs is the outgoing argument map keyed by actual tool field
|
|
// names. In structured mode it carries typed values unchanged; in command
|
|
// mode it carries only the synthesized command field.
|
|
structuredArgs map[string]any
|
|
// Command-mode encoding. commandArgv holds the resolved, unquoted argv in
|
|
// deterministic template order; commandString is its shell-safe joining.
|
|
commandField string
|
|
commandArgv []string
|
|
commandString string
|
|
// containmentGuard is the caller-executed guard expression. The Edge never
|
|
// evaluates it; it is returned verbatim to the caller.
|
|
containmentGuard string
|
|
// correlationDigest seals the complete issued payload. Receipt matching
|
|
// recomputes it before trusting any mutable in-memory fields.
|
|
correlationDigest string
|
|
}
|
|
|
|
// workspaceResult is a caller-reported workspace operation result the codec
|
|
// correlates against an issued payload.
|
|
type workspaceResult struct {
|
|
// callID is the tool call id the caller reports the result for. It must
|
|
// equal the issued public or provider id.
|
|
callID string
|
|
// status is the caller-reported outcome (e.g. "success", "error").
|
|
status string
|
|
// body is the caller-reported result body, if any.
|
|
body json.RawMessage
|
|
}
|
|
|
|
// workspaceResultReceipt records the correlation between a caller-reported
|
|
// result and the binding/payload that produced the call.
|
|
type workspaceResultReceipt struct {
|
|
fingerprint string
|
|
alternative string
|
|
operation workspaceOperationKind
|
|
toolName string
|
|
publicCallID string
|
|
providerCallID string
|
|
path string
|
|
status string
|
|
// resultHash is a sha256 of the compacted result body, empty when opaque.
|
|
resultHash string
|
|
// matched is true only when identity, operation, path, guard, and the
|
|
// configured result matcher all correlate.
|
|
matched bool
|
|
// mismatchReason explains why matched is false.
|
|
mismatchReason string
|
|
}
|
|
|
|
// encodeWorkspaceCall produces a deterministic, safe payload for one operation
|
|
// of the compiled binding from an issued tool call. It preserves typed
|
|
// structured values, synthesizes deterministic shell-safe commands in command
|
|
// mode, carries the public/provider identities, and emits a caller-executable
|
|
// containment guard. It returns an error when the call does not match the bound
|
|
// tool, the mapped path is missing, or the path fails lexical containment.
|
|
func encodeWorkspaceCall(b *workspaceBinding, op workspaceOperationKind, call normalizedToolCall) (*workspaceEncodedPayload, error) {
|
|
if b == nil {
|
|
return nil, fmt.Errorf("nil binding")
|
|
}
|
|
ob := b.operation(op)
|
|
if ob == nil {
|
|
return nil, fmt.Errorf("binding has no %q operation", op)
|
|
}
|
|
if call.Arguments == nil {
|
|
return nil, fmt.Errorf("nil call arguments")
|
|
}
|
|
if strings.TrimSpace(call.ID) == "" {
|
|
return nil, fmt.Errorf("call is missing a public tool call id")
|
|
}
|
|
if strings.TrimSpace(call.Name) != ob.toolName {
|
|
return nil, fmt.Errorf("call tool %q does not match bound tool %q for operation %q", call.Name, ob.toolName, op)
|
|
}
|
|
|
|
rawPath, ok := lookupMappedArgument(call.Arguments, ob.pathField)
|
|
if !ok {
|
|
return nil, fmt.Errorf("call is missing mapped path field %q", ob.pathField)
|
|
}
|
|
pathStr, ok := rawPath.(string)
|
|
if !ok || strings.TrimSpace(pathStr) == "" {
|
|
return nil, fmt.Errorf("mapped path field %q is not a non-empty string", ob.pathField)
|
|
}
|
|
safePath := lexicalNormalizePath(pathStr)
|
|
if err := validateContainment(safePath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
payload := &workspaceEncodedPayload{
|
|
fingerprint: b.fingerprint,
|
|
alternative: b.alternativeName,
|
|
operation: op,
|
|
mode: ob.mode,
|
|
toolName: ob.toolName,
|
|
publicCallID: strings.TrimSpace(call.ID),
|
|
providerCallID: strings.TrimSpace(call.ProviderCallID),
|
|
safePath: safePath,
|
|
}
|
|
payload.containmentGuard = synthesizeContainmentGuard(safePath, ob.createsParents)
|
|
|
|
switch ob.mode {
|
|
case modeStructured:
|
|
if err := encodeStructured(payload, ob, call, safePath); err != nil {
|
|
return nil, err
|
|
}
|
|
case modeCommand:
|
|
if err := encodeCommand(payload, ob, call, safePath); err != nil {
|
|
return nil, err
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unknown binding mode %q", ob.mode)
|
|
}
|
|
if ob.mode == modeCommand {
|
|
payload.commandString = payload.containmentGuard + " && " + payload.commandString
|
|
payload.structuredArgs[ob.commandField] = payload.commandString
|
|
}
|
|
payload.correlationDigest = computePayloadCorrelationDigest(payload)
|
|
if payload.correlationDigest == "" {
|
|
return nil, fmt.Errorf("issued payload cannot be canonically correlated")
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
// encodeStructured drives the outgoing argument map only from the compiled
|
|
// argument map. The path is replaced with the containment-checked safe path;
|
|
// content and mode values are carried through byte-for-byte with their original
|
|
// types. No arbitrary extra fields are copied and no shell encoding is applied.
|
|
func encodeStructured(payload *workspaceEncodedPayload, ob *workspaceOperationBinding, call normalizedToolCall, safePath string) error {
|
|
args := make(map[string]any)
|
|
setMappedArgument(args, ob.pathField, safePath)
|
|
|
|
if ob.contentField != "" {
|
|
if value, ok := lookupMappedArgument(call.Arguments, ob.contentField); ok {
|
|
setMappedArgument(args, ob.contentField, cloneAnyValue(value))
|
|
} else if ob.op == opKindWrite {
|
|
return fmt.Errorf("write call is missing mapped content field %q", ob.contentField)
|
|
}
|
|
}
|
|
if ob.modeField != "" {
|
|
if value, ok := lookupMappedArgument(call.Arguments, ob.modeField); ok {
|
|
setMappedArgument(args, ob.modeField, cloneAnyValue(value))
|
|
}
|
|
}
|
|
|
|
payload.structuredArgs = args
|
|
return nil
|
|
}
|
|
|
|
// encodeCommand synthesizes a deterministic command from the fixed argv
|
|
// template. Placeholders {path} and {content} are substituted with the safe
|
|
// path and the mapped content; every other token is a literal. Each argv
|
|
// element is shell-safe single-quoted, so command output is stable regardless
|
|
// of Go map iteration order and content bytes are preserved exactly.
|
|
func encodeCommand(payload *workspaceEncodedPayload, ob *workspaceOperationBinding, call normalizedToolCall, safePath string) error {
|
|
var content string
|
|
if ob.contentField != "" {
|
|
if value, ok := lookupMappedArgument(call.Arguments, ob.contentField); ok {
|
|
content = commandArgumentString(value)
|
|
} else if ob.op == opKindWrite {
|
|
return fmt.Errorf("write call is missing mapped content field %q", ob.contentField)
|
|
}
|
|
}
|
|
|
|
argv := make([]string, 0, len(ob.argvTemplate))
|
|
for _, token := range ob.argvTemplate {
|
|
switch token {
|
|
case "{path}":
|
|
argv = append(argv, safePath)
|
|
case "{content}":
|
|
argv = append(argv, content)
|
|
default:
|
|
argv = append(argv, token)
|
|
}
|
|
}
|
|
|
|
quoted := make([]string, len(argv))
|
|
for i, arg := range argv {
|
|
quoted[i] = singleQuoteShell(arg)
|
|
}
|
|
|
|
payload.commandField = ob.commandField
|
|
payload.commandArgv = argv
|
|
payload.commandString = strings.Join(quoted, " ")
|
|
payload.structuredArgs = map[string]any{ob.commandField: payload.commandString}
|
|
return nil
|
|
}
|
|
|
|
// setMappedArgument assigns value at the dot-path key within args, creating
|
|
// intermediate maps as needed.
|
|
func setMappedArgument(args map[string]any, dotPath string, value any) {
|
|
parts := strings.Split(dotPath, ".")
|
|
current := args
|
|
for i := 0; i < len(parts)-1; i++ {
|
|
next, ok := current[parts[i]].(map[string]any)
|
|
if !ok {
|
|
next = make(map[string]any)
|
|
current[parts[i]] = next
|
|
}
|
|
current = next
|
|
}
|
|
current[parts[len(parts)-1]] = value
|
|
}
|
|
|
|
// commandArgumentString renders a mapped value for command substitution.
|
|
// Strings are used as-is; other JSON values are marshaled deterministically.
|
|
func commandArgumentString(value any) string {
|
|
if s, ok := value.(string); ok {
|
|
return s
|
|
}
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
// lexicalNormalizePath applies deterministic path normalization without
|
|
// touching the filesystem: it trims, converts backslashes, collapses repeated
|
|
// slashes, and resolves "." segments while preserving a leading slash so
|
|
// validateContainment can reject absolute paths. ".." segments are preserved
|
|
// so validateContainment can reject traversal.
|
|
func lexicalNormalizePath(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
isAbsolute := strings.HasPrefix(raw, "/")
|
|
raw = strings.ReplaceAll(raw, "\\", "/")
|
|
for strings.Contains(raw, "//") {
|
|
raw = strings.ReplaceAll(raw, "//", "/")
|
|
}
|
|
parts := strings.Split(raw, "/")
|
|
resolved := make([]string, 0, len(parts))
|
|
for i, part := range parts {
|
|
if part == "." {
|
|
continue
|
|
}
|
|
if i == 0 && part == "" && isAbsolute {
|
|
resolved = append(resolved, "")
|
|
continue
|
|
}
|
|
if part == "" {
|
|
continue
|
|
}
|
|
resolved = append(resolved, part)
|
|
}
|
|
return strings.Join(resolved, "/")
|
|
}
|
|
|
|
// validateContainment lexically rejects paths that cannot be safely contained
|
|
// in the workspace before any encoding: empty, over-long, absolute, traversal,
|
|
// null-byte, and shell-metacharacter paths.
|
|
func validateContainment(path string) error {
|
|
if path == "" {
|
|
return fmt.Errorf("empty path")
|
|
}
|
|
if len(path) > 4096 {
|
|
return fmt.Errorf("path exceeds maximum length of 4096 characters")
|
|
}
|
|
if strings.HasPrefix(path, "/") {
|
|
return fmt.Errorf("absolute path is not allowed: %q", path)
|
|
}
|
|
for _, segment := range strings.Split(path, "/") {
|
|
if segment == ".." {
|
|
return fmt.Errorf("path traversal is not allowed: %q", path)
|
|
}
|
|
}
|
|
if strings.ContainsRune(path, 0) {
|
|
return fmt.Errorf("path contains null byte")
|
|
}
|
|
for _, r := range path {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
case r >= 'A' && r <= 'Z':
|
|
case r >= '0' && r <= '9':
|
|
case r == '.' || r == '-' || r == '_' || r == '/' || r == ' ':
|
|
default:
|
|
return fmt.Errorf("path contains unsafe character %q", string(r))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// synthesizeContainmentGuard returns a concrete caller-executed shell guard.
|
|
// It resolves the canonical workspace cwd and either the existing target or a
|
|
// canonical existing ancestor before the operation. Resolving the target itself
|
|
// when it already exists is essential: checking only the parent would allow a
|
|
// final-component symlink to escape the workspace. Parent-capable operations
|
|
// may retain a validated nonexistent suffix after fencing their nearest existing
|
|
// ancestor; operations without that capability still require the immediate
|
|
// parent to exist. The Edge never evaluates this guard or accesses a workspace.
|
|
func synthesizeContainmentGuard(relPath string, createsParents bool) string {
|
|
quoted := singleQuoteShell(relPath)
|
|
var b strings.Builder
|
|
b.WriteString("{ ")
|
|
b.WriteString(`IOP_WS_ROOT=$(realpath "${IOP_WORKSPACE_CWD:-.}") || exit 1; `)
|
|
b.WriteString(`if [ "$IOP_WS_ROOT" = "/" ]; then IOP_WS_PREFIX=""; else IOP_WS_PREFIX="$IOP_WS_ROOT"; fi; `)
|
|
b.WriteString(`IOP_WS_CANDIDATE="$IOP_WS_ROOT/`)
|
|
b.WriteString(relPath)
|
|
b.WriteString(`"; `)
|
|
b.WriteString(`if [ -e "$IOP_WS_CANDIDATE" ] || [ -L "$IOP_WS_CANDIDATE" ]; then IOP_WS_TARGET=$(realpath "$IOP_WS_CANDIDATE") || exit 1; `)
|
|
b.WriteString(`else `)
|
|
if createsParents {
|
|
b.WriteString(`IOP_WS_ANCESTOR="$IOP_WS_CANDIDATE"; IOP_WS_SUFFIX=""; `)
|
|
b.WriteString(`while [ ! -e "$IOP_WS_ANCESTOR" ] && [ ! -L "$IOP_WS_ANCESTOR" ]; do IOP_WS_NAME=$(basename -- "$IOP_WS_ANCESTOR") || exit 1; `)
|
|
b.WriteString(`if [ -n "$IOP_WS_SUFFIX" ]; then IOP_WS_SUFFIX="$IOP_WS_NAME/$IOP_WS_SUFFIX"; else IOP_WS_SUFFIX="$IOP_WS_NAME"; fi; `)
|
|
b.WriteString(`IOP_WS_ANCESTOR=$(dirname -- "$IOP_WS_ANCESTOR") || exit 1; done; `)
|
|
b.WriteString(`IOP_WS_ANCESTOR=$(realpath "$IOP_WS_ANCESTOR") || exit 1; `)
|
|
b.WriteString(`IOP_WS_TARGET="$IOP_WS_ANCESTOR/$IOP_WS_SUFFIX"; `)
|
|
} else {
|
|
b.WriteString(`IOP_WS_PARENT=$(realpath "$(dirname -- "$IOP_WS_CANDIDATE")") || exit 1; `)
|
|
b.WriteString(`IOP_WS_TARGET="$IOP_WS_PARENT/$(basename -- `)
|
|
b.WriteString(quoted)
|
|
b.WriteString(`)"; `)
|
|
}
|
|
b.WriteString(`fi; `)
|
|
b.WriteString(`case "$IOP_WS_TARGET/" in "$IOP_WS_PREFIX"/*) : ;; *) echo 'iop: path escapes workspace root' >&2; exit 1 ;; esac; }`)
|
|
return b.String()
|
|
}
|
|
|
|
// singleQuoteShell returns a POSIX single-quoted encoding of s. Bytes inside
|
|
// single quotes are literal, so content is preserved exactly; embedded single
|
|
// quotes are closed, escaped, and reopened.
|
|
func singleQuoteShell(s string) string {
|
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
|
}
|
|
|
|
// matchResultReceipt correlates a caller-reported result against an issued
|
|
// payload. A matched receipt requires the reported call id to equal the issued
|
|
// public or provider id, the result body to parse, and the operation's
|
|
// configured result matcher to match the normalized {status, result} envelope.
|
|
// Opaque, error-shaped, wrong-id, and matcher-mismatched results do not match.
|
|
func matchResultReceipt(b *workspaceBinding, payload *workspaceEncodedPayload, result workspaceResult) *workspaceResultReceipt {
|
|
receipt := &workspaceResultReceipt{
|
|
operation: payload.operation,
|
|
toolName: payload.toolName,
|
|
path: payload.safePath,
|
|
status: result.status,
|
|
}
|
|
if b != nil {
|
|
receipt.fingerprint = b.fingerprint
|
|
receipt.alternative = b.alternativeName
|
|
}
|
|
receipt.publicCallID = payload.publicCallID
|
|
receipt.providerCallID = payload.providerCallID
|
|
if len(result.body) > 0 {
|
|
receipt.resultHash = sha256ResultHash(result.body)
|
|
}
|
|
|
|
if reason := matchResultCorrelation(b, payload, result); reason != "" {
|
|
receipt.mismatchReason = reason
|
|
return receipt
|
|
}
|
|
ob := b.operation(payload.operation)
|
|
|
|
normalized, err := normalizeResultEnvelope(result)
|
|
if err != nil {
|
|
receipt.mismatchReason = "result body is not valid JSON"
|
|
return receipt
|
|
}
|
|
if hasExplicitErrorSignal(normalized) {
|
|
receipt.mismatchReason = "result contains an explicit error signal"
|
|
return receipt
|
|
}
|
|
if !deepSubsetMatch(map[string]any(ob.resultMatcher), normalized) {
|
|
receipt.mismatchReason = "result does not satisfy the configured result matcher"
|
|
return receipt
|
|
}
|
|
|
|
receipt.matched = true
|
|
return receipt
|
|
}
|
|
|
|
// matchResultCorrelation validates only immutable issue identity. Callers use
|
|
// it to distinguish an exact caller-reported operation failure from malformed,
|
|
// unknown, or untrusted continuation input before considering cleanup.
|
|
func matchResultCorrelation(b *workspaceBinding, payload *workspaceEncodedPayload, result workspaceResult) string {
|
|
if b == nil || payload == nil || b.fingerprint != payload.fingerprint {
|
|
return "payload does not belong to binding"
|
|
}
|
|
if payload.correlationDigest == "" || payload.correlationDigest != computePayloadCorrelationDigest(payload) {
|
|
return "issued payload correlation digest does not match"
|
|
}
|
|
if b.operation(payload.operation) == nil {
|
|
return "binding has no such operation"
|
|
}
|
|
reportedID := strings.TrimSpace(result.callID)
|
|
if reportedID == "" {
|
|
return "result is missing a tool call id"
|
|
}
|
|
if reportedID != payload.publicCallID && reportedID != payload.providerCallID {
|
|
return "result call id does not correlate with the issued call"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// workspaceResultIsExact reports whether a caller result carries a
|
|
// self-describing operation report. An explicit failure status is exact on its
|
|
// own; otherwise the non-empty body must decode into the normalized
|
|
// {status, result} envelope. Opaque or malformed success bodies are untrusted
|
|
// and stay fail-closed.
|
|
func workspaceResultIsExact(result workspaceResult) bool {
|
|
if hasExplicitErrorSignal(map[string]any{"status": result.status}) {
|
|
return true
|
|
}
|
|
if len(bytes.TrimSpace(result.body)) == 0 {
|
|
return false
|
|
}
|
|
_, err := normalizeResultEnvelope(result)
|
|
return err == nil
|
|
}
|
|
|
|
// normalizeResultEnvelope builds the {status, result} envelope the configured
|
|
// result matcher is evaluated against. An empty body yields a nil result, so an
|
|
// opaque result cannot satisfy a matcher that requires result fields.
|
|
func normalizeResultEnvelope(result workspaceResult) (map[string]any, error) {
|
|
envelope := map[string]any{"status": result.status}
|
|
if len(bytes.TrimSpace(result.body)) == 0 {
|
|
envelope["result"] = nil
|
|
return envelope, nil
|
|
}
|
|
var decoded any
|
|
decoder := json.NewDecoder(bytes.NewReader(result.body))
|
|
decoder.UseNumber()
|
|
if err := decoder.Decode(&decoded); err != nil {
|
|
return nil, err
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
|
if err == nil {
|
|
return nil, fmt.Errorf("multiple JSON values are not allowed")
|
|
}
|
|
return nil, err
|
|
}
|
|
envelope["result"] = decoded
|
|
return envelope, nil
|
|
}
|
|
|
|
// computePayloadCorrelationDigest binds every issued value that affects caller
|
|
// execution or receipt admission. json.Marshal gives map keys a canonical
|
|
// ordering, preserving typed values while avoiding Go map iteration variance.
|
|
func computePayloadCorrelationDigest(payload *workspaceEncodedPayload) string {
|
|
if payload == nil {
|
|
return ""
|
|
}
|
|
description := map[string]any{
|
|
"fingerprint": payload.fingerprint,
|
|
"alternative": payload.alternative,
|
|
"operation": string(payload.operation),
|
|
"mode": string(payload.mode),
|
|
"tool_name": payload.toolName,
|
|
"public_call_id": payload.publicCallID,
|
|
"provider_call_id": payload.providerCallID,
|
|
"safe_path": payload.safePath,
|
|
"structured_args": payload.structuredArgs,
|
|
"command_field": payload.commandField,
|
|
"command_argv": payload.commandArgv,
|
|
"command_string": payload.commandString,
|
|
"containment_guard": payload.containmentGuard,
|
|
}
|
|
raw, err := json.Marshal(description)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
sum := sha256.Sum256(raw)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// hasExplicitErrorSignal rejects success-shaped bodies that also declare an
|
|
// endpoint error. It intentionally treats only semantically non-empty error
|
|
// values as signals so optional null/false fields remain representable.
|
|
func hasExplicitErrorSignal(value any) bool {
|
|
switch v := value.(type) {
|
|
case map[string]any:
|
|
for key, child := range v {
|
|
normalizedKey := strings.ToLower(strings.TrimSpace(key))
|
|
if (normalizedKey == "error" || normalizedKey == "errors") && errorValuePresent(child) {
|
|
return true
|
|
}
|
|
if normalizedKey == "status" || normalizedKey == "type" {
|
|
if text, ok := child.(string); ok {
|
|
switch strings.ToLower(strings.TrimSpace(text)) {
|
|
case "error", "failed", "failure":
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
if hasExplicitErrorSignal(child) {
|
|
return true
|
|
}
|
|
}
|
|
case []any:
|
|
for _, child := range v {
|
|
if hasExplicitErrorSignal(child) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func errorValuePresent(value any) bool {
|
|
switch v := value.(type) {
|
|
case nil:
|
|
return false
|
|
case bool:
|
|
return v
|
|
case string:
|
|
return strings.TrimSpace(v) != ""
|
|
case []any:
|
|
return len(v) > 0
|
|
case map[string]any:
|
|
return len(v) > 0
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
// sha256ResultHash computes a sha256 hex digest of the compacted result body
|
|
// for stable, order-independent receipt hashing.
|
|
func sha256ResultHash(body json.RawMessage) string {
|
|
var buf bytes.Buffer
|
|
if err := json.Compact(&buf, body); err != nil {
|
|
buf.Reset()
|
|
buf.Write(body)
|
|
}
|
|
sum := sha256.Sum256(buf.Bytes())
|
|
return hex.EncodeToString(sum[:])
|
|
}
|