1.0 quality polish — code review, tests, bug fixes, documentation (#32)
Comprehensive quality pass targeting 1.0 release: - Code review and refactoring - Fix open bugs (#14, #16, #23) - Expand test coverage - Lint clean - README update with build instructions (#9) - Documentation improvements Branched from `next` (active dev branch). Reviewed-on: #32 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #32.
This commit is contained in:
+26
-12
@@ -85,11 +85,12 @@ type FileHashProgress struct {
|
||||
|
||||
// Builder constructs a manifest by adding files one at a time.
|
||||
type Builder struct {
|
||||
mu sync.Mutex
|
||||
files []*MFFilePath
|
||||
createdAt time.Time
|
||||
signingOptions *SigningOptions
|
||||
fixedUUID []byte // if set, use this UUID instead of generating one
|
||||
mu sync.Mutex
|
||||
files []*MFFilePath
|
||||
createdAt time.Time
|
||||
includeTimestamps bool
|
||||
signingOptions *SigningOptions
|
||||
fixedUUID []byte // if set, use this UUID instead of generating one
|
||||
}
|
||||
|
||||
// SetSeed derives a deterministic UUID from the given seed string.
|
||||
@@ -195,7 +196,7 @@ func (b *Builder) FileCount() int {
|
||||
// Returns an error if path is empty, size is negative, or hash is nil/empty.
|
||||
func (b *Builder) AddFileWithHash(path RelFilePath, size FileSize, mtime ModTime, hash Multihash) error {
|
||||
if err := ValidatePath(string(path)); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("add file: %w", err)
|
||||
}
|
||||
if size < 0 {
|
||||
return errors.New("size cannot be negative")
|
||||
@@ -219,6 +220,14 @@ func (b *Builder) AddFileWithHash(path RelFilePath, size FileSize, mtime ModTime
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetIncludeTimestamps controls whether the manifest includes a createdAt timestamp.
|
||||
// By default timestamps are omitted for deterministic output.
|
||||
func (b *Builder) SetIncludeTimestamps(include bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.includeTimestamps = include
|
||||
}
|
||||
|
||||
// SetSigningOptions sets the GPG signing options for the manifest.
|
||||
// If opts is non-nil, the manifest will be signed when Build() is called.
|
||||
func (b *Builder) SetSigningOptions(opts *SigningOptions) {
|
||||
@@ -239,9 +248,11 @@ func (b *Builder) Build(w io.Writer) error {
|
||||
|
||||
// Create inner manifest
|
||||
inner := &MFFile{
|
||||
Version: MFFile_VERSION_ONE,
|
||||
CreatedAt: newTimestampFromTime(b.createdAt),
|
||||
Files: b.files,
|
||||
Version: MFFile_VERSION_ONE,
|
||||
Files: b.files,
|
||||
}
|
||||
if b.includeTimestamps {
|
||||
inner.CreatedAt = newTimestampFromTime(b.createdAt)
|
||||
}
|
||||
|
||||
// Create a temporary manifest to use existing serialization
|
||||
@@ -253,15 +264,18 @@ func (b *Builder) Build(w io.Writer) error {
|
||||
|
||||
// Generate outer wrapper
|
||||
if err := m.generateOuter(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("build: generate outer: %w", err)
|
||||
}
|
||||
|
||||
// Generate final output
|
||||
if err := m.generate(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("build: generate: %w", err)
|
||||
}
|
||||
|
||||
// Write to output
|
||||
_, err := w.Write(m.output.Bytes())
|
||||
return err
|
||||
if err != nil {
|
||||
return fmt.Errorf("build: write output: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -163,6 +163,159 @@ func TestSetSeedDeterministic(t *testing.T) {
|
||||
assert.NotEqual(t, b1.fixedUUID, b3.fixedUUID, "different seeds should produce different UUIDs")
|
||||
}
|
||||
|
||||
func TestValidatePath(t *testing.T) {
|
||||
valid := []string{
|
||||
"file.txt",
|
||||
"dir/file.txt",
|
||||
"a/b/c/d.txt",
|
||||
"file with spaces.txt",
|
||||
"日本語.txt",
|
||||
}
|
||||
for _, p := range valid {
|
||||
t.Run("valid:"+p, func(t *testing.T) {
|
||||
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) {
|
||||
assert.Error(t, ValidatePath(tt.path))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilderAddFileSizeMismatch(t *testing.T) {
|
||||
b := NewBuilder()
|
||||
content := []byte("short")
|
||||
reader := bytes.NewReader(content)
|
||||
|
||||
// Declare wrong size
|
||||
_, err := b.AddFile("test.txt", FileSize(100), ModTime(time.Now()), reader, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "size mismatch")
|
||||
}
|
||||
|
||||
func TestBuilderAddFileInvalidPath(t *testing.T) {
|
||||
b := NewBuilder()
|
||||
content := []byte("data")
|
||||
reader := bytes.NewReader(content)
|
||||
|
||||
_, err := b.AddFile("", FileSize(len(content)), ModTime(time.Now()), reader, nil)
|
||||
assert.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) {
|
||||
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) {
|
||||
// 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(&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].Path)
|
||||
assert.Equal(t, "beta/delta.txt", mfiles[1].Path)
|
||||
assert.Equal(t, "beta/gamma.txt", mfiles[2].Path)
|
||||
|
||||
// Verify sizes
|
||||
assert.Equal(t, int64(len("alpha content")), mfiles[0].Size)
|
||||
|
||||
// Verify hashes are present
|
||||
for _, f := range mfiles {
|
||||
require.NotEmpty(t, f.Hashes, "file %s should have hashes", f.Path)
|
||||
assert.NotEmpty(t, f.Hashes[0].MultiHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewManifestFromReaderInvalidMagic(t *testing.T) {
|
||||
_, err := NewManifestFromReader(bytes.NewReader([]byte("NOT_VALID")))
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid file format")
|
||||
}
|
||||
|
||||
func TestNewManifestFromReaderEmpty(t *testing.T) {
|
||||
_, err := NewManifestFromReader(bytes.NewReader([]byte{}))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNewManifestFromReaderTruncated(t *testing.T) {
|
||||
// Just the magic with nothing after
|
||||
_, err := NewManifestFromReader(bytes.NewReader([]byte(MAGIC)))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestManifestString(t *testing.T) {
|
||||
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(&buf))
|
||||
|
||||
m, err := NewManifestFromReader(&buf)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, m.String(), "count=1")
|
||||
}
|
||||
|
||||
func TestBuilderBuildEmpty(t *testing.T) {
|
||||
b := NewBuilder()
|
||||
|
||||
@@ -173,3 +326,62 @@ func TestBuilderBuildEmpty(t *testing.T) {
|
||||
// Should still produce valid manifest with 0 files
|
||||
assert.True(t, strings.HasPrefix(buf.String(), MAGIC))
|
||||
}
|
||||
|
||||
func TestBuilderOmitsCreatedAtByDefault(t *testing.T) {
|
||||
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(&buf))
|
||||
|
||||
m, err := NewManifestFromReader(&buf)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, m.pbInner.CreatedAt, "createdAt should be nil by default for deterministic output")
|
||||
}
|
||||
|
||||
func TestBuilderIncludesCreatedAtWhenRequested(t *testing.T) {
|
||||
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(&buf))
|
||||
|
||||
m, err := NewManifestFromReader(&buf)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, m.pbInner.CreatedAt, "createdAt should be set when IncludeTimestamps is true")
|
||||
}
|
||||
|
||||
func TestBuilderDeterministicFileOrder(t *testing.T) {
|
||||
// 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(&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].Path, files2[i].Path)
|
||||
assert.Equal(t, files1[i].Size, files2[i].Size)
|
||||
}
|
||||
assert.Equal(t, "a.txt", files1[0].Path)
|
||||
assert.Equal(t, "b.txt", files1[1].Path)
|
||||
}
|
||||
|
||||
+49
-31
@@ -70,6 +70,8 @@ type Checker struct {
|
||||
fs afero.Fs
|
||||
// manifestPaths is a set of paths in the manifest for quick lookup
|
||||
manifestPaths map[RelFilePath]struct{}
|
||||
// manifestRelPath is the relative path of the manifest file from basePath (for exclusion)
|
||||
manifestRelPath RelFilePath
|
||||
// signature info from the manifest
|
||||
signature []byte
|
||||
signer []byte
|
||||
@@ -100,14 +102,25 @@ func NewChecker(manifestPath string, basePath string, fs afero.Fs) (*Checker, er
|
||||
manifestPaths[RelFilePath(f.Path)] = struct{}{}
|
||||
}
|
||||
|
||||
// Compute manifest's relative path from basePath for exclusion in FindExtraFiles
|
||||
absManifest, err := filepath.Abs(manifestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manifestRel, err := filepath.Rel(abs, absManifest)
|
||||
if err != nil {
|
||||
manifestRel = ""
|
||||
}
|
||||
|
||||
return &Checker{
|
||||
basePath: AbsFilePath(abs),
|
||||
files: files,
|
||||
fs: fs,
|
||||
manifestPaths: manifestPaths,
|
||||
signature: m.pbOuter.Signature,
|
||||
signer: m.pbOuter.Signer,
|
||||
signingPubKey: m.pbOuter.SigningPubKey,
|
||||
basePath: AbsFilePath(abs),
|
||||
files: files,
|
||||
fs: fs,
|
||||
manifestPaths: manifestPaths,
|
||||
manifestRelPath: RelFilePath(manifestRel),
|
||||
signature: m.pbOuter.Signature,
|
||||
signer: m.pbOuter.Signer,
|
||||
signingPubKey: m.pbOuter.SigningPubKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -170,6 +183,7 @@ func (c *Checker) Check(ctx context.Context, results chan<- Result, progress cha
|
||||
var failures FileCount
|
||||
|
||||
startTime := time.Now()
|
||||
lastProgressTime := time.Now()
|
||||
|
||||
for _, entry := range c.files {
|
||||
select {
|
||||
@@ -188,29 +202,34 @@ func (c *Checker) Check(ctx context.Context, results chan<- Result, progress cha
|
||||
results <- result
|
||||
}
|
||||
|
||||
// Send progress with rate and ETA calculation
|
||||
// Send progress at most once per second (rate-limited)
|
||||
if progress != nil {
|
||||
elapsed := time.Since(startTime)
|
||||
var bytesPerSec float64
|
||||
var eta time.Duration
|
||||
now := time.Now()
|
||||
isLast := checkedFiles == totalFiles
|
||||
if isLast || now.Sub(lastProgressTime) >= time.Second {
|
||||
elapsed := time.Since(startTime)
|
||||
var bytesPerSec float64
|
||||
var eta time.Duration
|
||||
|
||||
if elapsed > 0 && checkedBytes > 0 {
|
||||
bytesPerSec = float64(checkedBytes) / elapsed.Seconds()
|
||||
remainingBytes := totalBytes - checkedBytes
|
||||
if bytesPerSec > 0 {
|
||||
eta = time.Duration(float64(remainingBytes)/bytesPerSec) * time.Second
|
||||
if elapsed > 0 && checkedBytes > 0 {
|
||||
bytesPerSec = float64(checkedBytes) / elapsed.Seconds()
|
||||
remainingBytes := totalBytes - checkedBytes
|
||||
if bytesPerSec > 0 {
|
||||
eta = time.Duration(float64(remainingBytes)/bytesPerSec) * time.Second
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendCheckStatus(progress, CheckStatus{
|
||||
TotalFiles: totalFiles,
|
||||
CheckedFiles: checkedFiles,
|
||||
TotalBytes: totalBytes,
|
||||
CheckedBytes: checkedBytes,
|
||||
BytesPerSec: bytesPerSec,
|
||||
ETA: eta,
|
||||
Failures: failures,
|
||||
})
|
||||
sendCheckStatus(progress, CheckStatus{
|
||||
TotalFiles: totalFiles,
|
||||
CheckedFiles: checkedFiles,
|
||||
TotalBytes: totalBytes,
|
||||
CheckedBytes: checkedBytes,
|
||||
BytesPerSec: bytesPerSec,
|
||||
ETA: eta,
|
||||
Failures: failures,
|
||||
})
|
||||
lastProgressTime = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,14 +328,13 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip manifest files
|
||||
base := filepath.Base(rel)
|
||||
if base == "index.mf" || base == ".index.mf" {
|
||||
relPath := RelFilePath(rel)
|
||||
|
||||
// Skip the manifest file itself
|
||||
if relPath == c.manifestRelPath {
|
||||
return nil
|
||||
}
|
||||
|
||||
relPath := RelFilePath(rel)
|
||||
|
||||
// Check if path is in manifest
|
||||
if _, exists := c.manifestPaths[relPath]; !exists {
|
||||
if results != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package mfer
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -452,6 +453,61 @@ func TestCheckMissingFileDetectedWithoutFallback(t *testing.T) {
|
||||
assert.Equal(t, 0, statusCounts[StatusError], "no files should be ERROR")
|
||||
}
|
||||
|
||||
func TestFindExtraFilesSkipsDotfiles(t *testing.T) {
|
||||
// Regression test for #16: FindExtraFiles should not report dotfiles
|
||||
// or the manifest file itself as extra files.
|
||||
fs := afero.NewMemMapFs()
|
||||
files := map[string][]byte{
|
||||
"file1.txt": []byte("in manifest"),
|
||||
}
|
||||
createTestManifest(t, fs, "/data/.index.mf", files)
|
||||
createFilesOnDisk(t, fs, "/data", files)
|
||||
|
||||
// Add dotfiles and manifest file on disk
|
||||
require.NoError(t, afero.WriteFile(fs, "/data/.hidden", []byte("dotfile"), 0o644))
|
||||
require.NoError(t, fs.MkdirAll("/data/.git", 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, "/data/.git/config", []byte("git config"), 0o644))
|
||||
|
||||
chk, err := NewChecker("/data/.index.mf", "/data", fs)
|
||||
require.NoError(t, err)
|
||||
|
||||
results := make(chan Result, 10)
|
||||
err = chk.FindExtraFiles(context.Background(), results)
|
||||
require.NoError(t, err)
|
||||
|
||||
var extras []Result
|
||||
for r := range results {
|
||||
extras = append(extras, r)
|
||||
}
|
||||
|
||||
// Should report NO extra files — dotfiles and manifest should be skipped
|
||||
assert.Empty(t, extras, "FindExtraFiles should not report dotfiles or manifest file as extra; got: %v", extras)
|
||||
}
|
||||
|
||||
func TestFindExtraFilesSkipsManifestFile(t *testing.T) {
|
||||
// The manifest file itself should never be reported as extra
|
||||
fs := afero.NewMemMapFs()
|
||||
files := map[string][]byte{
|
||||
"file1.txt": []byte("content"),
|
||||
}
|
||||
createTestManifest(t, fs, "/data/index.mf", files)
|
||||
createFilesOnDisk(t, fs, "/data", files)
|
||||
|
||||
chk, err := NewChecker("/data/index.mf", "/data", fs)
|
||||
require.NoError(t, err)
|
||||
|
||||
results := make(chan Result, 10)
|
||||
err = chk.FindExtraFiles(context.Background(), results)
|
||||
require.NoError(t, err)
|
||||
|
||||
var extras []Result
|
||||
for r := range results {
|
||||
extras = append(extras, r)
|
||||
}
|
||||
|
||||
assert.Empty(t, extras, "manifest file should not be reported as extra; got: %v", extras)
|
||||
}
|
||||
|
||||
func TestCheckEmptyManifest(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
// Create manifest with no files
|
||||
@@ -473,3 +529,40 @@ func TestCheckEmptyManifest(t *testing.T) {
|
||||
}
|
||||
assert.Equal(t, 0, count)
|
||||
}
|
||||
|
||||
func TestCheckProgressRateLimited(t *testing.T) {
|
||||
// Create many small files - progress should be rate-limited, not one per file.
|
||||
// With rate-limiting to once per second, we should get far fewer progress
|
||||
// updates than files (plus one final update).
|
||||
fs := afero.NewMemMapFs()
|
||||
files := make(map[string][]byte, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
name := fmt.Sprintf("file%03d.txt", i)
|
||||
files[name] = []byte("content")
|
||||
}
|
||||
createTestManifest(t, fs, "/manifest.mf", files)
|
||||
createFilesOnDisk(t, fs, "/data", files)
|
||||
|
||||
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||
require.NoError(t, err)
|
||||
|
||||
results := make(chan Result, 200)
|
||||
progress := make(chan CheckStatus, 200)
|
||||
err = chk.Check(context.Background(), results, progress)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Drain results
|
||||
for range results {
|
||||
}
|
||||
|
||||
// Count progress updates
|
||||
var progressCount int
|
||||
for range progress {
|
||||
progressCount++
|
||||
}
|
||||
|
||||
// Should be far fewer than 100 (rate-limited to once per second)
|
||||
// At minimum we get the final update
|
||||
assert.GreaterOrEqual(t, progressCount, 1, "should get at least the final progress update")
|
||||
assert.Less(t, progressCount, 100, "progress should be rate-limited, not one per file")
|
||||
}
|
||||
|
||||
+4
-4
@@ -44,7 +44,7 @@ func (m *manifest) deserializeInner() error {
|
||||
// Verify hash of compressed data before decompression
|
||||
h := sha256.New()
|
||||
if _, err := h.Write(m.pbOuter.InnerMessage); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("deserialize: hash write: %w", err)
|
||||
}
|
||||
sha256Hash := h.Sum(nil)
|
||||
if !bytes.Equal(sha256Hash, m.pbOuter.Sha256) {
|
||||
@@ -72,7 +72,7 @@ func (m *manifest) deserializeInner() error {
|
||||
|
||||
zr, err := zstd.NewReader(bb)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("deserialize: zstd reader: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
@@ -85,7 +85,7 @@ func (m *manifest) deserializeInner() error {
|
||||
limitedReader := io.LimitReader(zr, maxSize)
|
||||
dat, err := io.ReadAll(limitedReader)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("deserialize: decompress: %w", err)
|
||||
}
|
||||
if int64(len(dat)) >= MaxDecompressedSize {
|
||||
return fmt.Errorf("decompressed data exceeds maximum allowed size of %d bytes", MaxDecompressedSize)
|
||||
@@ -100,7 +100,7 @@ func (m *manifest) deserializeInner() error {
|
||||
// Deserialize inner message
|
||||
m.pbInner = new(MFFile)
|
||||
if err := proto.Unmarshal(dat, m.pbInner); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("deserialize: unmarshal inner: %w", err)
|
||||
}
|
||||
|
||||
// Validate inner UUID
|
||||
|
||||
+7
-7
@@ -20,7 +20,7 @@ type SigningOptions struct {
|
||||
// gpgSign creates a detached signature of the data using the specified key.
|
||||
// Returns the armored detached signature.
|
||||
func gpgSign(data []byte, keyID GPGKeyID) ([]byte, error) {
|
||||
cmd := exec.Command("gpg",
|
||||
cmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||
"--detach-sign",
|
||||
"--armor",
|
||||
"--local-user", string(keyID),
|
||||
@@ -42,7 +42,7 @@ func gpgSign(data []byte, keyID GPGKeyID) ([]byte, error) {
|
||||
// gpgExportPublicKey exports the public key for the specified key ID.
|
||||
// Returns the armored public key.
|
||||
func gpgExportPublicKey(keyID GPGKeyID) ([]byte, error) {
|
||||
cmd := exec.Command("gpg",
|
||||
cmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||
"--export",
|
||||
"--armor",
|
||||
string(keyID),
|
||||
@@ -65,7 +65,7 @@ func gpgExportPublicKey(keyID GPGKeyID) ([]byte, error) {
|
||||
|
||||
// gpgGetKeyFingerprint gets the full fingerprint for a key ID.
|
||||
func gpgGetKeyFingerprint(keyID GPGKeyID) ([]byte, error) {
|
||||
cmd := exec.Command("gpg",
|
||||
cmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||
"--with-colons",
|
||||
"--fingerprint",
|
||||
string(keyID),
|
||||
@@ -114,7 +114,7 @@ func gpgExtractPubKeyFingerprint(pubKey []byte) (string, error) {
|
||||
}
|
||||
|
||||
// Import the public key into the temporary keyring
|
||||
importCmd := exec.Command("gpg",
|
||||
importCmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||
"--homedir", tmpDir,
|
||||
"--import",
|
||||
pubKeyFile,
|
||||
@@ -126,7 +126,7 @@ func gpgExtractPubKeyFingerprint(pubKey []byte) (string, error) {
|
||||
}
|
||||
|
||||
// List keys to get fingerprint
|
||||
listCmd := exec.Command("gpg",
|
||||
listCmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||
"--homedir", tmpDir,
|
||||
"--with-colons",
|
||||
"--fingerprint",
|
||||
@@ -184,7 +184,7 @@ func gpgVerify(data, signature, pubKey []byte) error {
|
||||
}
|
||||
|
||||
// Import the public key into the temporary keyring
|
||||
importCmd := exec.Command("gpg",
|
||||
importCmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||
"--homedir", tmpDir,
|
||||
"--import",
|
||||
pubKeyFile,
|
||||
@@ -196,7 +196,7 @@ func gpgVerify(data, signature, pubKey []byte) error {
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
verifyCmd := exec.Command("gpg",
|
||||
verifyCmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||
"--homedir", tmpDir,
|
||||
"--verify",
|
||||
sigFile,
|
||||
|
||||
+15
-24
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.0
|
||||
// protoc v6.33.4
|
||||
// source: mf.proto
|
||||
|
||||
package mfer
|
||||
@@ -329,6 +329,9 @@ func (x *MFFileOuter) GetSigningPubKey() []byte {
|
||||
type MFFilePath struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// required attributes:
|
||||
// Path invariants: must be valid UTF-8, use forward slashes only,
|
||||
// be relative (no leading /), contain no ".." segments, and no
|
||||
// empty segments (no "//").
|
||||
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
|
||||
Size int64 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"`
|
||||
// gotta have at least one:
|
||||
@@ -337,7 +340,6 @@ type MFFilePath struct {
|
||||
MimeType *string `protobuf:"bytes,301,opt,name=mimeType,proto3,oneof" json:"mimeType,omitempty"`
|
||||
Mtime *Timestamp `protobuf:"bytes,302,opt,name=mtime,proto3,oneof" json:"mtime,omitempty"`
|
||||
Ctime *Timestamp `protobuf:"bytes,303,opt,name=ctime,proto3,oneof" json:"ctime,omitempty"`
|
||||
Atime *Timestamp `protobuf:"bytes,304,opt,name=atime,proto3,oneof" json:"atime,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -414,13 +416,6 @@ func (x *MFFilePath) GetCtime() *Timestamp {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *MFFilePath) GetAtime() *Timestamp {
|
||||
if x != nil {
|
||||
return x.Atime
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type MFFileChecksum struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// 1.0 golang implementation must write a multihash here
|
||||
@@ -566,7 +561,7 @@ const file_mf_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"_signatureB\t\n" +
|
||||
"\a_signerB\x10\n" +
|
||||
"\x0e_signingPubKey\"\xa2\x02\n" +
|
||||
"\x0e_signingPubKey\"\xf0\x01\n" +
|
||||
"\n" +
|
||||
"MFFilePath\x12\x12\n" +
|
||||
"\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" +
|
||||
@@ -576,13 +571,10 @@ const file_mf_proto_rawDesc = "" +
|
||||
"\x05mtime\x18\xae\x02 \x01(\v2\n" +
|
||||
".TimestampH\x01R\x05mtime\x88\x01\x01\x12&\n" +
|
||||
"\x05ctime\x18\xaf\x02 \x01(\v2\n" +
|
||||
".TimestampH\x02R\x05ctime\x88\x01\x01\x12&\n" +
|
||||
"\x05atime\x18\xb0\x02 \x01(\v2\n" +
|
||||
".TimestampH\x03R\x05atime\x88\x01\x01B\v\n" +
|
||||
".TimestampH\x02R\x05ctime\x88\x01\x01B\v\n" +
|
||||
"\t_mimeTypeB\b\n" +
|
||||
"\x06_mtimeB\b\n" +
|
||||
"\x06_ctimeB\b\n" +
|
||||
"\x06_atime\".\n" +
|
||||
"\x06_ctime\".\n" +
|
||||
"\x0eMFFileChecksum\x12\x1c\n" +
|
||||
"\tmultiHash\x18\x01 \x01(\fR\tmultiHash\"\xd6\x01\n" +
|
||||
"\x06MFFile\x12)\n" +
|
||||
@@ -627,15 +619,14 @@ var file_mf_proto_depIdxs = []int32{
|
||||
6, // 2: MFFilePath.hashes:type_name -> MFFileChecksum
|
||||
3, // 3: MFFilePath.mtime:type_name -> Timestamp
|
||||
3, // 4: MFFilePath.ctime:type_name -> Timestamp
|
||||
3, // 5: MFFilePath.atime:type_name -> Timestamp
|
||||
2, // 6: MFFile.version:type_name -> MFFile.Version
|
||||
5, // 7: MFFile.files:type_name -> MFFilePath
|
||||
3, // 8: MFFile.createdAt:type_name -> Timestamp
|
||||
9, // [9:9] is the sub-list for method output_type
|
||||
9, // [9:9] is the sub-list for method input_type
|
||||
9, // [9:9] is the sub-list for extension type_name
|
||||
9, // [9:9] is the sub-list for extension extendee
|
||||
0, // [0:9] is the sub-list for field type_name
|
||||
2, // 5: MFFile.version:type_name -> MFFile.Version
|
||||
5, // 6: MFFile.files:type_name -> MFFilePath
|
||||
3, // 7: MFFile.createdAt:type_name -> Timestamp
|
||||
8, // [8:8] is the sub-list for method output_type
|
||||
8, // [8:8] is the sub-list for method input_type
|
||||
8, // [8:8] is the sub-list for extension type_name
|
||||
8, // [8:8] is the sub-list for extension extendee
|
||||
0, // [0:8] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_mf_proto_init() }
|
||||
|
||||
@@ -59,7 +59,6 @@ message MFFilePath {
|
||||
optional string mimeType = 301;
|
||||
optional Timestamp mtime = 302;
|
||||
optional Timestamp ctime = 303;
|
||||
optional Timestamp atime = 304;
|
||||
}
|
||||
|
||||
message MFFileChecksum {
|
||||
|
||||
+9
-5
@@ -43,11 +43,12 @@ type ScanStatus struct {
|
||||
|
||||
// ScannerOptions configures scanner behavior.
|
||||
type ScannerOptions struct {
|
||||
IncludeDotfiles bool // Include files and directories starting with a dot (default: exclude)
|
||||
FollowSymLinks bool // Resolve symlinks instead of skipping them
|
||||
Fs afero.Fs // Filesystem to use, defaults to OsFs if nil
|
||||
SigningOptions *SigningOptions // GPG signing options (nil = no signing)
|
||||
Seed string // If set, derive a deterministic UUID from this seed
|
||||
IncludeDotfiles bool // Include files and directories starting with a dot (default: exclude)
|
||||
FollowSymLinks bool // Resolve symlinks instead of skipping them
|
||||
IncludeTimestamps bool // Include createdAt timestamp in manifest (default: omit for determinism)
|
||||
Fs afero.Fs // Filesystem to use, defaults to OsFs if nil
|
||||
SigningOptions *SigningOptions // GPG signing options (nil = no signing)
|
||||
Seed string // If set, derive a deterministic UUID from this seed
|
||||
}
|
||||
|
||||
// FileEntry represents a file that has been enumerated.
|
||||
@@ -274,6 +275,9 @@ func (s *Scanner) ToManifest(ctx context.Context, w io.Writer, progress chan<- S
|
||||
s.mu.RUnlock()
|
||||
|
||||
builder := NewBuilder()
|
||||
if s.options.IncludeTimestamps {
|
||||
builder.SetIncludeTimestamps(true)
|
||||
}
|
||||
if s.options.SigningOptions != nil {
|
||||
builder.SetSigningOptions(s.options.SigningOptions)
|
||||
}
|
||||
|
||||
@@ -352,8 +352,10 @@ func TestIsHiddenPath(t *testing.T) {
|
||||
{"/absolute/.hidden", true},
|
||||
{"./relative", false}, // path.Clean removes leading ./
|
||||
{"a/b/c/.d/e", true},
|
||||
{".", false}, // current directory is not hidden
|
||||
{"/", false}, // root is not hidden
|
||||
{".", false}, // current directory is not hidden (#14)
|
||||
{"/", false}, // root is not hidden
|
||||
{"./", false}, // current directory with trailing slash
|
||||
{"./file.txt", false}, // file in current directory
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
+6
-6
@@ -34,12 +34,12 @@ func (m *manifest) generate() error {
|
||||
}
|
||||
dat, err := proto.MarshalOptions{Deterministic: true}.Marshal(m.pbOuter)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("serialize: marshal outer: %w", err)
|
||||
}
|
||||
m.output = bytes.NewBuffer([]byte(MAGIC))
|
||||
_, err = m.output.Write(dat)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("serialize: write output: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -60,18 +60,18 @@ func (m *manifest) generateOuter() error {
|
||||
|
||||
innerData, err := proto.MarshalOptions{Deterministic: true}.Marshal(m.pbInner)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("serialize: marshal inner: %w", err)
|
||||
}
|
||||
|
||||
// Compress the inner data
|
||||
idc := new(bytes.Buffer)
|
||||
zw, err := zstd.NewWriter(idc, zstd.WithEncoderLevel(zstd.SpeedBestCompression))
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("serialize: create compressor: %w", err)
|
||||
}
|
||||
_, err = zw.Write(innerData)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("serialize: compress: %w", err)
|
||||
}
|
||||
_ = zw.Close()
|
||||
|
||||
@@ -80,7 +80,7 @@ func (m *manifest) generateOuter() error {
|
||||
// Hash the compressed data for integrity verification before decompression
|
||||
h := sha256.New()
|
||||
if _, err := h.Write(compressedData); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("serialize: hash write: %w", err)
|
||||
}
|
||||
sha256Hash := h.Sum(nil)
|
||||
|
||||
|
||||
+6
-2
@@ -27,8 +27,12 @@ func (b BaseURL) JoinPath(path RelFilePath) (FileURL, error) {
|
||||
base.Path += "/"
|
||||
}
|
||||
|
||||
// Parse and encode the relative path
|
||||
ref, err := url.Parse(url.PathEscape(string(path)))
|
||||
// Encode each path segment individually to preserve slashes
|
||||
segments := strings.Split(string(path), "/")
|
||||
for i, seg := range segments {
|
||||
segments[i] = url.PathEscape(seg)
|
||||
}
|
||||
ref, err := url.Parse(strings.Join(segments, "/"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package mfer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBaseURLJoinPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
base BaseURL
|
||||
path RelFilePath
|
||||
expected string
|
||||
}{
|
||||
{"https://example.com/dir/", "file.txt", "https://example.com/dir/file.txt"},
|
||||
{"https://example.com/dir", "file.txt", "https://example.com/dir/file.txt"},
|
||||
{"https://example.com/", "sub/file.txt", "https://example.com/sub/file.txt"},
|
||||
{"https://example.com/dir/", "file with spaces.txt", "https://example.com/dir/file%20with%20spaces.txt"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.base)+"+"+string(tt.path), func(t *testing.T) {
|
||||
result, err := tt.base.JoinPath(tt.path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, string(result))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLString(t *testing.T) {
|
||||
b := BaseURL("https://example.com/")
|
||||
assert.Equal(t, "https://example.com/", b.String())
|
||||
}
|
||||
|
||||
func TestFileURLString(t *testing.T) {
|
||||
f := FileURL("https://example.com/file.txt")
|
||||
assert.Equal(t, "https://example.com/file.txt", f.String())
|
||||
}
|
||||
|
||||
func TestManifestURLString(t *testing.T) {
|
||||
m := ManifestURL("https://example.com/index.mf")
|
||||
assert.Equal(t, "https://example.com/index.mf", m.String())
|
||||
}
|
||||
Reference in New Issue
Block a user