diff --git a/common/errors.go b/common/errors.go new file mode 100644 index 0000000..1bcfeef --- /dev/null +++ b/common/errors.go @@ -0,0 +1,7 @@ +package common + +import "errors" + +var ( + ErrAccessDenied = errors.New("access denied") +) diff --git a/go.mod b/go.mod index 900dc68..555251e 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,15 @@ module git.gm6.ru/icewind/pfs 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 +) diff --git a/go.sum b/go.sum index 7790d7c..da27807 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,14 @@ 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/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= diff --git a/ifs.go b/ifs.go index cea1085..c9b416b 100644 --- a/ifs.go +++ b/ifs.go @@ -1,25 +1,15 @@ -package models +package pfs import ( - "time" - "git.gm6.ru/icewind/pfs/models" + "git.gm6.ru/icewind/pfs/stat" "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 { DirCreate(dir *models.Dir, 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 FileRemove(fileID uuid.UUID, userID uuid.UUID) error FileRead(fileID uuid.UUID, userID uuid.UUID) (*models.File, error) diff --git a/models/dir.go b/models/dir.go index a4de588..203ed1c 100644 --- a/models/dir.go +++ b/models/dir.go @@ -1,11 +1,17 @@ package models -import "github.com/google/uuid" +import ( + "time" + + "github.com/google/uuid" +) type Dir struct { - ID uuid.UUID `json:"id"` - ParentID *uuid.UUID `json:"parent_id"` - Name string `json:"name"` - OwnerID uuid.UUID `json:"owner_id"` - IsPublic bool `json:"is_public"` + ID uuid.UUID `json:"id"` + ParentID *uuid.UUID `json:"parent_id"` + Name string `json:"name"` + OwnerID uuid.UUID `json:"owner_id"` + IsPublic bool `json:"is_public"` + Created time.Time `json:"created"` + LastModified time.Time `json:"last_modified"` } diff --git a/pfs_db/models.go b/pfs_db/models.go new file mode 100644 index 0000000..3740835 --- /dev/null +++ b/pfs_db/models.go @@ -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 +} diff --git a/pfs_db/pfs_db.go b/pfs_db/pfs_db.go new file mode 100644 index 0000000..b18e1fe --- /dev/null +++ b/pfs_db/pfs_db.go @@ -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{}, + ) +} diff --git a/pfs_db/pfs_db_test.go b/pfs_db/pfs_db_test.go new file mode 100644 index 0000000..46e00fe --- /dev/null +++ b/pfs_db/pfs_db_test.go @@ -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") + } +} diff --git a/stat/fs_stat.go b/stat/fs_stat.go new file mode 100644 index 0000000..5b4376f --- /dev/null +++ b/stat/fs_stat.go @@ -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 +}