Add deterministic file ordering in Builder.Build() (closes #23)

Sort file entries by path (lexicographic byte-order) before serializing
the manifest. This ensures identical output regardless of file insertion
order. Add test verifying two different insertion orders produce the same
manifest file order.
This commit is contained in:
user 2026-02-10 18:35:37 -08:00
parent 7c91f43d76
commit bbf7b31940
2 changed files with 53 additions and 0 deletions

View File

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"sort"
"strings"
"sync"
"time"
@ -222,6 +223,11 @@ func (b *Builder) Build(w io.Writer) error {
b.mu.Lock()
defer b.mu.Unlock()
// Sort files by path for deterministic output (#23)
sort.Slice(b.files, func(i, j int) bool {
return b.files[i].Path < b.files[j].Path
})
// Create inner manifest
inner := &MFFile{
Version: MFFile_VERSION_ONE,

View File

@ -115,6 +115,53 @@ func TestNewTimestampFromTimeExtremeDate(t *testing.T) {
}
}
func TestBuilderBuildDeterministicOrder(t *testing.T) {
// Regression test for #23: files should be sorted by path in the manifest
// to ensure deterministic output regardless of insertion order.
buildManifest := func(paths []string) []byte {
b := NewBuilder()
for _, p := range paths {
content := []byte("content of " + p)
reader := bytes.NewReader(content)
_, err := b.AddFile(RelFilePath(p), FileSize(len(content)), ModTime(time.Now()), reader, nil)
require.NoError(t, err)
}
var buf bytes.Buffer
require.NoError(t, b.Build(&buf))
return buf.Bytes()
}
// Build with files in two different orders
order1 := []string{"z.txt", "a.txt", "m/b.txt", "m/a.txt", "b.txt"}
order2 := []string{"b.txt", "m/a.txt", "a.txt", "z.txt", "m/b.txt"}
manifest1 := buildManifest(order1)
manifest2 := buildManifest(order2)
// Parse both and verify file order is sorted
m1, err := NewManifestFromReader(bytes.NewReader(manifest1))
require.NoError(t, err)
m2, err := NewManifestFromReader(bytes.NewReader(manifest2))
require.NoError(t, err)
files1 := m1.Files()
files2 := m2.Files()
require.Len(t, files1, 5)
require.Len(t, files2, 5)
// Both should have same order
for i := range files1 {
assert.Equal(t, files1[i].Path, files2[i].Path, "file %d path mismatch", i)
}
// Verify the order is lexicographic
assert.Equal(t, "a.txt", files1[0].Path)
assert.Equal(t, "b.txt", files1[1].Path)
assert.Equal(t, "m/a.txt", files1[2].Path)
assert.Equal(t, "m/b.txt", files1[3].Path)
assert.Equal(t, "z.txt", files1[4].Path)
}
func TestBuilderBuildEmpty(t *testing.T) {
b := NewBuilder()