Files
zeekay c12503b4cf An encoding states its written form, not only its JSON one
MarshalJSON already wrote the word in quotes, so the encoding has always had a
written form; it just had nowhere to be read from except JSON. A URL carries
text and no JSON, so a read served as a GET asks with ?encoding=json and a
binder converting by kind read a uint8, failed, and left the request on Hex —
which is what zero is. A wrong answer with a 200 on it.

The mapping now lives once, in UnmarshalText, and UnmarshalJSON unquotes and
calls it. The JSON wire is untouched: encoding/json prefers a json.Unmarshaler
over a text one, and both spellings produce the same four words they always
did.
2026-08-29 22:16:36 -07:00

198 lines
4.9 KiB
Go

// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package formatting
import (
"bytes"
"encoding/hex"
"errors"
"fmt"
"math"
"strconv"
"strings"
"github.com/luxfi/crypto/hash"
)
const (
hexPrefix = "0x"
checksumLen = 4
)
var (
errEncodingOverFlow = errors.New("encoding overflow")
errInvalidEncoding = errors.New("invalid encoding")
errUnsupportedEncodingInMethod = errors.New("unsupported encoding in method")
errMissingChecksum = errors.New("input string is smaller than the checksum size")
errBadChecksum = errors.New("invalid input checksum")
errMissingHexPrefix = errors.New("missing 0x prefix to hex encoding")
)
// Encoding defines how bytes are converted to a string and vice versa
type Encoding uint8
const (
// Hex specifies a hex plus 4 byte checksum encoding format
Hex Encoding = iota
// HexNC specifies a hex encoding format
HexNC
// HexC specifies a hex plus 4 byte checksum encoding format
HexC
// JSON specifies the JSON encoding format
JSON
)
func (enc Encoding) String() string {
switch enc {
case Hex:
return "hex"
case HexNC:
return "hexnc"
case HexC:
return "hexc"
case JSON:
return "json"
default:
return errInvalidEncoding.Error()
}
}
func (enc Encoding) valid() bool {
switch enc {
case Hex, HexNC, HexC, JSON:
return true
}
return false
}
// MarshalText and UnmarshalText are the encoding's written form, which is the
// one it has always had: MarshalJSON writes this same word in quotes. It is
// said here as well because a URL carries text and no JSON. A read served as a
// GET asks for its encoding with ?encoding=json, and an encoding read as the
// number its kind is left every such request on Hex, which is what zero is.
func (enc Encoding) MarshalText() ([]byte, error) {
if !enc.valid() {
return nil, errInvalidEncoding
}
return []byte(enc.String()), nil
}
func (enc *Encoding) UnmarshalText(text []byte) error {
switch strings.ToLower(string(text)) {
case "hex":
*enc = Hex
case "hexnc":
*enc = HexNC
case "hexc":
*enc = HexC
case "json":
*enc = JSON
default:
return errInvalidEncoding
}
return nil
}
func (enc Encoding) MarshalJSON() ([]byte, error) {
text, err := enc.MarshalText()
if err != nil {
return nil, err
}
return []byte(`"` + string(text) + `"`), nil
}
// UnmarshalJSON reads the same word, quoted. The mapping is in UnmarshalText
// and is not written a second time; null is JSON's spelling of an absent field,
// which leaves the encoding as it was.
func (enc *Encoding) UnmarshalJSON(b []byte) error {
str := string(b)
if str == "null" {
return nil
}
word, err := strconv.Unquote(str)
if err != nil {
return errInvalidEncoding
}
return enc.UnmarshalText([]byte(word))
}
// Encode [bytes] to a string using the given encoding format [bytes] may be
// nil, in which case it will be treated the same as an empty slice.
func Encode(encoding Encoding, bytes []byte) (string, error) {
if !encoding.valid() {
return "", errInvalidEncoding
}
switch encoding {
case Hex, HexC:
bytesLen := len(bytes)
if bytesLen > math.MaxInt32-checksumLen {
return "", errEncodingOverFlow
}
checked := make([]byte, bytesLen+checksumLen)
copy(checked, bytes)
copy(checked[len(bytes):], hash.Checksum(bytes, checksumLen))
bytes = checked
}
switch encoding {
case Hex, HexNC, HexC:
return fmt.Sprintf("0x%x", bytes), nil
case JSON:
// JSON Marshal does not support []byte input and we rely on the
// router's json marshalling to marshal our interface{} into JSON
// in response. Therefore it is not supported in this call.
return "", errUnsupportedEncodingInMethod
default:
return "", errInvalidEncoding
}
}
// Decode [str] to bytes using the given encoding
// If [str] is the empty string, returns a nil byte slice and nil error
func Decode(encoding Encoding, str string) ([]byte, error) {
switch {
case !encoding.valid():
return nil, errInvalidEncoding
case len(str) == 0:
return nil, nil
}
var (
decodedBytes []byte
err error
)
switch encoding {
case Hex, HexNC, HexC:
if !strings.HasPrefix(str, hexPrefix) {
return nil, errMissingHexPrefix
}
decodedBytes, err = hex.DecodeString(str[2:])
case JSON:
// JSON unmarshalling requires interface and has no return values
// contrary to this method, therefore it is not supported in this call
return nil, errUnsupportedEncodingInMethod
default:
return nil, errInvalidEncoding
}
if err != nil {
return nil, err
}
switch encoding {
case Hex, HexC:
if len(decodedBytes) < checksumLen {
return nil, errMissingChecksum
}
// Verify the checksum
rawBytes := decodedBytes[:len(decodedBytes)-checksumLen]
checksum := decodedBytes[len(decodedBytes)-checksumLen:]
if !bytes.Equal(checksum, hash.Checksum(rawBytes, checksumLen)) {
return nil, errBadChecksum
}
decodedBytes = rawBytes
}
return decodedBytes, nil
}