security: remediate review B findings

This commit is contained in:
kory
2026-10-03 01:37:02 +08:00
parent ca4d54d5b3
commit 56b05b11d9
11 changed files with 674 additions and 35 deletions
+106
View File
@@ -0,0 +1,106 @@
package api
import (
"image"
"image/color"
"image/png"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"chfs-ng/internal/auth"
"chfs-ng/internal/storage"
)
func TestAuditExistEnforcesReadPermission(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "secret.txt"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.None}}}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/chfs/exist?file=secret.txt", nil)
s.exist(rr, req)
if rr.Code != 403 {
t.Fatalf("exist status=%d want 403", rr.Code)
}
}
func TestAuditThumbnailRejectsOversizeBeforeDecode(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "big.bin"), make([]byte, 64), 0600); err != nil {
t.Fatal(err)
}
s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.Read}}, ImagePreview: true, ThumbnailMaxSize: 32}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/chfs/thumbnail?file=big.bin", nil)
s.thumbnail(rr, req)
if rr.Code != 413 {
t.Fatalf("thumbnail status=%d want 413", rr.Code)
}
}
func TestAuditZipLimitsUseOriginalFileSizes(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "a.txt"), make([]byte, 100), 0600); err != nil {
t.Fatal(err)
}
s := &Server{Store: storage.Local{Root: root}, ZipMaxSize: 99, ZipMaxFiles: 10}
a := &auth.Account{Mask: auth.Read}
if err := s.checkZipLimits(a, []string{""}); err == nil {
t.Fatal("expected zip size limit")
}
}
func TestAuditSearchLimit(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"a.txt", "b.txt", "c.txt"} {
if err := os.WriteFile(filepath.Join(root, name), []byte(name), 0600); err != nil {
t.Fatal(err)
}
}
s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.Read}}, SearchMaxResults: 2}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/chfs/search?str=.txt", nil)
s.search(rr, req)
if rr.Code != 200 {
t.Fatalf("search status=%d", rr.Code)
}
if len(rr.Body.Bytes()) == 0 || !contains(rr.Body.String(), `"truncated":true`) {
t.Fatalf("search did not report truncation: %s", rr.Body.String())
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}())
}
func TestAuditThumbnailPixelLimit(t *testing.T) {
root := t.TempDir()
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
img.Set(0, 0, color.White)
f, err := os.Create(filepath.Join(root, "large.png"))
if err != nil {
t.Fatal(err)
}
if err := png.Encode(f, img); err != nil {
f.Close()
t.Fatal(err)
}
f.Close()
s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.Read}}, ImagePreview: true, ThumbnailMaxPixels: 100}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/chfs/thumbnail?file=large.png", nil)
s.thumbnail(rr, req)
if rr.Code != 415 {
t.Fatalf("thumbnail pixel-limit status=%d want 415", rr.Code)
}
}
+176 -11
View File
@@ -47,6 +47,7 @@ type Server struct {
WebDAVRoot string
WebDAVFS webdav.FileSystem
Allow string
TrustedProxies string
MultiRoot bool
RemoveMode int
ImagePreview bool
@@ -56,6 +57,14 @@ type Server struct {
Log *opslog.Logger
UserStore *auth.UserStore
UserHome string
ThumbnailMaxSize int64
ThumbnailMaxPixels int64
ZipMaxSize int64
ZipMaxFiles int
UploadMaxSize int64
UploadMaxTotalSize int64
UploadMaxFiles int
SearchMaxResults int
ThumbnailCache *thumbnailCache
thumbnailCacheOnce sync.Once
}
@@ -138,11 +147,11 @@ func (s *Server) logOp(r *http.Request, action string) {
if a != nil {
user = a.Username
}
_ = s.Log.Write(clientIPString(r), user, action)
_ = s.Log.Write(s.clientIPString(r), user, action)
}
func clientIPString(r *http.Request) string {
ip := clientIP(r)
func (s *Server) clientIPString(r *http.Request) string {
ip := s.clientIP(r)
if ip == nil {
return "-"
}
@@ -516,6 +525,10 @@ func (s *Server) exist(w http.ResponseWriter, r *http.Request) {
return
}
p := strings.Trim(strings.ReplaceAll(raw, "\\", "/"), "/")
if !account(r, s).MaskFor(p).HasRead() {
writeJSONError(w, http.StatusForbidden, "has no privilege")
return
}
_, e := s.Store.Stat(p)
if e != nil {
w.WriteHeader(http.StatusNotFound)
@@ -639,7 +652,7 @@ func (s *Server) password(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, http.StatusBadRequest, "invalid json")
return
}
if a.Password != req.CurrentPassword && a.PasswordHash != auth.PasswordHash(req.CurrentPassword) {
if a.Password != req.CurrentPassword && !auth.VerifyPasswordHash(a.PasswordHash, req.CurrentPassword) {
writeJSONError(w, http.StatusBadRequest, "current password incorrect")
return
}
@@ -855,8 +868,24 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
// This prevents duplicate/bad entries from leaving a partial upload.
seen := make(map[string]struct{})
var saved []string
var totalUpload int64
var uploadFiles int
for _, hs := range r.MultipartForm.File {
for _, h := range hs {
uploadFiles++
if s.UploadMaxFiles > 0 && uploadFiles > s.UploadMaxFiles {
http.Error(w, "upload file count limit exceeded", http.StatusRequestEntityTooLarge)
return
}
if s.UploadMaxSize > 0 && h.Size > s.UploadMaxSize {
http.Error(w, "upload file too large", http.StatusRequestEntityTooLarge)
return
}
totalUpload += h.Size
if s.UploadMaxTotalSize > 0 && totalUpload > s.UploadMaxTotalSize {
http.Error(w, "upload total size limit exceeded", http.StatusRequestEntityTooLarge)
return
}
name := filepath.ToSlash(h.Filename)
if name == "" || strings.HasPrefix(name, "/") || unsafePath(name) {
http.Error(w, "invalid filename", 400)
@@ -881,7 +910,7 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
for _, hs := range r.MultipartForm.File {
for _, h := range hs {
target := path.Join(dir, filepath.ToSlash(h.Filename))
if e := savePart(s.Store, target, h); e != nil {
if e := savePart(s.Store, target, h, s.UploadMaxSize); e != nil {
for _, previous := range saved {
_ = s.Store.Remove(previous)
}
@@ -908,7 +937,7 @@ func createExclusive(st storage.Storage, target string) (*os.File, error) {
return st.Create(target)
}
func savePart(st storage.Storage, target string, h *multipart.FileHeader) error {
func savePart(st storage.Storage, target string, h *multipart.FileHeader, maxSize int64) error {
in, e := h.Open()
if e != nil {
return e
@@ -919,8 +948,18 @@ func savePart(st storage.Storage, target string, h *multipart.FileHeader) error
return e
}
copyErr := func() error {
_, err := io.Copy(f, in)
return err
var src io.Reader = in
if maxSize > 0 {
src = io.LimitReader(in, maxSize+1)
}
n, err := io.Copy(f, src)
if err != nil {
return err
}
if maxSize > 0 && n > maxSize {
return fmt.Errorf("upload file too large")
}
return nil
}()
closeErr := f.Close()
if copyErr != nil {
@@ -953,8 +992,24 @@ func (s *Server) upload2(w http.ResponseWriter, r *http.Request) {
// Preflight the complete multipart batch before creating anything.
// This prevents duplicate/bad entries from leaving a partial upload.
seen := make(map[string]struct{})
var totalUpload int64
var uploadFiles int
for _, hs := range r.MultipartForm.File {
for _, h := range hs {
uploadFiles++
if s.UploadMaxFiles > 0 && uploadFiles > s.UploadMaxFiles {
http.Error(w, "upload file count limit exceeded", http.StatusRequestEntityTooLarge)
return
}
if s.UploadMaxSize > 0 && h.Size > s.UploadMaxSize {
http.Error(w, "upload file too large", http.StatusRequestEntityTooLarge)
return
}
totalUpload += h.Size
if s.UploadMaxTotalSize > 0 && totalUpload > s.UploadMaxTotalSize {
http.Error(w, "upload total size limit exceeded", http.StatusRequestEntityTooLarge)
return
}
name := filepath.ToSlash(h.Filename)
name = strings.TrimPrefix(name, "/")
if name == "" || unsafePath(name) {
@@ -981,7 +1036,7 @@ func (s *Server) upload2(w http.ResponseWriter, r *http.Request) {
for _, h := range hs {
name := strings.TrimPrefix(filepath.ToSlash(h.Filename), "/")
target := path.Join(dir, name)
if err := savePart(s.Store, target, h); err != nil {
if err := savePart(s.Store, target, h, s.UploadMaxSize); err != nil {
for _, previous := range saved {
_ = s.Store.Remove(previous)
}
@@ -1015,6 +1070,11 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) {
}
needle := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("str")))
out := make([]searchFileInfo, 0)
maxResults := s.SearchMaxResults
if maxResults < 0 {
maxResults = 0
}
truncated := false
var walk func(string) error
walk = func(dir string) error {
items, err := s.Store.List(dir)
@@ -1022,6 +1082,10 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) {
return err
}
for _, fi := range items {
if maxResults > 0 && len(out) >= maxResults {
truncated = true
return nil
}
p := path.Join(dir, fi.Name())
if !a.MaskFor(p).HasRead() {
continue
@@ -1041,7 +1105,7 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), 500)
return
}
writeJSON(w, map[string]any{"virtualRoot": 0, "currentMask": permString(rootMask), "files": out})
writeJSON(w, map[string]any{"virtualRoot": 0, "currentMask": permString(rootMask), "files": out, "truncated": truncated})
}
func sharedPath(parts ...string) string {
@@ -1183,6 +1247,10 @@ func (s *Server) thumbnail(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
if s.ThumbnailMaxSize > 0 && fi.Size() > s.ThumbnailMaxSize {
writeJSONError(w, http.StatusRequestEntityTooLarge, "thumbnail file too large")
return
}
key := thumbnailCacheKey{Path: file, Size: fi.Size(), ModTime: fi.ModTime().UnixNano()}
s.thumbnailCacheOnce.Do(func() {
if s.ThumbnailCache == nil {
@@ -1194,6 +1262,12 @@ func (s *Server) thumbnail(w http.ResponseWriter, r *http.Request) {
if err != nil {
return nil, err
}
if s.ThumbnailMaxPixels > 0 {
b := src.Bounds()
if int64(b.Dx())*int64(b.Dy()) > s.ThumbnailMaxPixels {
return nil, fmt.Errorf("thumbnail dimensions too large")
}
}
thumb := transform.Resize(src, 320, 240, transform.Linear)
var buf bytes.Buffer
if err := png.Encode(&buf, thumb); err != nil {
@@ -1287,6 +1361,62 @@ func (s *Server) qrcode(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(data)
}
func (s *Server) zipStats(a *auth.Account, roots []string) (int64, int, error) {
var total int64
files := 0
seen := map[string]bool{}
var walk func(string) error
walk = func(p string) error {
if seen[p] {
return nil
}
seen[p] = true
fi, err := s.Store.Stat(p)
if err != nil {
return err
}
if !a.MaskFor(p).HasRead() {
return nil
}
if !fi.IsDir() {
files++
total += fi.Size()
return nil
}
items, err := s.Store.List(p)
if err != nil {
return err
}
for _, it := range items {
child := path.Join(p, it.Name())
if err := walk(child); err != nil {
return err
}
}
return nil
}
for _, root := range roots {
if err := walk(root); err != nil {
return 0, 0, err
}
}
return total, files, nil
}
func (s *Server) checkZipLimits(a *auth.Account, roots []string) error {
total, files, err := s.zipStats(a, roots)
if err != nil {
return err
}
if s.ZipMaxFiles > 0 && files > s.ZipMaxFiles {
return fmt.Errorf("zip file count limit exceeded")
}
if s.ZipMaxSize > 0 && total > s.ZipMaxSize {
return fmt.Errorf("zip size limit exceeded")
}
return nil
}
func (s *Server) downloadSelectedZip(w http.ResponseWriter, r *http.Request) {
a := account(r, s)
paths := r.URL.Query()["path"]
@@ -1312,6 +1442,10 @@ func (s *Server) downloadSelectedZip(w http.ResponseWriter, r *http.Request) {
return
}
}
if err := s.checkZipLimits(a, paths); err != nil {
writeJSONError(w, http.StatusRequestEntityTooLarge, err.Error())
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="download.zip"`)
zw := zip.NewWriter(w)
@@ -1403,6 +1537,10 @@ func (s *Server) downloadDir(w http.ResponseWriter, r *http.Request) {
}
}
}
if err := s.checkZipLimits(a, []string{p}); err != nil {
writeJSONError(w, http.StatusRequestEntityTooLarge, err.Error())
return
}
name := fi.Name()
if name == "." || name == "" {
name = "download"
@@ -2064,7 +2202,7 @@ func (f *webdavAuthFS) checkTree(ctx context.Context, name string, p auth.PermMa
func (s *Server) ipFilter(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.Allow != "" && !allowedIP(s.Allow, clientIP(r)) {
if s.Allow != "" && !allowedIP(s.Allow, s.clientIP(r)) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
@@ -2072,6 +2210,33 @@ func (s *Server) ipFilter(next http.Handler) http.Handler {
})
}
func (s *Server) clientIP(r *http.Request) net.IP {
host, _, _ := net.SplitHostPort(r.RemoteAddr)
remote := net.ParseIP(host)
if remote == nil {
remote = net.ParseIP(r.RemoteAddr)
}
if remote == nil || s.TrustedProxies == "" || !allowedIP(s.TrustedProxies, remote) {
return remote
}
// Only a trusted proxy may supply the forwarding chain. Walk right-to-left
// and return the first address that is not itself a trusted proxy.
parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
for i := len(parts) - 1; i >= 0; i-- {
ip := net.ParseIP(strings.TrimSpace(parts[i]))
if ip == nil {
continue
}
if !allowedIP(s.TrustedProxies, ip) {
return ip
}
}
if v := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); v != nil && !allowedIP(s.TrustedProxies, v) {
return v
}
return remote
}
func clientIP(r *http.Request) net.IP {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
+21 -3
View File
@@ -8,6 +8,7 @@ import (
"time"
jwt "github.com/golang-jwt/jwt/v4"
"golang.org/x/crypto/bcrypt"
)
type PermMask uint8
@@ -86,8 +87,10 @@ func ParseRules(values []string) (*Manager, error) {
}
func (m *Manager) Authenticate(user, pwd string) *Account {
if a, ok := m.Accounts[user]; ok && (a.Enabled || a.PasswordHash == "") && (a.Password == pwd || a.PasswordHash == PasswordHash(pwd)) {
return a
if a, ok := m.Accounts[user]; ok && (a.Enabled || a.PasswordHash == "") {
if a.Password == pwd || VerifyPasswordHash(a.PasswordHash, pwd) {
return a
}
}
if user == "" && pwd == "" {
return m.Guest
@@ -95,8 +98,23 @@ func (m *Manager) Authenticate(user, pwd string) *Account {
return nil
}
func PasswordHash(password string) string {
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return ""
}
return string(h)
}
func VerifyPasswordHash(stored, password string) bool {
if stored == "" {
return false
}
if strings.HasPrefix(stored, "$2a$") || strings.HasPrefix(stored, "$2b$") || strings.HasPrefix(stored, "$2y$") {
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(password)) == nil
}
// Legacy unsalted SHA-256 hashes remain readable for one migration cycle.
h := sha256.Sum256([]byte(password))
return hex.EncodeToString(h[:])
return hex.EncodeToString(h[:]) == stored
}
func (a *Account) MaskFor(path string) PermMask {
+31
View File
@@ -0,0 +1,31 @@
package auth
import (
"crypto/sha256"
"encoding/hex"
"testing"
)
func TestPasswordHashUsesBcryptAndVerifies(t *testing.T) {
hash := PasswordHash("correct horse battery staple")
if len(hash) < 4 || (hash[0:4] != "$2a$" && hash[0:4] != "$2b$") {
t.Fatalf("hash=%q is not bcrypt", hash)
}
if !VerifyPasswordHash(hash, "correct horse battery staple") {
t.Fatal("bcrypt password did not verify")
}
if VerifyPasswordHash(hash, "wrong") {
t.Fatal("wrong password verified")
}
}
func TestLegacySHA256PasswordStillVerifies(t *testing.T) {
sum := sha256.Sum256([]byte("legacy-pass"))
hash := hex.EncodeToString(sum[:])
if !VerifyPasswordHash(hash, "legacy-pass") {
t.Fatal("legacy hash did not verify")
}
if VerifyPasswordHash(hash, "wrong") {
t.Fatal("wrong legacy password verified")
}
}
+61 -18
View File
@@ -20,27 +20,36 @@ type AccountConfig struct {
}
type Config struct {
Path []string
Port int
Allow string
Rules []string
Log string
FileRemove int
ImagePreview bool
FolderDownload string
SessionTimeout int
JWTSecret string
HTMLTitle string
HTMLNotice string
UserFile string
UserHome string
SSLCert string
SSLKey string
Accounts []AccountConfig
Path []string
Port int
Allow string
TrustedProxies string
Rules []string
Log string
FileRemove int
ImagePreview bool
FolderDownload string
SessionTimeout int
JWTSecret string
HTMLTitle string
HTMLNotice string
UserFile string
UserHome string
ThumbnailMaxSize int64
ThumbnailMaxPixels int64
ZipMaxSize int64
ZipMaxFiles int
UploadMaxSize int64
UploadMaxTotalSize int64
UploadMaxFiles int
SearchMaxResults int
SSLCert string
SSLKey string
Accounts []AccountConfig
}
func Default() Config {
return Config{Path: []string{"."}, Port: 80, FileRemove: 1, ImagePreview: true, FolderDownload: "enable", SessionTimeout: 30, UserHome: "users"}
return Config{Path: []string{"."}, Port: 80, FileRemove: 1, ImagePreview: true, FolderDownload: "enable", SessionTimeout: 30, UserHome: "users", ThumbnailMaxSize: 32 << 20, ThumbnailMaxPixels: 40_000_000, ZipMaxSize: 2 << 30, ZipMaxFiles: 5000, UploadMaxSize: 2 << 30, UploadMaxTotalSize: 4 << 30, UploadMaxFiles: 100, SearchMaxResults: 500}
}
func Load(path string) (Config, error) {
@@ -101,6 +110,8 @@ func Load(path string) (Config, error) {
}
case "allow":
c.Allow = val
case "trusted-proxies":
c.TrustedProxies = val
case "rule":
if val != "" {
c.Rules = append(c.Rules, val)
@@ -133,6 +144,38 @@ func Load(path string) (Config, error) {
c.UserFile = val
case "user.home":
c.UserHome = strings.Trim(val, "/")
case "thumbnail.max-size":
if n, e := strconv.ParseInt(val, 10, 64); e == nil && n >= 0 {
c.ThumbnailMaxSize = n
}
case "thumbnail.max-pixels":
if n, e := strconv.ParseInt(val, 10, 64); e == nil && n >= 0 {
c.ThumbnailMaxPixels = n
}
case "zip.max-size":
if n, e := strconv.ParseInt(val, 10, 64); e == nil && n >= 0 {
c.ZipMaxSize = n
}
case "zip.max-files":
if n, e := strconv.Atoi(val); e == nil && n >= 0 {
c.ZipMaxFiles = n
}
case "upload.max-size":
if n, e := strconv.ParseInt(val, 10, 64); e == nil && n >= 0 {
c.UploadMaxSize = n
}
case "upload.max-total-size":
if n, e := strconv.ParseInt(val, 10, 64); e == nil && n >= 0 {
c.UploadMaxTotalSize = n
}
case "upload.max-files":
if n, e := strconv.Atoi(val); e == nil && n >= 0 {
c.UploadMaxFiles = n
}
case "search.max-results":
if n, e := strconv.Atoi(val); e == nil && n >= 0 {
c.SearchMaxResults = n
}
case "ssl.cert":
c.SSLCert = val
case "ssl.key":
+43 -1
View File
@@ -150,7 +150,49 @@ func (s Local) Trash(path string) error {
if e != nil {
return e
}
return wastebasket.Trash(p)
if err := wastebasket.Trash(p); err != nil {
return err
}
_ = cleanupSystemTrash(30 * 24 * time.Hour)
return nil
}
func cleanupSystemTrash(maxAge time.Duration) error {
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return err
}
base = filepath.Join(home, ".local", "share")
}
root := filepath.Join(base, "Trash")
filesDir := filepath.Join(root, "files")
infoDir := filepath.Join(root, "info")
now := time.Now()
entries, err := os.ReadDir(infoDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".trashinfo" {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if now.Sub(info.ModTime()) <= maxAge {
continue
}
name := strings.TrimSuffix(entry.Name(), ".trashinfo")
_ = os.Remove(filepath.Join(infoDir, entry.Name()))
_ = os.RemoveAll(filepath.Join(filesDir, name))
}
return nil
}
func (s Local) MoveToTrash(path, trashRoot string) error {
+42
View File
@@ -0,0 +1,42 @@
package storage
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestCleanupSystemTrashRemovesOldEntries(t *testing.T) {
data := t.TempDir()
t.Setenv("XDG_DATA_HOME", data)
files := filepath.Join(data, "Trash", "files")
info := filepath.Join(data, "Trash", "info")
if err := os.MkdirAll(files, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(info, 0755); err != nil {
t.Fatal(err)
}
oldInfo := filepath.Join(info, "old.txt.trashinfo")
oldFile := filepath.Join(files, "old.txt")
if err := os.WriteFile(oldInfo, []byte("[Trash Info]\n"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(oldFile, []byte("old"), 0600); err != nil {
t.Fatal(err)
}
old := time.Now().Add(-31 * 24 * time.Hour)
if err := os.Chtimes(oldInfo, old, old); err != nil {
t.Fatal(err)
}
if err := cleanupSystemTrash(30 * 24 * time.Hour); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(oldInfo); !os.IsNotExist(err) {
t.Fatalf("old info remains: %v", err)
}
if _, err := os.Stat(oldFile); !os.IsNotExist(err) {
t.Fatalf("old file remains: %v", err)
}
}