Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions pdp/contract/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ package contract
import (
"context"
"crypto/ecdsa"
"encoding/hex"
"fmt"
mbig "math/big"
"strings"
"time"
"unicode"
"unicode/utf8"

"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
Expand Down Expand Up @@ -109,6 +113,13 @@ func OfferingToCapabilities(offering PDPOfferingData, additionalCaps map[string]
// Add custom capabilities
for k, v := range additionalCaps {
keys = append(keys, k)
// try hexadecimal
if len(v)%2 == 0 && len(v) > 3 && strings.HasPrefix(v, "0x") {
if decoded, err := hex.DecodeString(v[2:]); err == nil {
values = append(values, decoded)
continue
}
}
values = append(values, []byte(v))
}

Expand Down Expand Up @@ -516,3 +527,31 @@ func DecodeAddressCapability(input []byte) common.Address {
// Lowest 20 bytes are the address
return common.BytesToAddress(buf[12:])
}

// ShouldHexEncodeCapability reports whether a capability value needs hex-encoding
// to safely round-trip through JSON and browser input fields. Returns true for
// invalid UTF-8 or control characters; false for valid text (including CJK/emoji).
// See https://pkg.go.dev/unicode/utf8#DecodeRune for RuneError semantics.
func ShouldHexEncodeCapability(b []byte) bool {
for i := 0; i < len(b); {
r, size := utf8.DecodeRune(b[i:])
if r == utf8.RuneError && size == 1 { // invalid UTF-8
return true
}
if unicode.IsControl(r) {
return true
}
i += size
}
return false
}

// EncodeCapabilityForDisplay returns a display string for a capability value.
// Binary data gets "0x" hex prefix; valid UTF-8 text passes through as-is.
// Pairs with hex-decoding in OfferingToCapabilities.
func EncodeCapabilityForDisplay(b []byte) string {
if ShouldHexEncodeCapability(b) {
return "0x" + hex.EncodeToString(b)
}
return string(b)
}
149 changes: 149 additions & 0 deletions pdp/contract/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package contract
import (
"bytes"
"encoding/hex"
"math/big"
"testing"

"github.com/ethereum/go-ethereum/common"
Expand Down Expand Up @@ -100,3 +101,151 @@ func TestDecodeAddressCapability_PadLeft(t *testing.T) {
}
}
}

func TestOfferingToCapabilities_AdditionalHex(t *testing.T) {
offering := PDPOfferingData{
"https://pdp.example.com",
big.NewInt(32),
big.NewInt(0x1000000000000000),
false,
false,
[]byte{1, 1, 1, 1, 1, 1, 1, 1, 1},
big.NewInt(6000),
big.NewInt(30),
"narnia",
common.HexToAddress("0x0000000000004946c0e9F43F4Dee607b0eF1fA1c"),
}
additionalCaps := make(map[string]string)
additionalCaps["coolEndorsement"] = "0xccccaaaaddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddaaaacccc"
additionalCaps["owner"] = "0x4a6f6B9fF1fc974096f9063a45Fd12bD5B928AD1"
keys, values, err := OfferingToCapabilities(offering, additionalCaps)
if err != nil {
t.Errorf("OfferingToCapabilities returned error %s", err)
}
if len(keys) != len(values) {
t.Errorf("length mismatch: %d keys for %d values", len(keys), len(values))
}
capabilities := make(map[string][]byte)
for i := range len(keys) {
if len(keys[i]) == 0 {
t.Errorf("Got empty key at index %d", i)
}
if values[i] == nil {
t.Errorf("Got nil value for key %s", keys[i])
}
if _, contains := capabilities[keys[i]]; contains {
t.Errorf("Got duplicate key %s", keys[i])
}
capabilities[keys[i]] = values[i]
}
for key, valueStr := range additionalCaps {
if value, contains := capabilities[key]; !contains {
t.Errorf("keys: Missing '%s' key from additionalCaps", key)
} else if expectedLength := (len(valueStr) - 2) / 2; expectedLength != len(value) {
t.Errorf("Wrong length for hex capability '%s': expected %d actual %d", key, expectedLength, len(value))
} else if !bytes.Equal(value, mustHex(additionalCaps[key])) {
t.Errorf("Mismatching value for key '%s': expected %s actual 0x%x", key, valueStr, value)
}
}
}

func TestShouldHexEncodeCapability(t *testing.T) {
tests := []struct {
name string
input []byte
wantHex bool
}{
// Valid UTF-8 text - should NOT be hex encoded
{
name: "ASCII text",
input: []byte("hello world"),
wantHex: false,
},
{
name: "URL",
input: []byte("https://example.com/path?query=value"),
wantHex: false,
},
{
name: "Chinese characters",
input: []byte("北京"),
wantHex: false,
},
{
name: "Japanese characters",
input: []byte("東京"),
wantHex: false,
},
{
name: "Emoji",
input: []byte("🚀🎉"),
wantHex: false,
},
{
name: "Mixed Unicode",
input: []byte("Hello 世界 🌍"),
wantHex: false,
},
{
name: "Empty string",
input: []byte(""),
wantHex: false,
},

// Binary data - should be hex encoded
{
name: "Null byte",
input: []byte("hello\x00world"),
wantHex: true,
},
{
name: "Control character (bell)",
input: []byte("hello\x07world"),
wantHex: true,
},
{
name: "Control character (tab)",
input: []byte("hello\tworld"),
wantHex: true,
},
{
name: "Control character (newline)",
input: []byte("hello\nworld"),
wantHex: true,
},
{
name: "DEL character",
input: []byte("hello\x7Fworld"),
wantHex: true,
},
{
name: "C1 control character",
input: []byte("hello\x80world"),
wantHex: true,
},
{
name: "Invalid UTF-8 sequence",
input: []byte{0xff, 0xfe},
wantHex: true,
},
{
name: "Ethereum address bytes",
input: mustHex("0x4a6f6B9fF1fc974096f9063a45Fd12bD5B928AD1"),
wantHex: true,
},
{
name: "Certificate-like binary with null",
input: []byte{0x30, 0x82, 0x00, 0x00, 0x02, 0x01},
wantHex: true,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := ShouldHexEncodeCapability(tc.input)
if got != tc.wantHex {
t.Errorf("ShouldHexEncodeCapability(%q) = %v, want %v", tc.input, got, tc.wantHex)
}
})
}
}
4 changes: 2 additions & 2 deletions web/api/webrpc/pdp.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,8 @@ func capabilitiesToOffering(keys []string, values [][]byte) (*FSPDPOffering, map
case contract.CapPaymentToken:
offering.PaymentTokenAddress = contract.DecodeAddressCapability(value).Hex()
default:
// Custom capability
customCaps[key] = string(value)
// Custom capability - encode for safe round-trip through JSON/browser
customCaps[key] = contract.EncodeCapabilityForDisplay(value)
}
}

Expand Down