210 lines
5.2 KiB
Go
210 lines
5.2 KiB
Go
package erasure
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
|
|
"github.com/klauspost/reedsolomon"
|
|
)
|
|
|
|
const (
|
|
DataShards = 4
|
|
ParityShards = 2
|
|
TotalShards = DataShards + ParityShards
|
|
)
|
|
|
|
// Service manages Reed-Solomon 4+2 erasure bundles.
|
|
type Service struct {
|
|
DB *sql.DB
|
|
}
|
|
|
|
func NewService(db *sql.DB) *Service {
|
|
return &Service{DB: db}
|
|
}
|
|
|
|
// Bundle metadata for a stored erasure set.
|
|
type Bundle struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
TotalShards int `json:"total_shards"`
|
|
DataShards int `json:"data_shards"`
|
|
ParityShards int `json:"parity_shards"`
|
|
Checksum string `json:"checksum"`
|
|
}
|
|
|
|
// Shard is one public shard payload.
|
|
type Shard struct {
|
|
BundleID string `json:"bundle_id"`
|
|
ShardIndex int `json:"shard_index"`
|
|
Data []byte `json:"-"`
|
|
Hex string `json:"hex,omitempty"`
|
|
}
|
|
|
|
// Encode splits data into 4+2 Reed-Solomon shards and persists them.
|
|
func (s *Service) Encode(ctx context.Context, name string, data []byte) (*Bundle, error) {
|
|
if len(data) == 0 {
|
|
return nil, fmt.Errorf("empty payload")
|
|
}
|
|
|
|
enc, err := reedsolomon.New(DataShards, ParityShards)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
shardSize := (len(data) + DataShards - 1) / DataShards
|
|
padded := make([]byte, shardSize*DataShards)
|
|
copy(padded, data)
|
|
shards, err := enc.Split(padded)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := enc.Encode(shards); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sum := sha256.Sum256(data)
|
|
bundleID := randomID()
|
|
_, err = s.DB.ExecContext(ctx, `
|
|
INSERT INTO erasure_bundles (id, name, total_shards, data_shards, parity_shards, checksum)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
bundleID, name, TotalShards, DataShards, ParityShards, hex.EncodeToString(sum[:]))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for i, shard := range shards {
|
|
_, err = s.DB.ExecContext(ctx, `
|
|
INSERT INTO erasure_shards (id, bundle_id, shard_index, data) VALUES (?, ?, ?, ?)`,
|
|
randomID(), bundleID, i, shard)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return &Bundle{
|
|
ID: bundleID,
|
|
Name: name,
|
|
TotalShards: TotalShards,
|
|
DataShards: DataShards,
|
|
ParityShards: ParityShards,
|
|
Checksum: hex.EncodeToString(sum[:]),
|
|
}, nil
|
|
}
|
|
|
|
// GetShard returns one shard by bundle ID and index.
|
|
func (s *Service) GetShard(ctx context.Context, bundleID string, index int) (*Shard, error) {
|
|
var data []byte
|
|
err := s.DB.QueryRowContext(ctx, `
|
|
SELECT data FROM erasure_shards WHERE bundle_id = ? AND shard_index = ?`,
|
|
bundleID, index).Scan(&data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Shard{BundleID: bundleID, ShardIndex: index, Data: data, Hex: hex.EncodeToString(data)}, nil
|
|
}
|
|
|
|
// ListShards returns shard indices available for a bundle.
|
|
func (s *Service) ListShards(ctx context.Context, bundleID string) ([]int, error) {
|
|
rows, err := s.DB.QueryContext(ctx, `
|
|
SELECT shard_index FROM erasure_shards WHERE bundle_id = ? ORDER BY shard_index`, bundleID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var indices []int
|
|
for rows.Next() {
|
|
var i int
|
|
if err := rows.Scan(&i); err != nil {
|
|
return nil, err
|
|
}
|
|
indices = append(indices, i)
|
|
}
|
|
return indices, rows.Err()
|
|
}
|
|
|
|
// GetBundle returns bundle metadata.
|
|
func (s *Service) GetBundle(ctx context.Context, bundleID string) (*Bundle, error) {
|
|
var b Bundle
|
|
err := s.DB.QueryRowContext(ctx, `
|
|
SELECT id, name, total_shards, data_shards, parity_shards, checksum
|
|
FROM erasure_bundles WHERE id = ?`, bundleID).
|
|
Scan(&b.ID, &b.Name, &b.TotalShards, &b.DataShards, &b.ParityShards, &b.Checksum)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &b, nil
|
|
}
|
|
|
|
// Reconstruct reads at least DataShards shards and rebuilds original bytes.
|
|
func (s *Service) Reconstruct(ctx context.Context, bundleID string, indices []int) ([]byte, error) {
|
|
if len(indices) < DataShards {
|
|
return nil, fmt.Errorf("need at least %d shards, got %d", DataShards, len(indices))
|
|
}
|
|
|
|
bundle, err := s.GetBundle(ctx, bundleID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
enc, err := reedsolomon.New(bundle.DataShards, bundle.ParityShards)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
shards := make([][]byte, bundle.TotalShards)
|
|
for _, idx := range indices {
|
|
if idx < 0 || idx >= bundle.TotalShards {
|
|
return nil, fmt.Errorf("invalid shard index %d", idx)
|
|
}
|
|
sh, err := s.GetShard(ctx, bundleID, idx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
shards[idx] = sh.Data
|
|
}
|
|
|
|
if err := enc.Reconstruct(shards); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]byte, 0, bundle.DataShards*len(shards[0]))
|
|
for i := 0; i < bundle.DataShards; i++ {
|
|
out = append(out, shards[i]...)
|
|
}
|
|
trimmed := trimNullPadding(out)
|
|
sum := sha256.Sum256(trimmed)
|
|
if hex.EncodeToString(sum[:]) != bundle.Checksum {
|
|
return nil, fmt.Errorf("reconstructed data checksum mismatch: bundle may be corrupt")
|
|
}
|
|
return trimmed, nil
|
|
}
|
|
|
|
func trimNullPadding(b []byte) []byte {
|
|
for i := len(b) - 1; i >= 0; i-- {
|
|
if b[i] != 0 {
|
|
return b[:i+1]
|
|
}
|
|
}
|
|
return b
|
|
}
|
|
|
|
// EncodeReader convenience wrapper.
|
|
func (s *Service) EncodeReader(ctx context.Context, name string, r io.Reader) (*Bundle, error) {
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.Encode(ctx, name, data)
|
|
}
|
|
|
|
func randomID() string {
|
|
b := make([]byte, 16)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|