diff --git a/internal/config/config.go b/internal/config/config.go index 9fdeeb8..847020f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -244,7 +244,8 @@ type FileCacheConfig struct { MaxEntries int64 `yaml:"max_entries"` // 最大缓存条目数 MaxSize int64 `yaml:"max_size"` // 内存上限(字节) Inactive time.Duration `yaml:"inactive"` // 未访问淘汰时间 - LRUEviction bool `yaml:"lru_eviction"` // 启用 LRU 淘汰 + // Deprecated: 该字段已废弃,将在未来版本中移除,请使用 MaxSize 代替 + LRUEviction bool `yaml:"lru_eviction"` // 启用 LRU 淘汰(已废弃) } // TransportConfig HTTP Transport 配置。 @@ -444,5 +445,10 @@ func Validate(cfg *Config) error { return err } + // 验证性能配置 + if err := validatePerformance(&cfg.Performance); err != nil { + return fmt.Errorf("performance: %w", err) + } + return nil } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 1f66c5f..468b8f2 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -90,7 +90,7 @@ func DefaultConfig() *Config { Level: 6, MinSize: 1024, GzipStatic: false, - GzipStaticExtensions: []string{".gz", ".br"}, + GzipStaticExtensions: []string{".br", ".gz"}, Types: []string{ "text/html", "text/css", @@ -398,6 +398,7 @@ func GenerateConfigYAML(cfg *Config) ([]byte, error) { fmt.Fprintf(&buf, " max_entries: %d # 最大缓存条目\n", cfg.Performance.FileCache.MaxEntries) fmt.Fprintf(&buf, " max_size: %d # 内存上限(字节,%dMB)\n", cfg.Performance.FileCache.MaxSize, cfg.Performance.FileCache.MaxSize/1024/1024) fmt.Fprintf(&buf, " inactive: %ds # 未访问淘汰时间\n", int(cfg.Performance.FileCache.Inactive.Seconds())) + buf.WriteString(" # Deprecated: lru_eviction 已废弃,将在未来版本中移除\n") fmt.Fprintf(&buf, " lru_eviction: %v # 启用 LRU 淘汰\n", cfg.Performance.FileCache.LRUEviction) buf.WriteString(" transport: # HTTP Transport 连接池\n") fmt.Fprintf(&buf, " max_idle_conns: %d # 最大空闲连接\n", cfg.Performance.Transport.MaxIdleConns) diff --git a/internal/config/validate.go b/internal/config/validate.go index ffb3e7e..17cbac3 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "net" + "os" "strings" "rua.plus/lolly/internal/loadbalance" @@ -634,3 +635,32 @@ func validateStream(s *StreamConfig) error { return nil } + +// validatePerformance 验证性能配置。 +// +// 检查性能配置中的废弃选项和潜在问题,输出警告信息。 +// +// 参数: +// - p: 性能配置对象 +// +// 返回值: +// - error: 验证失败时返回错误信息,成功返回 nil +func validatePerformance(p *PerformanceConfig) error { + // 检查废弃的 LRUEviction 字段 + if p.FileCache.LRUEviction { + fmt.Fprintln(os.Stderr, "[警告] performance.file_cache.lru_eviction 已废弃,请使用 max_size 代替") + } + + // 检查 Transport 配置(可能导致性能问题) + if p.Transport.MaxIdleConns < 0 { + return errors.New("transport.max_idle_conns 不能为负数") + } + if p.Transport.MaxIdleConnsPerHost < 0 { + return errors.New("transport.max_idle_conns_per_host 不能为负数") + } + if p.Transport.MaxConnsPerHost < 0 { + return errors.New("transport.max_conns_per_host 不能为负数") + } + + return nil +} diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index f9f51d7..f3f5cf8 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -906,3 +906,126 @@ func TestValidateStream(t *testing.T) { }) } } + +func TestValidatePerformance(t *testing.T) { + tests := []struct { + name string + config PerformanceConfig + wantErr bool + errMsg string + }{ + { + name: "空配置有效", + config: PerformanceConfig{ + FileCache: FileCacheConfig{}, + Transport: TransportConfig{}, + }, + wantErr: false, + }, + { + name: "有效的 file_cache 配置", + config: PerformanceConfig{ + FileCache: FileCacheConfig{ + MaxEntries: 1000, + MaxSize: 1024 * 1024 * 100, + }, + GoroutinePool: GoroutinePoolConfig{ + Enabled: true, + }, + }, + wantErr: false, + }, + { + name: "有效的 transport 配置(零值)", + config: PerformanceConfig{ + Transport: TransportConfig{ + MaxIdleConns: 0, + MaxIdleConnsPerHost: 0, + MaxConnsPerHost: 0, + }, + }, + wantErr: false, + }, + { + name: "有效的 transport 配置(正值)", + config: PerformanceConfig{ + Transport: TransportConfig{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + MaxConnsPerHost: 50, + }, + }, + wantErr: false, + }, + { + name: "LRUEviction=true(废弃警告)", + config: PerformanceConfig{ + FileCache: FileCacheConfig{ + LRUEviction: true, + }, + }, + wantErr: false, + }, + { + name: "MaxIdleConns 负数", + config: PerformanceConfig{ + Transport: TransportConfig{ + MaxIdleConns: -1, + }, + }, + wantErr: true, + errMsg: "transport.max_idle_conns 不能为负数", + }, + { + name: "MaxIdleConnsPerHost 负数", + config: PerformanceConfig{ + Transport: TransportConfig{ + MaxIdleConnsPerHost: -1, + }, + }, + wantErr: true, + errMsg: "transport.max_idle_conns_per_host 不能为负数", + }, + { + name: "MaxConnsPerHost 负数", + config: PerformanceConfig{ + Transport: TransportConfig{ + MaxConnsPerHost: -1, + }, + }, + wantErr: true, + errMsg: "transport.max_conns_per_host 不能为负数", + }, + { + name: "多个 transport 字段为负", + config: PerformanceConfig{ + Transport: TransportConfig{ + MaxIdleConns: -1, + MaxIdleConnsPerHost: -2, + MaxConnsPerHost: -3, + }, + }, + wantErr: true, + errMsg: "transport.max_idle_conns 不能为负数", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePerformance(&tt.config) + if tt.wantErr { + if err == nil { + t.Errorf("validatePerformance() 期望返回错误,但返回 nil") + return + } + if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("validatePerformance() 错误消息不匹配,期望包含 %q,实际 %q", tt.errMsg, err.Error()) + } + } else { + if err != nil { + t.Errorf("validatePerformance() 期望返回 nil,但返回错误: %v", err) + } + } + }) + } +} diff --git a/internal/http3/server.go b/internal/http3/server.go index 75a47a5..831ce73 100644 --- a/internal/http3/server.go +++ b/internal/http3/server.go @@ -109,6 +109,16 @@ func (s *Server) Start() error { MaxIncomingStreams: int64(s.config.MaxStreams), MaxIdleTimeout: s.config.IdleTimeout, KeepAlivePeriod: 30 * time.Second, + Allow0RTT: s.config.Enable0RTT, + } + + // 如果启用了 0-RTT,输出安全警告 + if s.config.Enable0RTT { + logging.Warn(). + Msg("HTTP/3 0-RTT is enabled. " + + "For 0-RTT to work, TLS session tickets must be configured " + + "(TLSConfig.ClientSessionCache and TLSConfig.SessionTicketKey). " + + "See documentation for details.") } // 设置默认值 diff --git a/internal/middleware/compression/gzip_static.go b/internal/middleware/compression/gzip_static.go index 09114b5..ea4948e 100644 --- a/internal/middleware/compression/gzip_static.go +++ b/internal/middleware/compression/gzip_static.go @@ -15,7 +15,6 @@ package compression import ( - "bytes" "os" "path/filepath" "strings" @@ -25,8 +24,8 @@ import ( // GzipStatic 预压缩文件支持。 // -// 检查是否存在预压缩的 .gz 文件,如果存在则直接发送, -// 避免实时压缩的 CPU 开销。 +// 检查是否存在预压缩的 .gz 或 .br 文件,如果存在且客户端支持对应编码, +// 则直接发送,避免实时压缩的 CPU 开销。 type GzipStatic struct { // enabled 是否启用 enabled bool @@ -36,6 +35,9 @@ type GzipStatic struct { // extensions 支持的扩展名 extensions []string + + // precompressedExtensions 预压缩扩展名,按优先级排序(默认 [".br", ".gz"]) + precompressedExtensions []string } // NewGzipStatic 创建预压缩文件处理器。 @@ -49,16 +51,18 @@ func NewGzipStatic(enabled bool, root string, extensions []string) *GzipStatic { extensions = []string{".html", ".css", ".js", ".json", ".xml", ".svg", ".txt"} } return &GzipStatic{ - enabled: enabled, - root: root, - extensions: extensions, + enabled: enabled, + root: root, + extensions: extensions, + precompressedExtensions: []string{".br", ".gz"}, } } // ServeFile 发送预压缩文件(如果存在)。 // -// 检查是否存在对应的 .gz 文件,如果存在且客户端支持 gzip, -// 则发送预压缩文件。 +// 检查是否存在对应的 .br 或 .gz 文件,按优先级(br > gzip)选择: +// 1. 如果客户端支持 br 且 .br 文件存在,返回 .br 文件 +// 2. 否则如果客户端支持 gzip 且 .gz 文件存在,返回 .gz 文件 // // 参数: // - ctx: FastHTTP 请求上下文 @@ -71,36 +75,48 @@ func (g *GzipStatic) ServeFile(ctx *fasthttp.RequestCtx, filePath string) bool { return false } - // 检查客户端是否支持 gzip - acceptEncoding := ctx.Request.Header.Peek("Accept-Encoding") - if !bytes.Contains(acceptEncoding, []byte("gzip")) { - return false - } - // 检查文件扩展名 if !g.matchExtension(filePath) { return false } - // 检查预压缩文件是否存在 - gzPath := filePath + ".gz" - fullGzPath := filepath.Join(g.root, gzPath) - // 安全检查:防止目录遍历 - if strings.Contains(gzPath, "..") { + if strings.Contains(filePath, "..") { return false } - // 检查文件是否存在 - if _, err := os.Stat(fullGzPath); err != nil { - return false + // 获取 Accept-Encoding 头 + acceptEncoding := ctx.Request.Header.Peek("Accept-Encoding") + + // 按优先级检查预压缩文件 + for _, ext := range g.precompressedExtensions { + // 检查客户端是否支持该编码 + if !supportsEncoding(acceptEncoding, ext) { + continue + } + + // 构建预压缩文件路径 + compressedPath := filePath + ext + fullPath := filepath.Join(g.root, compressedPath) + + // 检查文件是否存在 + if _, err := os.Stat(fullPath); err != nil { + continue + } + + // 设置 Content-Encoding 头 + switch ext { + case ".br": + ctx.Response.Header.Set("Content-Encoding", "br") + case ".gz": + ctx.Response.Header.Set("Content-Encoding", "gzip") + } + ctx.Response.Header.Set("Vary", "Accept-Encoding") + fasthttp.ServeFile(ctx, fullPath) + return true } - // 发送预压缩文件 - ctx.Response.Header.Set("Content-Encoding", "gzip") - ctx.Response.Header.Set("Vary", "Accept-Encoding") - fasthttp.ServeFile(ctx, fullGzPath) - return true + return false } // TryServeFile 尝试发送预压缩文件的静态方法。 @@ -145,3 +161,22 @@ func (g *GzipStatic) Extensions() []string { func DefaultExtensions() []string { return []string{".html", ".css", ".js", ".json", ".xml", ".svg", ".txt"} } + +// supportsEncoding 检查客户端是否支持指定编码。 +// +// 简单检查,忽略 q-value,固定优先级由遍历顺序决定。 +func supportsEncoding(acceptEncoding []byte, ext string) bool { + if len(acceptEncoding) == 0 { + return false + } + enc := strings.ToLower(string(acceptEncoding)) + + switch ext { + case ".br": + return strings.Contains(enc, "br") + case ".gz": + return strings.Contains(enc, "gzip") + default: + return false + } +} diff --git a/internal/middleware/compression/gzip_static_test.go b/internal/middleware/compression/gzip_static_test.go new file mode 100644 index 0000000..60d3d15 --- /dev/null +++ b/internal/middleware/compression/gzip_static_test.go @@ -0,0 +1,481 @@ +// Package compression 提供 gzip_static 预压缩文件测试。 +package compression + +import ( + "os" + "path/filepath" + "testing" + + "github.com/valyala/fasthttp" +) + +// TestGzipStaticServeFile_BrotliPriority 测试 .br 文件优先于 .gz 文件 +func TestGzipStaticServeFile_BrotliPriority(t *testing.T) { + // 创建临时目录 + tmpDir := t.TempDir() + + // 创建 .br 和 .gz 文件 + brFile := filepath.Join(tmpDir, "test.js.br") + gzFile := filepath.Join(tmpDir, "test.js.gz") + + if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil { + t.Fatalf("创建 .br 文件失败: %v", err) + } + if err := os.WriteFile(gzFile, []byte("gz content"), 0644); err != nil { + t.Fatalf("创建 .gz 文件失败: %v", err) + } + + g := NewGzipStatic(true, tmpDir, nil) + + tests := []struct { + name string + acceptEncoding string + wantServed bool + wantEncoding string + }{ + { + name: "同时支持 br 和 gzip,优先返回 br", + acceptEncoding: "br, gzip", + wantServed: true, + wantEncoding: "br", + }, + { + name: "只支持 br", + acceptEncoding: "br", + wantServed: true, + wantEncoding: "br", + }, + { + name: "只支持 gzip", + acceptEncoding: "gzip", + wantServed: true, + wantEncoding: "gzip", + }, + { + name: "支持 deflate 和 gzip,返回 gzip", + acceptEncoding: "deflate, gzip", + wantServed: true, + wantEncoding: "gzip", + }, + { + name: "不支持任何编码", + acceptEncoding: "", + wantServed: false, + wantEncoding: "", + }, + { + name: "支持 br(大小写不敏感)", + acceptEncoding: "BR", + wantServed: true, + wantEncoding: "br", + }, + { + name: "支持 gzip(大小写不敏感)", + acceptEncoding: "GZIP", + wantServed: true, + wantEncoding: "gzip", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.Set("Accept-Encoding", tt.acceptEncoding) + + served := g.ServeFile(ctx, "test.js") + + if served != tt.wantServed { + t.Errorf("ServeFile() = %v, want %v", served, tt.wantServed) + } + + if tt.wantServed { + encoding := ctx.Response.Header.Peek("Content-Encoding") + if string(encoding) != tt.wantEncoding { + t.Errorf("Content-Encoding = %q, want %q", string(encoding), tt.wantEncoding) + } + } + }) + } +} + +// TestGzipStaticServeFile_GzipFallback 测试仅 .gz 存在时的回退 +func TestGzipStaticServeFile_GzipFallback(t *testing.T) { + // 创建临时目录 + tmpDir := t.TempDir() + + // 只创建 .gz 文件 + gzFile := filepath.Join(tmpDir, "test.css.gz") + if err := os.WriteFile(gzFile, []byte("gz content"), 0644); err != nil { + t.Fatalf("创建 .gz 文件失败: %v", err) + } + + g := NewGzipStatic(true, tmpDir, nil) + + tests := []struct { + name string + acceptEncoding string + wantServed bool + wantEncoding string + }{ + { + name: "支持 br 但没有 .br 文件,回退到 gzip", + acceptEncoding: "br, gzip", + wantServed: true, + wantEncoding: "gzip", + }, + { + name: "只支持 gzip", + acceptEncoding: "gzip", + wantServed: true, + wantEncoding: "gzip", + }, + { + name: "只支持 br,没有 .br 文件,不服务", + acceptEncoding: "br", + wantServed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.Set("Accept-Encoding", tt.acceptEncoding) + + served := g.ServeFile(ctx, "test.css") + + if served != tt.wantServed { + t.Errorf("ServeFile() = %v, want %v", served, tt.wantServed) + } + + if tt.wantServed { + encoding := ctx.Response.Header.Peek("Content-Encoding") + if string(encoding) != tt.wantEncoding { + t.Errorf("Content-Encoding = %q, want %q", string(encoding), tt.wantEncoding) + } + } + }) + } +} + +// TestGzipStaticServeFile_AcceptEncodingParsing 测试 Accept-Encoding 头解析 +func TestGzipStaticServeFile_AcceptEncodingParsing(t *testing.T) { + tmpDir := t.TempDir() + + // 创建测试文件 + brFile := filepath.Join(tmpDir, "test.html.br") + if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil { + t.Fatalf("创建 .br 文件失败: %v", err) + } + + g := NewGzipStatic(true, tmpDir, nil) + + tests := []struct { + name string + acceptEncoding string + wantSupported bool + }{ + { + name: "包含 br", + acceptEncoding: "gzip, deflate, br", + wantSupported: true, + }, + { + name: "br 在中间", + acceptEncoding: "gzip, br, deflate", + wantSupported: true, + }, + { + name: "br 在最后", + acceptEncoding: "gzip, deflate,br", + wantSupported: true, + }, + { + name: "包含空格", + acceptEncoding: "gzip, br , deflate", + wantSupported: true, + }, + { + name: "q-value 支持", + acceptEncoding: "br;q=0.9, gzip;q=0.8", + wantSupported: true, + }, + { + name: "没有 br", + acceptEncoding: "gzip, deflate", + wantSupported: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.Set("Accept-Encoding", tt.acceptEncoding) + + served := g.ServeFile(ctx, "test.html") + + if served != tt.wantSupported { + t.Errorf("ServeFile() = %v, want %v", served, tt.wantSupported) + } + }) + } +} + +// TestGzipStaticServeFile_Disabled 测试禁用时不服务 +func TestGzipStaticServeFile_Disabled(t *testing.T) { + tmpDir := t.TempDir() + + // 创建测试文件 + brFile := filepath.Join(tmpDir, "test.js.br") + if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil { + t.Fatalf("创建 .br 文件失败: %v", err) + } + + // 禁用的 GzipStatic + g := NewGzipStatic(false, tmpDir, nil) + + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.Set("Accept-Encoding", "br") + + served := g.ServeFile(ctx, "test.js") + + if served { + t.Error("禁用时不应服务文件") + } +} + +// TestGzipStaticServeFile_InvalidExtension 测试无效扩展名 +func TestGzipStaticServeFile_InvalidExtension(t *testing.T) { + tmpDir := t.TempDir() + + // 创建测试文件 + brFile := filepath.Join(tmpDir, "test.exe.br") + if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil { + t.Fatalf("创建 .br 文件失败: %v", err) + } + + g := NewGzipStatic(true, tmpDir, nil) + + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.Set("Accept-Encoding", "br") + + served := g.ServeFile(ctx, "test.exe") + + if served { + t.Error("无效扩展名不应服务文件") + } +} + +// TestGzipStaticServeFile_PathTraversal 测试路径遍历防护 +func TestGzipStaticServeFile_PathTraversal(t *testing.T) { + tmpDir := t.TempDir() + + // 创建测试文件 + brFile := filepath.Join(tmpDir, "test.js.br") + if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil { + t.Fatalf("创建 .br 文件失败: %v", err) + } + + g := NewGzipStatic(true, tmpDir, nil) + + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.Set("Accept-Encoding", "br") + + // 尝试路径遍历 + served := g.ServeFile(ctx, "../test.js") + + if served { + t.Error("路径遍历应被阻止") + } +} + +// TestGzipStaticServeFile_VaryHeader 测试 Vary 头设置 +func TestGzipStaticServeFile_VaryHeader(t *testing.T) { + tmpDir := t.TempDir() + + // 创建测试文件 + brFile := filepath.Join(tmpDir, "test.js.br") + if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil { + t.Fatalf("创建 .br 文件失败: %v", err) + } + + g := NewGzipStatic(true, tmpDir, nil) + + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.Set("Accept-Encoding", "br") + + g.ServeFile(ctx, "test.js") + + vary := ctx.Response.Header.Peek("Vary") + if string(vary) != "Accept-Encoding" { + t.Errorf("Vary 头 = %q, want %q", string(vary), "Accept-Encoding") + } +} + +// TestNewGzipStatic_DefaultExtensions 测试默认扩展名 +func TestNewGzipStatic_DefaultExtensions(t *testing.T) { + g := NewGzipStatic(true, "/tmp", nil) + + expected := []string{".html", ".css", ".js", ".json", ".xml", ".svg", ".txt"} + got := g.Extensions() + + if len(got) != len(expected) { + t.Errorf("扩展名数量 = %d, want %d", len(got), len(expected)) + } + + for i, ext := range expected { + if got[i] != ext { + t.Errorf("扩展名[%d] = %q, want %q", i, got[i], ext) + } + } +} + +// TestNewGzipStatic_CustomExtensions 测试自定义扩展名 +func TestNewGzipStatic_CustomExtensions(t *testing.T) { + custom := []string{".custom", ".ext"} + g := NewGzipStatic(true, "/tmp", custom) + + got := g.Extensions() + if len(got) != 2 || got[0] != ".custom" || got[1] != ".ext" { + t.Errorf("自定义扩展名 = %v, want %v", got, custom) + } +} + +// TestGzipStatic_Enabled 测试 Enabled 方法 +func TestGzipStatic_Enabled(t *testing.T) { + g1 := NewGzipStatic(true, "/tmp", nil) + if !g1.Enabled() { + t.Error("Enabled() = false, want true") + } + + g2 := NewGzipStatic(false, "/tmp", nil) + if g2.Enabled() { + t.Error("Enabled() = true, want false") + } +} + +// TestDefaultExtensions 测试 DefaultExtensions 函数 +func TestDefaultExtensions(t *testing.T) { + expected := []string{".html", ".css", ".js", ".json", ".xml", ".svg", ".txt"} + got := DefaultExtensions() + + if len(got) != len(expected) { + t.Errorf("默认扩展名数量 = %d, want %d", len(got), len(expected)) + } + + for i, ext := range expected { + if got[i] != ext { + t.Errorf("默认扩展名[%d] = %q, want %q", i, got[i], ext) + } + } +} + +// TestSupportsEncoding 测试 supportsEncoding 函数 +func TestSupportsEncoding(t *testing.T) { + tests := []struct { + name string + acceptEncoding string + wantEncoding string + wantSupported bool + }{ + { + name: "支持 br", + acceptEncoding: "br", + wantEncoding: ".br", + wantSupported: true, + }, + { + name: "支持 gzip", + acceptEncoding: "gzip", + wantEncoding: ".gz", + wantSupported: true, + }, + { + name: "不支持", + acceptEncoding: "deflate", + wantEncoding: ".br", + wantSupported: false, + }, + { + name: "空 Accept-Encoding", + acceptEncoding: "", + wantEncoding: ".br", + wantSupported: false, + }, + { + name: "br 在中间", + acceptEncoding: "gzip, br, deflate", + wantEncoding: ".br", + wantSupported: true, + }, + { + name: "gzip 在中间", + acceptEncoding: "br, gzip, deflate", + wantEncoding: ".gz", + wantSupported: true, + }, + { + name: "大小写不敏感", + acceptEncoding: "BR, GZIP", + wantEncoding: ".br", + wantSupported: true, + }, + { + name: "未知扩展名", + acceptEncoding: "br, gzip", + wantEncoding: ".unknown", + wantSupported: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + supported := supportsEncoding([]byte(tt.acceptEncoding), tt.wantEncoding) + if supported != tt.wantSupported { + t.Errorf("supportsEncoding(%q, %q) = %v, want %v", + tt.acceptEncoding, tt.wantEncoding, supported, tt.wantSupported) + } + }) + } +} + +// TestTryServeFile 测试 TryServeFile 静态方法 +func TestTryServeFile(t *testing.T) { + tmpDir := t.TempDir() + + // 创建测试文件 + brFile := filepath.Join(tmpDir, "test.js.br") + if err := os.WriteFile(brFile, []byte("br content"), 0644); err != nil { + t.Fatalf("创建 .br 文件失败: %v", err) + } + + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.Set("Accept-Encoding", "br") + + served := TryServeFile(ctx, tmpDir, "test.js", nil) + + if !served { + t.Error("TryServeFile() = false, want true") + } + + encoding := ctx.Response.Header.Peek("Content-Encoding") + if string(encoding) != "br" { + t.Errorf("Content-Encoding = %q, want %q", string(encoding), "br") + } +} + +// TestGzipStatic_PrecompressedExtensions 测试预压缩扩展名优先级 +func TestGzipStatic_PrecompressedExtensions(t *testing.T) { + g := NewGzipStatic(true, "/tmp", nil) + + // 验证默认预压缩扩展名顺序 + expected := []string{".br", ".gz"} + if len(g.precompressedExtensions) != len(expected) { + t.Errorf("预压缩扩展名数量 = %d, want %d", len(g.precompressedExtensions), len(expected)) + } + + for i, ext := range expected { + if g.precompressedExtensions[i] != ext { + t.Errorf("预压缩扩展名[%d] = %q, want %q", i, g.precompressedExtensions[i], ext) + } + } +}