diff --git a/examples/gno.land/p/n2p5/haystack/gno.mod b/examples/gno.land/p/n2p5/haystack/gno.mod new file mode 100644 index 00000000000..ebd0d07a987 --- /dev/null +++ b/examples/gno.land/p/n2p5/haystack/gno.mod @@ -0,0 +1,6 @@ +module gno.land/p/n2p5/haystack + +require ( + gno.land/p/demo/avl v0.0.0-latest + gno.land/p/n2p5/haystack/needle v0.0.0-latest +) diff --git a/examples/gno.land/p/n2p5/haystack/haystack.gno b/examples/gno.land/p/n2p5/haystack/haystack.gno new file mode 100644 index 00000000000..0ab4953acb6 --- /dev/null +++ b/examples/gno.land/p/n2p5/haystack/haystack.gno @@ -0,0 +1,99 @@ +package haystack + +import ( + "encoding/hex" + "errors" + + "gno.land/p/demo/avl" + "gno.land/p/n2p5/haystack/needle" +) + +var ( + // ErrorNeedleNotFound is returned when a needle is not found in the haystack. + ErrorNeedleNotFound = errors.New("needle not found") + // ErrorNeedleLength is returned when a needle is not the correct length. + ErrorNeedleLength = errors.New("invalid needle length") + // ErrorHashLength is returned when a needle hash is not the correct length. + ErrorHashLength = errors.New("invalid hash length") + // ErrorDuplicateNeedle is returned when a needle already exists in the haystack. + ErrorDuplicateNeedle = errors.New("needle already exists") + // ErrorHashMismatch is returned when a needle hash does not match the needle. This should + // never happen and indicates a critical internal storage error. + ErrorHashMismatch = errors.New("storage error: hash mismatch") + // ErrorValueInvalidType is returned when a needle value is not a byte slice. This should + // never happen and indicates a critical internal storage error. + ErrorValueInvalidType = errors.New("storage error: invalid value type, expected []byte") +) + +const ( + // EncodedHashLength is the length of the hex-encoded needle hash. + EncodedHashLength = needle.HashLength * 2 + // EncodedPayloadLength is the length of the hex-encoded needle payload. + EncodedPayloadLength = needle.PayloadLength * 2 + // EncodedNeedleLength is the length of the hex-encoded needle. + EncodedNeedleLength = EncodedHashLength + EncodedPayloadLength +) + +// Haystack is a permissionless, append-only, content-addressed key-value store for fix +// length messages known as needles. A needle is a 192 byte byte slice with a 32 byte +// hash (sha256) and a 160 byte payload. +type Haystack struct{ internal *avl.Tree } + +// New creates a new instance of a Haystack key-value store. +func New() *Haystack { + return &Haystack{ + internal: avl.NewTree(), + } +} + +// Add takes a fixed-length hex-encoded needle bytes and adds it to the haystack key-value +// store. The key is the first 32 bytes of the needle hash (64 bytes hex-encoded) of the +// sha256 sum of the payload. The value is the 160 byte byte slice of the needle payload. +// An error is returned if the needle is found to be invalid. +func (h *Haystack) Add(needleHex string) error { + if len(needleHex) != EncodedNeedleLength { + return ErrorNeedleLength + } + b, err := hex.DecodeString(needleHex) + if err != nil { + return err + } + n, err := needle.FromBytes(b) + if err != nil { + return err + } + if h.internal.Has(needleHex[:EncodedHashLength]) { + return ErrorDuplicateNeedle + } + h.internal.Set(needleHex[:EncodedHashLength], n.Payload()) + return nil +} + +// Get takes a hex-encoded needle hash and returns the complete hex-encoded needle bytes +// and an error. Errors covers errors that span from the needle not being found, internal +// storage error inconsistencies, and invalid value types. +func (h *Haystack) Get(hash string) (string, error) { + if len(hash) != EncodedHashLength { + return "", ErrorHashLength + } + if _, err := hex.DecodeString(hash); err != nil { + return "", err + } + v, ok := h.internal.Get(hash) + if !ok { + return "", ErrorNeedleNotFound + } + b, ok := v.([]byte) + if !ok { + return "", ErrorValueInvalidType + } + n, err := needle.New(b) + if err != nil { + return "", err + } + needleHash := hex.EncodeToString(n.Hash()) + if needleHash != hash { + return "", ErrorHashMismatch + } + return hex.EncodeToString(n.Bytes()), nil +} diff --git a/examples/gno.land/p/n2p5/haystack/haystack_test.gno b/examples/gno.land/p/n2p5/haystack/haystack_test.gno new file mode 100644 index 00000000000..8291a101d73 --- /dev/null +++ b/examples/gno.land/p/n2p5/haystack/haystack_test.gno @@ -0,0 +1,94 @@ +package haystack + +import ( + "encoding/hex" + "testing" + + "gno.land/p/n2p5/haystack/needle" +) + +func TestHaystack(t *testing.T) { + t.Parallel() + + t.Run("New", func(t *testing.T) { + t.Parallel() + h := New() + if h == nil { + t.Error("New returned nil") + } + }) + + t.Run("Add", func(t *testing.T) { + t.Parallel() + h := New() + n, _ := needle.New(make([]byte, needle.PayloadLength)) + validNeedleHex := hex.EncodeToString(n.Bytes()) + + testTable := []struct { + needleHex string + err error + }{ + {validNeedleHex, nil}, + {validNeedleHex, ErrorDuplicateNeedle}, + {"bad" + validNeedleHex[3:], needle.ErrorInvalidHash}, + {"XXX" + validNeedleHex[3:], hex.InvalidByteError('X')}, + {validNeedleHex[:len(validNeedleHex)-2], ErrorNeedleLength}, + {validNeedleHex + "00", ErrorNeedleLength}, + {"000", ErrorNeedleLength}, + } + for _, tt := range testTable { + err := h.Add(tt.needleHex) + if err != tt.err { + t.Error(tt.needleHex, err.Error(), "!=", tt.err.Error()) + } + } + }) + + t.Run("Get", func(t *testing.T) { + t.Parallel() + h := New() + + // genNeedleHex returns a hex-encoded needle and its hash for a given index. + genNeedleHex := func(i int) (string, string) { + b := make([]byte, needle.PayloadLength) + b[0] = byte(i) + n, _ := needle.New(b) + return hex.EncodeToString(n.Bytes()), hex.EncodeToString(n.Hash()) + } + + // Add a valid needle to the haystack. + validNeedleHex, validHash := genNeedleHex(0) + h.Add(validNeedleHex) + + // Add a needle and break the value type. + _, brokenHashValueType := genNeedleHex(1) + h.internal.Set(brokenHashValueType, 0) + + // Add a needle with invalid hash. + _, invalidHash := genNeedleHex(2) + h.internal.Set(invalidHash, make([]byte, needle.PayloadLength)) + + testTable := []struct { + hash string + expected string + err error + }{ + {validHash, validNeedleHex, nil}, + {validHash[:len(validHash)-2], "", ErrorHashLength}, + {validHash + "00", "", ErrorHashLength}, + {"XXX" + validHash[3:], "", hex.InvalidByteError('X')}, + {"bad" + validHash[3:], "", ErrorNeedleNotFound}, + {brokenHashValueType, "", ErrorValueInvalidType}, + {invalidHash, "", ErrorHashMismatch}, + } + for _, tt := range testTable { + actual, err := h.Get(tt.hash) + if err != tt.err { + t.Error(tt.hash, err.Error(), "!=", tt.err.Error()) + } + if actual != tt.expected { + t.Error(tt.hash, actual, "!=", tt.expected) + } + } + }) +} diff --git a/examples/gno.land/p/n2p5/haystack/needle/gno.mod b/examples/gno.land/p/n2p5/haystack/needle/gno.mod new file mode 100644 index 00000000000..91f489282cf --- /dev/null +++ b/examples/gno.land/p/n2p5/haystack/needle/gno.mod @@ -0,0 +1 @@ +module gno.land/p/n2p5/haystack/needle diff --git a/examples/gno.land/p/n2p5/haystack/needle/needle.gno b/examples/gno.land/p/n2p5/haystack/needle/needle.gno new file mode 100644 index 00000000000..971bc31599a --- /dev/null +++ b/examples/gno.land/p/n2p5/haystack/needle/needle.gno @@ -0,0 +1,91 @@ +package needle + +import ( + "bytes" + "crypto/sha256" + "errors" +) + +const ( + // HashLength is the length in bytes of the hash prefix in any message + HashLength = 32 + // PayloadLength is the length of the remaining bytes of the message. + PayloadLength = 160 + // NeedleLength is the number of bytes required for a valid needle. + NeedleLength = HashLength + PayloadLength +) + +// Needle is a container for a 160 byte payload +// and a 32 byte sha256 hash of the payload. +type Needle struct { + hash [HashLength]byte + payload [PayloadLength]byte +} + +var ( + // ErrorInvalidHash is an error for in invalid hash + ErrorInvalidHash = errors.New("invalid hash") + // ErrorByteSliceLength is an error for an invalid byte slice length passed in to New or FromBytes + ErrorByteSliceLength = errors.New("invalid byte slice length") +) + +// New creates a Needle used for submitting a payload to a Haystack sever. It takes a Payload +// byte slice that is 160 bytes in length and returns a reference to a +// Needle and an error. The purpose of this function is to make it +// easy to create a new Needle from a payload. This function handles creating a sha256 +// hash of the payload, which is used by the Needle to submit to a haystack server. +func New(p []byte) (*Needle, error) { + if len(p) != PayloadLength { + return nil, ErrorByteSliceLength + } + var n Needle + sum := sha256.Sum256(p) + copy(n.hash[:], sum[:]) + copy(n.payload[:], p) + return &n, nil +} + +// FromBytes is intended convert raw bytes (from UDP or storage) into a Needle. +// It takes a byte slice and expects it to be exactly the length of NeedleLength. +// The byte slice should consist of the first 32 bytes being the sha256 hash of the +// payload and the payload bytes. This function verifies the length of the byte slice, +// copies the bytes into a private [192]byte array, and validates the Needle. It returns +// a reference to a Needle and an error. +func FromBytes(b []byte) (*Needle, error) { + if len(b) != NeedleLength { + return nil, ErrorByteSliceLength + } + var n Needle + copy(n.hash[:], b[:HashLength]) + copy(n.payload[:], b[HashLength:]) + if err := n.validate(); err != nil { + return nil, err + } + return &n, nil +} + +// Hash returns a copy of the bytes of the sha256 256 hash of the Needle payload. +func (n *Needle) Hash() []byte { + return n.Bytes()[:HashLength] +} + +// Payload returns a byte slice of the Needle payload +func (n *Needle) Payload() []byte { + return n.Bytes()[HashLength:] +} + +// Bytes returns a byte slice of the entire 192 byte hash + payload +func (n *Needle) Bytes() []byte { + b := make([]byte, NeedleLength) + copy(b, n.hash[:]) + copy(b[HashLength:], n.payload[:]) + return b +} + +// validate checks that a Needle has a valid hash, it returns either nil or an error. +func (n *Needle) validate() error { + if hash := sha256.Sum256(n.Payload()); !bytes.Equal(n.Hash(), hash[:]) { + return ErrorInvalidHash + } + return nil +} diff --git a/examples/gno.land/p/n2p5/haystack/needle/needle_test.gno b/examples/gno.land/p/n2p5/haystack/needle/needle_test.gno new file mode 100644 index 00000000000..aa81750fc00 --- /dev/null +++ b/examples/gno.land/p/n2p5/haystack/needle/needle_test.gno @@ -0,0 +1,157 @@ +package needle + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "testing" +) + +func TestNeedle(t *testing.T) { + t.Parallel() + t.Run("Bytes", func(t *testing.T) { + t.Parallel() + p, _ := hex.DecodeString("40e4350b03d8b0c9e340321210b259d9a20b19632929b4a219254a4269c11f820c75168c6a91d309f4b134a7d715a5ac408991e1cf9415995053cf8a4e185dae22a06617ac51ebf7d232bc49e567f90be4db815c2b88ca0d9a4ef7a5119c0e592c88dfb96706e6510fb8a657c0f70f6695ea310d24786e6d980e9b33cf2665342b965b2391f6bb982c4c5f6058b9cba58038d32452e07cdee9420a8bd7f514e1") + n, _ := New(p) + b := n.Bytes() + b[0], b[1], b[2], b[3] = 0, 0, 0, 0 + if bytes.Equal(n.Bytes(), b) { + t.Error("mutating Bytes() changed needle bytes") + } + }) + t.Run("Payload", func(t *testing.T) { + t.Parallel() + p, _ := hex.DecodeString("40e4350b03d8b0c9e340321210b259d9a20b19632929b4a219254a4269c11f820c75168c6a91d309f4b134a7d715a5ac408991e1cf9415995053cf8a4e185dae22a06617ac51ebf7d232bc49e567f90be4db815c2b88ca0d9a4ef7a5119c0e592c88dfb96706e6510fb8a657c0f70f6695ea310d24786e6d980e9b33cf2665342b965b2391f6bb982c4c5f6058b9cba58038d32452e07cdee9420a8bd7f514e1") + n, _ := New(p) + payload := n.Payload() + if !bytes.Equal(p, payload) { + t.Error("payload imported by New does not match needle.Payload()") + } + payload[0] = 0 + pl := n.Payload() + if bytes.Equal(pl, payload) { + t.Error("mutating Payload() changed needle payload") + } + }) + t.Run("Hash", func(t *testing.T) { + t.Parallel() + p, _ := hex.DecodeString("40e4350b03d8b0c9e340321210b259d9a20b19632929b4a219254a4269c11f820c75168c6a91d309f4b134a7d715a5ac408991e1cf9415995053cf8a4e185dae22a06617ac51ebf7d232bc49e567f90be4db815c2b88ca0d9a4ef7a5119c0e592c88dfb96706e6510fb8a657c0f70f6695ea310d24786e6d980e9b33cf2665342b965b2391f6bb982c4c5f6058b9cba58038d32452e07cdee9420a8bd7f514e1") + n, _ := New(p) + hash := n.Hash() + h := sha256.Sum256(p) + if !bytes.Equal(h[:], hash) { + t.Error("exported hash is invalid") + } + hash[0] = 0 + h2 := n.Hash() + if bytes.Equal(h2, hash) { + t.Error("mutating Hash() changed needle hash") + } + }) +} + +func TestNew(t *testing.T) { + t.Parallel() + + p, _ := hex.DecodeString("40e4350b03d8b0c9e340321210b259d9a20b19632929b4a219254a4269c11f820c75168c6a91d309f4b134a7d715a5ac408991e1cf9415995053cf8a4e185dae22a06617ac51ebf7d232bc49e567f90be4db815c2b88ca0d9a4ef7a5119c0e592c88dfb96706e6510fb8a657c0f70f6695ea310d24786e6d980e9b33cf2665342b965b2391f6bb982c4c5f6058b9cba58038d32452e07cdee9420a8bd7f514e1") + expected, _ := hex.DecodeString("f1b462c84a0c51dad44293951f0b084a8871b3700ac1b9fc7a53a20bc0ba0fed40e4350b03d8b0c9e340321210b259d9a20b19632929b4a219254a4269c11f820c75168c6a91d309f4b134a7d715a5ac408991e1cf9415995053cf8a4e185dae22a06617ac51ebf7d232bc49e567f90be4db815c2b88ca0d9a4ef7a5119c0e592c88dfb96706e6510fb8a657c0f70f6695ea310d24786e6d980e9b33cf2665342b965b2391f6bb982c4c5f6058b9cba58038d32452e07cdee9420a8bd7f514e1") + + testTable := []struct { + payload []byte + expected []byte + hasError bool + description string + }{ + { + payload: p, + expected: expected, + hasError: false, + description: "expected payload", + }, + { + payload: p[:PayloadLength-1], + expected: nil, + hasError: true, + description: "payload invalid length (too small)", + }, + { + payload: append(p, byte(1)), + expected: nil, + hasError: true, + description: "payload invalid length (too large)", + }, + } + + for _, test := range testTable { + n, err := New(test.payload) + if err != nil { + if !test.hasError { + t.Errorf("test: %v had error: %v", test.description, err) + } + } else if !bytes.Equal(n.Bytes(), test.expected) { + t.Errorf("%v, bytes not equal\n%x\n%x", test.description, n.Bytes(), test.expected) + } + } +} + +func TestFromBytes(t *testing.T) { + t.Parallel() + + validRaw, _ := hex.DecodeString("f1b462c84a0c51dad44293951f0b084a8871b3700ac1b9fc7a53a20bc0ba0fed40e4350b03d8b0c9e340321210b259d9a20b19632929b4a219254a4269c11f820c75168c6a91d309f4b134a7d715a5ac408991e1cf9415995053cf8a4e185dae22a06617ac51ebf7d232bc49e567f90be4db815c2b88ca0d9a4ef7a5119c0e592c88dfb96706e6510fb8a657c0f70f6695ea310d24786e6d980e9b33cf2665342b965b2391f6bb982c4c5f6058b9cba58038d32452e07cdee9420a8bd7f514e1") + validExpected, _ := hex.DecodeString("f1b462c84a0c51dad44293951f0b084a8871b3700ac1b9fc7a53a20bc0ba0fed40e4350b03d8b0c9e340321210b259d9a20b19632929b4a219254a4269c11f820c75168c6a91d309f4b134a7d715a5ac408991e1cf9415995053cf8a4e185dae22a06617ac51ebf7d232bc49e567f90be4db815c2b88ca0d9a4ef7a5119c0e592c88dfb96706e6510fb8a657c0f70f6695ea310d24786e6d980e9b33cf2665342b965b2391f6bb982c4c5f6058b9cba58038d32452e07cdee9420a8bd7f514e1") + invalidHash, _ := hex.DecodeString("182e0ca0d2fb1da76da6caf36a9d0d2838655632e85891216dc8b545d8f1410940e4350b03d8b0c9e340321210b259d9a20b19632929b4a219254a4269c11f820c75168c6a91d309f4b134a7d715a5ac408991e1cf9415995053cf8a4e185dae22a06617ac51ebf7d232bc49e567f90be4db815c2b88ca0d9a4ef7a5119c0e592c88dfb96706e6510fb8a657c0f70f6695ea310d24786e6d980e9b33cf2665342b965b2391f6bb982c4c5f6058b9cba58038d32452e07cdee9420a8bd7f514e1") + + testTable := []struct { + rawBytes []byte + expected []byte + hasError bool + description string + }{ + { + rawBytes: validRaw, + expected: validExpected, + hasError: false, + description: "valid raw bytes", + }, + { + rawBytes: make([]byte, 0), + expected: nil, + hasError: true, + description: "empty bytes", + }, + { + rawBytes: make([]byte, NeedleLength-1), + expected: nil, + hasError: true, + description: "too few bytes, one less than expected", + }, + { + rawBytes: make([]byte, 0), + expected: nil, + hasError: true, + description: "too few bytes, no bytes", + }, + { + rawBytes: make([]byte, NeedleLength+1), + expected: nil, + hasError: true, + description: "too many bytes", + }, + { + rawBytes: invalidHash, + expected: nil, + hasError: true, + description: "invalid hash", + }, + } + for _, test := range testTable { + n, err := FromBytes(test.rawBytes) + if err != nil { + if !test.hasError { + t.Errorf("test: %v had error: %v", test.description, err) + } + } else if !bytes.Equal(n.Bytes(), test.expected) { + t.Errorf("%v, bytes not equal\n%x\n%x", test.description, n.Bytes(), test.expected) + } + } +} diff --git a/examples/gno.land/r/n2p5/haystack/gno.mod b/examples/gno.land/r/n2p5/haystack/gno.mod new file mode 100644 index 00000000000..9203eb2d3b1 --- /dev/null +++ b/examples/gno.land/r/n2p5/haystack/gno.mod @@ -0,0 +1,8 @@ +module gno.land/r/n2p5/haystack + +require ( + gno.land/p/demo/testutils v0.0.0-latest + gno.land/p/demo/urequire v0.0.0-latest + gno.land/p/n2p5/haystack v0.0.0-latest + gno.land/p/n2p5/haystack/needle v0.0.0-latest +) diff --git a/examples/gno.land/r/n2p5/haystack/haystack.gno b/examples/gno.land/r/n2p5/haystack/haystack.gno new file mode 100644 index 00000000000..397de1e3e3d --- /dev/null +++ b/examples/gno.land/r/n2p5/haystack/haystack.gno @@ -0,0 +1,32 @@ +package haystack + +import ( + "gno.land/p/n2p5/haystack" +) + +var storage = haystack.New() + +func Render(path string) string { + return ` +Put a Needle in the Haystack. +` +} + +// Add takes a fixed-length hex-encoded needle bytes and adds it to the haystack key-value store. +// If storage encounters an error, it will panic. +func Add(needleHex string) { + err := storage.Add(needleHex) + if err != nil { + panic(err) + } +} + +// Get takes a fixed-length hex-encoded needle hash and returns the hex-encoded needle bytes. +// If storage encounters an error, it will panic. +func Get(hashHex string) string { + needleHex, err := storage.Get(hashHex) + if err != nil { + panic(err) + } + return needleHex +} diff --git a/examples/gno.land/r/n2p5/haystack/haystack_test.gno b/examples/gno.land/r/n2p5/haystack/haystack_test.gno new file mode 100644 index 00000000000..52dadf8bf9e --- /dev/null +++ b/examples/gno.land/r/n2p5/haystack/haystack_test.gno @@ -0,0 +1,70 @@ +package haystack + +import ( + "encoding/hex" + "std" + "testing" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/urequire" + "gno.land/p/n2p5/haystack" + "gno.land/p/n2p5/haystack/needle" +) + +func TestHaystack(t *testing.T) { + t.Parallel() + // needleHex returns a hex-encoded needle and its hash for a given index. + genNeedleHex := func(i int) (string, string) { + b := make([]byte, needle.PayloadLength) + b[0] = byte(i) + n, _ := needle.New(b) + return hex.EncodeToString(n.Bytes()), hex.EncodeToString(n.Hash()) + } + + u1 := testutils.TestAddress("u1") + u2 := testutils.TestAddress("u2") + + t.Run("Add", func(t *testing.T) { + t.Parallel() + + n1, _ := genNeedleHex(1) + n2, _ := genNeedleHex(2) + n3, _ := genNeedleHex(3) + + std.TestSetOrigCaller(u1) + urequire.NotPanics(t, func() { Add(n1) }) + urequire.PanicsWithMessage(t, + haystack.ErrorDuplicateNeedle.Error(), + func() { + Add(n1) + }) + std.TestSetOrigCaller(u2) + urequire.NotPanics(t, func() { Add(n2) }) + urequire.NotPanics(t, func() { Add(n3) }) + }) + + t.Run("Get", func(t *testing.T) { + t.Parallel() + + n1, h1 := genNeedleHex(4) + _, h2 := genNeedleHex(5) + + std.TestSetOrigCaller(u1) + urequire.NotPanics(t, func() { Add(n1) }) + urequire.NotPanics(t, func() { + result := Get(h1) + urequire.Equal(t, n1, result) + }) + + std.TestSetOrigCaller(u2) + urequire.NotPanics(t, func() { + result := Get(h1) + urequire.Equal(t, n1, result) + }) + urequire.PanicsWithMessage(t, + haystack.ErrorNeedleNotFound.Error(), + func() { + Get(h2) + }) + }) +}