Skip to content

Commit ef4e662

Browse files
committed
torchwood: add HashReaderOverlay
1 parent 45bbcc1 commit ef4e662

4 files changed

Lines changed: 322 additions & 15 deletions

File tree

NEWS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
- Added `NewCosignatureVerifierFromKey` to produce a verifier from a
1212
`crypto.PublicKey` instead of a vkey.
1313

14+
- Added `HashReaderOverlay`, a `tlog.HashReader` that extends a tree with the
15+
stored hashes of appended records, held in memory.
16+
1417
### mpt
1518

1619
- New package with a Merkle Patricia Trie implementation.

cmd/spicy/spicy.go

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -169,18 +169,19 @@ func main() {
169169
log.Fatalf("could not parse latest checkpoint: %v", err)
170170
}
171171

172-
hashes := make(map[int64]tlog.Hash)
173-
hashReader := tlog.HashReaderFunc(func(indexes []int64) ([]tlog.Hash, error) {
172+
edgeHashes := make(map[int64]tlog.Hash)
173+
edgeReader := tlog.HashReaderFunc(func(indexes []int64) ([]tlog.Hash, error) {
174174
list := make([]tlog.Hash, 0, len(indexes))
175175
for _, id := range indexes {
176-
h, ok := hashes[id]
176+
h, ok := edgeHashes[id]
177177
if !ok {
178-
return nil, fmt.Errorf("index %d not in hashes", id)
178+
return nil, fmt.Errorf("index %d not in edge", id)
179179
}
180180
list = append(list, h)
181181
}
182182
return list, nil
183183
})
184+
hashReader := torchwood.NewHashReaderOverlay(c.N, edgeReader)
184185

185186
edge, err := os.ReadFile(filepath.Join(*assetsFlag, "edge"))
186187
if err != nil {
@@ -210,38 +211,34 @@ func main() {
210211
if err != nil {
211212
log.Fatalf("malformed edge file: %v", err)
212213
}
213-
hashes[idx[i]] = hash
214+
edgeHashes[idx[i]] = hash
214215
}
215216

216217
fmt.Fprintf(os.Stderr, "Log loaded.\n")
217218
fmt.Fprintf(os.Stderr, " - Name: %s\n", c.Origin)
218219
fmt.Fprintf(os.Stderr, " - Current size: %d\n", c.N)
219220
fmt.Fprintf(os.Stderr, " - Assets directory: %s\n", *assetsFlag)
220221

221-
for i, path := range flag.Args() {
222+
for _, path := range flag.Args() {
222223
if _, err := os.Stat(path + ".spicy"); err == nil {
223224
log.Fatalf("spicy signature already exists for %q", path)
224225
}
225226
f, err := os.ReadFile(path)
226227
if err != nil {
227228
log.Fatalf("could not read %q: %v", path, err)
228229
}
229-
n := c.N + int64(i)
230-
hh, err := tlog.StoredHashes(n, f, hashReader)
231-
if err != nil {
230+
n := hashReader.Size()
231+
if err := hashReader.AppendRecordHash(tlog.RecordHash(f)); err != nil {
232232
log.Fatalf("could not append %q: %v", path, err)
233233
}
234-
for k, h := range hh {
235-
hashes[tlog.StoredHashIndex(0, n)+int64(k)] = h
236-
}
237234
entryPath := filepath.Join(*assetsFlag, strconv.FormatInt(n, 10))
238235
if err := os.WriteFile(entryPath, f, 0644); err != nil {
239236
log.Fatalf("could not copy %q to assets: %v", path, err)
240237
}
241238
fmt.Fprintf(os.Stderr, " + %q is now entry %d\n", path, n)
242239
}
243240

244-
N := c.N + int64(len(flag.Args()))
241+
N := hashReader.Size()
245242
th, err := tlog.TreeHash(N, hashReader)
246243
if err != nil {
247244
log.Fatalf("could not compute tree hash: %v", err)
@@ -255,8 +252,12 @@ func main() {
255252
log.Fatalf("could not sign new checkpoint: %v", err)
256253
}
257254
newEdge := fmt.Sprintf("size %d\n", N)
258-
for _, idx := range torchwood.RightEdge(N) {
259-
newEdge += fmt.Sprintf("%s\n", hashes[idx])
255+
newEdgeHashes, err := hashReader.ReadHashes(torchwood.RightEdge(N))
256+
if err != nil {
257+
log.Fatalf("could not read new edge hashes: %v", err)
258+
}
259+
for _, h := range newEdgeHashes {
260+
newEdge += fmt.Sprintf("%s\n", h)
260261
}
261262

262263
if err := os.WriteFile(filepath.Join(*assetsFlag, "latest"), newCheckpoint, 0644); err != nil {

overlay.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package torchwood
2+
3+
import (
4+
"fmt"
5+
6+
"golang.org/x/mod/sumdb/tlog"
7+
)
8+
9+
// A HashReaderOverlay is a [tlog.HashReader] for a tree that extends an
10+
// original tree with appended records, whose stored hashes are held in memory.
11+
//
12+
// It can be used to stage the hashes of new records before they are persisted,
13+
// and to compute tree hashes, subtree hashes, proofs, and tiles that span both
14+
// the original tree and the appended records.
15+
//
16+
// The underlying HashReader is responsible for serving any requested hashes in
17+
// the original tree. Which hashes are requested depends on the caller:
18+
// [HashReaderOverlay.AppendRecordHash], [tlog.StoredHashes], [tlog.TreeHash],
19+
// and [SubtreeHash] only need the [RightEdge] of the original tree (as long as
20+
// the tree or subtree extends past the original tree), while
21+
// [tlog.ReadTileData] might need more (such as the full contents of the
22+
// rightmost tiles).
23+
type HashReaderOverlay struct {
24+
r tlog.HashReader
25+
size int64
26+
base int64 // tlog.StoredHashCount of the original tree size
27+
hashes []tlog.Hash
28+
}
29+
30+
// NewHashReaderOverlay returns a [HashReaderOverlay] over an original tree of
31+
// size n, whose hashes are served by r. If n is 0, r may be nil.
32+
//
33+
// n must not be negative or greater than 2^62, the largest supported tree
34+
// size; NewHashReaderOverlay panics otherwise.
35+
func NewHashReaderOverlay(n int64, r tlog.HashReader) *HashReaderOverlay {
36+
if n < 0 || n > maxN {
37+
panic("tlog: tree size out of range in NewHashReaderOverlay")
38+
}
39+
return &HashReaderOverlay{r: r, size: n, base: tlog.StoredHashCount(n)}
40+
}
41+
42+
// Size returns the size of the extended tree: the size of the original tree
43+
// plus the number of appended leaf records.
44+
func (o *HashReaderOverlay) Size() int64 {
45+
return o.size
46+
}
47+
48+
// AppendRecordHash extends the tree by one record with the given record hash
49+
// (as produced by [tlog.RecordHash]), computing and adding the record's stored
50+
// hashes to the overlay.
51+
//
52+
// The hashes of completed subtrees may be requested from the overlay, which
53+
// needs at most the [RightEdge] of the original tree from the underlying
54+
// HashReader.
55+
func (o *HashReaderOverlay) AppendRecordHash(rh tlog.Hash) error {
56+
if o.size >= maxN {
57+
return fmt.Errorf("tlog: tree size out of range in AppendRecordHash")
58+
}
59+
hashes, err := tlog.StoredHashesForRecordHash(o.size, rh, o)
60+
if err != nil {
61+
return err
62+
}
63+
o.hashes = append(o.hashes, hashes...)
64+
o.size++
65+
return nil
66+
}
67+
68+
// ReadHashes implements [tlog.HashReader]. Any hashes in the original tree are
69+
// requested from the underlying HashReader in a single ReadHashes call.
70+
func (o *HashReaderOverlay) ReadHashes(indexes []int64) ([]tlog.Hash, error) {
71+
list := make([]tlog.Hash, len(indexes))
72+
var origIndexes []int64
73+
var origPositions []int
74+
for i, id := range indexes {
75+
switch {
76+
case id < 0 || id >= o.base+int64(len(o.hashes)):
77+
return nil, fmt.Errorf("tlog: index %d out of range for tree of size %d in HashReaderOverlay", id, o.size)
78+
case id < o.base:
79+
origIndexes = append(origIndexes, id)
80+
origPositions = append(origPositions, i)
81+
default:
82+
list[i] = o.hashes[id-o.base]
83+
}
84+
}
85+
if len(origIndexes) > 0 {
86+
if o.r == nil {
87+
return nil, fmt.Errorf("tlog: no underlying HashReader for original tree in HashReaderOverlay")
88+
}
89+
hashes, err := o.r.ReadHashes(origIndexes)
90+
if err != nil {
91+
return nil, err
92+
}
93+
if len(hashes) != len(origIndexes) {
94+
return nil, fmt.Errorf("tlog: ReadHashes(%d indexes) = %d hashes", len(origIndexes), len(hashes))
95+
}
96+
for i, h := range hashes {
97+
list[origPositions[i]] = h
98+
}
99+
}
100+
return list, nil
101+
}

overlay_test.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package torchwood_test
2+
3+
import (
4+
"fmt"
5+
"slices"
6+
"testing"
7+
8+
"filippo.io/torchwood"
9+
"golang.org/x/mod/sumdb/tlog"
10+
)
11+
12+
// TestHashReaderOverlayRightEdge checks that appending records, computing tree
13+
// and subtree hashes, and proving new records only need the right edge of the
14+
// original tree.
15+
func TestHashReaderOverlayRightEdge(t *testing.T) {
16+
const N = 130
17+
leaves, full := buildTree(t, N)
18+
for n0 := int64(0); n0 <= N; n0++ {
19+
edge := make(map[int64]tlog.Hash)
20+
if n0 > 0 {
21+
idx := torchwood.RightEdge(n0)
22+
hashes, err := full.ReadHashes(idx)
23+
fatalIfErr(t, err)
24+
for i, id := range idx {
25+
edge[id] = hashes[i]
26+
}
27+
}
28+
r := tlog.HashReaderFunc(func(indexes []int64) ([]tlog.Hash, error) {
29+
list := make([]tlog.Hash, 0, len(indexes))
30+
for _, id := range indexes {
31+
h, ok := edge[id]
32+
if !ok {
33+
return nil, fmt.Errorf("index %d not on the right edge of tree of size %d", id, n0)
34+
}
35+
list = append(list, h)
36+
}
37+
return list, nil
38+
})
39+
40+
overlay := torchwood.NewHashReaderOverlay(n0, r)
41+
for n := n0; n < N; n++ {
42+
if overlay.Size() != n {
43+
t.Fatalf("n0=%d: Size() = %d, want %d", n0, overlay.Size(), n)
44+
}
45+
fatalIfErr(t, overlay.AppendRecordHash(leaves[n]))
46+
47+
got, err := tlog.TreeHash(n+1, overlay)
48+
fatalIfErr(t, err)
49+
want, err := tlog.TreeHash(n+1, full)
50+
fatalIfErr(t, err)
51+
if got != want {
52+
t.Fatalf("n0=%d: TreeHash(%d) = %v, want %v", n0, n+1, got, want)
53+
}
54+
}
55+
56+
th, err := tlog.TreeHash(N, overlay)
57+
fatalIfErr(t, err)
58+
for i := n0; i < N; i++ {
59+
proof, err := tlog.ProveRecord(N, i, overlay)
60+
fatalIfErr(t, err)
61+
if err := tlog.CheckRecord(proof, N, th, i, leaves[i]); err != nil {
62+
t.Fatalf("n0=%d: CheckRecord(%d): %v", n0, i, err)
63+
}
64+
}
65+
66+
for start := int64(0); start < N; start++ {
67+
// Subtrees wholly within the original tree may not be on its
68+
// right edge, so only check those extending past it.
69+
for end := max(start+1, n0+1); end <= N; end++ {
70+
if !torchwood.ValidSubtree(start, end) {
71+
continue
72+
}
73+
got, err := torchwood.SubtreeHash(start, end, overlay)
74+
fatalIfErr(t, err)
75+
want, err := torchwood.SubtreeHash(start, end, full)
76+
fatalIfErr(t, err)
77+
if got != want {
78+
t.Fatalf("n0=%d: SubtreeHash(%d, %d) = %v, want %v", n0, start, end, got, want)
79+
}
80+
}
81+
}
82+
}
83+
}
84+
85+
// TestHashReaderOverlayFullTree checks subtree hashes and tile contents
86+
// against a reader for the whole tree, with the underlying reader restricted
87+
// to the original tree.
88+
func TestHashReaderOverlayFullTree(t *testing.T) {
89+
const N = 130
90+
leaves, full := buildTree(t, N)
91+
for n0 := int64(0); n0 <= N; n0++ {
92+
base := tlog.StoredHashCount(n0)
93+
r := tlog.HashReaderFunc(func(indexes []int64) ([]tlog.Hash, error) {
94+
for _, id := range indexes {
95+
if id >= base {
96+
return nil, fmt.Errorf("index %d beyond the original tree of size %d", id, n0)
97+
}
98+
}
99+
return full.ReadHashes(indexes)
100+
})
101+
102+
overlay := torchwood.NewHashReaderOverlay(n0, r)
103+
for n := n0; n < N; n++ {
104+
fatalIfErr(t, overlay.AppendRecordHash(leaves[n]))
105+
}
106+
107+
for start := int64(0); start < N; start++ {
108+
for end := start + 1; end <= N; end++ {
109+
if !torchwood.ValidSubtree(start, end) {
110+
continue
111+
}
112+
got, err := torchwood.SubtreeHash(start, end, overlay)
113+
fatalIfErr(t, err)
114+
want, err := torchwood.SubtreeHash(start, end, full)
115+
fatalIfErr(t, err)
116+
if got != want {
117+
t.Fatalf("n0=%d: SubtreeHash(%d, %d) = %v, want %v", n0, start, end, got, want)
118+
}
119+
}
120+
}
121+
122+
for _, tile := range tlog.NewTiles(2, n0, N) {
123+
got, err := tlog.ReadTileData(tile, overlay)
124+
fatalIfErr(t, err)
125+
want, err := tlog.ReadTileData(tile, full)
126+
fatalIfErr(t, err)
127+
if !slices.Equal(got, want) {
128+
t.Fatalf("n0=%d: ReadTileData(%v) = %x, want %x", n0, tile, got, want)
129+
}
130+
}
131+
}
132+
}
133+
134+
func TestHashReaderOverlayBatchedReads(t *testing.T) {
135+
const n0 = 100
136+
leaves, full := buildTree(t, 130)
137+
var reads int
138+
r := tlog.HashReaderFunc(func(indexes []int64) ([]tlog.Hash, error) {
139+
reads++
140+
return full.ReadHashes(indexes)
141+
})
142+
overlay := torchwood.NewHashReaderOverlay(n0, r)
143+
for n := int64(n0); n < 130; n++ {
144+
fatalIfErr(t, overlay.AppendRecordHash(leaves[n]))
145+
}
146+
147+
// Interleave original tree and overlay indexes.
148+
base := tlog.StoredHashCount(n0)
149+
indexes := []int64{0, base, 1, base + 1, 2, base + 2, 3}
150+
reads = 0
151+
got, err := overlay.ReadHashes(indexes)
152+
fatalIfErr(t, err)
153+
if reads != 1 {
154+
t.Errorf("ReadHashes made %d underlying reads, want 1", reads)
155+
}
156+
want, err := full.ReadHashes(indexes)
157+
fatalIfErr(t, err)
158+
if !slices.Equal(got, want) {
159+
t.Errorf("ReadHashes(%v) = %v, want %v", indexes, got, want)
160+
}
161+
}
162+
163+
func TestHashReaderOverlayErrors(t *testing.T) {
164+
leaves, full := buildTree(t, 10)
165+
166+
// Requests beyond the extended tree are rejected.
167+
overlay := torchwood.NewHashReaderOverlay(10, full)
168+
if _, err := overlay.ReadHashes([]int64{tlog.StoredHashCount(10)}); err == nil {
169+
t.Errorf("expected error for index beyond the tree size")
170+
}
171+
if _, err := overlay.ReadHashes([]int64{-1}); err == nil {
172+
t.Errorf("expected error for negative index")
173+
}
174+
175+
// A nil underlying reader works from size 0...
176+
overlay = torchwood.NewHashReaderOverlay(0, nil)
177+
for n := int64(0); n < 10; n++ {
178+
fatalIfErr(t, overlay.AppendRecordHash(leaves[n]))
179+
}
180+
got, err := tlog.TreeHash(10, overlay)
181+
fatalIfErr(t, err)
182+
want, err := tlog.TreeHash(10, full)
183+
fatalIfErr(t, err)
184+
if got != want {
185+
t.Errorf("TreeHash(10) = %v, want %v", got, want)
186+
}
187+
188+
// ...but fails requests for a non-empty original tree.
189+
overlay = torchwood.NewHashReaderOverlay(10, nil)
190+
if _, err := tlog.TreeHash(10, overlay); err == nil {
191+
t.Errorf("expected error for nil underlying reader")
192+
}
193+
// Record 10 completes no subtrees, so it doesn't need the original tree.
194+
fatalIfErr(t, overlay.AppendRecordHash(leaves[0]))
195+
// Record 11 completes the subtree [10, 12) and needs the hash of [8, 10).
196+
if err := overlay.AppendRecordHash(leaves[1]); err == nil {
197+
t.Errorf("expected error appending a record that completes a subtree")
198+
}
199+
if overlay.Size() != 11 {
200+
t.Errorf("Size() = %d, want 11 after the failed append", overlay.Size())
201+
}
202+
}

0 commit comments

Comments
 (0)