package storage_test import ( "bytes" "errors" "mime/multipart" "os" "path/filepath" "testing" "github.com/EthanCodeCraft/xlgo-core/config" "github.com/EthanCodeCraft/xlgo-core/storage" ) // makeFileHeader 用 multipart 真实构造一个 *multipart.FileHeader,content 为文件内容。 func makeFileHeader(t *testing.T, field, filename, contentType string, content []byte) *multipart.FileHeader { t.Helper() body := &bytes.Buffer{} w := multipart.NewWriter(body) h := make(map[string][]string) h["Content-Disposition"] = []string{`form-data; name="` + field + `"; filename="` + filename + `"`} h["Content-Type"] = []string{contentType} part, err := w.CreatePart(h) if err != nil { t.Fatalf("CreatePart: %v", err) } if _, err := part.Write(content); err != nil { t.Fatalf("part.Write: %v", err) } if err := w.Close(); err != nil { t.Fatalf("writer.Close: %v", err) } r := multipart.NewReader(body, w.Boundary()) form, err := r.ReadForm(int64(len(content) + 1024)) if err != nil { t.Fatalf("ReadForm: %v", err) } defer form.RemoveAll() if len(form.File[field]) == 0 { t.Fatalf("no file field %q in form", field) } return form.File[field][0] } func newLocalStorageWithPolicy(t *testing.T, policy config.UploadPolicy, maxRead int64) *storage.LocalStorage { t.Helper() dir := t.TempDir() return storage.NewLocalStorage(&config.LocalStorageConfig{ Path: dir, BaseURL: "http://localhost/uploads", Upload: policy, MaxReadBytes: maxRead, }) } // ===== C4a:路径穿越 ===== // 回归 C4a:Delete/Get/Exists 的 `..` 路径必须被拒绝,不能触碰根目录之外的文件。 func TestLocalStoragePathTraversal(t *testing.T) { dir := t.TempDir() s := storage.NewLocalStorage(&config.LocalStorageConfig{Path: dir, BaseURL: "http://localhost/uploads"}) // 在 TempDir 之外放一个蜜罐文件,确保穿越不会删/读它。 sibling := filepath.Join(filepath.Dir(dir), "xlgo_c4_canary_"+filepath.Base(dir)+".txt") if err := os.WriteFile(sibling, []byte("canary"), 0644); err != nil { t.Fatalf("write canary: %v", err) } defer os.Remove(sibling) // 用 `..` 指向 canary(dir 的父目录下)。 escapeRel := "../" + filepath.Base(sibling) // Delete 必须拒绝 if err := s.Delete(escapeRel); !errors.Is(err, storage.ErrPathTraversal) { t.Errorf("Delete(%q) err = %v, want ErrPathTraversal", escapeRel, err) } // canary 必须仍然存在(未被删除) if _, err := os.Stat(sibling); err != nil { t.Errorf("canary file was deleted by traversal Delete: %v", err) } // Get 必须拒绝 if _, err := s.Get(escapeRel); !errors.Is(err, storage.ErrPathTraversal) { t.Errorf("Get(%q) err = %v, want ErrPathTraversal", escapeRel, err) } // Exists 必须返回 (false, ErrPathTraversal)(而非穿越探测到 canary) if ok, err := s.Exists(escapeRel); ok || !errors.Is(err, storage.ErrPathTraversal) { t.Errorf("Exists(%q) = (%v, %v), want (false, ErrPathTraversal)", escapeRel, ok, err) } // 绝对路径也必须拒绝 abs := sibling if err := s.Delete(abs); !errors.Is(err, storage.ErrPathTraversal) { t.Errorf("Delete(absolute %q) err = %v, want ErrPathTraversal", abs, err) } } // 回归 C4a:正常相对路径不受误伤(合法用法回归)。 func TestLocalStorageNormalPathStillWorks(t *testing.T) { dir := t.TempDir() s := storage.NewLocalStorage(&config.LocalStorageConfig{Path: dir, BaseURL: "http://localhost/uploads"}) // 直接在 root 下放一个文件,用正常相对路径访问。 target := filepath.Join(dir, "sub", "ok.txt") if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(target, []byte("hello"), 0644); err != nil { t.Fatalf("write: %v", err) } rel := filepath.ToSlash(filepath.Join("sub", "ok.txt")) if ok, err := s.Exists(rel); err != nil || !ok { t.Errorf("Exists(normal) = (%v, %v), want (true, nil)", ok, err) } data, err := s.Get(rel) if err != nil { t.Errorf("Get(normal) err = %v", err) } if string(data) != "hello" { t.Errorf("Get(normal) = %q, want 'hello'", string(data)) } if err := s.Delete(rel); err != nil { t.Errorf("Delete(normal) err = %v", err) } } // 回归 C4a:Upload 的 subdir 含 `..` 必须拒绝,且不在根目录外创建文件。 func TestLocalStorageUploadTraversalSubdir(t *testing.T) { dir := t.TempDir() s := storage.NewLocalStorage(&config.LocalStorageConfig{Path: dir, BaseURL: "http://localhost/uploads"}) fh := makeFileHeader(t, "file", "ok.txt", "text/plain", []byte("hi")) if _, err := s.Upload(fh, "../evil"); !errors.Is(err, storage.ErrPathTraversal) { t.Errorf("Upload subdir ../ err = %v, want ErrPathTraversal", err) } // 根目录之外不应出现 evil 目录 evilDir := filepath.Join(filepath.Dir(dir), "evil") if _, err := os.Stat(evilDir); err == nil { t.Errorf("traversal Upload created dir outside root: %s", evilDir) } // 绝对路径 subdir 也拒绝 if _, err := s.Upload(fh, dir); !errors.Is(err, storage.ErrPathTraversal) { t.Errorf("Upload absolute subdir err = %v, want ErrPathTraversal", err) } } // 回归 C4a:UploadFromBytes 的 subdir 含 `..` 必须拒绝。 func TestLocalStorageUploadFromBytesTraversalSubdir(t *testing.T) { dir := t.TempDir() s := storage.NewLocalStorage(&config.LocalStorageConfig{Path: dir, BaseURL: "http://localhost/uploads"}) if _, err := s.UploadFromBytes([]byte("hi"), "ok.txt", "../../etc"); !errors.Is(err, storage.ErrPathTraversal) { t.Errorf("UploadFromBytes subdir ../../etc err = %v, want ErrPathTraversal", err) } } // ===== C4c:Get 读封顶 ===== // 回归 C4c:MaxReadBytes 封顶,超限文件 Get 返回错误,不再 OOM。 func TestLocalStorageGetReadLimit(t *testing.T) { s := newLocalStorageWithPolicy(t, config.UploadPolicy{}, 10) // 限制 10 字节 // MaxReadBytes=10 的实例不限上传;上传 20 字节后 Get 应被封顶拒绝。 content := []byte("0123456789ABCDEFGHIJ") // 20 字节 fh := makeFileHeader(t, "file", "big.txt", "text/plain", content) rel, err := s.Upload(fh, "docs") if err != nil { t.Fatalf("Upload big file: %v", err) } if _, err := s.Get(rel); !errors.Is(err, storage.ErrReadTooLarge) { t.Errorf("Get over-limit err = %v, want ErrReadTooLarge", err) } // 小文件应正常读取。 small := newLocalStorageWithPolicy(t, config.UploadPolicy{}, 100) fh2 := makeFileHeader(t, "file", "small.txt", "text/plain", []byte("small")) rel2, err := small.Upload(fh2, "docs") if err != nil { t.Fatalf("Upload small: %v", err) } data, err := small.Get(rel2) if err != nil { t.Errorf("Get small err = %v", err) } if string(data) != "small" { t.Errorf("Get small = %q, want 'small'", string(data)) } } // ===== C4b:上传策略 ===== // 回归 C4b:MaxSizeBytes 超限拒绝。 func TestLocalStorageUploadSizeLimit(t *testing.T) { s := newLocalStorageWithPolicy(t, config.UploadPolicy{MaxSizeBytes: 5}, 0) fh := makeFileHeader(t, "file", "big.txt", "text/plain", []byte("0123456789")) // 10 字节 if _, err := s.Upload(fh, "docs"); !errors.Is(err, storage.ErrUploadTooLarge) { t.Errorf("Upload over size err = %v, want ErrUploadTooLarge", err) } } // 回归 C4b:AllowedExts 白名单——不允许的扩展名拒绝,允许的通过。 func TestLocalStorageUploadExtWhitelist(t *testing.T) { s := newLocalStorageWithPolicy(t, config.UploadPolicy{AllowedExts: []string{".jpg"}}, 0) // evil.php 拒绝 fh := makeFileHeader(t, "file", "evil.php", "text/plain", []byte("