Skip to content

Commit 6a0a5c7

Browse files
beck-8rvagg
andauthored
fix(commp): prevent panic on short input by handling nil twinHold (#31)
Add documentation clarifying Reset() cleanup requirements and tests for edge cases around insufficient data handling. Co-Authored-By: Rod Vagg <rod@vagg.org>
1 parent fe57ba8 commit 6a0a5c7

File tree

2 files changed

+159
-2
lines changed

2 files changed

+159
-2
lines changed

commp.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@ import (
2121
// Calc is an implementation of a commP "hash" calculator, implementing the
2222
// familiar hash.Hash interface. The zero-value of this object is ready to
2323
// accept Write()s without further initialization.
24+
//
25+
// Write() starts background goroutines that must be cleaned up. Always defer
26+
// Reset() to ensure cleanup, especially if Digest() may return an error:
27+
//
28+
// cp := &commp.Calc{}
29+
// defer cp.Reset()
30+
//
31+
// if _, err := io.Copy(cp, reader); err != nil {
32+
// return err
33+
// }
34+
// commP, paddedSize, err := cp.Digest()
35+
// if err != nil {
36+
// return err
37+
// }
2438
type Calc struct {
2539
state
2640
mu sync.Mutex
@@ -114,8 +128,9 @@ func (cp *Calc) Sum(buf []byte) []byte {
114128

115129
// Digest collapses the internal hash state and returns the resulting raw 32
116130
// bytes of commP and the padded piece size, or alternatively an error in
117-
// case of insufficient accumulated state. On success invokes Reset(), which
118-
// terminates all goroutines kicked off by Write().
131+
// case of insufficient accumulated state. On success, the internal state is
132+
// reset and all goroutines are terminated. On error, callers must call
133+
// Reset() to clean up background goroutines before abandoning the Calc object.
119134
func (cp *Calc) Digest() (commP []byte, paddedPieceSize uint64, err error) {
120135
cp.mu.Lock()
121136

@@ -297,6 +312,13 @@ func (cp *Calc) addLayer(myIdx uint) {
297312

298313
// I am last
299314
if myIdx == MaxLayers || cp.layerQueues[myIdx+2] == nil {
315+
if twinHold == nil {
316+
// If twinHold is nil, we haven't processed enough data.
317+
// Send a zero-value hash to prevent panic and unblock the receiver.
318+
cp.resultCommP <- make([]byte, 32)
319+
return
320+
}
321+
300322
cp.resultCommP <- append(make([]byte, 0, 32), twinHold[0:32]...)
301323
return
302324
}

commp_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,3 +254,138 @@ func getTestCases(path string, shortOnly bool) ([]testCase, error) {
254254

255255
return ret, nil
256256
}
257+
258+
// TestResetAfterSmallWrite verifies Reset() handles the case where Write()
259+
// was called (starting the addLayer goroutine) but no complete slabs were
260+
// processed. The goroutine must handle nil twinHold when receiving the
261+
// close signal.
262+
func TestResetAfterSmallWrite(t *testing.T) {
263+
cp := &Calc{}
264+
265+
// Write a single byte - this initializes internal state and starts
266+
// the addLayer(0) goroutine, but data stays in buffer (not processed
267+
// into slabs since it's less than bufferSize)
268+
_, err := cp.Write([]byte{0x42})
269+
if err != nil {
270+
t.Fatalf("Write failed: %v", err)
271+
}
272+
273+
// Reset() closes channels and waits for goroutine cleanup.
274+
// Must not panic even though no slabs were processed.
275+
cp.Reset()
276+
}
277+
278+
// TestResetWithoutWrite verifies Reset() is safe on an uninitialized Calc.
279+
// Write(empty) is a no-op, so no goroutine is started.
280+
func TestResetWithoutWrite(t *testing.T) {
281+
cp := &Calc{}
282+
283+
// Write zero bytes is a no-op
284+
_, err := cp.Write([]byte{})
285+
if err != nil {
286+
t.Fatalf("Write failed: %v", err)
287+
}
288+
289+
// Reset on never-initialized calc is safe
290+
cp.Reset()
291+
}
292+
293+
// TestDigestErrorThenReset verifies that Reset() correctly cleans up after
294+
// Digest() returns an error due to insufficient data. When Digest() errors,
295+
// it does NOT close channels, so callers must call Reset() for cleanup.
296+
func TestDigestErrorThenReset(t *testing.T) {
297+
cp := &Calc{}
298+
299+
// Write less than MinPiecePayload (65 bytes)
300+
data := make([]byte, 23)
301+
_, err := cp.Write(data)
302+
if err != nil {
303+
t.Fatalf("Write failed: %v", err)
304+
}
305+
306+
// Digest returns error for insufficient data
307+
_, _, err = cp.Digest()
308+
if err == nil {
309+
t.Fatal("Expected error from Digest() with insufficient data")
310+
}
311+
312+
// Reset cleans up goroutine - must not panic
313+
cp.Reset()
314+
}
315+
316+
// TestDigestWithoutWrite verifies Digest() without any writes returns an
317+
// error and does not panic. No goroutine is started in this case.
318+
func TestDigestWithoutWrite(t *testing.T) {
319+
cp := &Calc{}
320+
321+
// Digest with no data returns error
322+
_, _, err := cp.Digest()
323+
if err == nil {
324+
t.Fatal("Expected error from Digest() with no data")
325+
}
326+
327+
// Reset on never-initialized calc is safe
328+
cp.Reset()
329+
}
330+
331+
// TestMultipleResets verifies that calling Reset() multiple times is safe.
332+
func TestMultipleResets(t *testing.T) {
333+
cp := &Calc{}
334+
335+
// Write some data to start goroutine
336+
_, err := cp.Write([]byte{0x42})
337+
if err != nil {
338+
t.Fatalf("Write failed: %v", err)
339+
}
340+
341+
// Multiple resets are safe
342+
cp.Reset()
343+
cp.Reset()
344+
cp.Reset()
345+
}
346+
347+
// TestReuseAfterDigestError verifies that a Calc can be reused after
348+
// Digest() returns an error, provided Reset() is called first.
349+
func TestReuseAfterDigestError(t *testing.T) {
350+
cp := &Calc{}
351+
352+
// Write 64 bytes (one less than MinPiecePayload)
353+
data := make([]byte, 64)
354+
for i := range data {
355+
data[i] = byte(i)
356+
}
357+
_, err := cp.Write(data)
358+
if err != nil {
359+
t.Fatalf("Write failed: %v", err)
360+
}
361+
362+
// Digest fails due to insufficient data
363+
_, _, err = cp.Digest()
364+
if err == nil {
365+
t.Fatal("Expected error from Digest() with 64 bytes")
366+
}
367+
368+
// Reset to clean up
369+
cp.Reset()
370+
371+
// Calc is reusable after reset
372+
data2 := make([]byte, 127)
373+
for i := range data2 {
374+
data2[i] = byte(i)
375+
}
376+
_, err = cp.Write(data2)
377+
if err != nil {
378+
t.Fatalf("Write after reset failed: %v", err)
379+
}
380+
381+
commP, size, err := cp.Digest()
382+
if err != nil {
383+
t.Fatalf("Digest after reset failed: %v", err)
384+
}
385+
if len(commP) != 32 {
386+
t.Errorf("Expected 32-byte commP, got %d", len(commP))
387+
}
388+
if size != 128 { // 127 bytes pads to 128 (FR32: 127 -> 128)
389+
t.Errorf("Expected piece size 128, got %d", size)
390+
}
391+
}

0 commit comments

Comments
 (0)