Initial commit
This commit is contained in:
252
internal/protocol/protocol.go
Normal file
252
internal/protocol/protocol.go
Normal file
@@ -0,0 +1,252 @@
|
||||
// @tag protocol
|
||||
// @tag core
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
CRLF = "\r\n"
|
||||
MaxBulkLen = 512 * 1024 * 1024 // 512 MB limit for bulk strings - prevents memory exhaustion
|
||||
MaxArrayLen = 1024 * 1024 // 1M elements max - prevents array parsing attacks
|
||||
)
|
||||
|
||||
// Protocol errors - separated for clear error handling
|
||||
var (
|
||||
ErrProtocolError = errors.New("protocol error")
|
||||
ErrNotEnoughData = errors.New("not enough data")
|
||||
)
|
||||
|
||||
// Value represents any RESP value - using interface for simplicity with type switches.
|
||||
// This mirrors Redis protocol flexibility while keeping implementation simple.
|
||||
type Value any
|
||||
|
||||
// Error represents an error response from Redis (-ERR...)
|
||||
type Error struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e Error) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// Array represents an array of values (*...) - used for commands and responses.
|
||||
type Array []Value
|
||||
|
||||
// Bulk represents a bulk string ($...) - binary safe, used for most arguments.
|
||||
type Bulk []byte
|
||||
|
||||
func (b Bulk) String() string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// Int represents an integer response (:12345) - used for counts and numeric responses.
|
||||
type Int int64
|
||||
|
||||
// Status represents a simple string response (+OK) - used for simple confirmations.
|
||||
type Status string
|
||||
|
||||
func (s Status) String() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
// Parse reads and parses a single RESP value from the reader.
|
||||
// This is the main entry point for parsing incoming Redis commands.
|
||||
// Uses bufio.Reader for efficient I/O with built-in buffering.
|
||||
func Parse(reader *bufio.Reader) (Value, error) {
|
||||
// Read first line to determine message type
|
||||
line, err := readLine(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(line) == 0 {
|
||||
return nil, ErrProtocolError
|
||||
}
|
||||
|
||||
// Route based on first character - this is the RESP type indicator
|
||||
switch line[0] {
|
||||
case '+': // Simple string: +OK\r\n
|
||||
return Status(line[1:]), nil
|
||||
case '-': // Error: -ERR message\r\n
|
||||
return Error{Message: string(line[1:])}, nil
|
||||
case ':': // Integer: :12345\r\n
|
||||
n, err := strconv.ParseInt(string(line[1:]), 10, 64)
|
||||
if err != nil {
|
||||
return nil, ErrProtocolError
|
||||
}
|
||||
return Int(n), nil
|
||||
case '$': // Bulk string: $6\r\nfoobar\r\n or $-1 for null
|
||||
return parseBulk(reader, line)
|
||||
case '*': // Array: *2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n
|
||||
return parseArray(reader, line)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown type: %c", line[0])
|
||||
}
|
||||
}
|
||||
|
||||
// readLine reads a single line from the reader, handling CRLF.
|
||||
// Uses ReadLine for efficiency - it handles buffering internally.
|
||||
func readLine(reader *bufio.Reader) ([]byte, error) {
|
||||
line, isPrefix, err := reader.ReadLine()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isPrefix {
|
||||
return nil, fmt.Errorf("line too long")
|
||||
}
|
||||
return line, nil
|
||||
}
|
||||
|
||||
// parseBulk handles bulk string parsing ($<length>\r\n<data>\r\n).
|
||||
// Bulk strings are used for binary-safe string arguments and responses.
|
||||
func parseBulk(reader *bufio.Reader, line []byte) (Value, error) {
|
||||
// Parse the length field after the $ prefix
|
||||
size, err := strconv.Atoi(string(line[1:]))
|
||||
if err != nil {
|
||||
return nil, ErrProtocolError
|
||||
}
|
||||
|
||||
// $-1 means null bulk string - legitimate empty value
|
||||
if size == -1 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Validate size to prevent memory exhaustion attacks
|
||||
if size < 0 || size > MaxBulkLen {
|
||||
return nil, fmt.Errorf("bulk string too large: %d bytes", size)
|
||||
}
|
||||
|
||||
// Read exactly size bytes plus CRLF delimiter
|
||||
// Using ReadFull ensures we get all data or an error
|
||||
buf := make([]byte, size+len(CRLF))
|
||||
_, err = io.ReadFull(reader, buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify proper CRLF termination - prevents protocol injection
|
||||
if !bytes.HasSuffix(buf, []byte(CRLF)) {
|
||||
return nil, ErrProtocolError
|
||||
}
|
||||
|
||||
return Bulk(buf[:size]), nil
|
||||
}
|
||||
|
||||
// parseArray handles array parsing (*<count>\r\n<elements>...).
|
||||
// Arrays are the fundamental structure for Redis commands.
|
||||
func parseArray(reader *bufio.Reader, line []byte) (Value, error) {
|
||||
count, err := strconv.Atoi(string(line[1:]))
|
||||
if err != nil {
|
||||
return nil, ErrProtocolError
|
||||
}
|
||||
|
||||
// *-1 means null array - rare but valid
|
||||
if count == -1 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Limit array size to prevent DoS via large arrays
|
||||
if count < 0 || count > MaxArrayLen {
|
||||
return nil, fmt.Errorf("array too large: %d elements", count)
|
||||
}
|
||||
|
||||
// Pre-allocate with expected size to reduce reallocations
|
||||
result := make([]Value, count)
|
||||
for i := range count {
|
||||
val, err := Parse(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i] = val
|
||||
}
|
||||
|
||||
return Array(result), nil
|
||||
}
|
||||
|
||||
// Response builders - these create RESP-encoded responses to send to clients
|
||||
|
||||
// MakeError creates an error response (-ERR message).
|
||||
func MakeError(msg string) []byte {
|
||||
return []byte("-" + msg + CRLF)
|
||||
}
|
||||
|
||||
// MakeStatus creates a simple status response (+OK).
|
||||
func MakeStatus(status string) []byte {
|
||||
return []byte("+" + status + CRLF)
|
||||
}
|
||||
|
||||
// MakeInt creates an integer response (:12345).
|
||||
func MakeInt(n int64) []byte {
|
||||
return []byte(":" + strconv.FormatInt(n, 10) + CRLF)
|
||||
}
|
||||
|
||||
// MakeBulk creates a bulk string response ($<len>\r\n<data>\r\n).
|
||||
// Handles nil gracefully by returning null bulk string.
|
||||
func MakeBulk(data []byte) []byte {
|
||||
if data == nil {
|
||||
return []byte("$-1" + CRLF)
|
||||
}
|
||||
return []byte("$" + strconv.Itoa(len(data)) + CRLF + string(data) + CRLF)
|
||||
}
|
||||
|
||||
// MakeMultiBulk creates a RESP array from Value slices - used for pub/sub messages.
|
||||
func MakeMultiBulk(values []Value) []byte {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("*" + strconv.Itoa(len(values)) + CRLF)
|
||||
for _, v := range values {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
buf.WriteString("$-1\r\n")
|
||||
case string:
|
||||
buf.Write(MakeBulk([]byte(val)))
|
||||
case []byte:
|
||||
buf.Write(MakeBulk(val))
|
||||
case int64:
|
||||
buf.Write(MakeInt(val))
|
||||
case Int:
|
||||
buf.Write(MakeInt(int64(val)))
|
||||
case Error:
|
||||
buf.Write(MakeError(val.Message))
|
||||
case Status:
|
||||
buf.Write(MakeStatus(string(val)))
|
||||
default:
|
||||
buf.Write(MakeBulk([]byte(fmt.Sprintf("%v", val))))
|
||||
}
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// ToBytes converts a parsed Value back to its RESP wire format.
|
||||
// This is useful for converting parsed commands back to bytes for relay or logging.
|
||||
// nolint: cyclop
|
||||
func ToBytes(v Value) []byte {
|
||||
if v == nil {
|
||||
return MakeBulk(nil)
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return MakeBulk([]byte(val))
|
||||
case []byte:
|
||||
return MakeBulk(val)
|
||||
case Bulk:
|
||||
return MakeBulk([]byte(val))
|
||||
case int64:
|
||||
return MakeInt(val)
|
||||
case Int:
|
||||
return MakeInt(int64(val))
|
||||
case Error:
|
||||
return MakeError(val.Message)
|
||||
case Status:
|
||||
return MakeStatus(string(val))
|
||||
case Array:
|
||||
return MakeMultiBulk(val)
|
||||
default:
|
||||
return MakeBulk([]byte(fmt.Sprintf("%v", val)))
|
||||
}
|
||||
}
|
||||
206
internal/protocol/protocol_test.go
Normal file
206
internal/protocol/protocol_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSimpleString(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected Status
|
||||
}{
|
||||
{"+OK\r\n", "OK"},
|
||||
{"+PONG\r\n", "PONG"},
|
||||
{"+\r\n", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
reader := bufio.NewReader(bytes.NewReader([]byte(tt.input)))
|
||||
val, err := Parse(reader)
|
||||
if err != nil {
|
||||
t.Errorf("Parse(%q) error: %v", tt.input, err)
|
||||
continue
|
||||
}
|
||||
status, ok := val.(Status)
|
||||
if !ok {
|
||||
t.Errorf("Parse(%q) got %T, want Status", tt.input, val)
|
||||
continue
|
||||
}
|
||||
if status != tt.expected {
|
||||
t.Errorf("Parse(%q) = %q, want %q", tt.input, status, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseError(t *testing.T) {
|
||||
input := "-ERR unknown command\r\n"
|
||||
reader := bufio.NewReader(bytes.NewReader([]byte(input)))
|
||||
val, err := Parse(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
errVal, ok := val.(Error)
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want Error", val)
|
||||
}
|
||||
if errVal.Message != "ERR unknown command" {
|
||||
t.Errorf("got %q, want 'ERR unknown command'", errVal.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInteger(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected Int
|
||||
}{
|
||||
{":0\r\n", 0},
|
||||
{":12345\r\n", 12345},
|
||||
{":-100\r\n", -100},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
reader := bufio.NewReader(bytes.NewReader([]byte(tt.input)))
|
||||
val, err := Parse(reader)
|
||||
if err != nil {
|
||||
t.Errorf("Parse(%q) error: %v", tt.input, err)
|
||||
continue
|
||||
}
|
||||
n, ok := val.(Int)
|
||||
if !ok {
|
||||
t.Errorf("Parse(%q) got %T, want Int", tt.input, val)
|
||||
continue
|
||||
}
|
||||
if n != tt.expected {
|
||||
t.Errorf("Parse(%q) = %d, want %d", tt.input, n, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBulkString(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected []byte
|
||||
}{
|
||||
{"$5\r\nhello\r\n", []byte("hello")},
|
||||
{"$0\r\n\r\n", []byte("")},
|
||||
{"$-1\r\n", nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
reader := bufio.NewReader(bytes.NewReader([]byte(tt.input)))
|
||||
val, err := Parse(reader)
|
||||
if err != nil {
|
||||
t.Errorf("Parse(%q) error: %v", tt.input, err)
|
||||
continue
|
||||
}
|
||||
if tt.expected == nil {
|
||||
if val != nil {
|
||||
t.Errorf("Parse(%q) = %v, want nil", tt.input, val)
|
||||
}
|
||||
continue
|
||||
}
|
||||
bulk, ok := val.(Bulk)
|
||||
if !ok {
|
||||
t.Errorf("Parse(%q) got %T, want Bulk", tt.input, val)
|
||||
continue
|
||||
}
|
||||
if string(bulk) != string(tt.expected) {
|
||||
t.Errorf("Parse(%q) = %q, want %q", tt.input, string(bulk), string(tt.expected))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArray(t *testing.T) {
|
||||
input := "*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n"
|
||||
reader := bufio.NewReader(bytes.NewReader([]byte(input)))
|
||||
val, err := Parse(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
arr, ok := val.(Array)
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want Array", val)
|
||||
}
|
||||
if len(arr) != 2 {
|
||||
t.Errorf("array length = %d, want 2", len(arr))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmptyArray(t *testing.T) {
|
||||
input := "*0\r\n"
|
||||
reader := bufio.NewReader(bytes.NewReader([]byte(input)))
|
||||
val, err := Parse(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
arr, ok := val.(Array)
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want Array", val)
|
||||
}
|
||||
if len(arr) != 0 {
|
||||
t.Errorf("array length = %d, want 0", len(arr))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeStatus(t *testing.T) {
|
||||
result := MakeStatus("OK")
|
||||
expected := "+OK\r\n"
|
||||
if string(result) != expected {
|
||||
t.Errorf("MakeStatus() = %q, want %q", string(result), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeInt(t *testing.T) {
|
||||
result := MakeInt(42)
|
||||
expected := ":42\r\n"
|
||||
if string(result) != expected {
|
||||
t.Errorf("MakeInt() = %q, want %q", string(result), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeBulk(t *testing.T) {
|
||||
tests := []struct {
|
||||
input []byte
|
||||
expected string
|
||||
}{
|
||||
{[]byte("hello"), "$5\r\nhello\r\n"},
|
||||
{[]byte(""), "$0\r\n\r\n"},
|
||||
{nil, "$-1\r\n"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := MakeBulk(tt.input)
|
||||
if string(result) != tt.expected {
|
||||
t.Errorf("MakeBulk(%q) = %q, want %q", tt.input, string(result), tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeMultiBulk(t *testing.T) {
|
||||
result := MakeMultiBulk([]Value{Bulk("message"), Bulk("mychannel"), Bulk("hello")})
|
||||
if len(result) == 0 {
|
||||
t.Error("MakeMultiBulk returned empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
input Value
|
||||
expected string
|
||||
}{
|
||||
{nil, "$-1\r\n"},
|
||||
{Status("OK"), "+OK\r\n"},
|
||||
{Int(42), ":42\r\n"},
|
||||
{Error{Message: "ERR"}, "-ERR\r\n"},
|
||||
{Bulk("test"), "$4\r\ntest\r\n"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := ToBytes(tt.input)
|
||||
if string(result) != tt.expected {
|
||||
t.Errorf("ToBytes(%v) = %q, want %q", tt.input, string(result), tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user