AddFileWithHash rejects hashes that are not multihashes (closes #129)
check / check (push) Waiting to run
check / check (push) Waiting to run
AddFileWithHash took any non-empty bytes as a hash, so the builder could write a manifest that mfer refuses to load. It now decodes the hash with go-multihash and also requires a digest of at least 32 bytes, the SHA-256 length the reader's decoding-cost limit assumes: a valid but shorter multihash, such as an empty identity hash or SHA-1, still makes a manifest of one-character paths too costly to load. Test fixtures that used 34 zero bytes, which is not a valid multihash, now use a SHA-256 multihash. Model: opus-5-5
This commit is contained in:
+17
-4
@@ -35,7 +35,8 @@ var (
|
|||||||
errPathDotDot = errors.New("contains '..' segment")
|
errPathDotDot = errors.New("contains '..' segment")
|
||||||
errSizeMismatch = errors.New("size mismatch")
|
errSizeMismatch = errors.New("size mismatch")
|
||||||
errNegativeSize = errors.New("size cannot be negative")
|
errNegativeSize = errors.New("size cannot be negative")
|
||||||
errEmptyHash = errors.New("hash cannot be nil or empty")
|
errHashNotMultihash = errors.New("hash is not a valid multihash")
|
||||||
|
errHashTooShort = errors.New("hash digest is too short")
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidatePath checks that a file path conforms to manifest path invariants:
|
// ValidatePath checks that a file path conforms to manifest path invariants:
|
||||||
@@ -228,7 +229,8 @@ func (b *Builder) FileCount() int {
|
|||||||
|
|
||||||
// AddFileWithHash adds a file entry with a pre-computed hash.
|
// AddFileWithHash adds a file entry with a pre-computed hash.
|
||||||
// This is useful when the hash is already known (e.g., from an existing manifest).
|
// This is useful when the hash is already known (e.g., from an existing manifest).
|
||||||
// Returns an error if path is empty, size is negative, or hash is nil/empty.
|
// Returns an error if path is invalid, size is negative, or hash is not a
|
||||||
|
// multihash with a digest of at least 32 bytes, as long as SHA-256's.
|
||||||
func (b *Builder) AddFileWithHash(
|
func (b *Builder) AddFileWithHash(
|
||||||
path RelFilePath,
|
path RelFilePath,
|
||||||
size FileSize,
|
size FileSize,
|
||||||
@@ -244,8 +246,19 @@ func (b *Builder) AddFileWithHash(
|
|||||||
return errNegativeSize
|
return errNegativeSize
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(hash) == 0 {
|
decoded, err := multihash.Decode(hash)
|
||||||
return errEmptyHash
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %w", errHashNotMultihash, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reader's limit on decoding cost (maxDecodedGrowth) assumes every
|
||||||
|
// hash is at least as long as a SHA-256 multihash, so a manifest of
|
||||||
|
// shorter ones could fail to load.
|
||||||
|
if len(decoded.Digest) < sha256.Size {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %d bytes, at least %d needed",
|
||||||
|
errHashTooShort, len(decoded.Digest), sha256.Size,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry := &MFFilePath{
|
entry := &MFFilePath{
|
||||||
|
|||||||
+71
-27
@@ -4,11 +4,13 @@ package mfer
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/multiformats/go-multihash"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -42,9 +44,10 @@ func TestBuilderAddFileWithHash(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
hash := make([]byte, 34) // SHA256 multihash is 34 bytes
|
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), hash)
|
err = b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), hash)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 1, b.FileCount())
|
assert.Equal(t, 1, b.FileCount())
|
||||||
}
|
}
|
||||||
@@ -52,12 +55,14 @@ func TestBuilderAddFileWithHash(t *testing.T) {
|
|||||||
func TestBuilderAddFileWithHashValidation(t *testing.T) {
|
func TestBuilderAddFileWithHashValidation(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
sha256Hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
t.Run("empty path", func(t *testing.T) {
|
t.Run("empty path", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
hash := make([]byte, 34)
|
err := b.AddFileWithHash("", 100, ModTime(time.Now()), sha256Hash)
|
||||||
err := b.AddFileWithHash("", 100, ModTime(time.Now()), hash)
|
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "path")
|
assert.Contains(t, err.Error(), "path")
|
||||||
})
|
})
|
||||||
@@ -66,41 +71,79 @@ func TestBuilderAddFileWithHashValidation(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
hash := make([]byte, 34)
|
err := b.AddFileWithHash("test.txt", -1, ModTime(time.Now()), sha256Hash)
|
||||||
err := b.AddFileWithHash("test.txt", -1, ModTime(time.Now()), hash)
|
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "size")
|
assert.Contains(t, err.Error(), "size")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("nil hash", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
|
||||||
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), nil)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "hash")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("empty hash", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
b := NewBuilder()
|
|
||||||
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), []byte{})
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "hash")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("valid inputs", func(t *testing.T) {
|
t.Run("valid inputs", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
hash := make([]byte, 34)
|
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), sha256Hash)
|
||||||
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), hash)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 1, b.FileCount())
|
assert.Equal(t, 1, b.FileCount())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuilderAddFileWithHashRejectsBadHashes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sha1Hash, err := multihash.Encode(make([]byte, 20), multihash.SHA1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hash Multihash
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"nil", nil, errHashNotMultihash},
|
||||||
|
{"empty", []byte{}, errHashNotMultihash},
|
||||||
|
{"one byte", []byte{0x12}, errHashNotMultihash},
|
||||||
|
// A SHA-256 code and 32-byte length, then only two bytes of digest.
|
||||||
|
{"malformed", []byte{0x12, 0x20, 0x01, 0x02}, errHashNotMultihash},
|
||||||
|
// A valid multihash, but its 20-byte SHA-1 digest is too short.
|
||||||
|
{"SHA-1", sha1Hash, errHashTooShort},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
b := NewBuilder()
|
||||||
|
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), tt.hash)
|
||||||
|
require.ErrorIs(t, err, tt.want)
|
||||||
|
assert.Equal(t, 0, b.FileCount())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entries made from the shortest inputs AddFileWithHash accepts (a
|
||||||
|
// one-character path, an empty file, a modification time at the epoch and
|
||||||
|
// a SHA-256 multihash) are the most costly to decode for their size, and a
|
||||||
|
// manifest of them still loads.
|
||||||
|
func TestBuilderShortestEntriesLoad(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
const names = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
|
||||||
|
b := NewBuilder()
|
||||||
|
for _, name := range strings.Split(names, "") {
|
||||||
|
err = b.AddFileWithHash(RelFilePath(name), 0, ModTime(time.Unix(0, 0)), hash)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
require.NoError(t, b.Build(context.Background(), &buf))
|
||||||
|
|
||||||
|
m, err := NewManifestFromReader(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, m.Files(), len(names))
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuilderBuild(t *testing.T) {
|
func TestBuilderBuild(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -356,7 +399,8 @@ func TestBuilderBuildRoundTrip(t *testing.T) {
|
|||||||
func TestBuilderBuildRoundTripLargeManifest(t *testing.T) {
|
func TestBuilderBuildRoundTripLargeManifest(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
hash := make([]byte, 34) // multihash: 2-byte prefix + 32-byte SHA-256
|
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -36,7 +36,8 @@ const (
|
|||||||
decodedMIMETypeSize = 16
|
decodedMIMETypeSize = 16
|
||||||
|
|
||||||
// Each file entry mfer writes holds a path of at least one byte, a
|
// Each file entry mfer writes holds a path of at least one byte, a
|
||||||
// 34-byte SHA-256 multihash and a modification time: at least 47 bytes,
|
// multihash at least as long as SHA-256's 34 bytes (AddFileWithHash
|
||||||
|
// refuses shorter ones) and a modification time: at least 47 bytes,
|
||||||
// counted at 336. So its manifests add up to at most about 7.15 times
|
// counted at 336. So its manifests add up to at most about 7.15 times
|
||||||
// their size, and this limit is about 12% above that.
|
// their size, and this limit is about 12% above that.
|
||||||
maxDecodedGrowth = 8
|
maxDecodedGrowth = 8
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/klauspost/compress/zstd"
|
"github.com/klauspost/compress/zstd"
|
||||||
|
"github.com/multiformats/go-multihash"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"google.golang.org/protobuf/encoding/protowire"
|
"google.golang.org/protobuf/encoding/protowire"
|
||||||
@@ -205,7 +206,8 @@ func TestDeserializeDropsUnknownFields(t *testing.T) {
|
|||||||
func TestDeserializeLoadsDensestManifest(t *testing.T) {
|
func TestDeserializeLoadsDensestManifest(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
hash := make([]byte, 34) // multihash: 2-byte prefix + 32-byte SHA-256
|
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
b.SetIncludeTimestamps(true)
|
b.SetIncludeTimestamps(true)
|
||||||
@@ -227,7 +229,8 @@ func TestDeserializeLoadsDensestManifest(t *testing.T) {
|
|||||||
func TestDeserializeValidManifestRoundTrips(t *testing.T) {
|
func TestDeserializeValidManifestRoundTrips(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
hash := make([]byte, 34) // multihash: 2-byte prefix + 32-byte SHA-256
|
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
b := NewBuilder()
|
b := NewBuilder()
|
||||||
require.NoError(t, b.AddFileWithHash("dir/file.txt", 123, ModTime{}, hash))
|
require.NoError(t, b.AddFileWithHash("dir/file.txt", 123, ModTime{}, hash))
|
||||||
|
|||||||
Reference in New Issue
Block a user