//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" ) 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) }) } } // 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) }