diff --git a/cmd/internal/migrations/lists.go b/cmd/internal/migrations/lists.go index 22a3953..686a36c 100644 --- a/cmd/internal/migrations/lists.go +++ b/cmd/internal/migrations/lists.go @@ -70,6 +70,8 @@ var Migrations = []Migration{ v3migrations.MigrateSessionConfig, v3migrations.MigrateSessionExtractor, v3migrations.MigrateSessionStore, + v3migrations.MigrateStorageVersions, + v3migrations.MigrateSessionRelease, v3migrations.MigrateKeyAuthConfig, v3migrations.MigrateJWTExtractor, v3migrations.MigratePasetoExtractor, diff --git a/cmd/internal/migrations/v3/session_release.go b/cmd/internal/migrations/v3/session_release.go new file mode 100644 index 0000000..da89415 --- /dev/null +++ b/cmd/internal/migrations/v3/session_release.go @@ -0,0 +1,158 @@ +package v3 + +import ( + "fmt" + "regexp" + "strings" + + semver "github.com/Masterminds/semver/v3" + "github.com/spf13/cobra" + + "github.com/gofiber/cli/cmd/internal" +) + +const releaseComment = "// Important: Manual cleanup required" + +// MigrateSessionRelease adds defer sess.Release() after store.Get() calls +// when using the Store Pattern (legacy pattern). +// This is required in v3 for manual session lifecycle management. +func MigrateSessionRelease(cmd *cobra.Command, cwd string, _, _ *semver.Version) error { + // Match patterns like: + // sess, err := store.Get(c) + // sess, err := store.GetByID(ctx, sessionID) + // session, err := myStore.Get(c) + // Capture: variable name, store variable name, method call + reStoreGet := regexp.MustCompile(`(?m)^(\s*)(\w+),\s*(\w+)\s*:=\s*(\w+)\.(Get(?:ByID)?)\(`) + + changed, err := internal.ChangeFileContent(cwd, func(content string) string { + lines := strings.Split(content, "\n") + result := make([]string, 0, len(lines)) + + for i := 0; i < len(lines); i++ { + line := lines[i] + result = append(result, line) + + // Check if this line matches a store.Get() call + matches := reStoreGet.FindStringSubmatch(line) + if len(matches) < 6 { + continue + } + + indent := matches[1] + sessVar := matches[2] + errVar := matches[3] + + // Look for the error check pattern after this line + // Common patterns: + // if err != nil { + // if err != nil { return ... } + nextLineIdx := i + 1 + if nextLineIdx >= len(lines) { + continue + } + + nextLine := strings.TrimSpace(lines[nextLineIdx]) + + // Check if the next line starts an error check + if !strings.HasPrefix(nextLine, "if "+errVar+" != nil") { + continue + } + + // Find where the error block ends + blockEnd := findErrorBlockEnd(lines, nextLineIdx) + + // Insert defer after the error block + if blockEnd < 0 || blockEnd >= len(lines) { + continue + } + + // Check if there's already a defer sess.Release() after the error block + hasRelease := false + searchEnd := blockEnd + 20 + if searchEnd > len(lines) { + searchEnd = len(lines) + } + for j := blockEnd + 1; j < searchEnd; j++ { + if strings.Contains(lines[j], sessVar+".Release()") { + hasRelease = true + break + } + // Stop searching if we hit a closing brace at the same or lower indent level + // Only stop on lines that are purely closing braces (possibly with trailing comments) + trimmed := strings.TrimSpace(lines[j]) + if strings.HasPrefix(trimmed, "}") && !strings.Contains(trimmed, "{") && !strings.Contains(trimmed, "else") { + break + } + } + + if hasRelease { + // Skip ahead to avoid re-processing these lines + for i < blockEnd { + i++ + if i < len(lines) { + result = append(result, lines[i]) + } + } + continue + } + + // Insert the defer statement after the error block + deferLine := indent + "defer " + sessVar + ".Release() " + releaseComment + + // Skip ahead in the loop to include all lines up to blockEnd + for i < blockEnd { + i++ + if i < len(lines) { + result = append(result, lines[i]) + } + } + + // Now insert the defer line + result = append(result, deferLine) + } + + return strings.Join(result, "\n") + }) + if err != nil { + return fmt.Errorf("failed to add session Release() calls: %w", err) + } + if !changed { + return nil + } + + cmd.Println("Adding defer sess.Release() for Store Pattern usage") + return nil +} + +// findErrorBlockEnd finds the end of an error handling block +// Returns the line index of the closing brace, or -1 if not found +// Note: This uses simple brace counting and may not handle braces in strings/comments, +// but is sufficient for migration purposes with typical Go error handling patterns. +func findErrorBlockEnd(lines []string, startIdx int) int { + if startIdx >= len(lines) { + return -1 + } + + line := strings.TrimSpace(lines[startIdx]) + + // Check if it's a single-line if statement + if strings.Contains(line, "{") && strings.Contains(line, "}") { + return startIdx + } + + // Multi-line block: find the matching closing brace + if strings.Contains(line, "{") { + braceCount := 1 + for i := startIdx + 1; i < len(lines); i++ { + currLine := lines[i] + braceCount += strings.Count(currLine, "{") + braceCount -= strings.Count(currLine, "}") + + if braceCount == 0 { + return i + } + } + } + + return -1 +} diff --git a/cmd/internal/migrations/v3/session_release_test.go b/cmd/internal/migrations/v3/session_release_test.go new file mode 100644 index 0000000..7fa8862 --- /dev/null +++ b/cmd/internal/migrations/v3/session_release_test.go @@ -0,0 +1,182 @@ +package v3 + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_MigrateSessionRelease(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "msessionrelease") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + content := `package main + +import ( + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/session" +) + +func handler(c fiber.Ctx) error { + store := session.NewStore() + sess, err := store.Get(c) + if err != nil { + return err + } + + sess.Set("key", "value") + return sess.Save() +} +` + + err = os.WriteFile(filepath.Join(dir, "main.go"), []byte(content), 0o600) + require.NoError(t, err) + + cmd := &cobra.Command{} + err = MigrateSessionRelease(cmd, dir, nil, nil) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "main.go")) // #nosec G304 + require.NoError(t, err) + + result := string(data) + assert.Contains(t, result, "defer sess.Release() // Important: Manual cleanup required") +} + +func Test_MigrateSessionRelease_AlreadyHasDefer(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "msessionrelease") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + content := `package main + +import ( + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/session" +) + +func handler(c fiber.Ctx) error { + store := session.NewStore() + sess, err := store.Get(c) + if err != nil { + return err + } + defer sess.Release() + + sess.Set("key", "value") + return sess.Save() +} +` + + err = os.WriteFile(filepath.Join(dir, "main.go"), []byte(content), 0o600) + require.NoError(t, err) + + cmd := &cobra.Command{} + err = MigrateSessionRelease(cmd, dir, nil, nil) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "main.go")) // #nosec G304 + require.NoError(t, err) + + result := string(data) + // Should not add another defer + firstIdx := strings.Index(result, "defer sess.Release()") + lastIdx := strings.LastIndex(result, "defer sess.Release()") + assert.Equal(t, firstIdx, lastIdx, "Should only have one defer sess.Release()") +} + +func Test_MigrateSessionRelease_GetByID(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "msessionrelease") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + content := `package main + +import ( + "context" + "github.com/gofiber/fiber/v3/middleware/session" +) + +func backgroundTask(sessionID string) { + store := session.NewStore() + sess, err := store.GetByID(context.Background(), sessionID) + if err != nil { + return + } + + sess.Set("last_task", "value") + sess.Save() +} +` + + err = os.WriteFile(filepath.Join(dir, "main.go"), []byte(content), 0o600) + require.NoError(t, err) + + cmd := &cobra.Command{} + err = MigrateSessionRelease(cmd, dir, nil, nil) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "main.go")) // #nosec G304 + require.NoError(t, err) + + result := string(data) + assert.Contains(t, result, "defer sess.Release() // Important: Manual cleanup required") +} + +func Test_MigrateSessionRelease_MultilineErrorCheck(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "msessionrelease") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + content := `package main + +import ( + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/session" +) + +func handler(c fiber.Ctx) error { + store := session.NewStore() + sess, err := store.Get(c) + if err != nil { + c.Status(500) + return err + } + + sess.Set("key", "value") + return sess.Save() +} +` + + err = os.WriteFile(filepath.Join(dir, "main.go"), []byte(content), 0o600) + require.NoError(t, err) + + cmd := &cobra.Command{} + err = MigrateSessionRelease(cmd, dir, nil, nil) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "main.go")) // #nosec G304 + require.NoError(t, err) + + result := string(data) + assert.Contains(t, result, "defer sess.Release() // Important: Manual cleanup required") + + // Verify defer comes after the error block + deferIdx := strings.Index(result, "defer sess.Release()") + errorBlockEnd := strings.Index(result, "}") + assert.Greater(t, deferIdx, errorBlockEnd, "defer should come after error block") +} diff --git a/cmd/internal/migrations/v3/storage_versions.go b/cmd/internal/migrations/v3/storage_versions.go new file mode 100644 index 0000000..2f195a3 --- /dev/null +++ b/cmd/internal/migrations/v3/storage_versions.go @@ -0,0 +1,95 @@ +package v3 + +import ( + "fmt" + "regexp" + + semver "github.com/Masterminds/semver/v3" + "github.com/spf13/cobra" + + "github.com/gofiber/cli/cmd/internal" +) + +// reStorageImport matches storage imports with or without version suffix. +// Examples: +// +// "github.com/gofiber/storage/sqlite3" +// "github.com/gofiber/storage/redis/v2" +// "github.com/gofiber/storage/postgres/v3" +var reStorageImport = regexp.MustCompile(`"github\.com/gofiber/storage/([a-zA-Z0-9_-]+)(?:/v(\d+))?"`) + +// storageMinimumVersions maps storage package names to their minimum required major version +// for Fiber v3 compatibility. These represent the target versions that storage packages +// should be migrated to based on their latest stable releases in the gofiber/storage repository. +var storageMinimumVersions = map[string]string{ + // v3 adapters (minimum required major version) + "postgres": "v3", // Latest: v3.3.1 + "redis": "v3", // Latest: v3.4.2 + + // v2 adapters (minimum required major version) + "arangodb": "v2", // Latest: v2.2.2 + "azureblob": "v2", // Latest: v2.2.2 + "badger": "v2", // Latest: v2.1.2 + "bbolt": "v2", // Latest: v2.1.2 + "couchbase": "v2", // Latest: v2.2.2 + "dynamodb": "v2", // Latest: v2.2.2 + "etcd": "v2", // Latest: v2.2.0 + "memcache": "v2", // Latest: v2.1.1 + "memory": "v2", // Latest: v2.1.1 + "mongodb": "v2", // Latest: v2.2.1 + "mssql": "v2", // Latest: v2.1.2 + "mysql": "v2", // Latest: v2.3.0 + "pebble": "v2", // Latest: v2.1.1 + "ristretto": "v2", // Latest: v2.1.1 + "s3": "v2", // Latest: v2.4.2 + "sqlite3": "v2", // Latest: v2.2.2 + + // Note: v1/unversioned adapters are intentionally excluded as they don't + // require migration. These packages remain on v0.x or v1.x versions: + // aerospike, cassandra, clickhouse, cloudflarekv, coherence, leveldb, + // minio, mockstorage, nats, neo4j, rueidis, scylladb, surrealdb, valkey +} + +// MigrateStorageVersions updates storage package imports to use the correct latest version. +// This migration handles storage packages from github.com/gofiber/storage/*. +func MigrateStorageVersions(cmd *cobra.Command, cwd string, _, _ *semver.Version) error { + changed, err := internal.ChangeFileContent(cwd, func(content string) string { + // Replace storage imports to add or update the version suffix + return reStorageImport.ReplaceAllStringFunc(content, func(match string) string { + // Extract the storage package name and current version + submatches := reStorageImport.FindStringSubmatch(match) + if len(submatches) < 2 { + return match + } + storagePkg := submatches[1] + currentVersion := "" + if len(submatches) > 2 && submatches[2] != "" { + currentVersion = "v" + submatches[2] + } + + // Get the minimum required version for this storage package + minVersion, ok := storageMinimumVersions[storagePkg] + if !ok { + // Unknown package - leave unchanged + return match + } + + // If already at the correct version, skip + if currentVersion == minVersion { + return match + } + + // Return the updated import with the correct version + return fmt.Sprintf(`"github.com/gofiber/storage/%s/%s"`, storagePkg, minVersion) + }) + }) + if err != nil { + return fmt.Errorf("failed to migrate storage versions: %w", err) + } + if !changed { + return nil + } + + cmd.Println("Migrated storage package versions") + return nil +} diff --git a/cmd/internal/migrations/v3/storage_versions_test.go b/cmd/internal/migrations/v3/storage_versions_test.go new file mode 100644 index 0000000..9485f37 --- /dev/null +++ b/cmd/internal/migrations/v3/storage_versions_test.go @@ -0,0 +1,219 @@ +package v3 + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_MigrateStorageVersions(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "mstorageversions") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + content := `package main + +import ( + "github.com/gofiber/fiber/v3" + "github.com/gofiber/storage/sqlite3" + "github.com/gofiber/storage/redis" + "github.com/gofiber/storage/postgres" +) + +func main() { + store := sqlite3.New() + cache := redis.New() + pg := postgres.New() +} +` + + err = os.WriteFile(filepath.Join(dir, "main.go"), []byte(content), 0o600) + require.NoError(t, err) + + cmd := &cobra.Command{} + err = MigrateStorageVersions(cmd, dir, nil, nil) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "main.go")) // #nosec G304 + require.NoError(t, err) + + result := string(data) + assert.Contains(t, result, `"github.com/gofiber/storage/sqlite3/v2"`) + assert.Contains(t, result, `"github.com/gofiber/storage/redis/v3"`) + assert.Contains(t, result, `"github.com/gofiber/storage/postgres/v3"`) + assert.NotContains(t, result, `"github.com/gofiber/storage/sqlite3"`) + assert.NotContains(t, result, `"github.com/gofiber/storage/redis"`) + assert.NotContains(t, result, `"github.com/gofiber/storage/postgres"`) +} + +func Test_MigrateStorageVersions_AlreadyV2(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "mstorageversions") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + content := `package main + +import ( + "github.com/gofiber/fiber/v3" + "github.com/gofiber/storage/sqlite3/v2" + "github.com/gofiber/storage/redis/v3" +) + +func main() { + store := sqlite3.New() + cache := redis.New() +} +` + + err = os.WriteFile(filepath.Join(dir, "main.go"), []byte(content), 0o600) + require.NoError(t, err) + + cmd := &cobra.Command{} + err = MigrateStorageVersions(cmd, dir, nil, nil) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "main.go")) // #nosec G304 + require.NoError(t, err) + + result := string(data) + // Should remain unchanged + assert.Contains(t, result, `"github.com/gofiber/storage/sqlite3/v2"`) + assert.Contains(t, result, `"github.com/gofiber/storage/redis/v3"`) + // Should not have duplicate version suffixes + assert.NotContains(t, result, `"github.com/gofiber/storage/sqlite3/v2/v2"`) + assert.NotContains(t, result, `"github.com/gofiber/storage/redis/v3/v3"`) +} + +func Test_MigrateStorageVersions_MixedVersions(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "mstorageversions") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + content := `package main + +import ( + "github.com/gofiber/storage/sqlite3" + "github.com/gofiber/storage/redis/v3" + "github.com/gofiber/storage/postgres" +) + +func main() { + store := sqlite3.New() + cache := redis.New() + pg := postgres.New() +} +` + + err = os.WriteFile(filepath.Join(dir, "main.go"), []byte(content), 0o600) + require.NoError(t, err) + + cmd := &cobra.Command{} + err = MigrateStorageVersions(cmd, dir, nil, nil) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "main.go")) // #nosec G304 + require.NoError(t, err) + + result := string(data) + // Only the non-versioned imports should be updated + assert.Contains(t, result, `"github.com/gofiber/storage/sqlite3/v2"`) + assert.Contains(t, result, `"github.com/gofiber/storage/postgres/v3"`) + // Already versioned should remain unchanged + assert.Contains(t, result, `"github.com/gofiber/storage/redis/v3"`) + // Should not have old versions + assert.NotContains(t, result, `"github.com/gofiber/storage/sqlite3"`) + assert.NotContains(t, result, `"github.com/gofiber/storage/postgres"`) +} + +func Test_MigrateStorageVersions_V2ToV3(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "mstorageversions") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + content := `package main + +import ( + "github.com/gofiber/storage/redis/v2" + "github.com/gofiber/storage/postgres/v2" + "github.com/gofiber/storage/sqlite3/v2" +) + +func main() { + cache := redis.New() + pg := postgres.New() + store := sqlite3.New() +} +` + + err = os.WriteFile(filepath.Join(dir, "main.go"), []byte(content), 0o600) + require.NoError(t, err) + + cmd := &cobra.Command{} + err = MigrateStorageVersions(cmd, dir, nil, nil) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "main.go")) // #nosec G304 + require.NoError(t, err) + + result := string(data) + // redis and postgres should upgrade from v2 to v3 + assert.Contains(t, result, `"github.com/gofiber/storage/redis/v3"`) + assert.Contains(t, result, `"github.com/gofiber/storage/postgres/v3"`) + // sqlite3 stays at v2 (no v3 available) + assert.Contains(t, result, `"github.com/gofiber/storage/sqlite3/v2"`) + // Should not have old v2 versions for redis/postgres + assert.NotContains(t, result, `"github.com/gofiber/storage/redis/v2"`) + assert.NotContains(t, result, `"github.com/gofiber/storage/postgres/v2"`) +} + +func Test_MigrateStorageVersions_UnknownPackages(t *testing.T) { + t.Parallel() + + dir, err := os.MkdirTemp("", "mstorageversions") + require.NoError(t, err) + defer func() { require.NoError(t, os.RemoveAll(dir)) }() + + content := `package main + +import ( + "github.com/gofiber/storage/aerospike" + "github.com/gofiber/storage/nats" + "github.com/gofiber/storage/sqlite3" +) + +func main() { + aero := aerospike.New() + n := nats.New() + store := sqlite3.New() +} +` + + err = os.WriteFile(filepath.Join(dir, "main.go"), []byte(content), 0o600) + require.NoError(t, err) + + cmd := &cobra.Command{} + err = MigrateStorageVersions(cmd, dir, nil, nil) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "main.go")) // #nosec G304 + require.NoError(t, err) + + result := string(data) + // Known package should be updated + assert.Contains(t, result, `"github.com/gofiber/storage/sqlite3/v2"`) + // Unknown packages should remain unchanged (they're v1/unversioned) + assert.Contains(t, result, `"github.com/gofiber/storage/aerospike"`) + assert.Contains(t, result, `"github.com/gofiber/storage/nats"`) +}