Adds a --seed CLI flag to 'generate' that derives a deterministic UUID from the seed value by hashing it 1,000,000,000 times with SHA-256. This makes manifest generation fully reproducible when the same seed and input files are provided. - Builder.SetSeed(seed) method for programmatic use - deriveSeedUUID() extracted for testability - MFER_SEED env var also supported - Test with reduced iteration count for speed
174 lines
4.7 KiB
Go
174 lines
4.7 KiB
Go
package mfer
|
|
|
|
import (
|
|
"bytes"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestNewBuilder(t *testing.T) {
|
|
b := NewBuilder()
|
|
assert.NotNil(t, b)
|
|
assert.Equal(t, 0, b.FileCount())
|
|
}
|
|
|
|
func TestBuilderAddFile(t *testing.T) {
|
|
b := NewBuilder()
|
|
content := []byte("test content")
|
|
reader := bytes.NewReader(content)
|
|
|
|
bytesRead, err := b.AddFile("test.txt", FileSize(len(content)), ModTime(time.Now()), reader, nil)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, FileSize(len(content)), bytesRead)
|
|
assert.Equal(t, 1, b.FileCount())
|
|
}
|
|
|
|
func TestBuilderAddFileWithHash(t *testing.T) {
|
|
b := NewBuilder()
|
|
hash := make([]byte, 34) // SHA256 multihash is 34 bytes
|
|
|
|
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), hash)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, b.FileCount())
|
|
}
|
|
|
|
func TestBuilderAddFileWithHashValidation(t *testing.T) {
|
|
t.Run("empty path", func(t *testing.T) {
|
|
b := NewBuilder()
|
|
hash := make([]byte, 34)
|
|
err := b.AddFileWithHash("", 100, ModTime(time.Now()), hash)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "path")
|
|
})
|
|
|
|
t.Run("negative size", func(t *testing.T) {
|
|
b := NewBuilder()
|
|
hash := make([]byte, 34)
|
|
err := b.AddFileWithHash("test.txt", -1, ModTime(time.Now()), hash)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "size")
|
|
})
|
|
|
|
t.Run("nil hash", func(t *testing.T) {
|
|
b := NewBuilder()
|
|
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), nil)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "hash")
|
|
})
|
|
|
|
t.Run("empty hash", func(t *testing.T) {
|
|
b := NewBuilder()
|
|
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), []byte{})
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "hash")
|
|
})
|
|
|
|
t.Run("valid inputs", func(t *testing.T) {
|
|
b := NewBuilder()
|
|
hash := make([]byte, 34)
|
|
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), hash)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 1, b.FileCount())
|
|
})
|
|
}
|
|
|
|
func TestBuilderBuild(t *testing.T) {
|
|
b := NewBuilder()
|
|
content := []byte("test content")
|
|
reader := bytes.NewReader(content)
|
|
|
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime(time.Now()), reader, nil)
|
|
require.NoError(t, err)
|
|
|
|
var buf bytes.Buffer
|
|
err = b.Build(&buf)
|
|
require.NoError(t, err)
|
|
|
|
// Should have magic bytes
|
|
assert.True(t, strings.HasPrefix(buf.String(), MAGIC))
|
|
}
|
|
|
|
func TestNewTimestampFromTimeExtremeDate(t *testing.T) {
|
|
// Regression test: newTimestampFromTime used UnixNano() which panics
|
|
// for dates outside ~1678-2262. Now uses Nanosecond() which is safe.
|
|
tests := []struct {
|
|
name string
|
|
time time.Time
|
|
}{
|
|
{"zero time", time.Time{}},
|
|
{"year 1000", time.Date(1000, 1, 1, 0, 0, 0, 0, time.UTC)},
|
|
{"year 3000", time.Date(3000, 1, 1, 0, 0, 0, 123456789, time.UTC)},
|
|
{"unix epoch", time.Unix(0, 0)},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Should not panic
|
|
ts := newTimestampFromTime(tt.time)
|
|
assert.Equal(t, tt.time.Unix(), ts.Seconds)
|
|
assert.Equal(t, int32(tt.time.Nanosecond()), ts.Nanos)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuilderDeterministicOutput(t *testing.T) {
|
|
buildManifest := func() []byte {
|
|
b := NewBuilder()
|
|
// Use a fixed createdAt and UUID so output is reproducible
|
|
b.createdAt = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
b.fixedUUID = make([]byte, 16) // all zeros
|
|
|
|
mtime := ModTime(time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC))
|
|
|
|
// Add files in reverse order to test sorting
|
|
files := []struct {
|
|
path string
|
|
content string
|
|
}{
|
|
{"c/file.txt", "content c"},
|
|
{"a/file.txt", "content a"},
|
|
{"b/file.txt", "content b"},
|
|
}
|
|
for _, f := range files {
|
|
r := bytes.NewReader([]byte(f.content))
|
|
_, err := b.AddFile(RelFilePath(f.path), FileSize(len(f.content)), mtime, r, nil)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
err := b.Build(&buf)
|
|
require.NoError(t, err)
|
|
return buf.Bytes()
|
|
}
|
|
|
|
out1 := buildManifest()
|
|
out2 := buildManifest()
|
|
assert.Equal(t, out1, out2, "two builds with same input should produce byte-identical output")
|
|
}
|
|
|
|
func TestDeriveSeedUUID(t *testing.T) {
|
|
// Use a small iteration count for testing (production uses 1B)
|
|
uuid1 := deriveSeedUUID("test-seed-value", 1000)
|
|
uuid2 := deriveSeedUUID("test-seed-value", 1000)
|
|
assert.Equal(t, uuid1, uuid2, "same seed should produce same UUID")
|
|
assert.Len(t, uuid1, 16, "UUID should be 16 bytes")
|
|
|
|
uuid3 := deriveSeedUUID("different-seed", 1000)
|
|
assert.NotEqual(t, uuid1, uuid3, "different seeds should produce different UUIDs")
|
|
}
|
|
|
|
func TestBuilderBuildEmpty(t *testing.T) {
|
|
b := NewBuilder()
|
|
|
|
var buf bytes.Buffer
|
|
err := b.Build(&buf)
|
|
require.NoError(t, err)
|
|
|
|
// Should still produce valid manifest with 0 files
|
|
assert.True(t, strings.HasPrefix(buf.String(), MAGIC))
|
|
}
|