Update golangci-lint to v2.12.2 with canonical config (closes #60)
Some checks failed
check / check (push) Has been cancelled

Adopts golangci-lint v2.12.2 and the canonical .golangci.yml (default: all), and fixes all resulting findings across the tree.

Two intended behavior changes: absent MFFilePath.Mtime is handled explicitly in freshen, list and export rather than dereferenced (main panicked); gpg positional key IDs now follow an explicit -- end-of-options marker.

All twelve reworded user-visible error messages restored to byte-identical parity with main and pinned by tests.
This commit was merged in pull request #59.
This commit is contained in:
2026-08-10 16:06:12 +02:00
parent 6d19de74e7
commit de476708e9
40 changed files with 4012 additions and 1798 deletions

View File

@@ -1,3 +1,4 @@
//nolint:testpackage // white-box tests exercise unexported internals
package cli
import (
@@ -16,13 +17,25 @@ import (
"sneak.berlin/go/mfer/mfer"
)
const (
testFileTxt = "file.txt"
testDirFile = "dir/file.txt"
testIndexMF = "https://example.com/path/index.mf"
// Exactly what url.Parse renders, with no wrapper of our own.
urlParseControlCharErr = `parse "http://example.com/\x7f": ` +
`net/url: invalid control character in URL`
)
func TestEncodeFilePath(t *testing.T) {
t.Parallel()
tests := []struct {
input string
expected string
}{
{"file.txt", "file.txt"},
{"dir/file.txt", "dir/file.txt"},
{testFileTxt, testFileTxt},
{testDirFile, testDirFile},
{"my file.txt", "my%20file.txt"},
{"dir/my file.txt", "dir/my%20file.txt"},
{"file#1.txt", "file%231.txt"},
@@ -33,6 +46,8 @@ func TestEncodeFilePath(t *testing.T) {
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
result := encodeFilePath(tt.input)
assert.Equal(t, tt.expected, result)
})
@@ -40,23 +55,27 @@ func TestEncodeFilePath(t *testing.T) {
}
func TestSanitizePath(t *testing.T) {
t.Parallel()
// Valid paths that should be accepted
validTests := []struct {
input string
expected string
}{
{"file.txt", "file.txt"},
{"dir/file.txt", "dir/file.txt"},
{testFileTxt, testFileTxt},
{testDirFile, testDirFile},
{"dir/subdir/file.txt", "dir/subdir/file.txt"},
{"./file.txt", "file.txt"},
{"./dir/file.txt", "dir/file.txt"},
{"dir/./file.txt", "dir/file.txt"},
{"./file.txt", testFileTxt},
{"./dir/file.txt", testDirFile},
{"dir/./file.txt", testDirFile},
}
for _, tt := range validTests {
t.Run("valid:"+tt.input, func(t *testing.T) {
t.Parallel()
result, err := sanitizePath(tt.input)
assert.NoError(t, err)
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
@@ -78,6 +97,8 @@ func TestSanitizePath(t *testing.T) {
for _, tt := range invalidTests {
t.Run("invalid:"+tt.desc, func(t *testing.T) {
t.Parallel()
_, err := sanitizePath(tt.input)
assert.Error(t, err, "expected error for path: %s", tt.input)
})
@@ -85,36 +106,115 @@ func TestSanitizePath(t *testing.T) {
}
func TestResolveManifestURL(t *testing.T) {
t.Parallel()
tests := []struct {
input string
expected string
}{
// Already ends with .mf - use as-is
{"https://example.com/path/index.mf", "https://example.com/path/index.mf"},
{testIndexMF, testIndexMF},
{"https://example.com/path/custom.mf", "https://example.com/path/custom.mf"},
{"https://example.com/foo.mf", "https://example.com/foo.mf"},
// Directory with trailing slash - append index.mf
{"https://example.com/path/", "https://example.com/path/index.mf"},
{"https://example.com/path/", testIndexMF},
{"https://example.com/", "https://example.com/index.mf"},
// Directory without trailing slash - add slash and index.mf
{"https://example.com/path", "https://example.com/path/index.mf"},
{"https://example.com/path", testIndexMF},
{"https://example.com", "https://example.com/index.mf"},
// With query strings
{"https://example.com/path?foo=bar", "https://example.com/path/index.mf?foo=bar"},
{
"https://example.com/path?foo=bar",
"https://example.com/path/index.mf?foo=bar",
},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
result, err := resolveManifestURL(tt.input)
assert.NoError(t, err)
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
// The sole caller wraps this error as "invalid URL: %w", so
// resolveManifestURL must return url.Parse's error unadorned.
t.Run("invalid:control character", func(t *testing.T) {
t.Parallel()
_, err := resolveManifestURL("http://example.com/\x7f")
require.ErrorContains(t, err, urlParseControlCharErr)
assert.NotContains(t, err.Error(), "failed to parse URL")
})
}
// scanToManifest scans sourceFs and returns the serialized manifest bytes.
func scanToManifest(t *testing.T, sourceFs afero.Fs) []byte {
t.Helper()
s := mfer.NewScannerWithOptions(&mfer.ScannerOptions{Fs: sourceFs})
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
var manifestBuf bytes.Buffer
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
return manifestBuf.Bytes()
}
// chdirTemp switches the working directory to a fresh temp dir for the
// duration of the test and returns its path.
func chdirTemp(t *testing.T) string {
t.Helper()
destDir := t.TempDir()
origDir, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(destDir))
t.Cleanup(func() { _ = os.Chdir(origDir) })
return destDir
}
// fetchTestHandler serves the manifest at /index.mf and the given files
// at their paths.
func fetchTestHandler(
manifestData []byte, testFiles map[string][]byte,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/index.mf" {
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(manifestData)
return
}
// Strip leading slash
if len(path) > 0 && path[0] == '/' {
path = path[1:]
}
content, exists := testFiles[path]
if !exists {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(content)
}
}
//nolint:paralleltest // changes the process-global working directory
func TestFetchFromHTTP(t *testing.T) {
// Create source filesystem with test files
sourceFs := afero.NewMemMapFs()
@@ -134,51 +234,14 @@ func TestFetchFromHTTP(t *testing.T) {
}
// Generate manifest using scanner
opts := &mfer.ScannerOptions{
Fs: sourceFs,
}
s := mfer.NewScannerWithOptions(opts)
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
var manifestBuf bytes.Buffer
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
manifestData := manifestBuf.Bytes()
manifestData := scanToManifest(t, sourceFs)
// Create HTTP server that serves the source filesystem
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/index.mf" {
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(manifestData)
return
}
// Strip leading slash
if len(path) > 0 && path[0] == '/' {
path = path[1:]
}
content, exists := testFiles[path]
if !exists {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(content)
}))
server := httptest.NewServer(fetchTestHandler(manifestData, testFiles))
defer server.Close()
// Create destination directory
destDir, err := os.MkdirTemp("", "mfer-fetch-test-*")
require.NoError(t, err)
defer func() { _ = os.RemoveAll(destDir) }()
// Change to dest directory for the test
origDir, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(destDir))
defer func() { _ = os.Chdir(origDir) }()
// Change to a fresh destination directory for the test
destDir := chdirTemp(t)
// Parse the manifest to get file entries
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
@@ -189,132 +252,125 @@ func TestFetchFromHTTP(t *testing.T) {
// Download each file using downloadFile
progress := make(chan DownloadProgress, 10)
go func() {
for range progress {
// Drain progress channel
for p := range progress {
_ = p // drain progress channel
}
}()
baseURL := server.URL + "/"
for _, f := range files {
localPath, err := sanitizePath(f.Path)
localPath, err := sanitizePath(f.GetPath())
require.NoError(t, err)
fileURL := baseURL + f.Path
err = downloadFile(fileURL, localPath, f, progress)
require.NoError(t, err, "failed to download %s", f.Path)
fileURL := baseURL + f.GetPath()
err = downloadFile(context.Background(), fileURL, localPath, f, progress)
require.NoError(t, err, "failed to download %s", f.GetPath())
}
close(progress)
// Verify downloaded files match originals
for path, expectedContent := range testFiles {
downloadedPath := filepath.Join(destDir, path)
//nolint:gosec // test-controlled path
downloadedContent, err := os.ReadFile(downloadedPath)
require.NoError(t, err, "failed to read downloaded file %s", path)
assert.Equal(t, expectedContent, downloadedContent, "content mismatch for %s", path)
assert.Equal(t, expectedContent, downloadedContent,
"content mismatch for %s", path)
}
}
//nolint:paralleltest // changes the process-global working directory
func TestFetchHashMismatch(t *testing.T) {
// Create source filesystem with a test file
sourceFs := afero.NewMemMapFs()
originalContent := []byte("Original content")
require.NoError(t, afero.WriteFile(sourceFs, "/file.txt", originalContent, 0o644))
// Generate manifest
opts := &mfer.ScannerOptions{Fs: sourceFs}
s := mfer.NewScannerWithOptions(opts)
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
// Generate and parse manifest
manifestData := scanToManifest(t, sourceFs)
var manifestBuf bytes.Buffer
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
// Parse manifest
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestBuf.Bytes()))
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
require.NoError(t, err)
files := manifest.Files()
require.Len(t, files, 1)
// Create server that serves DIFFERENT content (to trigger hash mismatch)
tamperedContent := []byte("Tampered content!")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(tamperedContent)
}))
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(tamperedContent)
}))
defer server.Close()
// Create temp directory
destDir, err := os.MkdirTemp("", "mfer-fetch-hash-test-*")
require.NoError(t, err)
defer func() { _ = os.RemoveAll(destDir) }()
origDir, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(destDir))
defer func() { _ = os.Chdir(origDir) }()
// Work in a fresh temp directory
chdirTemp(t)
// Try to download - should fail with hash mismatch
err = downloadFile(server.URL+"/file.txt", "file.txt", files[0], nil)
assert.Error(t, err)
err = downloadFile(context.Background(),
server.URL+"/file.txt", testFileTxt, files[0], nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "mismatch")
// Verify temp file was cleaned up
_, err = os.Stat(".file.txt.tmp")
assert.True(t, os.IsNotExist(err), "temp file should be cleaned up on hash mismatch")
assert.True(t, os.IsNotExist(err),
"temp file should be cleaned up on hash mismatch")
// Verify final file was not created
_, err = os.Stat("file.txt")
assert.True(t, os.IsNotExist(err), "final file should not exist on hash mismatch")
_, err = os.Stat(testFileTxt)
assert.True(t, os.IsNotExist(err),
"final file should not exist on hash mismatch")
}
//nolint:paralleltest // changes the process-global working directory
func TestFetchSizeMismatch(t *testing.T) {
// Create source filesystem with a test file
sourceFs := afero.NewMemMapFs()
originalContent := []byte("Original content with specific size")
require.NoError(t, afero.WriteFile(sourceFs, "/file.txt", originalContent, 0o644))
// Generate manifest
opts := &mfer.ScannerOptions{Fs: sourceFs}
s := mfer.NewScannerWithOptions(opts)
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
// Generate and parse manifest
manifestData := scanToManifest(t, sourceFs)
var manifestBuf bytes.Buffer
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
// Parse manifest
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestBuf.Bytes()))
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
require.NoError(t, err)
files := manifest.Files()
require.Len(t, files, 1)
// Create server that serves content with wrong size
wrongSizeContent := []byte("Short")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(wrongSizeContent)
}))
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(wrongSizeContent)
}))
defer server.Close()
// Create temp directory
destDir, err := os.MkdirTemp("", "mfer-fetch-size-test-*")
require.NoError(t, err)
defer func() { _ = os.RemoveAll(destDir) }()
origDir, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(destDir))
defer func() { _ = os.Chdir(origDir) }()
// Work in a fresh temp directory
chdirTemp(t)
// Try to download - should fail with size mismatch
err = downloadFile(server.URL+"/file.txt", "file.txt", files[0], nil)
assert.Error(t, err)
err = downloadFile(context.Background(),
server.URL+"/file.txt", testFileTxt, files[0], nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "size mismatch")
// Verify temp file was cleaned up
_, err = os.Stat(".file.txt.tmp")
assert.True(t, os.IsNotExist(err), "temp file should be cleaned up on size mismatch")
assert.True(t, os.IsNotExist(err),
"temp file should be cleaned up on size mismatch")
}
//nolint:paralleltest // changes the process-global working directory
func TestFetchProgress(t *testing.T) {
// Create source filesystem with a larger test file
sourceFs := afero.NewMemMapFs()
@@ -322,53 +378,47 @@ func TestFetchProgress(t *testing.T) {
content := bytes.Repeat([]byte("x"), 100*1024) // 100KB
require.NoError(t, afero.WriteFile(sourceFs, "/large.txt", content, 0o644))
// Generate manifest
opts := &mfer.ScannerOptions{Fs: sourceFs}
s := mfer.NewScannerWithOptions(opts)
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
// Generate and parse manifest
manifestData := scanToManifest(t, sourceFs)
var manifestBuf bytes.Buffer
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
// Parse manifest
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestBuf.Bytes()))
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
require.NoError(t, err)
files := manifest.Files()
require.Len(t, files, 1)
// Create server that serves the content
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", "102400")
// Write in chunks to allow progress reporting
reader := bytes.NewReader(content)
_, _ = io.Copy(w, reader)
}))
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", "102400")
// Write in chunks to allow progress reporting
reader := bytes.NewReader(content)
_, _ = io.Copy(w, reader)
}))
defer server.Close()
// Create temp directory
destDir, err := os.MkdirTemp("", "mfer-fetch-progress-test-*")
require.NoError(t, err)
defer func() { _ = os.RemoveAll(destDir) }()
origDir, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(destDir))
defer func() { _ = os.Chdir(origDir) }()
// Work in a fresh temp directory
chdirTemp(t)
// Set up progress channel and collect updates
progress := make(chan DownloadProgress, 100)
var progressUpdates []DownloadProgress
done := make(chan struct{})
go func() {
for p := range progress {
progressUpdates = append(progressUpdates, p)
}
close(done)
}()
// Download
err = downloadFile(server.URL+"/large.txt", "large.txt", files[0], progress)
err = downloadFile(context.Background(),
server.URL+"/large.txt", "large.txt", files[0], progress)
close(progress)
<-done
@@ -380,7 +430,8 @@ func TestFetchProgress(t *testing.T) {
// Verify final progress shows complete
if len(progressUpdates) > 0 {
last := progressUpdates[len(progressUpdates)-1]
assert.Equal(t, int64(len(content)), last.BytesRead, "final progress should show all bytes read")
assert.Equal(t, int64(len(content)), last.BytesRead,
"final progress should show all bytes read")
assert.Equal(t, "large.txt", last.Path)
}