AddFileWithHash rejects hashes that are not multihashes (closes #129)
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:
2026-10-04 10:08:33 +00:00
parent 76116005c8
commit 5c90f5572c
4 changed files with 95 additions and 34 deletions
+17 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
+5 -2
View File
@@ -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))