From c122e89f40d9d64a34ba2bb6c94178ba252f5389 Mon Sep 17 00:00:00 2001 From: Vi Date: Thu, 5 Feb 2026 20:30:26 +0000 Subject: [PATCH] feat(io): add Sigil composable transform framework (port from Enchantrix) (#345) (#353) Co-authored-by: Claude Co-authored-by: Claude Opus 4.6 --- pkg/io/sigil/sigil.go | 70 ++++++ pkg/io/sigil/sigil_test.go | 422 +++++++++++++++++++++++++++++++++++++ pkg/io/sigil/sigils.go | 273 ++++++++++++++++++++++++ 3 files changed, 765 insertions(+) create mode 100644 pkg/io/sigil/sigil.go create mode 100644 pkg/io/sigil/sigil_test.go create mode 100644 pkg/io/sigil/sigils.go diff --git a/pkg/io/sigil/sigil.go b/pkg/io/sigil/sigil.go new file mode 100644 index 00000000..69feed84 --- /dev/null +++ b/pkg/io/sigil/sigil.go @@ -0,0 +1,70 @@ +// Package sigil provides the Sigil composable transform framework for reversible +// and irreversible data transformations. +// +// Sigils are the core abstraction -- each sigil implements a specific transformation +// (encoding, compression, hashing) with a uniform interface. Sigils can be chained +// together to create transformation pipelines via Transmute and Untransmute. +// +// Example usage: +// +// hexSigil, _ := sigil.NewSigil("hex") +// base64Sigil, _ := sigil.NewSigil("base64") +// encoded, _ := sigil.Transmute(data, []sigil.Sigil{hexSigil, base64Sigil}) +// decoded, _ := sigil.Untransmute(encoded, []sigil.Sigil{hexSigil, base64Sigil}) +package sigil + +// Sigil defines the interface for a composable data transformer. +// +// A Sigil represents a single transformation unit that can be applied to byte data. +// Sigils may be reversible (encoding, compression) or irreversible (hashing). +// +// For reversible sigils: Out(In(x)) == x for all valid x +// For irreversible sigils: Out returns the input unchanged +// For symmetric sigils: In(x) == Out(x) +// +// Implementations must handle nil input by returning nil without error, +// and empty input by returning an empty slice without error. +type Sigil interface { + // In applies the forward transformation to the data. + // For encoding sigils, this encodes the data. + // For compression sigils, this compresses the data. + // For hash sigils, this computes the digest. + In(data []byte) ([]byte, error) + + // Out applies the reverse transformation to the data. + // For reversible sigils, this recovers the original data. + // For irreversible sigils (e.g., hashing), this returns the input unchanged. + Out(data []byte) ([]byte, error) +} + +// Transmute applies a series of sigils to data in forward sequence. +// +// Each sigil's In method is called in order, with the output of one sigil +// becoming the input of the next. If any sigil returns an error, Transmute +// stops immediately and returns nil with that error. +func Transmute(data []byte, sigils []Sigil) ([]byte, error) { + var err error + for _, s := range sigils { + data, err = s.In(data) + if err != nil { + return nil, err + } + } + return data, nil +} + +// Untransmute applies a series of sigils to data in reverse sequence. +// +// Each sigil's Out method is called in reverse order, unwinding a previous +// Transmute operation. If any sigil returns an error, Untransmute stops +// immediately and returns nil with that error. +func Untransmute(data []byte, sigils []Sigil) ([]byte, error) { + var err error + for i := len(sigils) - 1; i >= 0; i-- { + data, err = sigils[i].Out(data) + if err != nil { + return nil, err + } + } + return data, nil +} diff --git a/pkg/io/sigil/sigil_test.go b/pkg/io/sigil/sigil_test.go new file mode 100644 index 00000000..17aa2efa --- /dev/null +++ b/pkg/io/sigil/sigil_test.go @@ -0,0 +1,422 @@ +package sigil + +import ( + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// ReverseSigil +// --------------------------------------------------------------------------- + +func TestReverseSigil_Good(t *testing.T) { + s := &ReverseSigil{} + + out, err := s.In([]byte("hello")) + require.NoError(t, err) + assert.Equal(t, []byte("olleh"), out) + + // Symmetric: Out does the same thing. + restored, err := s.Out(out) + require.NoError(t, err) + assert.Equal(t, []byte("hello"), restored) +} + +func TestReverseSigil_Bad(t *testing.T) { + s := &ReverseSigil{} + + // Empty input returns empty. + out, err := s.In([]byte{}) + require.NoError(t, err) + assert.Equal(t, []byte{}, out) +} + +func TestReverseSigil_Ugly(t *testing.T) { + s := &ReverseSigil{} + + // Nil input returns nil. + out, err := s.In(nil) + require.NoError(t, err) + assert.Nil(t, out) + + out, err = s.Out(nil) + require.NoError(t, err) + assert.Nil(t, out) +} + +// --------------------------------------------------------------------------- +// HexSigil +// --------------------------------------------------------------------------- + +func TestHexSigil_Good(t *testing.T) { + s := &HexSigil{} + data := []byte("hello world") + + encoded, err := s.In(data) + require.NoError(t, err) + assert.Equal(t, []byte(hex.EncodeToString(data)), encoded) + + decoded, err := s.Out(encoded) + require.NoError(t, err) + assert.Equal(t, data, decoded) +} + +func TestHexSigil_Bad(t *testing.T) { + s := &HexSigil{} + + // Invalid hex input. + _, err := s.Out([]byte("zzzz")) + assert.Error(t, err) + + // Empty input. + out, err := s.In([]byte{}) + require.NoError(t, err) + assert.Equal(t, []byte{}, out) +} + +func TestHexSigil_Ugly(t *testing.T) { + s := &HexSigil{} + + out, err := s.In(nil) + require.NoError(t, err) + assert.Nil(t, out) + + out, err = s.Out(nil) + require.NoError(t, err) + assert.Nil(t, out) +} + +// --------------------------------------------------------------------------- +// Base64Sigil +// --------------------------------------------------------------------------- + +func TestBase64Sigil_Good(t *testing.T) { + s := &Base64Sigil{} + data := []byte("composable transforms") + + encoded, err := s.In(data) + require.NoError(t, err) + assert.Equal(t, []byte(base64.StdEncoding.EncodeToString(data)), encoded) + + decoded, err := s.Out(encoded) + require.NoError(t, err) + assert.Equal(t, data, decoded) +} + +func TestBase64Sigil_Bad(t *testing.T) { + s := &Base64Sigil{} + + // Invalid base64 (wrong padding). + _, err := s.Out([]byte("!!!")) + assert.Error(t, err) + + // Empty input. + out, err := s.In([]byte{}) + require.NoError(t, err) + assert.Equal(t, []byte{}, out) +} + +func TestBase64Sigil_Ugly(t *testing.T) { + s := &Base64Sigil{} + + out, err := s.In(nil) + require.NoError(t, err) + assert.Nil(t, out) + + out, err = s.Out(nil) + require.NoError(t, err) + assert.Nil(t, out) +} + +// --------------------------------------------------------------------------- +// GzipSigil +// --------------------------------------------------------------------------- + +func TestGzipSigil_Good(t *testing.T) { + s := &GzipSigil{} + data := []byte("the quick brown fox jumps over the lazy dog") + + compressed, err := s.In(data) + require.NoError(t, err) + assert.NotEqual(t, data, compressed) + + decompressed, err := s.Out(compressed) + require.NoError(t, err) + assert.Equal(t, data, decompressed) +} + +func TestGzipSigil_Bad(t *testing.T) { + s := &GzipSigil{} + + // Invalid gzip data. + _, err := s.Out([]byte("not gzip")) + assert.Error(t, err) + + // Empty input compresses to a valid gzip stream. + compressed, err := s.In([]byte{}) + require.NoError(t, err) + assert.NotEmpty(t, compressed) // gzip header is always present + + decompressed, err := s.Out(compressed) + require.NoError(t, err) + assert.Equal(t, []byte{}, decompressed) +} + +func TestGzipSigil_Ugly(t *testing.T) { + s := &GzipSigil{} + + out, err := s.In(nil) + require.NoError(t, err) + assert.Nil(t, out) + + out, err = s.Out(nil) + require.NoError(t, err) + assert.Nil(t, out) +} + +// --------------------------------------------------------------------------- +// JSONSigil +// --------------------------------------------------------------------------- + +func TestJSONSigil_Good(t *testing.T) { + s := &JSONSigil{Indent: false} + data := []byte(`{ "key" : "value" }`) + + compacted, err := s.In(data) + require.NoError(t, err) + assert.Equal(t, []byte(`{"key":"value"}`), compacted) + + // Out is passthrough. + passthrough, err := s.Out(compacted) + require.NoError(t, err) + assert.Equal(t, compacted, passthrough) +} + +func TestJSONSigil_Good_Indent(t *testing.T) { + s := &JSONSigil{Indent: true} + data := []byte(`{"key":"value"}`) + + indented, err := s.In(data) + require.NoError(t, err) + assert.Contains(t, string(indented), "\n") + assert.Contains(t, string(indented), " ") +} + +func TestJSONSigil_Bad(t *testing.T) { + s := &JSONSigil{Indent: false} + + // Invalid JSON. + _, err := s.In([]byte("not json")) + assert.Error(t, err) +} + +func TestJSONSigil_Ugly(t *testing.T) { + s := &JSONSigil{Indent: false} + + // json.Compact on nil/empty will produce an error (invalid JSON). + _, err := s.In(nil) + assert.Error(t, err) + + // Out with nil is passthrough. + out, err := s.Out(nil) + require.NoError(t, err) + assert.Nil(t, out) +} + +// --------------------------------------------------------------------------- +// HashSigil +// --------------------------------------------------------------------------- + +func TestHashSigil_Good(t *testing.T) { + data := []byte("hash me") + + tests := []struct { + name string + sigilName string + size int + }{ + {"md5", "md5", md5.Size}, + {"sha1", "sha1", sha1.Size}, + {"sha256", "sha256", sha256.Size}, + {"sha512", "sha512", sha512.Size}, + {"sha224", "sha224", sha256.Size224}, + {"sha384", "sha384", sha512.Size384}, + {"sha512-224", "sha512-224", 28}, + {"sha512-256", "sha512-256", 32}, + {"sha3-224", "sha3-224", 28}, + {"sha3-256", "sha3-256", 32}, + {"sha3-384", "sha3-384", 48}, + {"sha3-512", "sha3-512", 64}, + {"ripemd160", "ripemd160", 20}, + {"blake2s-256", "blake2s-256", 32}, + {"blake2b-256", "blake2b-256", 32}, + {"blake2b-384", "blake2b-384", 48}, + {"blake2b-512", "blake2b-512", 64}, + {"md4", "md4", 16}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, err := NewSigil(tt.sigilName) + require.NoError(t, err) + + hashed, err := s.In(data) + require.NoError(t, err) + assert.Len(t, hashed, tt.size) + + // Out is passthrough. + passthrough, err := s.Out(hashed) + require.NoError(t, err) + assert.Equal(t, hashed, passthrough) + }) + } +} + +func TestHashSigil_Bad(t *testing.T) { + // Unsupported hash constant. + s := &HashSigil{Hash: 0} + _, err := s.In([]byte("data")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not available") +} + +func TestHashSigil_Ugly(t *testing.T) { + // Hashing empty data should still produce a valid digest. + s, err := NewSigil("sha256") + require.NoError(t, err) + + hashed, err := s.In([]byte{}) + require.NoError(t, err) + assert.Len(t, hashed, sha256.Size) +} + +// --------------------------------------------------------------------------- +// NewSigil factory +// --------------------------------------------------------------------------- + +func TestNewSigil_Good(t *testing.T) { + names := []string{ + "reverse", "hex", "base64", "gzip", "json", "json-indent", + "md4", "md5", "sha1", "sha224", "sha256", "sha384", "sha512", + "ripemd160", + "sha3-224", "sha3-256", "sha3-384", "sha3-512", + "sha512-224", "sha512-256", + "blake2s-256", "blake2b-256", "blake2b-384", "blake2b-512", + } + + for _, name := range names { + t.Run(name, func(t *testing.T) { + s, err := NewSigil(name) + require.NoError(t, err) + assert.NotNil(t, s) + }) + } +} + +func TestNewSigil_Bad(t *testing.T) { + _, err := NewSigil("nonexistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown sigil name") +} + +func TestNewSigil_Ugly(t *testing.T) { + _, err := NewSigil("") + assert.Error(t, err) +} + +// --------------------------------------------------------------------------- +// Transmute / Untransmute +// --------------------------------------------------------------------------- + +func TestTransmute_Good(t *testing.T) { + data := []byte("round trip") + + hexSigil, err := NewSigil("hex") + require.NoError(t, err) + base64Sigil, err := NewSigil("base64") + require.NoError(t, err) + + chain := []Sigil{hexSigil, base64Sigil} + + encoded, err := Transmute(data, chain) + require.NoError(t, err) + assert.NotEqual(t, data, encoded) + + decoded, err := Untransmute(encoded, chain) + require.NoError(t, err) + assert.Equal(t, data, decoded) +} + +func TestTransmute_Good_MultiSigil(t *testing.T) { + data := []byte("multi sigil pipeline test data") + + reverseSigil, err := NewSigil("reverse") + require.NoError(t, err) + hexSigil, err := NewSigil("hex") + require.NoError(t, err) + base64Sigil, err := NewSigil("base64") + require.NoError(t, err) + + chain := []Sigil{reverseSigil, hexSigil, base64Sigil} + + encoded, err := Transmute(data, chain) + require.NoError(t, err) + + decoded, err := Untransmute(encoded, chain) + require.NoError(t, err) + assert.Equal(t, data, decoded) +} + +func TestTransmute_Good_GzipRoundTrip(t *testing.T) { + data := []byte("compress then encode then decode then decompress") + + gzipSigil, err := NewSigil("gzip") + require.NoError(t, err) + hexSigil, err := NewSigil("hex") + require.NoError(t, err) + + chain := []Sigil{gzipSigil, hexSigil} + + encoded, err := Transmute(data, chain) + require.NoError(t, err) + + decoded, err := Untransmute(encoded, chain) + require.NoError(t, err) + assert.Equal(t, data, decoded) +} + +func TestTransmute_Bad(t *testing.T) { + // Transmute with a sigil that will fail: hex decode on non-hex input. + hexSigil := &HexSigil{} + + // Calling Out (decode) with invalid input via manual chain. + _, err := Untransmute([]byte("not-hex!!"), []Sigil{hexSigil}) + assert.Error(t, err) +} + +func TestTransmute_Ugly(t *testing.T) { + // Empty sigil chain is a no-op. + data := []byte("unchanged") + + result, err := Transmute(data, nil) + require.NoError(t, err) + assert.Equal(t, data, result) + + result, err = Untransmute(data, nil) + require.NoError(t, err) + assert.Equal(t, data, result) + + // Nil data through a chain. + hexSigil, _ := NewSigil("hex") + result, err = Transmute(nil, []Sigil{hexSigil}) + require.NoError(t, err) + assert.Nil(t, result) +} diff --git a/pkg/io/sigil/sigils.go b/pkg/io/sigil/sigils.go new file mode 100644 index 00000000..3afc2072 --- /dev/null +++ b/pkg/io/sigil/sigils.go @@ -0,0 +1,273 @@ +package sigil + +import ( + "bytes" + "compress/gzip" + "crypto" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "io" + + "golang.org/x/crypto/blake2b" + "golang.org/x/crypto/blake2s" + "golang.org/x/crypto/md4" + "golang.org/x/crypto/ripemd160" + "golang.org/x/crypto/sha3" +) + +// ReverseSigil is a symmetric Sigil that reverses the bytes of the payload. +// Both In and Out perform the same reversal operation. +type ReverseSigil struct{} + +// In reverses the bytes of the data. +func (s *ReverseSigil) In(data []byte) ([]byte, error) { + if data == nil { + return nil, nil + } + reversed := make([]byte, len(data)) + for i, j := 0, len(data)-1; i < len(data); i, j = i+1, j-1 { + reversed[i] = data[j] + } + return reversed, nil +} + +// Out reverses the bytes of the data (symmetric with In). +func (s *ReverseSigil) Out(data []byte) ([]byte, error) { + return s.In(data) +} + +// HexSigil is a Sigil that encodes/decodes data to/from hexadecimal. +// In encodes the data, Out decodes it. +type HexSigil struct{} + +// In encodes the data to hexadecimal. +func (s *HexSigil) In(data []byte) ([]byte, error) { + if data == nil { + return nil, nil + } + dst := make([]byte, hex.EncodedLen(len(data))) + hex.Encode(dst, data) + return dst, nil +} + +// Out decodes the data from hexadecimal. +func (s *HexSigil) Out(data []byte) ([]byte, error) { + if data == nil { + return nil, nil + } + dst := make([]byte, hex.DecodedLen(len(data))) + _, err := hex.Decode(dst, data) + return dst, err +} + +// Base64Sigil is a Sigil that encodes/decodes data to/from standard base64. +// In encodes the data, Out decodes it. +type Base64Sigil struct{} + +// In encodes the data to base64. +func (s *Base64Sigil) In(data []byte) ([]byte, error) { + if data == nil { + return nil, nil + } + dst := make([]byte, base64.StdEncoding.EncodedLen(len(data))) + base64.StdEncoding.Encode(dst, data) + return dst, nil +} + +// Out decodes the data from base64. +func (s *Base64Sigil) Out(data []byte) ([]byte, error) { + if data == nil { + return nil, nil + } + dst := make([]byte, base64.StdEncoding.DecodedLen(len(data))) + n, err := base64.StdEncoding.Decode(dst, data) + return dst[:n], err +} + +// GzipSigil is a Sigil that compresses/decompresses data using gzip. +// In compresses the data, Out decompresses it. +type GzipSigil struct{} + +// In compresses the data using gzip. +func (s *GzipSigil) In(data []byte) ([]byte, error) { + if data == nil { + return nil, nil + } + var b bytes.Buffer + gz := gzip.NewWriter(&b) + if _, err := gz.Write(data); err != nil { + return nil, err + } + if err := gz.Close(); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +// Out decompresses the data using gzip. +func (s *GzipSigil) Out(data []byte) ([]byte, error) { + if data == nil { + return nil, nil + } + r, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) +} + +// JSONSigil is a Sigil that compacts or indents JSON data. +// Out is a passthrough (no-op). +type JSONSigil struct { + Indent bool +} + +// In compacts or indents the JSON data depending on the Indent field. +func (s *JSONSigil) In(data []byte) ([]byte, error) { + if s.Indent { + var out bytes.Buffer + err := json.Indent(&out, data, "", " ") + return out.Bytes(), err + } + var out bytes.Buffer + err := json.Compact(&out, data) + return out.Bytes(), err +} + +// Out is a passthrough for JSONSigil. The primary use is formatting. +func (s *JSONSigil) Out(data []byte) ([]byte, error) { + return data, nil +} + +// HashSigil is a Sigil that hashes data using a specified algorithm. +// In computes the hash digest, Out is a passthrough. +type HashSigil struct { + Hash crypto.Hash +} + +// NewHashSigil creates a new HashSigil for the given hash algorithm. +func NewHashSigil(h crypto.Hash) *HashSigil { + return &HashSigil{Hash: h} +} + +// In hashes the data using the configured algorithm. +func (s *HashSigil) In(data []byte) ([]byte, error) { + var h io.Writer + switch s.Hash { + case crypto.MD4: + h = md4.New() + case crypto.MD5: + h = md5.New() + case crypto.SHA1: + h = sha1.New() + case crypto.SHA224: + h = sha256.New224() + case crypto.SHA256: + h = sha256.New() + case crypto.SHA384: + h = sha512.New384() + case crypto.SHA512: + h = sha512.New() + case crypto.RIPEMD160: + h = ripemd160.New() + case crypto.SHA3_224: + h = sha3.New224() + case crypto.SHA3_256: + h = sha3.New256() + case crypto.SHA3_384: + h = sha3.New384() + case crypto.SHA3_512: + h = sha3.New512() + case crypto.SHA512_224: + h = sha512.New512_224() + case crypto.SHA512_256: + h = sha512.New512_256() + case crypto.BLAKE2s_256: + h, _ = blake2s.New256(nil) + case crypto.BLAKE2b_256: + h, _ = blake2b.New256(nil) + case crypto.BLAKE2b_384: + h, _ = blake2b.New384(nil) + case crypto.BLAKE2b_512: + h, _ = blake2b.New512(nil) + default: + return nil, errors.New("sigil: hash algorithm not available") + } + + h.Write(data) + return h.(interface{ Sum([]byte) []byte }).Sum(nil), nil +} + +// Out is a passthrough for HashSigil. Hashing is irreversible. +func (s *HashSigil) Out(data []byte) ([]byte, error) { + return data, nil +} + +// NewSigil is a factory function that returns a Sigil based on a string name. +// It is the primary way to create Sigil instances. +// +// Supported names: reverse, hex, base64, gzip, json, json-indent, +// md4, md5, sha1, sha224, sha256, sha384, sha512, ripemd160, +// sha3-224, sha3-256, sha3-384, sha3-512, sha512-224, sha512-256, +// blake2s-256, blake2b-256, blake2b-384, blake2b-512. +func NewSigil(name string) (Sigil, error) { + switch name { + case "reverse": + return &ReverseSigil{}, nil + case "hex": + return &HexSigil{}, nil + case "base64": + return &Base64Sigil{}, nil + case "gzip": + return &GzipSigil{}, nil + case "json": + return &JSONSigil{Indent: false}, nil + case "json-indent": + return &JSONSigil{Indent: true}, nil + case "md4": + return NewHashSigil(crypto.MD4), nil + case "md5": + return NewHashSigil(crypto.MD5), nil + case "sha1": + return NewHashSigil(crypto.SHA1), nil + case "sha224": + return NewHashSigil(crypto.SHA224), nil + case "sha256": + return NewHashSigil(crypto.SHA256), nil + case "sha384": + return NewHashSigil(crypto.SHA384), nil + case "sha512": + return NewHashSigil(crypto.SHA512), nil + case "ripemd160": + return NewHashSigil(crypto.RIPEMD160), nil + case "sha3-224": + return NewHashSigil(crypto.SHA3_224), nil + case "sha3-256": + return NewHashSigil(crypto.SHA3_256), nil + case "sha3-384": + return NewHashSigil(crypto.SHA3_384), nil + case "sha3-512": + return NewHashSigil(crypto.SHA3_512), nil + case "sha512-224": + return NewHashSigil(crypto.SHA512_224), nil + case "sha512-256": + return NewHashSigil(crypto.SHA512_256), nil + case "blake2s-256": + return NewHashSigil(crypto.BLAKE2s_256), nil + case "blake2b-256": + return NewHashSigil(crypto.BLAKE2b_256), nil + case "blake2b-384": + return NewHashSigil(crypto.BLAKE2b_384), nil + case "blake2b-512": + return NewHashSigil(crypto.BLAKE2b_512), nil + default: + return nil, errors.New("sigil: unknown sigil name: " + name) + } +}