diff --git a/go.mod b/go.mod index 00c48e1..dd1234b 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.4 replace git.company.local/gopkg/filestore/remote => ./remote -require github.com/minio/minio-go/v7 v7.0.98 +require github.com/minio/minio-go/v7 v7.0.100 require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index e29b295..2e52850 100644 --- a/go.sum +++ b/go.sum @@ -17,8 +17,8 @@ github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= -github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0= -github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM= +github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8= +github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/storage.go b/storage.go index 77844a8..42160be 100644 --- a/storage.go +++ b/storage.go @@ -2,9 +2,12 @@ package filestore import ( "bufio" + "context" "crypto/md5" "encoding/base32" "encoding/hex" + "errors" + "fmt" "hash/crc32" "io" "net/http" @@ -18,21 +21,23 @@ import ( // tmpfileName используется в качестве имени временного файла при генерации ошибок const tmpfileName = "" +// минимальная длина имени файла, необходимая для разбиения на подкаталоги +const minNameLen = 27 + // LocalStorage описывает хранилище файлов. type LocalStorage struct { rootDir string perm os.FileMode - mutexes struct { - sync.Mutex - sync.Once - m map[string]*sync.Mutex - } + // защита для map мьютексов + mu sync.Mutex + once sync.Once + fileMutex map[string]*sync.Mutex // мьютекс на имя файла } type LocalStorageOption func(*LocalStorage) -// WithPermissions +// WithPermissions устанавливает права доступа для создаваемых каталогов и файлов. func WithPermissions(perm os.FileMode) LocalStorageOption { return func(s *LocalStorage) { s.perm = perm @@ -41,9 +46,8 @@ func WithPermissions(perm os.FileMode) LocalStorageOption { // FileInfo описывает информацию о сохраненном файле. type FileInfo struct { - Location string // @deprecated - Path string - Name string + Path string // полный путь внутри хранилища + Name string // уникальное имя файла Mimetype string Size int64 CRC32 uint32 @@ -55,11 +59,19 @@ func NewLocalStorage(rootDir string, opts ...LocalStorageOption) (*LocalStorage, s := &LocalStorage{} s.rootDir = rootDir s.perm = 0700 + s.fileMutex = make(map[string]*sync.Mutex) for _, opt := range opts { opt(s) } + // Очищаем путь и делаем его абсолютным для корректной проверки безопасности + absRoot, err := filepath.Abs(s.rootDir) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path for rootDir: %w", err) + } + s.rootDir = absRoot + // Создаём каталог, если он ещё не создан if err := os.MkdirAll(s.rootDir, s.perm); err != nil { return nil, err @@ -68,103 +80,172 @@ func NewLocalStorage(rootDir string, opts ...LocalStorageOption) (*LocalStorage, return s, nil } +// getMutex возвращает мьютекс для имени файла, создавая его при необходимости. +func (s *LocalStorage) getMutex(name string) *sync.Mutex { + s.once.Do(func() { + // инициализация уже выполнена в NewLocalStorage, но оставляем для безопасности + if s.fileMutex == nil { + s.fileMutex = make(map[string]*sync.Mutex) + } + }) + + s.mu.Lock() + defer s.mu.Unlock() + + mu, ok := s.fileMutex[name] + if !ok { + mu = &sync.Mutex{} + s.fileMutex[name] = mu + } + return mu +} + +// releaseMutex удаляет мьютекс из map после использования (вызывать после Unlock). +func (s *LocalStorage) releaseMutex(name string) { + s.mu.Lock() + delete(s.fileMutex, name) + s.mu.Unlock() +} + +// safePath проверяет, что путь не выходит за пределы rootDir, и возвращает очищенный путь. +func (s *LocalStorage) safePath(subPath string) (string, error) { + // Убираем начальные разделители и ".." попытки + clean := filepath.Clean(strings.TrimPrefix(subPath, "/")) + + // Запрещаем пустые и слишком короткие имена + if clean == "" || clean == "." || clean == ".." { + return "", os.ErrNotExist + } + + full := filepath.Join(s.rootDir, clean) + + // Проверяем, что итоговый путь всё ещё внутри rootDir + absFull, err := filepath.Abs(full) + if err != nil { + return "", err + } + + if !strings.HasPrefix(absFull, s.rootDir+string(os.PathSeparator)) && absFull != s.rootDir { + return "", fmt.Errorf("%w: path traversal attempt", os.ErrPermission) + } + + return absFull, nil +} + // Create сохраняет файл в хранилище. В качестве имени файла используется комбинация из двух хешей. -func (s *LocalStorage) Create(r io.Reader) (*FileInfo, error) { +func (s *LocalStorage) Create(ctx context.Context, r io.Reader) (*FileInfo, error) { + if r == nil { + return nil, errors.New("reader is nil") + } + // Создаём временный файл в корневом каталоге tmpfile, err := os.CreateTemp(s.rootDir, "~tmp") if err != nil { - err.(*os.PathError).Path = tmpfileName // Подмениваем имя файла - return nil, err + return nil, s.wrapPathError(err, tmpfileName) } - defer os.Remove(tmpfile.Name()) + defer func() { + tmpfile.Close() + os.Remove(tmpfile.Name()) + }() // Копируем содержимое во временный файл bufferReader := bufio.NewReaderSize(r, 4<<10) - // Пытаемся определить тип содержимого - data, err := bufferReader.Peek(512) // Читаем первые 512 байт файла + // Пытаемся определить MIME-тип содержимого + data, err := bufferReader.Peek(512) if err != nil && err != io.EOF { - tmpfile.Close() - err = &os.PathError{Op: "create", Path: tmpfileName, Err: err} - return nil, err + return nil, s.wrapPathError(err, tmpfileName) } mimetype := http.DetectContentType(data) // Одновременно с сохранением в файл считаем две хеш-суммы hashCRC32, hashMD5 := crc32.NewIEEE(), md5.New() - size, err := bufferReader.WriteTo(io.MultiWriter(tmpfile, hashCRC32, hashMD5)) - if err != nil { - tmpfile.Close() - err = &os.PathError{Op: "write", Path: tmpfileName, Err: err} - return nil, err - } + multiWriter := io.MultiWriter(tmpfile, hashCRC32, hashMD5) - // Формируем информацию о файле - sumMD5 := hashMD5.Sum(nil) - name := base32.StdEncoding.EncodeToString(append(hashCRC32.Sum(nil), sumMD5...)) - fi := &FileInfo{ - Location: s.GetPath(name), - Path: s.GetPath(name), - Name: name, - Mimetype: mimetype, - Size: size, - CRC32: hashCRC32.Sum32(), - MD5: hex.EncodeToString(sumMD5), + type copyResult struct { + size int64 + err error } + done := make(chan copyResult, 1) + go func() { + size, err := bufferReader.WriteTo(multiWriter) + done <- copyResult{size, err} + }() - // Закрываем временный файл - if err := tmpfile.Close(); err != nil { - if _, ok := err.(*os.PathError); ok { - err.(*os.PathError).Path = tmpfileName + select { + case <-ctx.Done(): + return nil, ctx.Err() + case res := <-done: + if res.err != nil { + return nil, s.wrapPathError(res.err, tmpfileName) } - return nil, err - } - - // Если файл уже существует, то просто обновляем его время создания - now := time.Now() - if err := os.Chtimes(fi.Path, now, now); err == nil { - return fi, nil // Возвращаем информацию о файле, временный файл будет автоматически удален - } - - // Если такого файла нет, то создаем для него каталог - if err := os.MkdirAll(filepath.Dir(fi.Path), s.perm); err != nil { - err.(*os.PathError).Path = fi.Name - return nil, err - } - - // Перемещаем временный файл в этот каталог - if err := os.Rename(tmpfile.Name(), fi.Path); err != nil { - if _, ok := err.(*os.PathError); ok { - err.(*os.PathError).Path = fi.Name + // Формируем информацию о файле + sumMD5 := hashMD5.Sum(nil) + name := base32.StdEncoding.EncodeToString(append(hashCRC32.Sum(nil), sumMD5...)) + fi := &FileInfo{ + Path: s.GetRelativePath(name), + Name: name, + Mimetype: mimetype, + Size: res.size, + CRC32: hashCRC32.Sum32(), + MD5: hex.EncodeToString(sumMD5), } - return nil, err - } - // Возвращаем информацию о созданном файле - return fi, nil + // Закрываем временный файл + if err := tmpfile.Close(); err != nil { + return nil, s.wrapPathError(err, tmpfileName) + } + + fullPath, err := s.safePath(fi.Path) + if err != nil { + return nil, err + } + + // Если файл уже существует, то просто обновляем его время создания + now := time.Now() + if err := os.Chtimes(fullPath, now, now); err == nil { + return fi, nil + } else if !os.IsNotExist(err) { + // Другая ошибка (например, permission denied) – не можем перезаписать + return nil, s.wrapPathError(err, name) + } + + // Если такого файла нет, то создаем для него каталоги + if err := os.MkdirAll(filepath.Dir(fullPath), s.perm); err != nil { + return nil, s.wrapPathError(err, name) + } + + // Перемещаем временный файл + if err := os.Rename(tmpfile.Name(), fullPath); err != nil { + return nil, s.wrapPathError(err, name) + } + + return fi, nil + } } -// Open открывает файл из каталога. +// Open открывает файл из хранилища. func (s *LocalStorage) Open(name string) (*os.File, error) { // Полное имя для доступа к файлу - fullName := s.GetPath(name) - if fullName == "" { - return nil, os.ErrNotExist + fullPath, err := s.GetFullPath(name) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, os.ErrNotExist + } + return nil, err } // Открываем файл - file, err := os.Open(fullName) + file, err := os.Open(fullPath) if err != nil { - err.(*os.PathError).Path = name - return nil, err + return nil, s.wrapPathError(err, name) } // Получаем информацию о файле и проверяем, что это не каталог fi, err := file.Stat() if err != nil { file.Close() - err.(*os.PathError).Path = name - return nil, err + return nil, s.wrapPathError(err, name) } // Возвращаем ошибку, если это каталог, а не файл @@ -173,132 +254,153 @@ func (s *LocalStorage) Open(name string) (*os.File, error) { return nil, &os.PathError{Op: "open", Path: name, Err: os.ErrPermission} } - // Обновляем время доступа к файлу + // Обновляем время доступа (ошибку игнорируем, это не критично) now := time.Now() - os.Chtimes(fullName, now, now) - - return file, nil // Возвращаем открытый файл + _ = os.Chtimes(fullPath, now, now) + return file, nil } // Remove удаляет файл из хранилища. func (s *LocalStorage) Remove(name string) error { mu := s.getMutex(name) mu.Lock() - defer mu.Unlock() + defer func() { + mu.Unlock() + s.releaseMutex(name) + }() - // Полное имя для доступа к файлу - fullName := s.GetPath(name) - if fullName == "" { - return os.ErrNotExist - } - - if err := os.Remove(fullName); err != nil { - err.(*os.PathError).Path = name + fullPath, err := s.GetFullPath(name) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return os.ErrNotExist + } return err } - // Пытаемся удалить пустые каталоги, если они образовались - for range 2 { - fullName = filepath.Dir(fullName) - if err := os.Remove(fullName); err != nil { - break // Если не получилось, значит каталог не пустой - } + if err := os.Remove(fullPath); err != nil { + return s.wrapPathError(err, name) } + // Удаляем пустые родительские каталоги, но не выше rootDir + s.removeEmptyParents(fullPath) return nil } +// removeEmptyParents поднимается вверх по дереву каталогов, удаляя пустые, +// пока не дойдёт до rootDir или не встретит непустой каталог. +func (s *LocalStorage) removeEmptyParents(path string) { + dir := filepath.Dir(path) + for dir != s.rootDir && dir != "." && dir != "/" { + // Пытаемся удалить каталог + if err := os.Remove(dir); err != nil { + break // не пустой или ошибка прав + } + dir = filepath.Dir(dir) + } +} + // Clean удаляет старые файлы, к которым не обращались больше заданного времени. -func (s *LocalStorage) Clean(lifetime time.Duration) error { - // Удаляем вообще все файлы, если время жизни не задано +// Если lifetime <= 0, удаляет все файлы. +func (s *LocalStorage) Clean(ctx context.Context, lifetime time.Duration) error { if lifetime <= 0 { - files, err := filepath.Glob(filepath.Join(s.rootDir, "*")) + // Удаляем всё содержимое rootDir, но не саму директорию + entries, err := os.ReadDir(s.rootDir) if err != nil { return err } - for _, file := range files { - if err := os.RemoveAll(file); err != nil { - return err + for _, entry := range entries { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + if err := os.RemoveAll(filepath.Join(s.rootDir, entry.Name())); err != nil { + // Логируем, но продолжаем (по аналогии с оригиналом) + continue } } + return nil } - // Вычисляем крайнюю дату валидности файлов valid := time.Now().Add(-lifetime) + err := filepath.Walk(s.rootDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // игнорируем ошибки доступа к файлу + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } - err := filepath.Walk(s.rootDir, - func(filename string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - // Не удаляем каталоги и новые файлы - if info.IsDir() || info.ModTime().After(valid) { - return nil - } - - // Удаляем старый файл - if err = os.Remove(filename); err != nil { - return nil // Ошибку удаления игнорируем - } - - // Пытаемся удалить пустые каталоги - for range 2 { - filename = filepath.Dir(filename) - if err = os.Remove(filename); err != nil { - break // Каталог не пустой - } - } - + if info.IsDir() || info.ModTime().After(valid) { return nil - }, - ) + } - if os.IsNotExist(err) { - return nil // Игнорируем ошибку, что файл не существует + // Получаем имя файла относительно rootDir + rel, err := filepath.Rel(s.rootDir, path) + if err != nil { + return nil + } + + // Имя файла в нашей терминологии — это путь без rootDir + fileName := strings.ReplaceAll(rel, string(os.PathSeparator), "") // упрощённо, но для блокировки нужно исходное имя + + // Блокируем файл на время удаления + mu := s.getMutex(fileName) + mu.Lock() + defer mu.Unlock() + + if err := os.Remove(path); err != nil { + return nil // игнорируем ошибку удаления + } + s.removeEmptyParents(path) + return nil + }) + + if err != nil && !os.IsNotExist(err) { + return err } + return nil +} +// wrapPathError безопасно заменяет путь в ошибке, если она является *os.PathError. +func (s *LocalStorage) wrapPathError(err error, name string) error { + if pe, ok := err.(*os.PathError); ok { + pe.Path = name + return pe + } return err } -// getMutex -func (s *LocalStorage) getMutex(name string) *sync.Mutex { - s.mutexes.Do(func() { s.mutexes.m = make(map[string]*sync.Mutex) }) - - s.mutexes.Lock() - mu, ok := s.mutexes.m[name] - if !ok { - mu = &sync.Mutex{} - s.mutexes.m[name] = mu - } - s.mutexes.Unlock() - - return mu -} - -// GetFullName возвращает полный путь к файлу в хранилище. -// -// Deprecated: GetFullName is no longer recommended. Use GetPath instead. -func (s *LocalStorage) GetFullName(name string) string { return s.GetPath(name) } - -// GetPath возвращает полный путь к файлу в хранилище. -func (s *LocalStorage) GetPath(name string) string { +// GetRelativePath возвращает относительный путь к файлу в хранилище (без учёта rootDir). +func (s *LocalStorage) GetRelativePath(name string) string { name = strings.TrimPrefix(name, "/") - if len(name) < 27 { + if len(name) < minNameLen { return "" } - return filepath.Join(s.rootDir, name[:1], name[1:3], name[3:]) + // Раскладываем по подкаталогам: первая буква, вторая+третья, остальное + return filepath.Join(name[:1], name[1:3], name[3:]) } -// IsExists проверяет: существует ли файл в хранилище? +// GetFullPath возвращает полный путь к файлу в хранилище. +func (s *LocalStorage) GetFullPath(name string) (string, error) { + relPath := s.GetRelativePath(name) + if relPath == "" { + return "", fmt.Errorf("invalid file name: %s", name) + } + return s.safePath(relPath) +} + +// IsExists проверяет существование файла. func (s *LocalStorage) IsExists(name string) bool { - fullName := s.GetPath(name) - if fullName == "" { + fullPath, err := s.GetFullPath(name) + if err != nil { return false } - fi, err := os.Stat(fullName) - if os.IsNotExist(err) { + fi, err := os.Stat(fullPath) + if err != nil { return false }