Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ipns): reading records with raw []byte Value #830

Merged
merged 3 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ The following emojis are used to highlight certain changes:

### Fixed

- `ipns`: Improved interop with legacy clients by restoring support for `[]byte` CID in `Value` field. `Value()` will convert it to a valid `path.Path`. Empty `Value()` will produce `NoopPath` (`/ipfs/bafkqaaa`) to avoid breaking existing code that expects a valid record to always produce a valid content path. [#830](https://github.com/ipfs/boxo/pull/830)

### Security


Expand Down
36 changes: 32 additions & 4 deletions ipns/record.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
ipns_pb "github.com/ipfs/boxo/ipns/pb"
"github.com/ipfs/boxo/path"
"github.com/ipfs/boxo/util"
"github.com/ipfs/go-cid"
logging "github.com/ipfs/go-log/v2"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
Expand All @@ -31,6 +32,9 @@
// the only supported Validity type.
const ValidityEOL ValidityType = 0

// NoopValue is an identity CID that points at zero bytes.
const NoopValue = "/ipfs/bafkqaaa"

// Record represents an [IPNS Record].
//
// [IPNS Record]: https://specs.ipfs.tech/ipns/ipns-record/
Expand Down Expand Up @@ -89,10 +93,29 @@
return nil, err
}

p, err := path.NewPath(string(value))
if len(value) == 0 {
// To maximize interop across implementations, turn empty value into zero-length identity CID.
// This is a convenience placeholder used when the value in record is empty or does
// not matter because IPNS is used for custom CBOR in the Data field.
value = []byte(NoopValue)
}

// parse as a string with content path
if len(value) > 0 && value[0] == '/' {
p, err := path.NewPath(string(value))
if err != nil {
return nil, multierr.Combine(ErrInvalidPath, err)
}

Check warning on line 108 in ipns/record.go

View check run for this annotation

Codecov / codecov/patch

ipns/record.go#L107-L108

Added lines #L107 - L108 were not covered by tests
// done, finish fast
return p, nil
}

// fallback: for legacy/optimization reasons, the value could be a valid CID in byte form
binaryCid, err := cid.Cast(value)
if err != nil {
return nil, multierr.Combine(ErrInvalidPath, err)
}
p := path.FromCid(binaryCid)

return p, nil
}
Expand Down Expand Up @@ -232,6 +255,11 @@
// option [WithPublicKey]. In addition, records are, by default created with V1
// compatibility.
func NewRecord(sk ic.PrivKey, value path.Path, seq uint64, eol time.Time, ttl time.Duration, opts ...Option) (*Record, error) {
return newRecord(sk, []byte(value.String()), seq, eol, ttl, opts...)
}

// newRecord is a private version of NewRecord that allows arbitrary []byte values (used internally for testing)
func newRecord(sk ic.PrivKey, value []byte, seq uint64, eol time.Time, ttl time.Duration, opts ...Option) (*Record, error) {
options := processOptions(opts...)

node, err := createNode(value, seq, eol, ttl)
Expand Down Expand Up @@ -260,7 +288,7 @@
}

if options.v1Compatibility {
pb.Value = []byte(value.String())
pb.Value = value
typ := ipns_pb.IpnsRecord_EOL
pb.ValidityType = &typ
pb.Sequence = &seq
Expand Down Expand Up @@ -303,11 +331,11 @@
}, nil
}

func createNode(value path.Path, seq uint64, eol time.Time, ttl time.Duration) (datamodel.Node, error) {
func createNode(value []byte, seq uint64, eol time.Time, ttl time.Duration) (datamodel.Node, error) {
m := make(map[string]ipld.Node)
var keys []string

m[cborValueKey] = basicnode.NewBytes([]byte(value.String()))
m[cborValueKey] = basicnode.NewBytes(value)
keys = append(keys, cborValueKey)

m[cborValidityKey] = basicnode.NewBytes([]byte(util.FormatRFC3339(eol)))
Expand Down
75 changes: 75 additions & 0 deletions ipns/record_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
ipns_pb "github.com/ipfs/boxo/ipns/pb"
"github.com/ipfs/boxo/path"
"github.com/ipfs/boxo/util"
"github.com/ipfs/go-cid"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
basicnode "github.com/ipld/go-ipld-prime/node/basic"
ic "github.com/libp2p/go-libp2p/core/crypto"
Expand Down Expand Up @@ -45,6 +46,13 @@ func mustNewRecord(t *testing.T, sk ic.PrivKey, value path.Path, seq uint64, eol
return rec
}

func mustNewRawRecord(t *testing.T, sk ic.PrivKey, value []byte, seq uint64, eol time.Time, ttl time.Duration, opts ...Option) *Record {
rec, err := newRecord(sk, value, seq, eol, ttl, opts...)
require.NoError(t, err)
require.NoError(t, Validate(rec, sk.GetPublic()))
return rec
}

func mustMarshal(t *testing.T, entry *Record) []byte {
data, err := MarshalRecord(entry)
require.NoError(t, err)
Expand Down Expand Up @@ -259,6 +267,12 @@ func TestCBORDataSerialization(t *testing.T) {
func TestUnmarshal(t *testing.T) {
t.Parallel()

sk, _, _ := mustKeyPair(t, ic.Ed25519)

seq := uint64(0)
eol := time.Now().Add(time.Hour)
ttl := time.Minute * 10

t.Run("Errors on invalid bytes", func(t *testing.T) {
_, err := UnmarshalRecord([]byte("blah blah blah"))
require.ErrorIs(t, err, ErrInvalidRecord)
Expand Down Expand Up @@ -287,4 +301,65 @@ func TestUnmarshal(t *testing.T) {
_, err = UnmarshalRecord(data)
require.ErrorIs(t, err, ErrInvalidRecord)
})

t.Run("Errors on bad binary Value() unable to represent as a Path (interop)", func(t *testing.T) {
t.Parallel()

// create record with non-empty byte value that is not []byte CID nor a string with /ipfs/cid content path
rawByteValue := []byte{0x4A, 0x1B, 0x3C, 0x8D, 0x2E}
rec := mustNewRawRecord(t, sk, rawByteValue, seq, eol, ttl)

// confirm raw record has same bytes
require.Equal(t, rawByteValue, rec.pb.GetValue())
cborValue, err := rec.getBytesValue(cborValueKey)
require.NoError(t, err)
require.Equal(t, rawByteValue, cborValue)

// confirm rec.Value() returns error
recPath, err := rec.Value()
require.ErrorIs(t, err, ErrInvalidPath)
require.Empty(t, recPath)
})

t.Run("Reads record with empty Value() as zero-length identity CID (interop)", func(t *testing.T) {
t.Parallel()

// create record with empty byte value
rawByteValue := []byte{}
rec := mustNewRawRecord(t, sk, rawByteValue, seq, eol, ttl)

// confirm raw record has empty (0-length []byte) Value
require.Empty(t, rec.pb.GetValue())
cborValue, err := rec.getBytesValue(cborValueKey)
require.NoError(t, err)
require.Empty(t, cborValue)

// confirm rec.Value() returns NooPath for interop
recPath, err := rec.Value()
require.NoError(t, err)
require.Equal(t, NoopValue, recPath.String())
})

t.Run("Reads record with a []byte CID as Value() (interop)", func(t *testing.T) {
t.Parallel()

// create record with a valid CID in binary form
// instead of /ipfs/cid string
// (we need to support this because this was allowed since <2018)
testCid := "bafkqablimvwgy3y"
rawCidValue := cid.MustParse(testCid).Bytes()

rec := mustNewRawRecord(t, sk, rawCidValue, seq, eol, ttl)

// confirm raw record has same bytes
require.Equal(t, rawCidValue, rec.pb.GetValue())
cborValue, err := rec.getBytesValue(cborValueKey)
require.NoError(t, err)
require.Equal(t, rawCidValue, cborValue)

// confirm rec.Value() returns path for raw CID
recPath, err := rec.Value()
require.NoError(t, err)
require.Equal(t, "/ipfs/"+testCid, recPath.String())
})
}