Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
295 lines
6 KiB
Go
295 lines
6 KiB
Go
// Package approvalfact provides data-only Approval claim correspondence.
|
|
// It neither authenticates a provider nor decides permission or consumes objects.
|
|
package approvalfact
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"math"
|
|
"math/big"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const MaxJSONBytes = 1 << 20
|
|
|
|
var errJSON = errors.New("invalid Approval JSON")
|
|
|
|
// DecodeJSON retains numeric lexical types, refuses duplicates and invalid
|
|
// Unicode, and bounds work. A Go map previously decoded into float64 cannot
|
|
// recover the producer's integer-versus-floating identity; pass source bytes.
|
|
func DecodeJSON(raw []byte) (any, error) {
|
|
if len(raw) > MaxJSONBytes || !utf8.Valid(raw) || !validSurrogates(raw) {
|
|
return nil, errJSON
|
|
}
|
|
d := json.NewDecoder(bytes.NewReader(raw))
|
|
d.UseNumber()
|
|
var read func(int) (any, error)
|
|
read = func(depth int) (any, error) {
|
|
if depth > 128 {
|
|
return nil, errJSON
|
|
}
|
|
t, e := d.Token()
|
|
if e != nil {
|
|
return nil, errJSON
|
|
}
|
|
switch x := t.(type) {
|
|
case json.Delim:
|
|
if x == '{' {
|
|
m := map[string]any{}
|
|
for d.More() {
|
|
k, e := d.Token()
|
|
if e != nil {
|
|
return nil, errJSON
|
|
}
|
|
s, ok := k.(string)
|
|
if !ok {
|
|
return nil, errJSON
|
|
}
|
|
if _, ok = m[s]; ok {
|
|
return nil, errJSON
|
|
}
|
|
v, e := read(depth + 1)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
m[s] = v
|
|
}
|
|
end, e := d.Token()
|
|
if e != nil || end != json.Delim('}') {
|
|
return nil, errJSON
|
|
}
|
|
return m, nil
|
|
}
|
|
if x == '[' {
|
|
a := []any{}
|
|
for d.More() {
|
|
v, e := read(depth + 1)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
a = append(a, v)
|
|
}
|
|
end, e := d.Token()
|
|
if e != nil || end != json.Delim(']') {
|
|
return nil, errJSON
|
|
}
|
|
return a, nil
|
|
}
|
|
return nil, errJSON
|
|
case json.Number:
|
|
if _, e := pythonNumber(x); e != nil {
|
|
return nil, e
|
|
}
|
|
return x, nil
|
|
case string, bool, nil:
|
|
return x, nil
|
|
default:
|
|
return nil, errJSON
|
|
}
|
|
}
|
|
v, e := read(0)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
if _, e = d.Token(); e != io.EOF {
|
|
return nil, errJSON
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// encoding/json replaces lone UTF-16 surrogates, whereas the Python producer
|
|
// cannot encode them as UTF-8. Reject before the decoder can erase that fact.
|
|
func validSurrogates(raw []byte) bool {
|
|
in := false
|
|
for i := 0; i < len(raw); i++ {
|
|
if raw[i] == '"' {
|
|
in = !in
|
|
continue
|
|
}
|
|
if !in || raw[i] != '\\' {
|
|
continue
|
|
}
|
|
i++
|
|
if i >= len(raw) {
|
|
return false
|
|
}
|
|
if raw[i] != 'u' {
|
|
continue
|
|
}
|
|
if i+4 >= len(raw) {
|
|
return false
|
|
}
|
|
n, e := strconv.ParseUint(string(raw[i+1:i+5]), 16, 16)
|
|
if e != nil {
|
|
return false
|
|
}
|
|
i += 4
|
|
if n >= 0xdc00 && n <= 0xdfff {
|
|
return false
|
|
}
|
|
if n < 0xd800 || n > 0xdbff {
|
|
continue
|
|
}
|
|
if i+6 >= len(raw) || raw[i+1] != '\\' || raw[i+2] != 'u' {
|
|
return false
|
|
}
|
|
low, e := strconv.ParseUint(string(raw[i+3:i+7]), 16, 16)
|
|
if e != nil || low < 0xdc00 || low > 0xdfff {
|
|
return false
|
|
}
|
|
i += 6
|
|
}
|
|
return !in
|
|
}
|
|
|
|
func pythonNumber(n json.Number) (string, error) {
|
|
s := string(n)
|
|
if !strings.ContainsAny(s, ".eE") {
|
|
i, ok := new(big.Int).SetString(s, 10)
|
|
if !ok {
|
|
return "", errJSON
|
|
}
|
|
return i.String(), nil
|
|
}
|
|
f, e := strconv.ParseFloat(s, 64)
|
|
if e != nil || math.IsInf(f, 0) || math.IsNaN(f) {
|
|
return "", errJSON
|
|
}
|
|
// Python repr uses shortest round-trip digits, fixed form for exponents
|
|
// -4..15, a mandatory .0 for integral floats, and two exponent digits.
|
|
sci := strconv.FormatFloat(f, 'e', -1, 64)
|
|
at := strings.IndexByte(sci, 'e')
|
|
exp, _ := strconv.Atoi(sci[at+1:])
|
|
if exp >= -4 && exp < 16 {
|
|
s = strconv.FormatFloat(f, 'f', -1, 64)
|
|
if !strings.Contains(s, ".") {
|
|
s += ".0"
|
|
}
|
|
return s, nil
|
|
}
|
|
sign := "+"
|
|
if exp < 0 {
|
|
sign = "-"
|
|
exp = -exp
|
|
}
|
|
digits := strconv.Itoa(exp)
|
|
if len(digits) < 2 {
|
|
digits = "0" + digits
|
|
}
|
|
return sci[:at] + "e" + sign + digits, nil
|
|
}
|
|
|
|
func quote(s string) string {
|
|
var b strings.Builder
|
|
b.WriteByte('"')
|
|
for _, r := range s {
|
|
switch r {
|
|
case '"', '\\':
|
|
b.WriteByte('\\')
|
|
b.WriteRune(r)
|
|
case '\b':
|
|
b.WriteString(`\b`)
|
|
case '\f':
|
|
b.WriteString(`\f`)
|
|
case '\n':
|
|
b.WriteString(`\n`)
|
|
case '\r':
|
|
b.WriteString(`\r`)
|
|
case '\t':
|
|
b.WriteString(`\t`)
|
|
default:
|
|
if r < 32 {
|
|
b.WriteString(`\u00`)
|
|
const h = "0123456789abcdef"
|
|
b.WriteByte(h[byte(r)>>4])
|
|
b.WriteByte(h[byte(r)&15])
|
|
} else {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
}
|
|
b.WriteByte('"')
|
|
return b.String()
|
|
}
|
|
|
|
func canonical(v any) (string, error) {
|
|
switch x := v.(type) {
|
|
case nil:
|
|
return "null", nil
|
|
case bool:
|
|
if x {
|
|
return "true", nil
|
|
}
|
|
return "false", nil
|
|
case string:
|
|
return quote(x), nil
|
|
case json.Number:
|
|
return pythonNumber(x)
|
|
case []any:
|
|
a := make([]string, len(x))
|
|
for i, v := range x {
|
|
s, e := canonical(v)
|
|
if e != nil {
|
|
return "", e
|
|
}
|
|
a[i] = s
|
|
}
|
|
return "[" + strings.Join(a, ",") + "]", nil
|
|
case map[string]any:
|
|
keys := make([]string, 0, len(x))
|
|
for k := range x {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
a := make([]string, 0, len(keys))
|
|
for _, k := range keys {
|
|
s, e := canonical(x[k])
|
|
if e != nil {
|
|
return "", e
|
|
}
|
|
a = append(a, quote(k)+":"+s)
|
|
}
|
|
return "{" + strings.Join(a, ",") + "}", nil
|
|
default:
|
|
return "", errJSON
|
|
}
|
|
}
|
|
|
|
// CanonicalBinding reproduces approval_engine.binding.binding_digest over the
|
|
// five native act fields. Extra binding metadata is excluded by that contract.
|
|
func CanonicalBinding(raw []byte) ([]byte, string, error) {
|
|
v, e := DecodeJSON(raw)
|
|
if e != nil {
|
|
return nil, "", e
|
|
}
|
|
m, ok := v.(map[string]any)
|
|
if !ok {
|
|
return nil, "", errJSON
|
|
}
|
|
act := map[string]any{}
|
|
for _, k := range []string{"action", "actor", "principal", "purpose"} {
|
|
s, ok := m[k].(string)
|
|
if !ok || s == "" {
|
|
return nil, "", errJSON
|
|
}
|
|
act[k] = s
|
|
}
|
|
target, ok := m["target"].(map[string]any)
|
|
if !ok {
|
|
return nil, "", errJSON
|
|
}
|
|
act["target"] = target
|
|
s, e := canonical(act)
|
|
if e != nil {
|
|
return nil, "", e
|
|
}
|
|
sum := sha256.Sum256([]byte(s))
|
|
return []byte(s), "sha256:" + hex.EncodeToString(sum[:]), nil
|
|
}
|