diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 468595d..1851cf3 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -11,7 +11,10 @@ defaults: jobs: test: name: Test - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} env: OUTPUTDIR: coverage COVERPROFILE: coverage.out @@ -28,11 +31,11 @@ jobs: run: make test - name: Upload coverage reports to Codecov - if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }} + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && matrix.os == 'ubuntu-latest' }} uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5.4.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: ${{ env.OUTPUTDIR }}/${{ env.COVERPROFILE }} flags: unittests disable_search: true - verbose: true \ No newline at end of file + verbose: true diff --git a/store/acceptance/benchmark.go b/store/acceptance/benchmark.go index c9dd5a6..868699e 100644 --- a/store/acceptance/benchmark.go +++ b/store/acceptance/benchmark.go @@ -5,6 +5,7 @@ package acceptance import ( "errors" + "strings" "testing" "github.com/bartventer/httpcache/store/driver" @@ -14,7 +15,7 @@ import ( func RunB(b *testing.B, factory Factory) { b.Helper() - key := "benchmark_key" + key := "benchmark_key" + strings.Repeat("x", 100) value := []byte("benchmark_value") b.Run("Get", func(b *testing.B) { benchmarkGet(b, factory.Make, key) }) b.Run("Set", func(b *testing.B) { benchmarkSet(b, factory.Make, key, value) }) diff --git a/store/fscache/benchmark_test.go b/store/fscache/benchmark_test.go index 8db2870..a1d63a1 100644 --- a/store/fscache/benchmark_test.go +++ b/store/fscache/benchmark_test.go @@ -12,7 +12,7 @@ import ( func BenchmarkFSCache(b *testing.B) { acceptance.RunB(b, acceptance.FactoryFunc(func() (driver.Conn, func()) { - u := makeRoot(b) + u := makeRootURL(b) cache, err := fromURL(u) if err != nil { b.Fatalf("Failed to create fscache: %v", err) diff --git a/store/fscache/filenamer_test.go b/store/fscache/filenamer_test.go index 6168b20..be158ce 100644 --- a/store/fscache/filenamer_test.go +++ b/store/fscache/filenamer_test.go @@ -16,7 +16,6 @@ package fscache import ( "encoding/base64" - "fmt" "path/filepath" "strings" "testing" @@ -24,22 +23,6 @@ import ( "github.com/bartventer/httpcache/internal/testutil" ) -func Example_fragmentFileName_short() { - url := "https://short.url/test" - path := fragmentFileName(url) - fmt.Println("Fragmented path:", path) - // Output: - // Fragmented path: aHR0cHM6Ly9zaG9ydC51cmwvdGVzdA -} - -func Example_fragmentFileName_long() { - url := "https://example.com/" + strings.Repeat("a", 255) - path := fragmentFileName(url) - fmt.Println("Fragmented path:", path) - // Output: - // Fragmented path: aHR0cHM6Ly9leGFtcGxlLmNvbS9hYWFhYWFhYWFhYWFhYWFh/YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFh/YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFh/YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFh/YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFh/YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFh/YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFh/YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE -} - func Test_fragmentFileName_fragmentedFileNameToKey(t *testing.T) { cases := []struct { name string diff --git a/store/fscache/fscache.go b/store/fscache/fscache.go index 8dc86ba..c8dab36 100644 --- a/store/fscache/fscache.go +++ b/store/fscache/fscache.go @@ -170,7 +170,9 @@ func WithEncryption(key string) Option { // WithBaseDir sets the base directory for the cache; default: user's OS cache directory. func WithBaseDir(base string) Option { return optionFunc(func(c *fsCache) error { - c.base = base + if base != "" { + c.base = base + } return nil }) } diff --git a/store/fscache/fscache_test.go b/store/fscache/fscache_test.go index f9ecd15..ca007f4 100644 --- a/store/fscache/fscache_test.go +++ b/store/fscache/fscache_test.go @@ -20,6 +20,8 @@ import ( "io" "io/fs" "net/url" + "path/filepath" + "runtime" "testing" "time" @@ -30,9 +32,10 @@ import ( func (c *fsCache) Close() error { return c.root.Close() } -func makeRoot(t testing.TB) *url.URL { +func makeRootURL(t testing.TB) *url.URL { t.Helper() - u, err := url.Parse("fscache://" + t.TempDir() + "?appname=testapp") + tempDir := filepath.ToSlash(t.TempDir()) + u, err := url.Parse("fscache://" + tempDir + "?appname=testapp") if err != nil { t.Fatalf("Failed to parse cache URL: %v", err) } @@ -41,7 +44,7 @@ func makeRoot(t testing.TB) *url.URL { func TestFSCache_Acceptance(t *testing.T) { acceptance.Run(t, acceptance.FactoryFunc(func() (driver.Conn, func()) { - u := makeRoot(t) + u := makeRootURL(t) cache, err := fromURL(u) testutil.RequireNoError(t, err, "Failed to create fscache") cleanup := func() { cache.Close() } @@ -50,7 +53,7 @@ func TestFSCache_Acceptance(t *testing.T) { } func Test_fsCache_SetError(t *testing.T) { - u := makeRoot(t) + u := makeRootURL(t) cache, err := fromURL(u) testutil.RequireNoError(t, err, "Failed to create fscache") t.Cleanup(func() { cache.Close() }) @@ -63,7 +66,7 @@ func Test_fsCache_SetError(t *testing.T) { } func Test_fsCache_KeysError(t *testing.T) { - u := makeRoot(t) + u := makeRootURL(t) cache, err := fromURL(u) testutil.RequireNoError(t, err, "Failed to create fscache") t.Cleanup(func() { cache.Close() }) @@ -96,7 +99,7 @@ func TestOpen(t *testing.T) { { name: "Valid Root Directory", args: args{ - dsn: "fscache://" + t.TempDir() + "?appname=myapp", + dsn: "fscache://" + filepath.ToSlash(t.TempDir()) + "?appname=myapp", }, assertion: func(tt *testing.T, got *fsCache, err error) { testutil.RequireNoError(tt, err) @@ -119,8 +122,13 @@ func TestOpen(t *testing.T) { dsn: "fscache://?appname=myapp", }, setup: func(tt *testing.T) { - tt.Setenv("XDG_CACHE_HOME", "") - tt.Setenv("HOME", "") + switch runtime.GOOS { + case "windows": + tt.Setenv("LocalAppData", "") + default: + tt.Setenv("XDG_CACHE_HOME", "") + tt.Setenv("HOME", "") + } }, assertion: func(tt *testing.T, got *fsCache, err error) { testutil.RequireErrorIs(tt, err, ErrUserCacheDir) @@ -140,7 +148,14 @@ func TestOpen(t *testing.T) { { name: "Invalid Root Directory", args: args{ - dsn: "fscache:///../invalid?appname=myapp", + dsn: "fscache://" + filepath.ToSlash( + filepath.VolumeName(t.TempDir())+"/../invalid", + ) + "?appname=myapp", + }, + setup: func(tt *testing.T) { + if runtime.GOOS == "windows" { + tt.Skip("Skipping invalid path test on Windows") + } }, assertion: func(tt *testing.T, got *fsCache, err error) { testutil.RequireErrorIs(tt, err, ErrCreateCacheDir) @@ -150,10 +165,7 @@ func TestOpen(t *testing.T) { { name: "Encryption Enabled with Key", args: args{ - dsn: "fscache://?appname=myapp&encrypt=aesgcm&encrypt_key=" + mustBase64Key( - t, - 16, - ), + dsn: "fscache://?appname=myapp&encrypt=aesgcm&encrypt_key=" + mustBase64Key(t, 16), }, assertion: func(tt *testing.T, got *fsCache, err error) { testutil.RequireNoError(tt, err) @@ -190,6 +202,11 @@ func TestOpen(t *testing.T) { args: args{ dsn: "fscache://?appname=myapp&connect_timeout=1ns&timeout=10s", }, + setup: func(tt *testing.T) { + if runtime.GOOS == "windows" { + tt.Skip("Skipping connect timeout test on Windows") + } + }, assertion: func(tt *testing.T, got *fsCache, err error) { testutil.RequireErrorIs(tt, err, context.DeadlineExceeded) testutil.AssertNil(tt, got) @@ -231,7 +248,7 @@ func Test_parseTimeout(t *testing.T) { } func TestFSCache_SetGet_WithEncryption(t *testing.T) { - u, err := url.Parse("fscache://" + t.TempDir() + + u, err := url.Parse("fscache://" + filepath.ToSlash(t.TempDir()) + "?appname=testapp&encrypt=aesgcm&encrypt_key=6S-Ks2YYOW0xMvTzKSv6QD30gZeOi1c6Ydr-As5csWk=") testutil.RequireNoError(t, err) cache, err := fromURL(u) diff --git a/store/fscache/issue16_test.go b/store/fscache/issue16_test.go new file mode 100644 index 0000000..a6ef1b9 --- /dev/null +++ b/store/fscache/issue16_test.go @@ -0,0 +1,129 @@ +// Copyright (c) 2025 Bart Venter +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fscache + +import ( + "fmt" + "os" + "runtime" + "strconv" + "strings" + "testing" + + "github.com/bartventer/httpcache/internal/testutil" +) + +func Test_Issue16_LongURLFragmentation(t *testing.T) { + t.Attr("GOOS", runtime.GOOS) + t.Attr("GOARCH", runtime.GOARCH) + t.Attr("GOVERSION", runtime.Version()) + + tempDir := t.TempDir() + cache, err := Open("test-fragmentation", WithBaseDir(tempDir)) + testutil.RequireNoError(t, err) + + tests := []struct { + name string + urlLen int + expectOK bool + }{ + {"normal URL", 100, true}, + {"long URL (1KB)", 1024, true}, + {"very long URL (4KB)", 4096, true}, + {"extremely long URL (10KB)", 10240, true}, + { + // While Go's stdlib handles long paths via \\?\ prefix on Windows, + // extremely deep directory hierarchies (2844+ levels) may still hit + // practical filesystem or OS limits beyond just path length. + // See fixLongPath logic in os/path_windows.go (https://cs.opensource.google/go/go/+/refs/tags/go1.25.3:src/os/path_windows.go;l=100;drc=79b809afb325ae266497e21597f126a3e98a1ef7) + "massive URL (100KB)", + 102400, + runtime.GOOS != "windows", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Generate URL of specified length + url := "https://example.com/" + strings.Repeat( + "x", + tt.urlLen-len("https://example.com/"), + ) + + // Get fragmentation details + filename := cache.fn.FileName(url) + depth := strings.Count(filename, string(os.PathSeparator)) + t.Attr("URLLength", strconv.Itoa(len(url))) + t.Attr("PathLength", strconv.Itoa(len(filename))) + t.Attr("DirectoryDepth", strconv.Itoa(depth)) + + // Test round-trip: Set -> Get -> Delete + data := []byte("test data") + err := cache.Set(url, data) + + if tt.expectOK { + testutil.RequireNoError(t, err) + + retrieved, getErr := cache.Get(url) + testutil.RequireNoError(t, getErr) + testutil.AssertEqual(t, string(data), string(retrieved)) + + setErr := cache.Delete(url) + testutil.RequireNoError(t, setErr) + + t.Logf("Successfully handled %d byte URL", len(url)) + } else if err == nil { + t.Errorf("Expected error for %d byte URL, but got none", len(url)) + } + }) + } +} + +func Benchmark_Issue16_LongURLs(b *testing.B) { + tempDir := b.TempDir() + cache, err := Open("bench-long-urls", WithBaseDir(tempDir)) + if err != nil { + b.Fatal(err) + } + + urlLengths := []int{100, 1000, 10000, 50000} + + for _, length := range urlLengths { + b.Run(fmt.Sprintf("url_length_%d", length), func(b *testing.B) { + url := "https://example.com/" + strings.Repeat("x", length-len("https://example.com/")) + data := []byte("benchmark data") + + b.ResetTimer() + for i := 0; b.Loop(); i++ { + key := fmt.Sprintf("%s-%d", url, i) + + err := cache.Set(key, data) + if err != nil { + b.Fatal(err) + } + + _, err = cache.Get(key) + if err != nil { + b.Fatal(err) + } + + err = cache.Delete(key) + if err != nil { + b.Fatal(err) + } + } + }) + } +}