alpha
This commit is contained in:
7
common/errors.go
Normal file
7
common/errors.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAccessDenied = errors.New("access denied")
|
||||||
|
)
|
||||||
13
go.mod
13
go.mod
@@ -2,4 +2,15 @@ module git.gm6.ru/icewind/pfs
|
|||||||
|
|
||||||
go 1.25.4
|
go 1.25.4
|
||||||
|
|
||||||
require github.com/google/uuid v1.6.0
|
require (
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
gorm.io/driver/sqlite v1.6.0
|
||||||
|
gorm.io/gorm v1.31.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
|
golang.org/x/text v0.20.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
12
go.sum
12
go.sum
@@ -1,2 +1,14 @@
|
|||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||||
|
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||||
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
|
|||||||
16
ifs.go
16
ifs.go
@@ -1,25 +1,15 @@
|
|||||||
package models
|
package pfs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.gm6.ru/icewind/pfs/models"
|
"git.gm6.ru/icewind/pfs/models"
|
||||||
|
"git.gm6.ru/icewind/pfs/stat"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IFSStat struct {
|
|
||||||
ID uuid.UUID
|
|
||||||
Name string
|
|
||||||
IsDir bool
|
|
||||||
Created time.Time
|
|
||||||
LastModified time.Time
|
|
||||||
OwnerID uuid.UUID
|
|
||||||
}
|
|
||||||
|
|
||||||
type IFS interface {
|
type IFS interface {
|
||||||
DirCreate(dir *models.Dir, userID uuid.UUID) error
|
DirCreate(dir *models.Dir, userID uuid.UUID) error
|
||||||
DirRemove(dirID uuid.UUID, userID uuid.UUID) error
|
DirRemove(dirID uuid.UUID, userID uuid.UUID) error
|
||||||
DirRead(dirID uuid.UUID, userID uuid.UUID) ([]IFSStat, error)
|
DirRead(dirID uuid.UUID, userID uuid.UUID) ([]stat.FSStat, error)
|
||||||
FileCreate(file *models.File) error
|
FileCreate(file *models.File) error
|
||||||
FileRemove(fileID uuid.UUID, userID uuid.UUID) error
|
FileRemove(fileID uuid.UUID, userID uuid.UUID) error
|
||||||
FileRead(fileID uuid.UUID, userID uuid.UUID) (*models.File, error)
|
FileRead(fileID uuid.UUID, userID uuid.UUID) (*models.File, error)
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "github.com/google/uuid"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
type Dir struct {
|
type Dir struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ParentID *uuid.UUID `json:"parent_id"`
|
ParentID *uuid.UUID `json:"parent_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
OwnerID uuid.UUID `json:"owner_id"`
|
OwnerID uuid.UUID `json:"owner_id"`
|
||||||
IsPublic bool `json:"is_public"`
|
IsPublic bool `json:"is_public"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
LastModified time.Time `json:"last_modified"`
|
||||||
}
|
}
|
||||||
|
|||||||
64
pfs_db/models.go
Normal file
64
pfs_db/models.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package pfs_db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DirModel struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
||||||
|
ParentID *uuid.UUID `gorm:"type:uuid;index"`
|
||||||
|
Parent *DirModel `gorm:"foreignKey:ParentID;constraint:OnDelete:CASCADE"`
|
||||||
|
|
||||||
|
Name string
|
||||||
|
OwnerID uuid.UUID
|
||||||
|
IsPublic bool
|
||||||
|
Created time.Time
|
||||||
|
LastModified time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DirModel) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
now := time.Now()
|
||||||
|
d.Created = now
|
||||||
|
d.LastModified = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DirModel) BeforeUpdate(tx *gorm.DB) error {
|
||||||
|
d.LastModified = time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileModel struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
||||||
|
DirID *uuid.UUID `gorm:"type:uuid;index"`
|
||||||
|
Dir *DirModel `gorm:"foreignKey:DirID;constraint:OnDelete:CASCADE"`
|
||||||
|
|
||||||
|
Name string
|
||||||
|
OwnerID uuid.UUID
|
||||||
|
IsPublic bool
|
||||||
|
Created time.Time
|
||||||
|
LastModified time.Time
|
||||||
|
|
||||||
|
FileData FileDataModel `gorm:"foreignKey:FileID;references:ID;constraint:OnDelete:CASCADE"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FileModel) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
now := time.Now()
|
||||||
|
f.Created = now
|
||||||
|
f.LastModified = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FileModel) BeforeUpdate(tx *gorm.DB) error {
|
||||||
|
f.LastModified = time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileDataModel struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
||||||
|
FileID uuid.UUID `gorm:"type:uuid;uniqueIndex;not null"`
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
301
pfs_db/pfs_db.go
Normal file
301
pfs_db/pfs_db.go
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
package pfs_db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.gm6.ru/icewind/pfs/common"
|
||||||
|
"git.gm6.ru/icewind/pfs/models"
|
||||||
|
"git.gm6.ru/icewind/pfs/stat"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PFSDB struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// ==================== DIR ====================
|
||||||
|
//
|
||||||
|
|
||||||
|
func (i *PFSDB) DirCreate(dir *models.Dir, userID uuid.UUID) error {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Проверяем ownership родителя (если есть)
|
||||||
|
if dir.ParentID != nil {
|
||||||
|
var parent DirModel
|
||||||
|
err := i.DB.First(
|
||||||
|
&parent,
|
||||||
|
"id = ? AND owner_id = ?",
|
||||||
|
*dir.ParentID,
|
||||||
|
userID,
|
||||||
|
).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return common.ErrAccessDenied
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbDir := DirModel{
|
||||||
|
ID: dir.ID,
|
||||||
|
ParentID: dir.ParentID,
|
||||||
|
Name: dir.Name,
|
||||||
|
OwnerID: dir.OwnerID,
|
||||||
|
IsPublic: dir.IsPublic,
|
||||||
|
Created: now,
|
||||||
|
LastModified: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
return i.DB.Create(&dbDir).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *PFSDB) DirRemove(dirID uuid.UUID, userID uuid.UUID) error {
|
||||||
|
var dir DirModel
|
||||||
|
|
||||||
|
if err := i.DB.First(
|
||||||
|
&dir,
|
||||||
|
"id = ? AND owner_id = ?",
|
||||||
|
dirID,
|
||||||
|
userID,
|
||||||
|
).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade делает БД
|
||||||
|
return i.DB.Delete(&dir).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *PFSDB) DirRead(dirID uuid.UUID, userID uuid.UUID) ([]stat.FSStat, error) {
|
||||||
|
var result []stat.FSStat
|
||||||
|
|
||||||
|
// Поддиректории
|
||||||
|
var dirs []DirModel
|
||||||
|
if err := i.DB.
|
||||||
|
Where("parent_id = ? AND owner_id = ?", dirID, userID).
|
||||||
|
Order("name").
|
||||||
|
Find(&dirs).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range dirs {
|
||||||
|
result = append(result, stat.FSStat{
|
||||||
|
ID: d.ID,
|
||||||
|
Name: d.Name,
|
||||||
|
IsDir: true,
|
||||||
|
Created: d.Created,
|
||||||
|
LastModified: d.LastModified,
|
||||||
|
OwnerID: d.OwnerID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Файлы
|
||||||
|
var files []FileModel
|
||||||
|
if err := i.DB.
|
||||||
|
Where("dir_id = ? AND owner_id = ?", dirID, userID).
|
||||||
|
Order("name").
|
||||||
|
Find(&files).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
result = append(result, stat.FSStat{
|
||||||
|
ID: f.ID,
|
||||||
|
Name: f.Name,
|
||||||
|
IsDir: false,
|
||||||
|
Created: f.Created,
|
||||||
|
LastModified: f.LastModified,
|
||||||
|
OwnerID: f.OwnerID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// ==================== FILE ====================
|
||||||
|
//
|
||||||
|
|
||||||
|
func (i *PFSDB) FileCreate(file *models.File) error {
|
||||||
|
file.ID = uuid.New()
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
return i.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
dbFile := FileModel{
|
||||||
|
ID: file.ID,
|
||||||
|
DirID: file.DirID,
|
||||||
|
Name: file.Name,
|
||||||
|
OwnerID: file.OwnerID,
|
||||||
|
IsPublic: file.IsPublic,
|
||||||
|
Created: now,
|
||||||
|
LastModified: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(&dbFile).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dbFileData := FileDataModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
FileID: file.ID,
|
||||||
|
Data: file.Data,
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Create(&dbFileData).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *PFSDB) FileRemove(fileID uuid.UUID, userID uuid.UUID) error {
|
||||||
|
var file FileModel
|
||||||
|
|
||||||
|
if err := i.DB.First(
|
||||||
|
&file,
|
||||||
|
"id = ? AND owner_id = ?",
|
||||||
|
fileID,
|
||||||
|
userID,
|
||||||
|
).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileData удалится через CASCADE
|
||||||
|
return i.DB.Delete(&file).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *PFSDB) FileRead(fileID uuid.UUID, userID uuid.UUID) (*models.File, error) {
|
||||||
|
var file FileModel
|
||||||
|
|
||||||
|
if err := i.DB.First(&file, "id = ?", fileID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !file.IsPublic && file.OwnerID != userID {
|
||||||
|
return nil, common.ErrAccessDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileData FileDataModel
|
||||||
|
if err := i.DB.First(
|
||||||
|
&fileData,
|
||||||
|
"file_id = ?",
|
||||||
|
fileID,
|
||||||
|
).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.File{
|
||||||
|
ID: file.ID,
|
||||||
|
DirID: file.DirID,
|
||||||
|
Name: file.Name,
|
||||||
|
OwnerID: file.OwnerID,
|
||||||
|
IsPublic: file.IsPublic,
|
||||||
|
Data: fileData.Data,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitPath(path string) []string {
|
||||||
|
path = strings.Trim(path, "/")
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return strings.Split(path, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *PFSDB) ResolveDirByPath(
|
||||||
|
path string,
|
||||||
|
userID uuid.UUID,
|
||||||
|
) (uuid.UUID, error) {
|
||||||
|
|
||||||
|
parts := splitPath(path)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return uuid.Nil, errors.New("empty path")
|
||||||
|
}
|
||||||
|
|
||||||
|
var current DirModel
|
||||||
|
|
||||||
|
// --- ищем root ---
|
||||||
|
if err := i.DB.Where(
|
||||||
|
"name = ? AND parent_id IS NULL AND owner_id = ?",
|
||||||
|
parts[0],
|
||||||
|
userID,
|
||||||
|
).First(¤t).Error; err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- идём по дереву ---
|
||||||
|
for _, name := range parts[1:] {
|
||||||
|
var next DirModel
|
||||||
|
|
||||||
|
err := i.DB.Where(
|
||||||
|
"name = ? AND parent_id = ? AND owner_id = ?",
|
||||||
|
name,
|
||||||
|
current.ID,
|
||||||
|
userID,
|
||||||
|
).First(&next).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return current.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *PFSDB) ResolveFileByPath(
|
||||||
|
path string,
|
||||||
|
userID uuid.UUID,
|
||||||
|
) (uuid.UUID, error) {
|
||||||
|
|
||||||
|
parts := splitPath(path)
|
||||||
|
if len(parts) < 1 {
|
||||||
|
return uuid.Nil, errors.New("invalid path")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := parts[len(parts)-1]
|
||||||
|
dirPath := parts[:len(parts)-1]
|
||||||
|
|
||||||
|
var dirID *uuid.UUID
|
||||||
|
|
||||||
|
// если файл в root
|
||||||
|
if len(dirPath) > 0 {
|
||||||
|
id, err := i.ResolveDirByPath(
|
||||||
|
"/"+strings.Join(dirPath, "/"),
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
dirID = &id
|
||||||
|
}
|
||||||
|
|
||||||
|
var file FileModel
|
||||||
|
|
||||||
|
err := i.DB.Where(
|
||||||
|
"name = ? AND dir_id = ? AND owner_id = ?",
|
||||||
|
fileName,
|
||||||
|
dirID,
|
||||||
|
userID,
|
||||||
|
).First(&file).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return file.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// ==================== MIGRATION ====================
|
||||||
|
//
|
||||||
|
|
||||||
|
func (i *PFSDB) Migrate() error {
|
||||||
|
return i.DB.AutoMigrate(
|
||||||
|
&DirModel{},
|
||||||
|
&FileModel{},
|
||||||
|
&FileDataModel{},
|
||||||
|
)
|
||||||
|
}
|
||||||
450
pfs_db/pfs_db_test.go
Normal file
450
pfs_db/pfs_db_test.go
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
package pfs_db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
//
|
||||||
|
// ==================== TEST SETUP ====================
|
||||||
|
//
|
||||||
|
|
||||||
|
func newTestDB(t *testing.T) *PFSDB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := gorm.Open(
|
||||||
|
sqlite.Open("file::memory:?cache=shared&_foreign_keys=on"),
|
||||||
|
&gorm.Config{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get sql db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 КРИТИЧНО для in-memory SQLite
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
if _, err := sqlDB.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
||||||
|
t.Fatalf("failed to enable foreign keys: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pfs := &PFSDB{DB: db}
|
||||||
|
|
||||||
|
if err := pfs.Migrate(); err != nil {
|
||||||
|
t.Fatalf("migration failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pfs
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// ==================== HELPERS ====================
|
||||||
|
//
|
||||||
|
|
||||||
|
func countDirs(t *testing.T, db *gorm.DB) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var c int64
|
||||||
|
if err := db.Model(&DirModel{}).Count(&c).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func countFiles(t *testing.T, db *gorm.DB) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var c int64
|
||||||
|
if err := db.Model(&FileModel{}).Count(&c).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func countFileData(t *testing.T, db *gorm.DB) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var c int64
|
||||||
|
if err := db.Model(&FileDataModel{}).Count(&c).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// ==================== TEST: DIR CASCADE ====================
|
||||||
|
//
|
||||||
|
|
||||||
|
func TestCascade_DeleteDirectoryTree(t *testing.T) {
|
||||||
|
pfs := newTestDB(t)
|
||||||
|
db := pfs.DB
|
||||||
|
|
||||||
|
userID := uuid.New()
|
||||||
|
|
||||||
|
// root
|
||||||
|
root := DirModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Name: "root",
|
||||||
|
OwnerID: userID,
|
||||||
|
}
|
||||||
|
if err := db.Create(&root).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// child
|
||||||
|
child := DirModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
ParentID: &root.ID,
|
||||||
|
Name: "child",
|
||||||
|
OwnerID: userID,
|
||||||
|
}
|
||||||
|
if err := db.Create(&child).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// grandchild
|
||||||
|
grandchild := DirModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
ParentID: &child.ID,
|
||||||
|
Name: "grandchild",
|
||||||
|
OwnerID: userID,
|
||||||
|
}
|
||||||
|
if err := db.Create(&grandchild).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// file inside grandchild
|
||||||
|
file := FileModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
DirID: &grandchild.ID,
|
||||||
|
Name: "file.txt",
|
||||||
|
OwnerID: userID,
|
||||||
|
}
|
||||||
|
if err := db.Create(&file).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileData := FileDataModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
FileID: file.ID,
|
||||||
|
Data: []byte("hello"),
|
||||||
|
}
|
||||||
|
if err := db.Create(&fileData).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanity check
|
||||||
|
if countDirs(t, db) != 3 {
|
||||||
|
t.Fatal("expected 3 dirs")
|
||||||
|
}
|
||||||
|
if countFiles(t, db) != 1 {
|
||||||
|
t.Fatal("expected 1 file")
|
||||||
|
}
|
||||||
|
if countFileData(t, db) != 1 {
|
||||||
|
t.Fatal("expected 1 file data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE ROOT → should cascade everything
|
||||||
|
if err := db.Delete(&root).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if countDirs(t, db) != 0 {
|
||||||
|
t.Fatal("dirs were not cascade deleted")
|
||||||
|
}
|
||||||
|
if countFiles(t, db) != 0 {
|
||||||
|
t.Fatal("files were not cascade deleted")
|
||||||
|
}
|
||||||
|
if countFileData(t, db) != 0 {
|
||||||
|
t.Fatal("file data was not cascade deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// ==================== TEST: FILE CASCADE ====================
|
||||||
|
//
|
||||||
|
|
||||||
|
func TestCascade_DeleteFileRemovesData(t *testing.T) {
|
||||||
|
pfs := newTestDB(t)
|
||||||
|
db := pfs.DB
|
||||||
|
|
||||||
|
userID := uuid.New()
|
||||||
|
|
||||||
|
file := FileModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Name: "test.txt",
|
||||||
|
OwnerID: userID,
|
||||||
|
}
|
||||||
|
if err := db.Create(&file).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileData := FileDataModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
FileID: file.ID,
|
||||||
|
Data: []byte("payload"),
|
||||||
|
}
|
||||||
|
if err := db.Create(&fileData).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if countFiles(t, db) != 1 || countFileData(t, db) != 1 {
|
||||||
|
t.Fatal("setup failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete file
|
||||||
|
if err := db.Delete(&file).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if countFiles(t, db) != 0 {
|
||||||
|
t.Fatal("file not deleted")
|
||||||
|
}
|
||||||
|
if countFileData(t, db) != 0 {
|
||||||
|
t.Fatal("file data not cascade deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// ==================== TEST: FK ENFORCEMENT ====================
|
||||||
|
//
|
||||||
|
|
||||||
|
func TestForeignKey_RejectOrphanFileData(t *testing.T) {
|
||||||
|
pfs := newTestDB(t)
|
||||||
|
db := pfs.DB
|
||||||
|
|
||||||
|
orphan := FileDataModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
FileID: uuid.New(), // несуществующий файл
|
||||||
|
Data: []byte("bad"),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.Create(&orphan).Error
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected FK violation, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStress_CascadeDeleteLargeTree(t *testing.T) {
|
||||||
|
pfs := newTestDB(t)
|
||||||
|
db := pfs.DB
|
||||||
|
|
||||||
|
userID := uuid.New()
|
||||||
|
|
||||||
|
const (
|
||||||
|
dirCount = 200
|
||||||
|
filesPerDir = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// --- root ---
|
||||||
|
root := DirModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Name: "root",
|
||||||
|
OwnerID: userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(&root).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- цепочка директорий ---
|
||||||
|
parentID := root.ID
|
||||||
|
var dirs []DirModel
|
||||||
|
|
||||||
|
for i := 0; i < dirCount; i++ {
|
||||||
|
dir := DirModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
ParentID: &parentID,
|
||||||
|
Name: "dir",
|
||||||
|
OwnerID: userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(&dir).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to insert dir %d: %w", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs = append(dirs, dir)
|
||||||
|
parentID = dir.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- файлы ---
|
||||||
|
for _, dir := range dirs {
|
||||||
|
for i := 0; i < filesPerDir; i++ {
|
||||||
|
fileID := uuid.New()
|
||||||
|
|
||||||
|
file := FileModel{
|
||||||
|
ID: fileID,
|
||||||
|
DirID: &dir.ID,
|
||||||
|
Name: "file",
|
||||||
|
OwnerID: userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(&file).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := FileDataModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
FileID: fileID,
|
||||||
|
Data: []byte("payload"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(&data).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Created: %d dirs, %d files", dirCount+1, dirCount*filesPerDir)
|
||||||
|
|
||||||
|
// --- удаление ---
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
if err := tx.Delete(&root).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
// --- проверки ---
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
if err := tx.Model(&DirModel{}).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
return fmt.Errorf("dirs not fully deleted: %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Model(&FileModel{}).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
return fmt.Errorf("files not fully deleted: %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Model(&FileDataModel{}).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
return fmt.Errorf("file data not fully deleted: %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Cascade delete took: %v", elapsed)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustCreate(t *testing.T, db *gorm.DB, v any) {
|
||||||
|
t.Helper()
|
||||||
|
if err := db.Create(v).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvePath(t *testing.T) {
|
||||||
|
pfs := newTestDB(t)
|
||||||
|
db := pfs.DB
|
||||||
|
|
||||||
|
userID := uuid.New()
|
||||||
|
|
||||||
|
// --- структура:
|
||||||
|
// /root/a/b/c
|
||||||
|
// /root/a/b/file.txt
|
||||||
|
|
||||||
|
root := DirModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Name: "root",
|
||||||
|
OwnerID: userID,
|
||||||
|
}
|
||||||
|
mustCreate(t, db, &root)
|
||||||
|
|
||||||
|
a := DirModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Name: "a",
|
||||||
|
ParentID: &root.ID,
|
||||||
|
OwnerID: userID,
|
||||||
|
}
|
||||||
|
mustCreate(t, db, &a)
|
||||||
|
|
||||||
|
b := DirModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Name: "b",
|
||||||
|
ParentID: &a.ID,
|
||||||
|
OwnerID: userID,
|
||||||
|
}
|
||||||
|
mustCreate(t, db, &b)
|
||||||
|
|
||||||
|
c := DirModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Name: "c",
|
||||||
|
ParentID: &b.ID,
|
||||||
|
OwnerID: userID,
|
||||||
|
}
|
||||||
|
mustCreate(t, db, &c)
|
||||||
|
|
||||||
|
file := FileModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Name: "file.txt",
|
||||||
|
DirID: &b.ID,
|
||||||
|
OwnerID: userID,
|
||||||
|
}
|
||||||
|
mustCreate(t, db, &file)
|
||||||
|
|
||||||
|
data := FileDataModel{
|
||||||
|
ID: uuid.New(),
|
||||||
|
FileID: file.ID,
|
||||||
|
Data: []byte("hello"),
|
||||||
|
}
|
||||||
|
mustCreate(t, db, &data)
|
||||||
|
|
||||||
|
// --- ResolveDir success ---
|
||||||
|
dirID, err := pfs.ResolveDirByPath("/root/a/b/c", userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dirID != c.ID {
|
||||||
|
t.Fatalf("expected %s got %s", c.ID, dirID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ResolveFile success ---
|
||||||
|
fileID, err := pfs.ResolveFileByPath("/root/a/b/file.txt", userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fileID != file.ID {
|
||||||
|
t.Fatalf("expected %s got %s", file.ID, fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- missing dir ---
|
||||||
|
_, err = pfs.ResolveDirByPath("/root/a/x", userID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- missing file ---
|
||||||
|
_, err = pfs.ResolveFileByPath("/root/a/b/missing.txt", userID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- другой пользователь ---
|
||||||
|
otherUser := uuid.New()
|
||||||
|
|
||||||
|
_, err = pfs.ResolveDirByPath("/root/a/b/c", otherUser)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected access error for other user")
|
||||||
|
}
|
||||||
|
}
|
||||||
16
stat/fs_stat.go
Normal file
16
stat/fs_stat.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package stat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FSStat struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Name string
|
||||||
|
IsDir bool
|
||||||
|
Created time.Time
|
||||||
|
LastModified time.Time
|
||||||
|
OwnerID uuid.UUID
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user