feat(proxy): 新增代理响应临时文件处理,保护内存
- TempFileHandler 检测响应大小,超过阈值写入临时文件 - TempFileCleaner 定期清理过期临时文件 - 避免 SSR 攻击和内存溢出风险 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1a9059b1ff
commit
d48caf5183
491
internal/proxy/tempfile.go
Normal file
491
internal/proxy/tempfile.go
Normal file
@ -0,0 +1,491 @@
|
||||
// Package proxy 提供反向代理功能的临时文件处理。
|
||||
//
|
||||
// 该文件包含代理响应的临时文件存储功能,用于保护内存:
|
||||
// - 响应大小检测
|
||||
// - 超过阈值时写入临时文件
|
||||
// - 超过最大值时返回 502
|
||||
// - 响应完成后自动清理
|
||||
//
|
||||
// 主要用途:
|
||||
//
|
||||
// 大响应场景下避免内存溢出。
|
||||
//
|
||||
// 注意事项:
|
||||
// - 使用 bodylimit.ParseSize 解析大小字符串
|
||||
// - 临时文件在响应完成后删除
|
||||
// - 无 Content-Length 时动态检测
|
||||
//
|
||||
// 作者:xfy
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
"rua.plus/lolly/internal/middleware/bodylimit"
|
||||
)
|
||||
|
||||
// TempFileManager 临时文件管理器。
|
||||
//
|
||||
// 管理代理响应的临时文件存储,保护内存免受大响应影响。
|
||||
//
|
||||
// 注意事项:
|
||||
// - 阈值和最大值的解析在初始化时完成
|
||||
// - 临时文件在响应后自动删除
|
||||
// - 并发安全
|
||||
type TempFileManager struct {
|
||||
// tempPath 临时文件存储目录
|
||||
tempPath string
|
||||
|
||||
// threshold 触发临时文件的阈值(字节)
|
||||
threshold int64
|
||||
|
||||
// maxSize 最大允许的响应大小(字节)
|
||||
maxSize int64
|
||||
|
||||
// activeFiles 正在使用的临时文件映射
|
||||
activeFiles map[string]*TempFile
|
||||
|
||||
// mu 保护 activeFiles 的互斥锁
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// TempFile 临时文件包装器。
|
||||
//
|
||||
// 包装临时文件,提供便捷的读写和自动清理功能。
|
||||
type TempFile struct {
|
||||
// file 底层文件句柄
|
||||
file *os.File
|
||||
|
||||
// path 文件路径
|
||||
path string
|
||||
|
||||
// size 当前写入大小
|
||||
size int64
|
||||
|
||||
// maxSize 最大允许大小
|
||||
maxSize int64
|
||||
|
||||
// exceeded 是否超过最大大小
|
||||
exceeded bool
|
||||
}
|
||||
|
||||
// NewTempFileManager 创建临时文件管理器。
|
||||
//
|
||||
// 参数:
|
||||
// - tempPath: 临时文件存储目录
|
||||
// - threshold: 触发阈值字符串(如 "1m")
|
||||
// - maxSize: 最大大小字符串(如 "1024m")
|
||||
//
|
||||
// 返回值:
|
||||
// - *TempFileManager: 临时文件管理器实例
|
||||
// - error: 解析大小失败时的错误
|
||||
func NewTempFileManager(tempPath, threshold, maxSize string) (*TempFileManager, error) {
|
||||
// 解析阈值
|
||||
thresholdBytes, err := bodylimit.ParseSize(threshold)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析 temp_file_threshold 失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析最大大小
|
||||
maxSizeBytes, err := bodylimit.ParseSize(maxSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析 max_temp_file_size 失败: %w", err)
|
||||
}
|
||||
|
||||
// 使用默认临时目录
|
||||
if tempPath == "" {
|
||||
tempPath = os.TempDir()
|
||||
}
|
||||
|
||||
// 确保临时目录存在
|
||||
if err := os.MkdirAll(tempPath, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建临时目录失败: %w", err)
|
||||
}
|
||||
|
||||
return &TempFileManager{
|
||||
tempPath: tempPath,
|
||||
threshold: thresholdBytes,
|
||||
maxSize: maxSizeBytes,
|
||||
activeFiles: make(map[string]*TempFile),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ShouldUseTempFile 检查是否应该使用临时文件。
|
||||
//
|
||||
// 参数:
|
||||
// - contentLength: Content-Length 值(-1 表示未知)
|
||||
//
|
||||
// 返回值:
|
||||
// - bool: 是否应使用临时文件
|
||||
func (m *TempFileManager) ShouldUseTempFile(contentLength int64) bool {
|
||||
// 如果已知大小且超过阈值,使用临时文件
|
||||
if contentLength >= 0 && contentLength >= m.threshold {
|
||||
return true
|
||||
}
|
||||
// 未知大小时由调用方决定(动态检测)
|
||||
return false
|
||||
}
|
||||
|
||||
// CreateTempFile 创建临时文件。
|
||||
//
|
||||
// 返回值:
|
||||
// - *TempFile: 临时文件实例
|
||||
// - error: 创建失败时的错误
|
||||
func (m *TempFileManager) CreateTempFile() (*TempFile, error) {
|
||||
// 创建临时文件
|
||||
file, err := os.CreateTemp(m.tempPath, "lolly-proxy-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建临时文件失败: %w", err)
|
||||
}
|
||||
|
||||
tf := &TempFile{
|
||||
file: file,
|
||||
path: file.Name(),
|
||||
maxSize: m.maxSize,
|
||||
}
|
||||
|
||||
// 记录活动文件
|
||||
m.mu.Lock()
|
||||
m.activeFiles[tf.path] = tf
|
||||
m.mu.Unlock()
|
||||
|
||||
return tf, nil
|
||||
}
|
||||
|
||||
// GetThreshold 获取触发阈值。
|
||||
func (m *TempFileManager) GetThreshold() int64 {
|
||||
return m.threshold
|
||||
}
|
||||
|
||||
// GetMaxSize 获取最大大小限制。
|
||||
func (m *TempFileManager) GetMaxSize() int64 {
|
||||
return m.maxSize
|
||||
}
|
||||
|
||||
// RemoveTempFile 移除临时文件记录。
|
||||
//
|
||||
// 参数:
|
||||
// - path: 临时文件路径
|
||||
func (m *TempFileManager) RemoveTempFile(path string) {
|
||||
m.mu.Lock()
|
||||
delete(m.activeFiles, path)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetActiveCount 获取活动临时文件数量。
|
||||
func (m *TempFileManager) GetActiveCount() int {
|
||||
m.mu.RLock()
|
||||
count := len(m.activeFiles)
|
||||
m.mu.RUnlock()
|
||||
return count
|
||||
}
|
||||
|
||||
// Write 写入数据到临时文件。
|
||||
//
|
||||
// 参数:
|
||||
// - data: 要写入的数据
|
||||
//
|
||||
// 返回值:
|
||||
// - int: 写入的字节数
|
||||
// - error: 写入失败或超过最大大小时的错误
|
||||
func (tf *TempFile) Write(data []byte) (int, error) {
|
||||
if tf.exceeded {
|
||||
return 0, fmt.Errorf("response exceeds max_temp_file_size")
|
||||
}
|
||||
|
||||
// 检查是否超过最大大小
|
||||
if tf.size+int64(len(data)) > tf.maxSize {
|
||||
tf.exceeded = true
|
||||
return 0, fmt.Errorf("response exceeds max_temp_file_size")
|
||||
}
|
||||
|
||||
n, err := tf.file.Write(data)
|
||||
tf.size += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// WriteTo 将临时文件内容写入响应。
|
||||
//
|
||||
// 参数:
|
||||
// - ctx: fasthttp 请求上下文
|
||||
// - statusCode: HTTP 状态码
|
||||
//
|
||||
// 返回值:
|
||||
// - error: 写入失败时的错误
|
||||
func (tf *TempFile) WriteTo(ctx *fasthttp.RequestCtx, statusCode int) error {
|
||||
// 关闭文件以便读取
|
||||
if err := tf.file.Close(); err != nil {
|
||||
return fmt.Errorf("关闭临时文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 重新打开文件用于读取
|
||||
file, err := os.Open(tf.path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开临时文件失败: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
// 设置状态码
|
||||
ctx.Response.SetStatusCode(statusCode)
|
||||
|
||||
// 流式传输文件内容
|
||||
buf := make([]byte, 64*1024) // 64KB 缓冲区
|
||||
for {
|
||||
n, err := file.Read(buf)
|
||||
if n > 0 {
|
||||
ctx.Response.AppendBody(buf[:n])
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取临时文件失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭并删除临时文件。
|
||||
func (tf *TempFile) Close() error {
|
||||
if tf.file != nil {
|
||||
_ = tf.file.Close()
|
||||
}
|
||||
if tf.path != "" {
|
||||
_ = os.Remove(tf.path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsExceeded 检查是否超过最大大小。
|
||||
func (tf *TempFile) IsExceeded() bool {
|
||||
return tf.exceeded
|
||||
}
|
||||
|
||||
// GetSize 获取当前写入大小。
|
||||
func (tf *TempFile) GetSize() int64 {
|
||||
return tf.size
|
||||
}
|
||||
|
||||
// GetPath 获取临时文件路径。
|
||||
func (tf *TempFile) GetPath() string {
|
||||
return tf.path
|
||||
}
|
||||
|
||||
// DynamicTempFileWriter 动态检测临时文件写入器。
|
||||
//
|
||||
// 用于未知 Content-Length 时的动态阈值检测。
|
||||
type DynamicTempFileWriter struct {
|
||||
// manager 临时文件管理器
|
||||
manager *TempFileManager
|
||||
|
||||
// tempFile 当前使用的临时文件(如果已切换)
|
||||
tempFile *TempFile
|
||||
|
||||
// buffer 缓冲区(在未超过阈值前使用)
|
||||
buffer []byte
|
||||
|
||||
// totalSize 已接收的总大小
|
||||
totalSize int64
|
||||
|
||||
// threshold 触发阈值
|
||||
threshold int64
|
||||
|
||||
// maxSize 最大大小限制
|
||||
maxSize int64
|
||||
|
||||
// switched 是否已切换到临时文件
|
||||
switched bool
|
||||
|
||||
// exceeded 是否超过最大大小
|
||||
exceeded bool
|
||||
}
|
||||
|
||||
// NewDynamicTempFileWriter 创建动态临时文件写入器。
|
||||
//
|
||||
// 参数:
|
||||
// - manager: 临时文件管理器
|
||||
//
|
||||
// 返回值:
|
||||
// - *DynamicTempFileWriter: 动态写入器实例
|
||||
func NewDynamicTempFileWriter(manager *TempFileManager) *DynamicTempFileWriter {
|
||||
return &DynamicTempFileWriter{
|
||||
manager: manager,
|
||||
buffer: make([]byte, 0, manager.GetThreshold()),
|
||||
threshold: manager.GetThreshold(),
|
||||
maxSize: manager.GetMaxSize(),
|
||||
}
|
||||
}
|
||||
|
||||
// Write 写入数据。
|
||||
//
|
||||
// 参数:
|
||||
// - data: 要写入的数据
|
||||
//
|
||||
// 返回值:
|
||||
// - error: 写入失败或超过最大大小时的错误
|
||||
func (w *DynamicTempFileWriter) Write(data []byte) error {
|
||||
if w.exceeded {
|
||||
return fmt.Errorf("response exceeds max_temp_file_size")
|
||||
}
|
||||
|
||||
dataLen := int64(len(data))
|
||||
|
||||
// 检查是否超过最大大小
|
||||
if w.totalSize+dataLen > w.maxSize {
|
||||
w.exceeded = true
|
||||
return fmt.Errorf("response exceeds max_temp_file_size")
|
||||
}
|
||||
|
||||
// 如果已经切换到临时文件,直接写入
|
||||
if w.switched {
|
||||
_, err := w.tempFile.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查是否需要切换到临时文件
|
||||
if w.totalSize+dataLen >= w.threshold {
|
||||
// 创建临时文件
|
||||
tf, err := w.manager.CreateTempFile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.tempFile = tf
|
||||
w.switched = true
|
||||
|
||||
// 将缓冲区数据写入临时文件
|
||||
if len(w.buffer) > 0 {
|
||||
_, err := w.tempFile.Write(w.buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.buffer = nil // 释放缓冲区
|
||||
}
|
||||
|
||||
// 写入新数据
|
||||
_, err = w.tempFile.Write(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// 继续累积到缓冲区
|
||||
w.buffer = append(w.buffer, data...)
|
||||
}
|
||||
|
||||
w.totalSize += dataLen
|
||||
return nil
|
||||
}
|
||||
|
||||
// Finalize 完成写入并返回结果。
|
||||
//
|
||||
// 参数:
|
||||
// - ctx: fasthttp 请求上下文
|
||||
// - statusCode: HTTP 状态码
|
||||
//
|
||||
// 返回值:
|
||||
// - error: 处理失败时的错误
|
||||
func (w *DynamicTempFileWriter) Finalize(ctx *fasthttp.RequestCtx, statusCode int) error {
|
||||
if w.exceeded {
|
||||
return fmt.Errorf("response exceeds max_temp_file_size")
|
||||
}
|
||||
|
||||
// 设置状态码
|
||||
ctx.Response.SetStatusCode(statusCode)
|
||||
|
||||
// 如果使用了临时文件,流式传输
|
||||
if w.switched && w.tempFile != nil {
|
||||
// 关闭文件以便读取
|
||||
if err := w.tempFile.file.Close(); err != nil {
|
||||
return fmt.Errorf("关闭临时文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 重新打开文件用于读取
|
||||
file, err := os.Open(w.tempFile.path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开临时文件失败: %w", err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
// 流式传输文件内容
|
||||
buf := make([]byte, 64*1024) // 64KB 缓冲区
|
||||
for {
|
||||
n, err := file.Read(buf)
|
||||
if n > 0 {
|
||||
ctx.Response.AppendBody(buf[:n])
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return fmt.Errorf("读取临时文件失败: %w", err)
|
||||
}
|
||||
}
|
||||
_ = file.Close()
|
||||
|
||||
// 删除临时文件
|
||||
_ = os.Remove(w.tempFile.path)
|
||||
w.manager.RemoveTempFile(w.tempFile.path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 未使用临时文件,直接返回缓冲区内容
|
||||
ctx.Response.SetBody(w.buffer)
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsExceeded 检查是否超过最大大小。
|
||||
func (w *DynamicTempFileWriter) IsExceeded() bool {
|
||||
return w.exceeded
|
||||
}
|
||||
|
||||
// GetTotalSize 获取总大小。
|
||||
func (w *DynamicTempFileWriter) GetTotalSize() int64 {
|
||||
return w.totalSize
|
||||
}
|
||||
|
||||
// Cleanup 清理资源。
|
||||
func (w *DynamicTempFileWriter) Cleanup() {
|
||||
if w.tempFile != nil {
|
||||
_ = w.tempFile.Close()
|
||||
if w.tempFile.path != "" {
|
||||
w.manager.RemoveTempFile(w.tempFile.path)
|
||||
}
|
||||
w.tempFile = nil
|
||||
}
|
||||
w.buffer = nil
|
||||
}
|
||||
|
||||
// defaultTempFileManager 默认临时文件管理器(未配置时使用)。
|
||||
var defaultTempFileManager *TempFileManager
|
||||
var defaultTempFileManagerOnce sync.Once
|
||||
|
||||
// GetDefaultTempFileManager 获取默认临时文件管理器。
|
||||
//
|
||||
// 默认配置:
|
||||
// - temp_path: 系统临时目录
|
||||
// - temp_file_threshold: 1m
|
||||
// - max_temp_file_size: 1024m
|
||||
//
|
||||
// 返回值:
|
||||
// - *TempFileManager: 默认临时文件管理器
|
||||
func GetDefaultTempFileManager() *TempFileManager {
|
||||
defaultTempFileManagerOnce.Do(func() {
|
||||
manager, err := NewTempFileManager("", "1m", "1024m")
|
||||
if err != nil {
|
||||
// 默认配置不应该出错,但如果出错则创建一个最小配置
|
||||
manager = &TempFileManager{
|
||||
tempPath: os.TempDir(),
|
||||
threshold: 1 << 20, // 1MB
|
||||
maxSize: 1 << 30, // 1GB
|
||||
activeFiles: make(map[string]*TempFile),
|
||||
}
|
||||
}
|
||||
defaultTempFileManager = manager
|
||||
})
|
||||
return defaultTempFileManager
|
||||
}
|
||||
279
internal/proxy/tempfile_cleaner.go
Normal file
279
internal/proxy/tempfile_cleaner.go
Normal file
@ -0,0 +1,279 @@
|
||||
// Package proxy 提供反向代理功能的临时文件清理。
|
||||
//
|
||||
// 该文件包含孤儿临时文件的清理功能:
|
||||
// - 定期扫描临时目录
|
||||
// - 删除过期的临时文件
|
||||
// - 后台 goroutine 运行
|
||||
//
|
||||
// 主要用途:
|
||||
//
|
||||
// 清理异常退出时遗留的临时文件。
|
||||
//
|
||||
// 注意事项:
|
||||
// - 只清理以 "lolly-proxy-" 前缀开头的文件
|
||||
// - 清理超过 1 小时的文件
|
||||
// - 可通过 stopCh 停止清理器
|
||||
//
|
||||
// 作者:xfy
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TempFileCleaner 临时文件清理器。
|
||||
//
|
||||
// 定期清理临时目录中的孤儿文件。
|
||||
//
|
||||
// 注意事项:
|
||||
// - 清理器在后台运行
|
||||
// - 可通过 Stop 方法停止
|
||||
// - 只清理以 "lolly-proxy-" 前缀开头的文件
|
||||
type TempFileCleaner struct {
|
||||
// tempPath 临时文件目录
|
||||
tempPath string
|
||||
|
||||
// interval 清理间隔
|
||||
interval time.Duration
|
||||
|
||||
// maxAge 文件最大存活时间
|
||||
maxAge time.Duration
|
||||
|
||||
// prefix 临时文件前缀
|
||||
prefix string
|
||||
|
||||
// stopCh 停止信号
|
||||
stopCh chan struct{}
|
||||
|
||||
// stopped 是否已停止
|
||||
stopped bool
|
||||
|
||||
// mu 保护 stopped
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// DefaultCleanupInterval 默认清理间隔(5 分钟)。
|
||||
const DefaultCleanupInterval = 5 * time.Minute
|
||||
|
||||
// DefaultMaxFileAge 默认文件最大存活时间(1 小时)。
|
||||
const DefaultMaxFileAge = time.Hour
|
||||
|
||||
// TempFilePrefix 临时文件前缀。
|
||||
const TempFilePrefix = "lolly-proxy-"
|
||||
|
||||
// NewTempFileCleaner 创建临时文件清理器。
|
||||
//
|
||||
// 参数:
|
||||
// - tempPath: 临时文件目录
|
||||
// - interval: 清理间隔(0 使用默认值 5 分钟)
|
||||
// - maxAge: 文件最大存活时间(0 使用默认值 1 小时)
|
||||
//
|
||||
// 返回值:
|
||||
// - *TempFileCleaner: 清理器实例
|
||||
func NewTempFileCleaner(tempPath string, interval, maxAge time.Duration) *TempFileCleaner {
|
||||
if interval <= 0 {
|
||||
interval = DefaultCleanupInterval
|
||||
}
|
||||
if maxAge <= 0 {
|
||||
maxAge = DefaultMaxFileAge
|
||||
}
|
||||
if tempPath == "" {
|
||||
tempPath = os.TempDir()
|
||||
}
|
||||
|
||||
return &TempFileCleaner{
|
||||
tempPath: tempPath,
|
||||
interval: interval,
|
||||
maxAge: maxAge,
|
||||
prefix: TempFilePrefix,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动清理器。
|
||||
//
|
||||
// 在后台启动一个 goroutine 定期清理临时文件。
|
||||
func (c *TempFileCleaner) Start() {
|
||||
go c.run()
|
||||
}
|
||||
|
||||
// Stop 停止清理器。
|
||||
//
|
||||
// 发送停止信号并等待清理器退出。
|
||||
func (c *TempFileCleaner) Stop() {
|
||||
c.mu.Lock()
|
||||
if c.stopped {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.stopped = true
|
||||
c.mu.Unlock()
|
||||
|
||||
close(c.stopCh)
|
||||
}
|
||||
|
||||
// IsStopped 检查清理器是否已停止。
|
||||
func (c *TempFileCleaner) IsStopped() bool {
|
||||
c.mu.RLock()
|
||||
stopped := c.stopped
|
||||
c.mu.RUnlock()
|
||||
return stopped
|
||||
}
|
||||
|
||||
// run 清理循环。
|
||||
func (c *TempFileCleaner) run() {
|
||||
ticker := time.NewTicker(c.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// 立即执行一次清理
|
||||
c.cleanup()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
c.cleanup()
|
||||
case <-c.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanup 执行一次清理。
|
||||
func (c *TempFileCleaner) cleanup() {
|
||||
// 读取目录
|
||||
entries, err := os.ReadDir(c.tempPath)
|
||||
if err != nil {
|
||||
// 目录读取失败,跳过本次清理
|
||||
return
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-c.maxAge)
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
|
||||
// 检查文件名前缀
|
||||
if !strings.HasPrefix(name, c.prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查文件年龄
|
||||
if info.ModTime().After(cutoff) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 删除过期文件
|
||||
fullPath := filepath.Join(c.tempPath, name)
|
||||
_ = os.Remove(fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
// GetTempPath 获取临时目录路径。
|
||||
func (c *TempFileCleaner) GetTempPath() string {
|
||||
return c.tempPath
|
||||
}
|
||||
|
||||
// GetInterval 获取清理间隔。
|
||||
func (c *TempFileCleaner) GetInterval() time.Duration {
|
||||
return c.interval
|
||||
}
|
||||
|
||||
// GetMaxAge 获取文件最大存活时间。
|
||||
func (c *TempFileCleaner) GetMaxAge() time.Duration {
|
||||
return c.maxAge
|
||||
}
|
||||
|
||||
// CleanupNow 立即执行一次清理(用于测试)。
|
||||
func (c *TempFileCleaner) CleanupNow() {
|
||||
c.cleanup()
|
||||
}
|
||||
|
||||
// CountOrphanFiles 统计孤儿临时文件数量。
|
||||
//
|
||||
// 返回值:
|
||||
// - int: 孤儿文件数量
|
||||
func (c *TempFileCleaner) CountOrphanFiles() int {
|
||||
entries, err := os.ReadDir(c.tempPath)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-c.maxAge)
|
||||
count := 0
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(entry.Name(), c.prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if info.ModTime().Before(cutoff) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// globalCleaner 全局清理器实例。
|
||||
var globalCleaner *TempFileCleaner
|
||||
var globalCleanerMu sync.RWMutex
|
||||
|
||||
// StartGlobalTempFileCleaner 启动全局临时文件清理器。
|
||||
//
|
||||
// 参数:
|
||||
// - tempPath: 临时文件目录
|
||||
func StartGlobalTempFileCleaner(tempPath string) {
|
||||
globalCleanerMu.Lock()
|
||||
defer globalCleanerMu.Unlock()
|
||||
|
||||
if globalCleaner != nil {
|
||||
globalCleaner.Stop()
|
||||
}
|
||||
|
||||
globalCleaner = NewTempFileCleaner(tempPath, 0, 0)
|
||||
globalCleaner.Start()
|
||||
}
|
||||
|
||||
// StopGlobalTempFileCleaner 停止全局临时文件清理器。
|
||||
func StopGlobalTempFileCleaner() {
|
||||
globalCleanerMu.Lock()
|
||||
defer globalCleanerMu.Unlock()
|
||||
|
||||
if globalCleaner != nil {
|
||||
globalCleaner.Stop()
|
||||
globalCleaner = nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetGlobalTempFileCleaner 获取全局临时文件清理器。
|
||||
//
|
||||
// 返回值:
|
||||
// - *TempFileCleaner: 全局清理器实例(可能为 nil)
|
||||
func GetGlobalTempFileCleaner() *TempFileCleaner {
|
||||
globalCleanerMu.RLock()
|
||||
defer globalCleanerMu.RUnlock()
|
||||
return globalCleaner
|
||||
}
|
||||
542
internal/proxy/tempfile_test.go
Normal file
542
internal/proxy/tempfile_test.go
Normal file
@ -0,0 +1,542 @@
|
||||
// Package proxy 提供临时文件处理功能的测试。
|
||||
//
|
||||
// 该文件测试临时文件模块的各项功能,包括:
|
||||
// - 临时文件管理器创建
|
||||
// - 阈值判定逻辑
|
||||
// - 临时文件写入
|
||||
// - 动态检测切换
|
||||
// - 超过最大大小处理
|
||||
// - 清理功能
|
||||
//
|
||||
// 作者:xfy
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// TestNewTempFileManager 测试临时文件管理器创建
|
||||
func TestNewTempFileManager(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tempPath string
|
||||
threshold string
|
||||
maxSize string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "正常创建",
|
||||
tempPath: t.TempDir(),
|
||||
threshold: "1mb",
|
||||
maxSize: "1024mb",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "使用默认临时目录",
|
||||
tempPath: "",
|
||||
threshold: "1mb",
|
||||
maxSize: "1024mb",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "无效阈值格式",
|
||||
tempPath: t.TempDir(),
|
||||
threshold: "invalid",
|
||||
maxSize: "1024mb",
|
||||
wantErr: true,
|
||||
errContains: "temp_file_threshold",
|
||||
},
|
||||
{
|
||||
name: "无效最大大小格式",
|
||||
tempPath: t.TempDir(),
|
||||
threshold: "1mb",
|
||||
maxSize: "invalid",
|
||||
wantErr: true,
|
||||
errContains: "max_temp_file_size",
|
||||
},
|
||||
{
|
||||
name: "使用字节单位",
|
||||
tempPath: t.TempDir(),
|
||||
threshold: "1048576",
|
||||
maxSize: "1073741824",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "使用 kb 单位",
|
||||
tempPath: t.TempDir(),
|
||||
threshold: "1024kb",
|
||||
maxSize: "1048576kb",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "使用 gb 单位",
|
||||
tempPath: t.TempDir(),
|
||||
threshold: "0.001gb",
|
||||
maxSize: "1gb",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
manager, err := NewTempFileManager(tt.tempPath, tt.threshold, tt.maxSize)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("NewTempFileManager() 期望错误但未返回")
|
||||
return
|
||||
}
|
||||
if tt.errContains != "" && !strContains(err.Error(), tt.errContains) {
|
||||
t.Errorf("NewTempFileManager() 错误 = %v, 应包含 %q", err, tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("NewTempFileManager() 意外错误 = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if manager == nil {
|
||||
t.Error("NewTempFileManager() 返回 nil")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证阈值和最大值
|
||||
if manager.GetThreshold() <= 0 {
|
||||
t.Error("GetThreshold() 应返回正数")
|
||||
}
|
||||
if manager.GetMaxSize() <= 0 {
|
||||
t.Error("GetMaxSize() 应返回正数")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTempFileManager_ShouldUseTempFile 测试阈值判定
|
||||
func TestTempFileManager_ShouldUseTempFile(t *testing.T) {
|
||||
manager, err := NewTempFileManager(t.TempDir(), "1mb", "1024mb")
|
||||
if err != nil {
|
||||
t.Fatalf("创建管理器失败: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
contentLength int64
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "正好等于阈值",
|
||||
contentLength: 1 << 20, // 1MB
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "超过阈值",
|
||||
contentLength: 2 << 20, // 2MB
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "低于阈值",
|
||||
contentLength: 512 << 10, // 512KB
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "未知大小",
|
||||
contentLength: -1,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "零大小",
|
||||
contentLength: 0,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := manager.ShouldUseTempFile(tt.contentLength)
|
||||
if got != tt.want {
|
||||
t.Errorf("ShouldUseTempFile(%d) = %v, want %v", tt.contentLength, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTempFile_Write 测试临时文件写入
|
||||
func TestTempFile_Write(t *testing.T) {
|
||||
manager, err := NewTempFileManager(t.TempDir(), "1mb", "10mb")
|
||||
if err != nil {
|
||||
t.Fatalf("创建管理器失败: %v", err)
|
||||
}
|
||||
|
||||
t.Run("正常写入", func(t *testing.T) {
|
||||
tf, err := manager.CreateTempFile()
|
||||
if err != nil {
|
||||
t.Fatalf("创建临时文件失败: %v", err)
|
||||
}
|
||||
defer func() { _ = tf.Close() }()
|
||||
|
||||
data := []byte("test data")
|
||||
n, err := tf.Write(data)
|
||||
if err != nil {
|
||||
t.Errorf("Write() 错误 = %v", err)
|
||||
return
|
||||
}
|
||||
if n != len(data) {
|
||||
t.Errorf("Write() 写入字节数 = %d, want %d", n, len(data))
|
||||
}
|
||||
if tf.GetSize() != int64(len(data)) {
|
||||
t.Errorf("GetSize() = %d, want %d", tf.GetSize(), len(data))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("超过最大大小", func(t *testing.T) {
|
||||
// 创建小阈值管理器
|
||||
smallManager, err := NewTempFileManager(t.TempDir(), "1mb", "100b")
|
||||
if err != nil {
|
||||
t.Fatalf("创建管理器失败: %v", err)
|
||||
}
|
||||
|
||||
tf, err := smallManager.CreateTempFile()
|
||||
if err != nil {
|
||||
t.Fatalf("创建临时文件失败: %v", err)
|
||||
}
|
||||
defer func() { _ = tf.Close() }()
|
||||
|
||||
data := make([]byte, 200) // 超过 100b
|
||||
_, err = tf.Write(data)
|
||||
if err == nil {
|
||||
t.Error("Write() 应返回错误(超过最大大小)")
|
||||
}
|
||||
if !tf.IsExceeded() {
|
||||
t.Error("IsExceeded() 应为 true")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTempFile_WriteTo 测试临时文件写入响应
|
||||
func TestTempFile_WriteTo(t *testing.T) {
|
||||
manager, err := NewTempFileManager(t.TempDir(), "1mb", "10mb")
|
||||
if err != nil {
|
||||
t.Fatalf("创建管理器失败: %v", err)
|
||||
}
|
||||
|
||||
tf, err := manager.CreateTempFile()
|
||||
if err != nil {
|
||||
t.Fatalf("创建临时文件失败: %v", err)
|
||||
}
|
||||
defer func() { _ = tf.Close() }()
|
||||
|
||||
// 写入测试数据
|
||||
data := []byte("response body content")
|
||||
_, err = tf.Write(data)
|
||||
if err != nil {
|
||||
t.Fatalf("写入数据失败: %v", err)
|
||||
}
|
||||
|
||||
// 写入响应
|
||||
ctx := &fasthttp.RequestCtx{}
|
||||
err = tf.WriteTo(ctx, 200)
|
||||
if err != nil {
|
||||
t.Errorf("WriteTo() 错误 = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证状态码
|
||||
if ctx.Response.StatusCode() != 200 {
|
||||
t.Errorf("StatusCode() = %d, want 200", ctx.Response.StatusCode())
|
||||
}
|
||||
|
||||
// 验证内容
|
||||
body := string(ctx.Response.Body())
|
||||
if body != "response body content" {
|
||||
t.Errorf("Body() = %q, want %q", body, "response body content")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTempFile_Close 测试临时文件关闭和清理
|
||||
func TestTempFile_Close(t *testing.T) {
|
||||
manager, err := NewTempFileManager(t.TempDir(), "1mb", "10mb")
|
||||
if err != nil {
|
||||
t.Fatalf("创建管理器失败: %v", err)
|
||||
}
|
||||
|
||||
tf, err := manager.CreateTempFile()
|
||||
if err != nil {
|
||||
t.Fatalf("创建临时文件失败: %v", err)
|
||||
}
|
||||
|
||||
path := tf.GetPath()
|
||||
|
||||
// 关闭文件
|
||||
err = tf.Close()
|
||||
if err != nil {
|
||||
t.Errorf("Close() 错误 = %v", err)
|
||||
}
|
||||
|
||||
// 验证文件已删除
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Error("Close() 后文件应被删除")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDynamicTempFileWriter 测试动态临时文件写入器
|
||||
func TestDynamicTempFileWriter(t *testing.T) {
|
||||
t.Run("小响应使用缓冲区", func(t *testing.T) {
|
||||
manager, err := NewTempFileManager(t.TempDir(), "1mb", "10mb")
|
||||
if err != nil {
|
||||
t.Fatalf("创建管理器失败: %v", err)
|
||||
}
|
||||
|
||||
writer := NewDynamicTempFileWriter(manager)
|
||||
defer writer.Cleanup()
|
||||
|
||||
// 写入小数据(低于阈值)
|
||||
data := []byte("small data")
|
||||
err = writer.Write(data)
|
||||
if err != nil {
|
||||
t.Errorf("Write() 错误 = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证最终化
|
||||
ctx := &fasthttp.RequestCtx{}
|
||||
err = writer.Finalize(ctx, 200)
|
||||
if err != nil {
|
||||
t.Errorf("Finalize() 错误 = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证内容
|
||||
body := string(ctx.Response.Body())
|
||||
if body != "small data" {
|
||||
t.Errorf("Body() = %q, want %q", body, "small data")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("大响应切换到临时文件", func(t *testing.T) {
|
||||
manager, err := NewTempFileManager(t.TempDir(), "100b", "10mb")
|
||||
if err != nil {
|
||||
t.Fatalf("创建管理器失败: %v", err)
|
||||
}
|
||||
|
||||
writer := NewDynamicTempFileWriter(manager)
|
||||
defer writer.Cleanup()
|
||||
|
||||
// 写入大数据(超过阈值)
|
||||
data := make([]byte, 200)
|
||||
for i := range data {
|
||||
data[i] = byte(i % 256)
|
||||
}
|
||||
err = writer.Write(data)
|
||||
if err != nil {
|
||||
t.Errorf("Write() 错误 = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证最终化
|
||||
ctx := &fasthttp.RequestCtx{}
|
||||
err = writer.Finalize(ctx, 200)
|
||||
if err != nil {
|
||||
t.Errorf("Finalize() 错误 = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证内容
|
||||
body := ctx.Response.Body()
|
||||
if len(body) != 200 {
|
||||
t.Errorf("Body() 长度 = %d, want 200", len(body))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("超过最大大小返回错误", func(t *testing.T) {
|
||||
manager, err := NewTempFileManager(t.TempDir(), "1mb", "100b")
|
||||
if err != nil {
|
||||
t.Fatalf("创建管理器失败: %v", err)
|
||||
}
|
||||
|
||||
writer := NewDynamicTempFileWriter(manager)
|
||||
defer writer.Cleanup()
|
||||
|
||||
// 写入超过最大值的数据
|
||||
data := make([]byte, 200)
|
||||
err = writer.Write(data)
|
||||
if err == nil {
|
||||
t.Error("Write() 应返回错误(超过最大大小)")
|
||||
}
|
||||
if !writer.IsExceeded() {
|
||||
t.Error("IsExceeded() 应为 true")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetDefaultTempFileManager 测试默认临时文件管理器
|
||||
func TestGetDefaultTempFileManager(t *testing.T) {
|
||||
manager1 := GetDefaultTempFileManager()
|
||||
if manager1 == nil {
|
||||
t.Fatal("GetDefaultTempFileManager() 返回 nil")
|
||||
}
|
||||
|
||||
// 验证单例
|
||||
manager2 := GetDefaultTempFileManager()
|
||||
if manager1 != manager2 {
|
||||
t.Error("GetDefaultTempFileManager() 应返回相同的实例")
|
||||
}
|
||||
|
||||
// 验证默认配置
|
||||
if manager1.GetThreshold() != 1<<20 {
|
||||
t.Errorf("默认阈值 = %d, want %d", manager1.GetThreshold(), 1<<20)
|
||||
}
|
||||
if manager1.GetMaxSize() != 1<<30 {
|
||||
t.Errorf("默认最大大小 = %d, want %d", manager1.GetMaxSize(), 1<<30)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTempFileCleaner 测试临时文件清理器
|
||||
func TestTempFileCleaner(t *testing.T) {
|
||||
t.Run("创建和启动", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
cleaner := NewTempFileCleaner(tempDir, time.Second, time.Second)
|
||||
|
||||
if cleaner.GetTempPath() != tempDir {
|
||||
t.Errorf("GetTempPath() = %s, want %s", cleaner.GetTempPath(), tempDir)
|
||||
}
|
||||
|
||||
cleaner.Start()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if cleaner.IsStopped() {
|
||||
t.Error("Start() 后 IsStopped() 应为 false")
|
||||
}
|
||||
|
||||
cleaner.Stop()
|
||||
|
||||
if !cleaner.IsStopped() {
|
||||
t.Error("Stop() 后 IsStopped() 应为 true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("清理过期文件", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// 创建一个过期的临时文件
|
||||
oldFile := filepath.Join(tempDir, TempFilePrefix+"old")
|
||||
if err := os.WriteFile(oldFile, []byte("old"), 0644); err != nil {
|
||||
t.Fatalf("创建测试文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 修改文件修改时间为过去
|
||||
oldTime := time.Now().Add(-2 * time.Hour)
|
||||
if err := os.Chtimes(oldFile, oldTime, oldTime); err != nil {
|
||||
t.Fatalf("修改文件时间失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建一个非过期的临时文件
|
||||
newFile := filepath.Join(tempDir, TempFilePrefix+"new")
|
||||
if err := os.WriteFile(newFile, []byte("new"), 0644); err != nil {
|
||||
t.Fatalf("创建测试文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 执行清理(1 小时过期时间)
|
||||
cleaner := NewTempFileCleaner(tempDir, time.Hour, time.Hour)
|
||||
cleaner.CleanupNow()
|
||||
|
||||
// 验证过期文件被删除
|
||||
if _, err := os.Stat(oldFile); !os.IsNotExist(err) {
|
||||
t.Error("过期文件应被删除")
|
||||
}
|
||||
|
||||
// 验证新文件保留
|
||||
if _, err := os.Stat(newFile); err != nil {
|
||||
t.Error("新文件应被保留")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("不清理非 lolly 前缀文件", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// 创建一个非 lolly 前缀的文件
|
||||
otherFile := filepath.Join(tempDir, "other-file")
|
||||
if err := os.WriteFile(otherFile, []byte("other"), 0644); err != nil {
|
||||
t.Fatalf("创建测试文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 修改文件修改时间为过去
|
||||
oldTime := time.Now().Add(-2 * time.Hour)
|
||||
if err := os.Chtimes(otherFile, oldTime, oldTime); err != nil {
|
||||
t.Fatalf("修改文件时间失败: %v", err)
|
||||
}
|
||||
|
||||
// 执行清理
|
||||
cleaner := NewTempFileCleaner(tempDir, time.Hour, time.Hour)
|
||||
cleaner.CleanupNow()
|
||||
|
||||
// 验证非 lolly 文件保留
|
||||
if _, err := os.Stat(otherFile); err != nil {
|
||||
t.Error("非 lolly 前缀文件应被保留")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("统计孤儿文件", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// 创建孤儿文件
|
||||
orphanFile := filepath.Join(tempDir, TempFilePrefix+"orphan")
|
||||
if err := os.WriteFile(orphanFile, []byte("orphan"), 0644); err != nil {
|
||||
t.Fatalf("创建测试文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 修改文件修改时间为过去
|
||||
oldTime := time.Now().Add(-2 * time.Hour)
|
||||
if err := os.Chtimes(orphanFile, oldTime, oldTime); err != nil {
|
||||
t.Fatalf("修改文件时间失败: %v", err)
|
||||
}
|
||||
|
||||
cleaner := NewTempFileCleaner(tempDir, time.Hour, time.Hour)
|
||||
count := cleaner.CountOrphanFiles()
|
||||
if count != 1 {
|
||||
t.Errorf("CountOrphanFiles() = %d, want 1", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestGlobalTempFileCleaner 测试全局临时文件清理器
|
||||
func TestGlobalTempFileCleaner(t *testing.T) {
|
||||
// 启动全局清理器
|
||||
StartGlobalTempFileCleaner(os.TempDir())
|
||||
|
||||
// 验证已启动
|
||||
cleaner := GetGlobalTempFileCleaner()
|
||||
if cleaner == nil {
|
||||
t.Error("GetGlobalTempFileCleaner() 不应返回 nil")
|
||||
}
|
||||
|
||||
// 停止全局清理器
|
||||
StopGlobalTempFileCleaner()
|
||||
|
||||
// 验证已停止
|
||||
cleaner = GetGlobalTempFileCleaner()
|
||||
if cleaner != nil {
|
||||
t.Error("StopGlobalTempFileCleaner() 后 GetGlobalTempFileCleaner() 应返回 nil")
|
||||
}
|
||||
}
|
||||
|
||||
// strContains 检查字符串是否包含子串
|
||||
func strContains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && strContainsHelper(s, substr))
|
||||
}
|
||||
|
||||
func strContainsHelper(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user