Enchantrix/pkg/trix/trix.go

262 lines
7.8 KiB
Go

// Package trix implements the TRIX binary container format (RFC-0002).
//
// The .trix format is a generic, protocol-agnostic container for storing
// arbitrary binary payloads alongside structured JSON metadata. It consists of:
//
// [Magic Number (4)] [Version (1)] [Header Length (4)] [JSON Header] [Payload]
//
// Key features:
// - Custom 4-byte magic number for application-specific identification
// - Extensible JSON header for metadata (content type, checksums, timestamps)
// - Optional integrity verification via configurable checksum algorithms
// - Integration with the Sigil transformation framework for encoding/compression
//
// Example usage:
//
// container := &trix.Trix{
// Header: map[string]interface{}{"content_type": "text/plain"},
// Payload: []byte("Hello, World!"),
// }
// encoded, _ := trix.Encode(container, "MYAP", nil)
package trix
import (
"bytes"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"github.com/Snider/Enchantrix/pkg/crypt"
"github.com/Snider/Enchantrix/pkg/enchantrix"
)
const (
// Version is the current version of the .trix file format.
// See RFC-0002 for version history and compatibility notes.
Version = 2
// MaxHeaderSize is the maximum allowed size for the header (16 MB).
// This limit prevents denial-of-service attacks via large header allocations.
MaxHeaderSize = 16 * 1024 * 1024 // 16 MB
)
var (
// ErrInvalidMagicNumber is returned when the magic number is incorrect.
ErrInvalidMagicNumber = errors.New("trix: invalid magic number")
// ErrInvalidVersion is returned when the version is incorrect.
ErrInvalidVersion = errors.New("trix: invalid version")
// ErrMagicNumberLength is returned when the magic number is not 4 bytes long.
ErrMagicNumberLength = errors.New("trix: magic number must be 4 bytes long")
// ErrNilSigil is returned when a sigil is nil.
ErrNilSigil = errors.New("trix: sigil cannot be nil")
// ErrChecksumMismatch is returned when the checksum does not match.
ErrChecksumMismatch = errors.New("trix: checksum mismatch")
// ErrHeaderTooLarge is returned when the header size exceeds the maximum allowed.
ErrHeaderTooLarge = errors.New("trix: header size exceeds maximum allowed")
)
// Trix represents a .trix container with header metadata and binary payload.
//
// The Header field holds arbitrary JSON-serializable metadata. Common fields include:
// - content_type: MIME type of the original payload
// - created_at: ISO 8601 timestamp
// - encryption_algorithm: Algorithm used for encryption (if applicable)
// - checksum: Hex-encoded integrity checksum (auto-populated if ChecksumAlgo is set)
//
// The InSigils and OutSigils fields specify transformation pipelines:
// - InSigils: Applied during Pack() in order (e.g., ["gzip", "base64"])
// - OutSigils: Applied during Unpack() in reverse order (defaults to InSigils)
type Trix struct {
// Header contains JSON-serializable metadata about the payload.
Header map[string]interface{}
// Payload is the binary data stored in the container.
Payload []byte
// InSigils lists sigil names to apply during Pack (forward transformation).
InSigils []string `json:"-"`
// OutSigils lists sigil names to apply during Unpack (reverse transformation).
// If empty, InSigils is used in reverse order.
OutSigils []string `json:"-"`
// ChecksumAlgo specifies the hash algorithm for integrity verification.
// If set, a checksum is computed and stored in the header during Encode.
ChecksumAlgo crypt.HashType `json:"-"`
}
// Encode serializes a Trix struct into the .trix binary format.
// It returns the encoded data as a byte slice.
func Encode(trix *Trix, magicNumber string, w io.Writer) ([]byte, error) {
if len(magicNumber) != 4 {
return nil, ErrMagicNumberLength
}
// Calculate and add checksum if an algorithm is specified
if trix.ChecksumAlgo != "" {
checksum := crypt.NewService().Hash(trix.ChecksumAlgo, string(trix.Payload))
trix.Header["checksum"] = checksum
trix.Header["checksum_algo"] = string(trix.ChecksumAlgo)
}
headerBytes, err := json.Marshal(trix.Header)
if err != nil {
return nil, err
}
headerLength := uint32(len(headerBytes))
// If no writer is provided, use an internal buffer.
// This maintains the original function signature's behavior of returning the byte slice.
var buf *bytes.Buffer
writer := w
if writer == nil {
buf = new(bytes.Buffer)
writer = buf
}
// Write Magic Number
if _, err := io.WriteString(writer, magicNumber); err != nil {
return nil, err
}
// Write Version
if _, err := writer.Write([]byte{byte(Version)}); err != nil {
return nil, err
}
// Write Header Length
if err := binary.Write(writer, binary.BigEndian, headerLength); err != nil {
return nil, err
}
// Write JSON Header
if _, err := writer.Write(headerBytes); err != nil {
return nil, err
}
// Write Payload
if _, err := writer.Write(trix.Payload); err != nil {
return nil, err
}
// If we used our internal buffer, return its bytes.
if buf != nil {
return buf.Bytes(), nil
}
// If an external writer was used, we can't return the bytes.
// The caller is responsible for the writer.
return nil, nil
}
// Decode deserializes the .trix binary format into a Trix struct.
// It returns the decoded Trix struct.
// Note: Sigils are not stored in the format and must be re-attached by the caller.
func Decode(data []byte, magicNumber string, r io.Reader) (*Trix, error) {
if len(magicNumber) != 4 {
return nil, ErrMagicNumberLength
}
var reader io.Reader
if r != nil {
reader = r
} else {
reader = bytes.NewReader(data)
}
// Read and Verify Magic Number
magic := make([]byte, 4)
if _, err := io.ReadFull(reader, magic); err != nil {
return nil, err
}
if string(magic) != magicNumber {
return nil, fmt.Errorf("%w: expected %s, got %s", ErrInvalidMagicNumber, magicNumber, string(magic))
}
// Read and Verify Version
versionByte := make([]byte, 1)
if _, err := io.ReadFull(reader, versionByte); err != nil {
return nil, err
}
if versionByte[0] != Version {
return nil, ErrInvalidVersion
}
// Read Header Length
var headerLength uint32
if err := binary.Read(reader, binary.BigEndian, &headerLength); err != nil {
return nil, err
}
// Sanity check the header length to prevent massive allocations.
if headerLength > MaxHeaderSize {
return nil, ErrHeaderTooLarge
}
// Read JSON Header
headerBytes := make([]byte, headerLength)
if _, err := io.ReadFull(reader, headerBytes); err != nil {
return nil, err
}
var header map[string]interface{}
if err := json.Unmarshal(headerBytes, &header); err != nil {
return nil, err
}
// Read Payload
payload, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
// Verify checksum if it exists in the header
if checksum, ok := header["checksum"].(string); ok {
algo, ok := header["checksum_algo"].(string)
if !ok {
return nil, errors.New("trix: checksum algorithm not found in header")
}
expectedChecksum := crypt.NewService().Hash(crypt.HashType(algo), string(payload))
if checksum != expectedChecksum {
return nil, ErrChecksumMismatch
}
}
return &Trix{
Header: header,
Payload: payload,
}, nil
}
// Pack applies the In method of all attached sigils to the payload.
// It modifies the Trix struct in place.
func (t *Trix) Pack() error {
for _, sigilName := range t.InSigils {
sigil, err := enchantrix.NewSigil(sigilName)
if err != nil {
return err
}
t.Payload, err = sigil.In(t.Payload)
if err != nil {
return err
}
}
return nil
}
// Unpack applies the Out method of all sigils in reverse order.
// It modifies the Trix struct in place.
func (t *Trix) Unpack() error {
sigilNames := t.OutSigils
if len(sigilNames) == 0 {
sigilNames = t.InSigils
}
for i := len(sigilNames) - 1; i >= 0; i-- {
sigilName := sigilNames[i]
sigil, err := enchantrix.NewSigil(sigilName)
if err != nil {
return err
}
t.Payload, err = sigil.Out(t.Payload)
if err != nil {
return err
}
}
return nil
}