check / check (push) Successful in 53s
downloadFile opened the temp file with os.Create, which opens and empties a file already at that name. If that file was a hard link to a file outside the destination directory, fetch overwrote the outside file. It now removes whatever is at the temp name, which removes only that name, and creates the temp file with O_EXCL, so the create fails if anything is still or again there. A leftover temp file from an interrupted run is still replaced. The new test puts a hard link at the temp name and checks that fetch succeeds and the outside file is unchanged. The command name is now the constant cmdFetch, like the other command names, because lint requires it once a third test uses it. Model: opus-5-5
526 lines
15 KiB
Go
526 lines
15 KiB
Go
//nolint:testpackage // white-box tests exercise unexported internals
|
|
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"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
|
|
}{
|
|
{testFileTxt, testFileTxt},
|
|
{testDirFile, testDirFile},
|
|
{"my file.txt", "my%20file.txt"},
|
|
{"dir/my file.txt", "dir/my%20file.txt"},
|
|
{"file#1.txt", "file%231.txt"},
|
|
{"file?v=1.txt", "file%3Fv=1.txt"},
|
|
{"path/to/file with spaces.txt", "path/to/file%20with%20spaces.txt"},
|
|
{"100%done.txt", "100%25done.txt"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
result := encodeFilePath(tt.input)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSanitizePath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Valid paths that should be accepted
|
|
validTests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{testFileTxt, testFileTxt},
|
|
{testDirFile, testDirFile},
|
|
{"dir/subdir/file.txt", "dir/subdir/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)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
|
|
// Invalid paths that should be rejected
|
|
invalidTests := []struct {
|
|
input string
|
|
desc string
|
|
}{
|
|
{"", "empty path"},
|
|
{"..", "parent directory"},
|
|
{"../file.txt", "parent traversal"},
|
|
{"../../file.txt", "double parent traversal"},
|
|
{"dir/../../../file.txt", "traversal escaping base"},
|
|
{"/etc/passwd", "absolute path"},
|
|
{"/file.txt", "absolute path with single component"},
|
|
{"dir/../../etc/passwd", "traversal to system file"},
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveManifestURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
// Already ends with .mf - use as-is
|
|
{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/", testIndexMF},
|
|
{"https://example.com/", "https://example.com/index.mf"},
|
|
|
|
// Directory without trailing slash - add slash and 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",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
result, err := resolveManifestURL(tt.input)
|
|
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()
|
|
|
|
testFiles := map[string][]byte{
|
|
"file1.txt": []byte("Hello, World!"),
|
|
"file2.txt": []byte("This is file 2 with more content."),
|
|
"subdir/file3.txt": []byte("Nested file content here."),
|
|
"subdir/deep/f.txt": []byte("Deeply nested file."),
|
|
}
|
|
|
|
for path, content := range testFiles {
|
|
fullPath := "/" + path // MemMapFs needs absolute paths
|
|
dir := filepath.Dir(fullPath)
|
|
require.NoError(t, sourceFs.MkdirAll(dir, 0o755))
|
|
require.NoError(t, afero.WriteFile(sourceFs, fullPath, content, 0o644))
|
|
}
|
|
|
|
// Generate manifest using scanner
|
|
manifestData := scanToManifest(t, sourceFs)
|
|
|
|
// Create HTTP server that serves the source filesystem
|
|
server := httptest.NewServer(fetchTestHandler(manifestData, testFiles))
|
|
defer server.Close()
|
|
|
|
// 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))
|
|
require.NoError(t, err)
|
|
|
|
files := manifest.Files()
|
|
require.Len(t, files, len(testFiles))
|
|
|
|
// Download each file using downloadFile
|
|
progress := make(chan DownloadProgress, 10)
|
|
|
|
go func() {
|
|
for p := range progress {
|
|
_ = p // drain progress channel
|
|
}
|
|
}()
|
|
|
|
baseURL := server.URL + "/"
|
|
|
|
for _, f := range files {
|
|
localPath, err := sanitizePath(f.GetPath())
|
|
require.NoError(t, err)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
//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 and parse manifest
|
|
manifestData := scanToManifest(t, sourceFs)
|
|
|
|
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, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
_, _ = w.Write(tamperedContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Work in a fresh temp directory
|
|
chdirTemp(t)
|
|
|
|
// Try to download - should fail with hash mismatch
|
|
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")
|
|
|
|
// Verify final file was not created
|
|
_, 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 and parse manifest
|
|
manifestData := scanToManifest(t, sourceFs)
|
|
|
|
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, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
_, _ = w.Write(wrongSizeContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
// Work in a fresh temp directory
|
|
chdirTemp(t)
|
|
|
|
// Try to download - should fail with size mismatch
|
|
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")
|
|
}
|
|
|
|
//nolint:paralleltest // changes the process-global working directory
|
|
func TestFetchProgress(t *testing.T) {
|
|
// Create source filesystem with a larger test file
|
|
sourceFs := afero.NewMemMapFs()
|
|
// Create content large enough to trigger multiple progress updates
|
|
content := bytes.Repeat([]byte("x"), 100*1024) // 100KB
|
|
require.NoError(t, afero.WriteFile(sourceFs, "/large.txt", content, 0o644))
|
|
|
|
// Generate and parse manifest
|
|
manifestData := scanToManifest(t, sourceFs)
|
|
|
|
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, _ *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()
|
|
|
|
// 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(context.Background(),
|
|
server.URL+"/large.txt", "large.txt", files[0], progress)
|
|
close(progress)
|
|
<-done
|
|
|
|
require.NoError(t, err)
|
|
|
|
// Verify we got progress updates
|
|
assert.NotEmpty(t, progressUpdates, "should have received progress updates")
|
|
|
|
// 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, "large.txt", last.Path)
|
|
}
|
|
|
|
// Verify file was downloaded correctly
|
|
downloaded, err := os.ReadFile("large.txt")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, content, downloaded)
|
|
}
|
|
|
|
// TestFetchRefusesSymlinks runs fetch into a destination directory that
|
|
// holds a symlink pointing outside it, in each of the three places fetch
|
|
// writes: a parent directory, the temp file, and the file itself, which
|
|
// the temp file is renamed onto; and once as a directory inside a plain
|
|
// directory. The fetch must fail and nothing outside may change.
|
|
//
|
|
//nolint:paralleltest // changes the process-global working directory
|
|
func TestFetchRefusesSymlinks(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
entry string // the manifest's only file
|
|
link string // symlink placed in the destination directory
|
|
target string // what link points to, relative to the outside directory
|
|
}{
|
|
{"parent directory", "sub/deeper/file.txt", "sub", "."},
|
|
{"directory inside a plain directory", "docs/data/passwd", "docs/data", "."},
|
|
{"temp file", testFileTxt, ".file.txt.tmp", "new.txt"},
|
|
{"file", testFileTxt, testFileTxt, "new.txt"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
content := []byte("fetched")
|
|
sourceFs := afero.NewMemMapFs()
|
|
require.NoError(t, sourceFs.MkdirAll(filepath.Dir("/"+tt.entry), 0o755))
|
|
require.NoError(t, afero.WriteFile(sourceFs, "/"+tt.entry, content, 0o644))
|
|
|
|
server := httptest.NewServer(fetchTestHandler(
|
|
scanToManifest(t, sourceFs), map[string][]byte{tt.entry: content}))
|
|
defer server.Close()
|
|
|
|
outside := t.TempDir()
|
|
|
|
chdirTemp(t)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(tt.link), 0o750))
|
|
require.NoError(t, os.Symlink(filepath.Join(outside, tt.target), tt.link))
|
|
|
|
opts := testOpts([]string{testApp, cmdFetch, "-q", server.URL}, afero.NewOsFs())
|
|
assert.Equal(t, 1, runCLI(opts))
|
|
assert.Contains(t, testStderr(t, opts), "failed to download "+tt.entry+
|
|
": symlink in path not allowed: "+tt.link)
|
|
|
|
written, err := os.ReadDir(outside)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, written, "fetch wrote outside the destination")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFetchReplacesHardLinkAtTempName runs fetch into a destination
|
|
// directory that holds, at the temp file's name, a hard link to a file
|
|
// outside it. To fetch that is an ordinary leftover from an interrupted
|
|
// earlier run: it must replace it and succeed, and the outside file must
|
|
// not change.
|
|
//
|
|
//nolint:paralleltest // changes the process-global working directory
|
|
func TestFetchReplacesHardLinkAtTempName(t *testing.T) {
|
|
content := []byte("fetched")
|
|
sourceFs := afero.NewMemMapFs()
|
|
require.NoError(t, afero.WriteFile(sourceFs, "/"+testFileTxt, content, 0o644))
|
|
|
|
server := httptest.NewServer(fetchTestHandler(
|
|
scanToManifest(t, sourceFs), map[string][]byte{testFileTxt: content}))
|
|
defer server.Close()
|
|
|
|
outsideFile := filepath.Join(t.TempDir(), "secret.txt")
|
|
require.NoError(t, os.WriteFile(outsideFile, []byte("outside"), 0o600))
|
|
|
|
chdirTemp(t)
|
|
require.NoError(t, os.Link(outsideFile, ".file.txt.tmp"))
|
|
|
|
opts := testOpts([]string{testApp, cmdFetch, "-q", server.URL}, afero.NewOsFs())
|
|
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
|
|
|
fetched, err := os.ReadFile(testFileTxt)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, content, fetched)
|
|
|
|
outside, err := os.ReadFile(outsideFile) //nolint:gosec // test-controlled path
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "outside", string(outside), "fetch wrote outside the destination")
|
|
}
|