Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
verbose: true
3 changes: 2 additions & 1 deletion store/acceptance/benchmark.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package acceptance

import (
"errors"
"strings"
"testing"

"github.com/bartventer/httpcache/store/driver"
Expand All @@ -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) })
Expand Down
2 changes: 1 addition & 1 deletion store/fscache/benchmark_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 0 additions & 17 deletions store/fscache/filenamer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,13 @@ package fscache

import (
"encoding/base64"
"fmt"
"path/filepath"
"strings"
"testing"

"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
Expand Down
4 changes: 3 additions & 1 deletion store/fscache/fscache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}
Expand Down
45 changes: 31 additions & 14 deletions store/fscache/fscache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import (
"io"
"io/fs"
"net/url"
"path/filepath"
"runtime"
"testing"
"time"

Expand All @@ -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)
}
Expand All @@ -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() }
Expand All @@ -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() })
Expand All @@ -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() })
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
129 changes: 129 additions & 0 deletions store/fscache/issue16_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright (c) 2025 Bart Venter <bartventer@proton.me>
//
// 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)
}
}
})
}
}