From 6febf288352b2f96ec10d501a58923f1b06a0c96 Mon Sep 17 00:00:00 2001 From: andrey300902 Date: Thu, 26 Mar 2026 22:13:07 +0300 Subject: [PATCH] Init merkle hashing --- 04-formats/merkle-hashing/README.md | 185 ++++++++++ 04-formats/merkle-hashing/cmd/merkle/main.go | 240 +++++++++++++ .../merkle-hashing/cmd/merkle/main_test.go | 147 ++++++++ 04-formats/merkle-hashing/example_data.json | 9 + 04-formats/merkle-hashing/go.mod | 4 + .../internal/merkle/example_test.go | 28 ++ .../merkle-hashing/internal/merkle/tree.go | 333 ++++++++++++++++++ .../internal/merkle/tree_test.go | 245 +++++++++++++ 8 files changed, 1191 insertions(+) create mode 100644 04-formats/merkle-hashing/README.md create mode 100644 04-formats/merkle-hashing/cmd/merkle/main.go create mode 100644 04-formats/merkle-hashing/cmd/merkle/main_test.go create mode 100644 04-formats/merkle-hashing/example_data.json create mode 100644 04-formats/merkle-hashing/go.mod create mode 100644 04-formats/merkle-hashing/internal/merkle/example_test.go create mode 100644 04-formats/merkle-hashing/internal/merkle/tree.go create mode 100644 04-formats/merkle-hashing/internal/merkle/tree_test.go diff --git a/04-formats/merkle-hashing/README.md b/04-formats/merkle-hashing/README.md new file mode 100644 index 00000000..38105783 --- /dev/null +++ b/04-formats/merkle-hashing/README.md @@ -0,0 +1,185 @@ +# Merkle Hashing for Data (Base and Branches) + +## О чём проект + +Реализация Merkle-дерева на Go 1.21 для демонстрации темы **Merkle hashing for data (base and branches)**. +Проект строит Merkle-дерево из набора данных, получает корень (`root`), умеет формировать и проверять Merkle proof для доказательства принадлежности элемента. + +## Что уже решаем + +- входные данные из JSON-файла; +- хэширование только через `SHA-256`; +- поддержка базы (`base`) и ветвей (`branches`) дерева; +- построение дерева и получение `root`; +- получение `proof` для произвольного листа; +- верификация `proof`; +- обновление листа и пересчёт цепочки вверх к корню; +- юнит-тесты. + +## Формат ввода (JSON) + +Пример файла `data.json`: + +```json +{ + "items": [ + "block-0", + "block-1", + "block-2", + "block-3" + ] +} +``` + +В коде можно хранить строки как `[]byte`, либо расширить типы на произвольные JSON-объекты. + +## Проектная структура + +- `internal/merkle` — библиотечная реализация Merkle-дерева; +- `cmd` (опционально) — можно добавить CLI позже; +- `README.md` — эта документация; +- `internal/merkle/*_test.go` — тесты. + +## Базовые термины + +- **Base (leaf)** — хэш исходных данных: + +`leaf = SHA-256(data)` + +- **Branch** — внутренний узел из двух детей: + +`parent = SHA-256(left.Hash || right.Hash)` + +- **Root** — последний оставшийся узел после сворачивания уровней. + +## Нечётное число узлов: важный выбор + +Есть два распространённых подхода: + +1. **Дублирование последнего (`duplicate last`)** + - при нечётном количестве узлов на уровне последний узел дублируется; + - ветви всегда идут попарно. + + Пример: + - листья: `A, B, C` + - уровни: `(A,B)->AB`, `C` дублируется -> `(C,C)->CC` + - root из `AB` и `CC` + + **Плюсы**: детерминированный размер proof, удобная структура. + + **Минусы**: семантически это изменение входа в логике построения (хотя для Merkle-дерева это классика). + +2. **Подъём последнего узла (`carry up`)** + - последний узел просто переносится на уровень выше без создания пары. + + Пример: + - листья: `A, B, C` + - уровень: `(A,B)->AB`, `C` без пары переносится вверх; + - root из `AB` и `C`. + + **Плюсы**: ближе к “реальному” дереву с разной степенью узлов. + + **Минусы**: длина proof может отличаться для разных листьев, нужно аккуратнее с верификацией. + +Для домашней работы обычно берут **дублирование последнего** как наиболее стандартное и удобное поведение. + +## Предлагаемое API + +- `type MerkleTree struct { ... }` +- `type ProofStep struct {` + `SiblingHash []byte` + `IsLeft bool` + `}` +- `func NewMerkleTreeFromJSON(path string) (*MerkleTree, error)` +- `func NewMerkleTree(data [][]byte) *MerkleTree` +- `func (t *MerkleTree) Root() []byte` +- `func (t *MerkleTree) GetProof(index int) ([]ProofStep, error)` +- `func (t *MerkleTree) Verify(data []byte, proof []ProofStep, root []byte) bool` +- `func (t *MerkleTree) UpdateLeaf(index int, newData []byte) error` + +## Кодирование хэшей + +По умолчанию для чтения/вывода в отчетах и тестах удобно использовать **hex**. +При желании можно заменить на base64 в нескольких местах: + +- формат сериализации `[]byte` в строку; +- логирование `root` и `proof`. + +## Сложность + +- Построение дерева: `O(n)` +- Проверка proof: `O(log n)` +- Обновление листа: `O(log n)` для пересчёта пути к корню + +## Примеры сценариев + +- Построение дерева из `data.json` +- Получение `proof` для элемента `i` +- Проверка proof для: + - валидного элемента (ожидаем `true`); + - изменённого элемента (ожидаем `false`). + +## Что проверить в тестах + +- пустой ввод (ожидаемая ошибка валидации); +- один элемент; +- два элемента; +- нечётный размер входа; +- все элементы уникальны/повторяются; +- корректная и невалидная верификация; +- после `UpdateLeaf` изменился только нужный путь к `root`. + +## Идея для отчёта + +В отчёте для курса хорошо показать: +- как формируются base и branches; +- почему меняется корень при изменении любого листа; +- зачем нужен proof; +- почему одинаковый набор данных всегда даёт тот же root. + +## Как проверить на своей машине + +```bash +cd "/Users/andrey300902/Desktop/merkle hashing" +go test ./... +``` + +### Ключевые команды CLI + +```bash +cd "/Users/andrey300902/Desktop/merkle hashing" +go run ./cmd/merkle build -json example_data.json + +go run ./cmd/merkle proof -json example_data.json -index 1 + +go run ./cmd/merkle verify -json example_data.json -index 1 -data block-1 + +go run ./cmd/merkle verify -json example_data.json -index 1 -data bad-value + +go run ./cmd/merkle update -json example_data.json -index 1 -data "block-1-updated" +``` + +### Пример verify-proof + +```bash +cd "/Users/andrey300902/Desktop/merkle hashing" +ROOT=$(go run ./cmd/merkle build -json example_data.json) +go run ./cmd/merkle proof -json example_data.json -index 1 > /tmp/merkle_proof.json +go run ./cmd/merkle verify-proof -root "$ROOT" -proof /tmp/merkle_proof.json -data block-1 +``` + +Параметр `-proof` для `verify-proof` может быть JSON-массивом: + +```json +[{"sibling":"", "is_left":true}] +``` +или объектом с полем `proof` (результат команды `proof`). + +Если нужна готовая входная выборка: +- `example_data.json` использует формат `{"items":[...]}` +- его можно читать через `NewMerkleTreeFromJSON(".../example_data.json")`. + +## Примечание для отчёта + +В задании используется подход `duplicate last` при нечётном числе узлов на уровне. +Это даёт фиксированную глубину proof и более удобную проверяемость. diff --git a/04-formats/merkle-hashing/cmd/merkle/main.go b/04-formats/merkle-hashing/cmd/merkle/main.go new file mode 100644 index 00000000..a7bf1484 --- /dev/null +++ b/04-formats/merkle-hashing/cmd/merkle/main.go @@ -0,0 +1,240 @@ +package main + +import ( + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "os" + + "merkle-hashing/internal/merkle" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, out, errOut io.Writer) int { + if len(args) < 1 { + usage(out) + return 1 + } + + switch args[0] { + case "build": + if err := runBuild(args[1:], out, errOut); err != nil { + fmt.Fprintln(errOut, err) + return 1 + } + case "proof": + if err := runProof(args[1:], out, errOut); err != nil { + fmt.Fprintln(errOut, err) + return 1 + } + case "verify": + if err := runVerify(args[1:], out, errOut); err != nil { + fmt.Fprintln(errOut, err) + return 1 + } + case "update": + if err := runUpdate(args[1:], out, errOut); err != nil { + fmt.Fprintln(errOut, err) + return 1 + } + case "verify-proof": + if err := runVerifyProof(args[1:], out, errOut); err != nil { + fmt.Fprintln(errOut, err) + return 1 + } + case "help": + usage(out) + return 0 + default: + usage(out) + return 1 + } + return 0 +} + +func runBuild(args []string, out, errOut io.Writer) error { + _ = errOut + fs := flag.NewFlagSet("build", flag.ContinueOnError) + jsonPath := fs.String("json", "", "Path to JSON file with items") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("build args error: %w", err) + } + + if *jsonPath == "" { + return fmt.Errorf("build: missing required flag -json") + } + + tree, err := merkle.NewMerkleTreeFromJSON(*jsonPath) + if err != nil { + return fmt.Errorf("build: %w", err) + } + + _, err = fmt.Fprintln(out, merkle.Hex(tree.Root())) + return err +} + +func runProof(args []string, out, errOut io.Writer) error { + _ = errOut + fs := flag.NewFlagSet("proof", flag.ContinueOnError) + jsonPath := fs.String("json", "", "Path to JSON file with items") + index := fs.Int("index", -1, "Leaf index to build proof for") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("proof args error: %w", err) + } + + if *jsonPath == "" { + return fmt.Errorf("proof: missing required flag -json") + } + if *index < 0 { + return fmt.Errorf("proof: missing or invalid -index") + } + + tree, err := merkle.NewMerkleTreeFromJSON(*jsonPath) + if err != nil { + return fmt.Errorf("proof: %w", err) + } + + proof, err := tree.GetProof(*index) + if err != nil { + return fmt.Errorf("proof: %w", err) + } + + response := make([]merkle.ProofStepJSON, 0, len(proof)) + for _, step := range proof { + response = append(response, merkle.ProofStepJSON{ + Sibling: merkle.Hex(step.SiblingHash), + IsLeft: step.IsLeft, + }) + } + + output := struct { + Index int `json:"index"` + Root string `json:"root"` + Proof []merkle.ProofStepJSON `json:"proof"` + }{ + Index: *index, + Root: merkle.Hex(tree.Root()), + Proof: response, + } + + body, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("proof: %w", err) + } + + _, err = fmt.Fprintln(out, string(body)) + return err +} + +func runVerify(args []string, out, errOut io.Writer) error { + _ = errOut + fs := flag.NewFlagSet("verify", flag.ContinueOnError) + jsonPath := fs.String("json", "", "Path to JSON file with items") + index := fs.Int("index", -1, "Leaf index to verify") + data := fs.String("data", "", "Expected leaf data") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("verify args error: %w", err) + } + + if *jsonPath == "" { + return fmt.Errorf("verify: missing required flag -json") + } + if *index < 0 { + return fmt.Errorf("verify: missing or invalid -index") + } + + tree, err := merkle.NewMerkleTreeFromJSON(*jsonPath) + if err != nil { + return fmt.Errorf("verify: %w", err) + } + + proof, err := tree.GetProof(*index) + if err != nil { + return fmt.Errorf("verify: %w", err) + } + + ok := tree.Verify([]byte(*data), proof, tree.Root()) + _, err = fmt.Fprintln(out, ok) + return err +} + +func runVerifyProof(args []string, out, errOut io.Writer) error { + _ = errOut + fs := flag.NewFlagSet("verify-proof", flag.ContinueOnError) + rootHex := fs.String("root", "", "Expected root hash in hex") + data := fs.String("data", "", "Expected leaf data") + proofPath := fs.String("proof", "", "Path to proof JSON file") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("verify-proof args error: %w", err) + } + + if *rootHex == "" { + return fmt.Errorf("verify-proof: missing required flag -root") + } + if *proofPath == "" { + return fmt.Errorf("verify-proof: missing required flag -proof") + } + + root, err := hex.DecodeString(*rootHex) + if err != nil { + return fmt.Errorf("verify-proof: invalid root hash") + } + + proofData, err := os.ReadFile(*proofPath) + if err != nil { + return fmt.Errorf("verify-proof: %w", err) + } + + proof, err := merkle.ParseProofJSON(proofData) + if err != nil { + return fmt.Errorf("verify-proof: %w", err) + } + + ok := merkle.VerifyProof([]byte(*data), proof, root) + _, err = fmt.Fprintln(out, ok) + return err +} + +func runUpdate(args []string, out, errOut io.Writer) error { + _ = errOut + fs := flag.NewFlagSet("update", flag.ContinueOnError) + jsonPath := fs.String("json", "", "Path to JSON file with items") + index := fs.Int("index", -1, "Leaf index to update") + data := fs.String("data", "", "New leaf data") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("update args error: %w", err) + } + + if *jsonPath == "" { + return fmt.Errorf("update: missing required flag -json") + } + if *index < 0 { + return fmt.Errorf("update: missing or invalid -index") + } + + tree, err := merkle.NewMerkleTreeFromJSON(*jsonPath) + if err != nil { + return fmt.Errorf("update: %w", err) + } + + if err := tree.UpdateLeaf(*index, []byte(*data)); err != nil { + return fmt.Errorf("update: %w", err) + } + + _, err = fmt.Fprintln(out, merkle.Hex(tree.Root())) + return err +} + +func usage(out io.Writer) { + fmt.Fprintln(out, `Usage: + merkle build -json + merkle proof -json -index + merkle verify -json -index -data + merkle verify-proof -root -proof -data + merkle update -json -index -data `) +} diff --git a/04-formats/merkle-hashing/cmd/merkle/main_test.go b/04-formats/merkle-hashing/cmd/merkle/main_test.go new file mode 100644 index 00000000..dea4d0c9 --- /dev/null +++ b/04-formats/merkle-hashing/cmd/merkle/main_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "merkle-hashing/internal/merkle" +) + +type proofResponse struct { + Index int `json:"index"` + Root string `json:"root"` + Proof []merkle.ProofStepJSON `json:"proof"` +} + +func runCommand(args ...string) (string, string, int) { + var out bytes.Buffer + var errOut bytes.Buffer + exitCode := run(args, &out, &errOut) + return out.String(), errOut.String(), exitCode +} + +func writeJSONData(t *testing.T) (string, string) { + t.Helper() + + tempDir := t.TempDir() + path := filepath.Join(tempDir, "data.json") + + content := []byte(`{"items":["a","b","c","d"]}`) + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("write file failed: %v", err) + } + + tree := merkle.NewMerkleTree([][]byte{ + []byte("a"), + []byte("b"), + []byte("c"), + []byte("d"), + }) + return path, merkle.Hex(tree.Root()) +} + +func TestCLIBuild(t *testing.T) { + t.Parallel() + + path, expectedRoot := writeJSONData(t) + stdout, stderr, code := runCommand("build", "-json", path) + if code != 0 { + t.Fatalf("expected exit code 0, got %d, err=%q", code, stderr) + } + if got := strings.TrimSpace(stdout); got != expectedRoot { + t.Fatalf("unexpected root: got %s, expected %s", got, expectedRoot) + } +} + +func TestCLIProof(t *testing.T) { + t.Parallel() + + path, expectedRoot := writeJSONData(t) + stdout, stderr, code := runCommand("proof", "-json", path, "-index", "1") + if code != 0 { + t.Fatalf("expected exit code 0, got %d, err=%q", code, stderr) + } + + var response proofResponse + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + t.Fatalf("proof output invalid json: %v", err) + } + + if response.Index != 1 { + t.Fatalf("expected index 1, got %d", response.Index) + } + if response.Root != expectedRoot { + t.Fatalf("expected proof root %s, got %s", expectedRoot, response.Root) + } + if len(response.Proof) == 0 { + t.Fatal("proof must not be empty for non-leaf-tree") + } +} + +func TestCLIVerifyAndVerifyProof(t *testing.T) { + t.Parallel() + + path, expectedRoot := writeJSONData(t) + proofOut, proofErr, proofCode := runCommand("proof", "-json", path, "-index", "1") + if proofCode != 0 { + t.Fatalf("proof command failed: code=%d err=%q", proofCode, proofErr) + } + + out := t.TempDir() + proofPath := filepath.Join(out, "proof.json") + if err := os.WriteFile(proofPath, []byte(proofOut), 0o600); err != nil { + t.Fatalf("write proof file failed: %v", err) + } + + stdout, _, code := runCommand("verify", "-json", path, "-index", "1", "-data", "b") + if code != 0 { + t.Fatal("verify command failed") + } + if strings.TrimSpace(stdout) != "true" { + t.Fatalf("expected verify to return true, got %s", stdout) + } + + stdout, _, code = runCommand("verify", "-json", path, "-index", "1", "-data", "wrong") + if code != 0 { + t.Fatal("verify command failed") + } + if strings.TrimSpace(stdout) != "false" { + t.Fatalf("expected verify false for wrong data, got %s", stdout) + } + + stdout, _, code = runCommand("verify-proof", "-root", expectedRoot, "-proof", proofPath, "-data", "b") + if code != 0 { + t.Fatal("verify-proof command failed") + } + if strings.TrimSpace(stdout) != "true" { + t.Fatalf("expected verify-proof true, got %s", stdout) + } +} + +func TestCLIUpdate(t *testing.T) { + t.Parallel() + + path, oldRoot := writeJSONData(t) + out, errOut, code := runCommand("update", "-json", path, "-index", "1", "-data", "z") + if code != 0 { + t.Fatalf("expected exit code 0, got %d err=%q", code, errOut) + } + + newRoot := strings.TrimSpace(out) + if newRoot == oldRoot { + t.Fatal("root should change after update") + } +} + +func TestCLIMissingCommand(t *testing.T) { + t.Parallel() + + _, _, code := runCommand("missing-command") + if code != 1 { + t.Fatalf("expected usage path to have exit code 1, got %d", code) + } +} diff --git a/04-formats/merkle-hashing/example_data.json b/04-formats/merkle-hashing/example_data.json new file mode 100644 index 00000000..da5e1549 --- /dev/null +++ b/04-formats/merkle-hashing/example_data.json @@ -0,0 +1,9 @@ +{ + "items": [ + "block-0", + "block-1", + "block-2", + "block-3", + "block-4" + ] +} diff --git a/04-formats/merkle-hashing/go.mod b/04-formats/merkle-hashing/go.mod new file mode 100644 index 00000000..25edf857 --- /dev/null +++ b/04-formats/merkle-hashing/go.mod @@ -0,0 +1,4 @@ +module merkle-hashing + +go 1.21 + diff --git a/04-formats/merkle-hashing/internal/merkle/example_test.go b/04-formats/merkle-hashing/internal/merkle/example_test.go new file mode 100644 index 00000000..df9709d7 --- /dev/null +++ b/04-formats/merkle-hashing/internal/merkle/example_test.go @@ -0,0 +1,28 @@ +package merkle + +import ( + "fmt" +) + +func ExampleMerkleTree() { + tree := NewMerkleTree([][]byte{ + []byte("a"), + []byte("b"), + []byte("c"), + }) + + root := tree.Root() + proof, err := tree.GetProof(1) + if err != nil { + fmt.Println("proof error:", err) + return + } + + ok := tree.Verify([]byte("b"), proof, root) + fmt.Println("verify:", ok) + fmt.Println("root length:", len(root)) + + // Output: + // verify: true + // root length: 32 +} diff --git a/04-formats/merkle-hashing/internal/merkle/tree.go b/04-formats/merkle-hashing/internal/merkle/tree.go new file mode 100644 index 00000000..57f1ca2b --- /dev/null +++ b/04-formats/merkle-hashing/internal/merkle/tree.go @@ -0,0 +1,333 @@ +package merkle + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" +) + +const hashSize = sha256.Size + +// Node is a Merkle tree vertex. +type Node struct { + Hash []byte + Data []byte + Left *Node + Right *Node +} + +// ProofStep represents one sibling hash in the Merkle proof. +type ProofStep struct { + SiblingHash []byte + IsLeft bool +} + +// ProofStepJSON is a JSON-friendly representation of ProofStep. +type ProofStepJSON struct { + Sibling string `json:"sibling"` + IsLeft bool `json:"is_left"` +} + +// MerkleTree stores all tree levels for proof construction. +type MerkleTree struct { + levels [][]*Node +} + +// Common merkle errors. +var ( + ErrEmptyData = errors.New("merkle: no data provided") + ErrNilTree = errors.New("merkle: tree is empty") + ErrInvalidIndex = errors.New("merkle: invalid leaf index") + ErrInvalidProof = errors.New("merkle: invalid proof") +) + +type jsonInput struct { + Items []string `json:"items"` +} + +// NewMerkleTreeFromJSON builds a tree from JSON: {"items":[...]}. +func NewMerkleTreeFromJSON(path string) (*MerkleTree, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var input jsonInput + if err := json.Unmarshal(content, &input); err != nil { + return nil, err + } + + if len(input.Items) == 0 { + return nil, ErrEmptyData + } + + data := make([][]byte, 0, len(input.Items)) + for _, item := range input.Items { + data = append(data, []byte(item)) + } + + tree := NewMerkleTree(data) + if tree == nil { + return nil, ErrEmptyData + } + + return tree, nil +} + +// NewMerkleTree builds a Merkle tree from raw items. +// Returns nil for empty input. +func NewMerkleTree(data [][]byte) *MerkleTree { + if len(data) == 0 { + return nil + } + + level := make([]*Node, 0, len(data)) + for _, item := range data { + itemCopy := make([]byte, len(item)) + copy(itemCopy, item) + level = append(level, &Node{ + Hash: hashBytes(itemCopy), + Data: itemCopy, + }) + } + + levels := make([][]*Node, 0, 1+len(data)/2) + levels = append(levels, level) + + for len(level) > 1 { + next := make([]*Node, 0, (len(level)+1)/2) + for i := 0; i < len(level); i += 2 { + left := level[i] + right := left + if i+1 < len(level) { + right = level[i+1] + } + + parentHash := hashPair(left.Hash, right.Hash) + next = append(next, &Node{ + Hash: parentHash, + Left: left, + Right: right, + }) + } + levels = append(levels, next) + level = next + } + + return &MerkleTree{levels: levels} +} + +// Root returns a copy of the Merkle root hash. +func (t *MerkleTree) Root() []byte { + if t == nil || len(t.levels) == 0 { + return nil + } + rootLevel := t.levels[len(t.levels)-1] + if len(rootLevel) == 0 { + return nil + } + return copyBytes(rootLevel[0].Hash) +} + +// GetProof builds Merkle proof for leaf at index. +func (t *MerkleTree) GetProof(index int) ([]ProofStep, error) { + if t == nil || len(t.levels) == 0 { + return nil, ErrNilTree + } + + if index < 0 || index >= len(t.levels[0]) { + return nil, ErrInvalidIndex + } + + if len(t.levels) == 1 { + return []ProofStep{}, nil + } + + current := index + proof := make([]ProofStep, 0, len(t.levels)-1) + + for level := 0; level < len(t.levels)-1; level++ { + nodes := t.levels[level] + var siblingIndex int + isLeft := false + + if current%2 == 0 { + siblingIndex = current + 1 + if siblingIndex >= len(nodes) { + siblingIndex = current + } + isLeft = false + } else { + siblingIndex = current - 1 + isLeft = true + } + + proof = append(proof, ProofStep{ + SiblingHash: copyBytes(nodes[siblingIndex].Hash), + IsLeft: isLeft, + }) + current /= 2 + } + + return proof, nil +} + +// Verify checks that data is included in a tree with given root using proof. +func (t *MerkleTree) Verify(data []byte, proof []ProofStep, root []byte) bool { + return VerifyProof(data, proof, root) +} + +// VerifyProof checks that data is included in a tree with a given root using proof. +func VerifyProof(data []byte, proof []ProofStep, root []byte) bool { + if len(root) != hashSize { + return false + } + current := hashBytes(data) + for _, step := range proof { + if len(step.SiblingHash) != hashSize { + return false + } + if step.IsLeft { + current = hashPair(step.SiblingHash, current) + } else { + current = hashPair(current, step.SiblingHash) + } + } + return bytesEqual(current, root) +} + +// UpdateLeaf updates one leaf and rebuilds all parent hashes on the path to root. +func (t *MerkleTree) UpdateLeaf(index int, newData []byte) error { + if t == nil || len(t.levels) == 0 { + return ErrNilTree + } + if index < 0 || index >= len(t.levels[0]) { + return ErrInvalidIndex + } + + dataCopy := make([]byte, len(newData)) + copy(dataCopy, newData) + + t.levels[0][index].Data = dataCopy + t.levels[0][index].Hash = hashBytes(dataCopy) + + current := index + for level := 0; level < len(t.levels)-1; level++ { + parentIndex := current / 2 + leftChildIndex := parentIndex * 2 + rightChildIndex := leftChildIndex + 1 + + left := t.levels[level][leftChildIndex] + right := left + if rightChildIndex < len(t.levels[level]) { + right = t.levels[level][rightChildIndex] + } + + t.levels[level+1][parentIndex].Hash = hashPair(left.Hash, right.Hash) + current = parentIndex + } + + return nil +} + +// ParseProofJSON parses proof from JSON representation. +func ParseProofJSON(data []byte) ([]ProofStep, error) { + list, err := parseProofJSONList(data) + if err == nil { + return parseProofStepList(list) + } + + var wrapped struct { + Proof []ProofStepJSON `json:"proof"` + } + if err := json.Unmarshal(data, &wrapped); err != nil { + return nil, err + } + if wrapped.Proof == nil { + return nil, fmt.Errorf("%w: proof field is missing", ErrInvalidProof) + } + + return parseProofStepList(wrapped.Proof) +} + +func parseProofJSONList(data []byte) ([]ProofStepJSON, error) { + var raw []ProofStepJSON + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + return raw, nil +} + +func parseProofStepList(raw []ProofStepJSON) ([]ProofStep, error) { + proof := make([]ProofStep, 0, len(raw)) + for i, step := range raw { + sibling, err := hex.DecodeString(step.Sibling) + if err != nil { + return nil, err + } + if len(sibling) != hashSize { + return nil, fmt.Errorf("%w: sibling hash at position %d has length %d", ErrInvalidProof, i, len(sibling)) + } + proof = append(proof, ProofStep{ + SiblingHash: sibling, + IsLeft: step.IsLeft, + }) + } + + return proof, nil +} + +// MarshalProofJSON serializes proof to JSON representation. +func MarshalProofJSON(proof []ProofStep) ([]byte, error) { + raw := make([]ProofStepJSON, 0, len(proof)) + for i, step := range proof { + if len(step.SiblingHash) != hashSize { + return nil, fmt.Errorf("%w: sibling hash at position %d has length %d", ErrInvalidProof, i, len(step.SiblingHash)) + } + raw = append(raw, ProofStepJSON{ + Sibling: Hex(step.SiblingHash), + IsLeft: step.IsLeft, + }) + } + + return json.Marshal(raw) +} + +func hashBytes(data []byte) []byte { + sum := sha256.Sum256(data) + return sum[:] +} + +func hashPair(left, right []byte) []byte { + joined := make([]byte, 0, len(left)+len(right)) + joined = append(joined, left...) + joined = append(joined, right...) + sum := sha256.Sum256(joined) + return sum[:] +} + +func copyBytes(input []byte) []byte { + output := make([]byte, len(input)) + copy(output, input) + return output +} + +// Hex returns hash as hex string for tests and debug. +func Hex(data []byte) string { + return hex.EncodeToString(data) +} + +func bytesEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/04-formats/merkle-hashing/internal/merkle/tree_test.go b/04-formats/merkle-hashing/internal/merkle/tree_test.go new file mode 100644 index 00000000..3bc45807 --- /dev/null +++ b/04-formats/merkle-hashing/internal/merkle/tree_test.go @@ -0,0 +1,245 @@ +package merkle + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestMerkleTreeRootOdd(t *testing.T) { + t.Parallel() + + data := [][]byte{ + []byte("block-0"), + []byte("block-1"), + []byte("block-2"), + } + + tree := NewMerkleTree(data) + if tree == nil { + t.Fatal("expected non-nil tree") + } + + h0 := hashBytes(data[0]) + h1 := hashBytes(data[1]) + h2 := hashBytes(data[2]) + + ab := hashPair(h0, h1) + cc := hashPair(h2, h2) + expected := hashPair(ab, cc) + + if !bytes.Equal(tree.Root(), expected) { + t.Fatalf("unexpected root: got %s, expected %s", Hex(tree.Root()), Hex(expected)) + } +} + +func TestMerkleTreeProofAndVerify(t *testing.T) { + t.Parallel() + + data := [][]byte{ + []byte("a"), + []byte("b"), + []byte("c"), + []byte("d"), + } + tree := NewMerkleTree(data) + if tree == nil { + t.Fatal("expected non-nil tree") + } + + proof, err := tree.GetProof(2) + if err != nil { + t.Fatalf("get proof failed: %v", err) + } + + root := tree.Root() + if !tree.Verify(data[2], proof, root) { + t.Fatalf("proof is invalid for correct data") + } + if tree.Verify([]byte("changed"), proof, root) { + t.Fatalf("proof should be invalid for changed data") + } +} + +func TestMerkleTreeRootSingleAndPair(t *testing.T) { + t.Parallel() + + single := NewMerkleTree([][]byte{[]byte("single")}) + if single == nil { + t.Fatal("expected non-nil tree") + } + expectedSingle := hashBytes([]byte("single")) + if !bytes.Equal(single.Root(), expectedSingle) { + t.Fatalf("unexpected root for single item: got %s, expected %s", Hex(single.Root()), Hex(expectedSingle)) + } + + pair := NewMerkleTree([][]byte{[]byte("left"), []byte("right")}) + if pair == nil { + t.Fatal("expected non-nil tree") + } + hLeft := hashBytes([]byte("left")) + hRight := hashBytes([]byte("right")) + expectedPair := hashPair(hLeft, hRight) + if !bytes.Equal(pair.Root(), expectedPair) { + t.Fatalf("unexpected root for pair: got %s, expected %s", Hex(pair.Root()), Hex(expectedPair)) + } +} + +func TestMerkleTreeDeterministicRoot(t *testing.T) { + t.Parallel() + + data := [][]byte{ + []byte("x"), + []byte("y"), + []byte("z"), + } + + r1 := NewMerkleTree(data).Root() + r2 := NewMerkleTree(data).Root() + if !bytes.Equal(r1, r2) { + t.Fatalf("expected deterministic root for the same input") + } +} + +func TestProofJSONRoundTrip(t *testing.T) { + t.Parallel() + + original := []ProofStep{ + {SiblingHash: hashBytes([]byte("first")), IsLeft: true}, + {SiblingHash: hashBytes([]byte("second")), IsLeft: false}, + } + + encoded, err := MarshalProofJSON(original) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + decoded, err := ParseProofJSON(encoded) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + + if len(decoded) != len(original) { + t.Fatalf("proof size mismatch: got %d, expected %d", len(decoded), len(original)) + } + + for i := range original { + if original[i].IsLeft != decoded[i].IsLeft { + t.Fatalf("isLeft mismatch at index %d", i) + } + if !bytes.Equal(original[i].SiblingHash, decoded[i].SiblingHash) { + t.Fatalf("sibling mismatch at index %d", i) + } + } +} + +func TestParseProofJSONInvalid(t *testing.T) { + t.Parallel() + + if _, err := ParseProofJSON([]byte(`[{"sibling":"not-hex","is_left":true}]`)); err == nil { + t.Fatalf("expected parse error for invalid hex") + } + + if _, err := ParseProofJSON([]byte(`[{"sibling":"AA","is_left":true}]`)); err == nil { + t.Fatalf("expected parse error for wrong hash size") + } + + wrapped := []byte(`{"index":1,"root":"abcdef","proof":[{"sibling":"` + Hex(hashBytes([]byte("first"))) + `","is_left":true}]}`) + parsed, err := ParseProofJSON(wrapped) + if err != nil { + t.Fatalf("wrapped parse failed: %v", err) + } + if len(parsed) != 1 { + t.Fatalf("expected wrapped proof size 1") + } + if !parsed[0].IsLeft { + t.Fatalf("expected is_left=true") + } + + if _, err := ParseProofJSON([]byte(`{"index":1,"root":"abcdef","proof":[]}`)); err != nil { + t.Fatalf("expected empty proof array to be valid") + } +} + +func TestMerkleTreeUpdateLeaf(t *testing.T) { + t.Parallel() + + data := [][]byte{ + []byte("a"), + []byte("b"), + []byte("c"), + } + tree := NewMerkleTree(data) + if tree == nil { + t.Fatal("expected non-nil tree") + } + oldRoot := copyBytes(tree.Root()) + + if err := tree.UpdateLeaf(1, []byte("new-block")); err != nil { + t.Fatalf("update leaf failed: %v", err) + } + + newRoot := tree.Root() + if bytes.Equal(oldRoot, newRoot) { + t.Fatalf("root must change after leaf update") + } + + proof, err := tree.GetProof(1) + if err != nil { + t.Fatalf("get proof failed: %v", err) + } + + if !tree.Verify([]byte("new-block"), proof, newRoot) { + t.Fatalf("proof must be valid for updated leaf") + } + if tree.Verify([]byte("b"), proof, newRoot) { + t.Fatalf("proof must be invalid for outdated leaf data") + } +} + +func TestMerkleTreeJSONLoading(t *testing.T) { + tmpDir := t.TempDir() + jsonPath := filepath.Join(tmpDir, "data.json") + + content := []byte(`{"items":["one","two","three"]}`) + if err := os.WriteFile(jsonPath, content, 0o600); err != nil { + t.Fatalf("write file failed: %v", err) + } + + tree, err := NewMerkleTreeFromJSON(jsonPath) + if err != nil { + t.Fatalf("create tree from json failed: %v", err) + } + if tree == nil || len(tree.Root()) == 0 { + t.Fatalf("tree root should exist") + } +} + +func TestMerkleTreeErrors(t *testing.T) { + t.Parallel() + + if _, err := NewMerkleTreeFromJSON("non_existing_file.json"); err == nil { + t.Fatalf("expected error for missing file") + } + + if tree := NewMerkleTree(nil); tree != nil { + t.Fatalf("expected nil tree for empty input") + } + + if _, err := NewMerkleTreeFromJSON(filepath.Join(t.TempDir(), "empty.json")); err == nil { + t.Fatalf("expected empty-json parsing error") + } + + tmpPath := filepath.Join(t.TempDir(), "empty-items.json") + if err := os.WriteFile(tmpPath, []byte(`{"items":[]}`), 0o600); err != nil { + t.Fatalf("write file failed: %v", err) + } + tree, err := NewMerkleTreeFromJSON(tmpPath) + if err != ErrEmptyData { + t.Fatalf("expected ErrEmptyData, got %v", err) + } + if tree != nil { + t.Fatalf("tree must be nil for empty items") + } +}