//nolint:testpackage // white-box tests exercise unexported internals package mfer import ( "bytes" "context" "fmt" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const testFileName = "file.txt" func TestNewBuilder(t *testing.T) { t.Parallel() b := NewBuilder() assert.NotNil(t, b) assert.Equal(t, 0, b.FileCount()) } func TestBuilderAddFile(t *testing.T) { t.Parallel() 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) { t.Parallel() 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.Parallel() t.Run("empty path", func(t *testing.T) { t.Parallel() b := NewBuilder() hash := make([]byte, 34) err := b.AddFileWithHash("", 100, ModTime(time.Now()), hash) require.Error(t, err) assert.Contains(t, err.Error(), "path") }) t.Run("negative size", func(t *testing.T) { t.Parallel() b := NewBuilder() hash := make([]byte, 34) err := b.AddFileWithHash("test.txt", -1, ModTime(time.Now()), hash) require.Error(t, err) 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.Parallel() b := NewBuilder() hash := make([]byte, 34) err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), hash) require.NoError(t, err) assert.Equal(t, 1, b.FileCount()) }) } func TestBuilderBuild(t *testing.T) { t.Parallel() 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(context.Background(), &buf) require.NoError(t, err) // Should have magic bytes assert.True(t, strings.HasPrefix(buf.String(), MAGIC)) } func TestNewTimestampFromTimeExtremeDate(t *testing.T) { t.Parallel() // 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) { t.Parallel() // Should not panic ts := newTimestampFromTime(tt.time) assert.Equal(t, tt.time.Unix(), ts.GetSeconds()) assert.Equal(t, tt.time.Nanosecond(), int(ts.GetNanos())) }) } } func TestBuilderDeterministicOutput(t *testing.T) { t.Parallel() 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(context.Background(), &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 TestSetSeedDeterministic(t *testing.T) { t.Parallel() b1 := NewBuilder() b1.SetSeed("test-seed-value") b2 := NewBuilder() b2.SetSeed("test-seed-value") assert.Equal(t, b1.fixedUUID, b2.fixedUUID, "same seed should produce same UUID") assert.Len(t, b1.fixedUUID, 16, "UUID should be 16 bytes") b3 := NewBuilder() b3.SetSeed("different-seed") assert.NotEqual(t, b1.fixedUUID, b3.fixedUUID, "different seeds should produce different UUIDs") } func TestValidatePath(t *testing.T) { t.Parallel() valid := []string{ testFileName, "dir/file.txt", "a/b/c/d.txt", "file with spaces.txt", "日本語.txt", //nolint:gosmopolitan // deliberately tests non-ASCII UTF-8 paths } for _, p := range valid { t.Run("valid:"+p, func(t *testing.T) { t.Parallel() assert.NoError(t, ValidatePath(p)) }) } invalid := []struct { path string desc string }{ {"", "empty"}, {"/absolute", "absolute path"}, {"has\\backslash", "backslash"}, {"has/../traversal", "dot-dot segment"}, {"has//double", "empty segment"}, {"..", "just dot-dot"}, {string([]byte{0xff, 0xfe}), "invalid UTF-8"}, } for _, tt := range invalid { t.Run("invalid:"+tt.desc, func(t *testing.T) { t.Parallel() assert.Error(t, ValidatePath(tt.path)) }) } } func TestBuilderAddFileSizeMismatch(t *testing.T) { t.Parallel() b := NewBuilder() content := []byte("short") reader := bytes.NewReader(content) // Declare wrong size _, err := b.AddFile("test.txt", FileSize(100), ModTime(time.Now()), reader, nil) require.Error(t, err) assert.Contains(t, err.Error(), "size mismatch") } func TestBuilderAddFileInvalidPath(t *testing.T) { t.Parallel() b := NewBuilder() content := []byte("data") reader := bytes.NewReader(content) _, err := b.AddFile("", FileSize(len(content)), ModTime(time.Now()), reader, nil) require.Error(t, err) reader.Reset(content) _, err = b.AddFile( "/absolute", FileSize(len(content)), ModTime(time.Now()), reader, nil, ) assert.Error(t, err) } func TestBuilderAddFileWithProgress(t *testing.T) { t.Parallel() b := NewBuilder() content := bytes.Repeat([]byte("x"), 1000) reader := bytes.NewReader(content) progress := make(chan FileHashProgress, 100) bytesRead, err := b.AddFile( "test.txt", FileSize(len(content)), ModTime(time.Now()), reader, progress, ) close(progress) require.NoError(t, err) assert.Equal(t, FileSize(1000), bytesRead) var updates []FileHashProgress for p := range progress { updates = append(updates, p) } assert.NotEmpty(t, updates) // Last update should show all bytes assert.Equal(t, FileSize(1000), updates[len(updates)-1].BytesRead) } func TestBuilderBuildRoundTrip(t *testing.T) { t.Parallel() // Build a manifest, deserialize it, verify all fields survive round-trip b := NewBuilder() now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) files := []struct { path string content []byte }{ {"alpha.txt", []byte("alpha content")}, {"beta/gamma.txt", []byte("gamma content")}, {"beta/delta.txt", []byte("delta content")}, } for _, f := range files { reader := bytes.NewReader(f.content) _, err := b.AddFile( RelFilePath(f.path), FileSize(len(f.content)), ModTime(now), reader, nil, ) require.NoError(t, err) } var buf bytes.Buffer require.NoError(t, b.Build(context.Background(), &buf)) m, err := NewManifestFromReader(&buf) require.NoError(t, err) mfiles := m.Files() require.Len(t, mfiles, 3) // Verify sorted order assert.Equal(t, "alpha.txt", mfiles[0].GetPath()) assert.Equal(t, "beta/delta.txt", mfiles[1].GetPath()) assert.Equal(t, "beta/gamma.txt", mfiles[2].GetPath()) // Verify sizes assert.Equal(t, int64(len("alpha content")), mfiles[0].GetSize()) // Verify hashes are present for _, f := range mfiles { require.NotEmpty(t, f.GetHashes(), "file %s should have hashes", f.GetPath()) assert.NotEmpty(t, f.GetHashes()[0].GetMultiHash()) } } // A manifest whose payload is larger than one zstd block (128 KiB) is // written as a frame asking for the writer's whole window, zstdWindowSize, // which is the most the parser accepts. func TestBuilderBuildRoundTripLargeManifest(t *testing.T) { t.Parallel() hash := make([]byte, 34) // multihash: 2-byte prefix + 32-byte SHA-256 b := NewBuilder() for i := range 4000 { path := RelFilePath(fmt.Sprintf("dir/file-%05d.txt", i)) require.NoError(t, b.AddFileWithHash(path, FileSize(i), ModTime{}, hash)) } var buf bytes.Buffer require.NoError(t, b.Build(context.Background(), &buf)) m, err := NewManifestFromReader(&buf) require.NoError(t, err) require.Greater(t, m.pbOuter.GetSize(), int64(128<<10)) assert.Len(t, m.Files(), 4000) } func TestNewManifestFromReaderInvalidMagic(t *testing.T) { t.Parallel() _, err := NewManifestFromReader(bytes.NewReader([]byte("NOT_VALID"))) require.Error(t, err) assert.Contains(t, err.Error(), "invalid file format") } func TestNewManifestFromReaderEmpty(t *testing.T) { t.Parallel() _, err := NewManifestFromReader(bytes.NewReader([]byte{})) assert.Error(t, err) } func TestNewManifestFromReaderTruncated(t *testing.T) { t.Parallel() // Just the magic with nothing after _, err := NewManifestFromReader(bytes.NewReader([]byte(MAGIC))) assert.Error(t, err) } func TestManifestString(t *testing.T) { t.Parallel() b := NewBuilder() content := []byte("test") 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 require.NoError(t, b.Build(context.Background(), &buf)) m, err := NewManifestFromReader(&buf) require.NoError(t, err) assert.Contains(t, m.String(), "count=1") } func TestBuilderBuildEmpty(t *testing.T) { t.Parallel() b := NewBuilder() var buf bytes.Buffer err := b.Build(context.Background(), &buf) require.NoError(t, err) // Should still produce valid manifest with 0 files assert.True(t, strings.HasPrefix(buf.String(), MAGIC)) } func TestBuilderOmitsCreatedAtByDefault(t *testing.T) { t.Parallel() b := NewBuilder() content := []byte("hello") _, err := b.AddFile( "test.txt", FileSize(len(content)), ModTime(time.Now()), bytes.NewReader(content), nil, ) 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.Nil(t, m.pbInner.GetCreatedAt(), "createdAt should be nil by default for deterministic output") } func TestBuilderIncludesCreatedAtWhenRequested(t *testing.T) { t.Parallel() b := NewBuilder() b.SetIncludeTimestamps(true) content := []byte("hello") _, err := b.AddFile( "test.txt", FileSize(len(content)), ModTime(time.Now()), bytes.NewReader(content), nil, ) 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.NotNil(t, m.pbInner.GetCreatedAt(), "createdAt should be set when IncludeTimestamps is true") } func TestBuilderDeterministicFileOrder(t *testing.T) { t.Parallel() // Two builds with same files in different order should produce same file ordering. // Note: UUIDs differ per build, so we compare parsed file lists, not raw bytes. buildAndParse := func(order []string) []*MFFilePath { b := NewBuilder() for _, name := range order { content := []byte("content of " + name) _, err := b.AddFile( RelFilePath(name), FileSize(len(content)), ModTime(time.Unix(1000, 0)), bytes.NewReader(content), nil, ) require.NoError(t, err) } var buf bytes.Buffer require.NoError(t, b.Build(context.Background(), &buf)) m, err := NewManifestFromReader(&buf) require.NoError(t, err) return m.Files() } files1 := buildAndParse([]string{"b.txt", "a.txt"}) files2 := buildAndParse([]string{"a.txt", "b.txt"}) require.Len(t, files1, 2) require.Len(t, files2, 2) for i := range files1 { assert.Equal(t, files1[i].GetPath(), files2[i].GetPath()) assert.Equal(t, files1[i].GetSize(), files2[i].GetSize()) } assert.Equal(t, "a.txt", files1[0].GetPath()) assert.Equal(t, "b.txt", files1[1].GetPath()) }