From d75f52ae98d2e9fd11336f8b75cf6dda9fcbb6c7 Mon Sep 17 00:00:00 2001 From: Bart Venter Date: Thu, 30 Oct 2025 11:52:39 +0000 Subject: [PATCH 1/6] test(fscache): add filesystem limits tests and benchmarks Add comprehensive testing for long URL handling to validate fragmentation behavior discussed in PR #17. - Test URL lengths up to 100KB - Benchmark performance scaling - Verify directory depth handling Related to #16, addresses concerns from PR #17 --- store/fscache/issue16_test.go | 121 ++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 store/fscache/issue16_test.go diff --git a/store/fscache/issue16_test.go b/store/fscache/issue16_test.go new file mode 100644 index 0000000..65959f6 --- /dev/null +++ b/store/fscache/issue16_test.go @@ -0,0 +1,121 @@ +// 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}, + {"massive URL (100KB)", 102400, true}, + } + + 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) + } + } + }) + } +} From ccde4ee68c98c20c8afb9940bc9a11329a888a10 Mon Sep 17 00:00:00 2001 From: Bart Venter Date: Thu, 30 Oct 2025 12:14:41 +0000 Subject: [PATCH 2/6] ci: add cross-platform testing in GitHub Actions workflow --- .github/workflows/default.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 468595d..cfbb606 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 @@ -35,4 +38,4 @@ jobs: files: ${{ env.OUTPUTDIR }}/${{ env.COVERPROFILE }} flags: unittests disable_search: true - verbose: true \ No newline at end of file + verbose: true From dab80251298851d1cce9ea529d997d2f8d39494a Mon Sep 17 00:00:00 2001 From: Bart Venter Date: Thu, 30 Oct 2025 13:54:38 +0000 Subject: [PATCH 3/6] test(fscache): ensure os-specific path handling --- store/fscache/benchmark_test.go | 2 +- store/fscache/fscache.go | 4 +++- store/fscache/fscache_test.go | 29 ++++++++++++++++++++--------- 3 files changed, 24 insertions(+), 11 deletions(-) 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/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..13d879d 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,9 @@ 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", }, assertion: func(tt *testing.T, got *fsCache, err error) { testutil.RequireErrorIs(tt, err, ErrCreateCacheDir) @@ -202,6 +212,7 @@ func TestOpen(t *testing.T) { if err != nil { t.Fatalf("Failed to parse URL: %v", err) } + t.Attr("URL", u.String()) if tt.setup != nil { tt.setup(t) } From 773d498849f248623edad591b2c86f357fddaab8 Mon Sep 17 00:00:00 2001 From: Bart Venter Date: Thu, 30 Oct 2025 13:55:21 +0000 Subject: [PATCH 4/6] ci: restrict Codecov upload to Ubuntu environment only --- .github/workflows/default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index cfbb606..1851cf3 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -31,7 +31,7 @@ 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 }} From b3ef329f9d8d59dd2e66e6121f50cf85a7199220 Mon Sep 17 00:00:00 2001 From: Bart Venter Date: Thu, 30 Oct 2025 14:39:16 +0000 Subject: [PATCH 5/6] refactor(tests): remove example functions and improve path handling in tests --- store/fscache/filenamer_test.go | 17 ----------------- store/fscache/fscache_test.go | 18 ++++++++++++------ store/fscache/issue16_test.go | 10 +++++++++- 3 files changed, 21 insertions(+), 24 deletions(-) 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_test.go b/store/fscache/fscache_test.go index 13d879d..ca007f4 100644 --- a/store/fscache/fscache_test.go +++ b/store/fscache/fscache_test.go @@ -152,6 +152,11 @@ func TestOpen(t *testing.T) { 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) testutil.AssertNil(tt, got) @@ -160,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) @@ -200,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) @@ -212,7 +219,6 @@ func TestOpen(t *testing.T) { if err != nil { t.Fatalf("Failed to parse URL: %v", err) } - t.Attr("URL", u.String()) if tt.setup != nil { tt.setup(t) } @@ -242,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 index 65959f6..a6ef1b9 100644 --- a/store/fscache/issue16_test.go +++ b/store/fscache/issue16_test.go @@ -43,7 +43,15 @@ func Test_Issue16_LongURLFragmentation(t *testing.T) { {"long URL (1KB)", 1024, true}, {"very long URL (4KB)", 4096, true}, {"extremely long URL (10KB)", 10240, true}, - {"massive URL (100KB)", 102400, 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 { From 4b8d5a84218a518d6d1e10b3e57cead9c6e68298 Mon Sep 17 00:00:00 2001 From: Bart Venter Date: Thu, 30 Oct 2025 15:43:03 +0000 Subject: [PATCH 6/6] test(store/acceptance): increase key length in benchmarks [skip ci] --- store/acceptance/benchmark.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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) })