From 3d903403c80828a937039564f24d5f4b9175ef3d Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Tue, 27 Oct 2026 12:12:59 -0700 Subject: [PATCH] fix(migrate): validate migration directory before provider creation --- migrate/migrate.go | 8 ++++++++ migrate/migrate_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/migrate/migrate.go b/migrate/migrate.go index df730d5..ca99821 100644 --- a/migrate/migrate.go +++ b/migrate/migrate.go @@ -15,6 +15,7 @@ import ( // New returns a provider for SQL files in dir and optional provider-local Go // migrations. Pass "." for files at the embedded root and a nonnil logger. +// The selected directory must exist, including when using only Go migrations. // Construction does not connect to db; the caller owns and closes db. // // PostgreSQL uses Goose's session advisory locker with its default lock ID and @@ -36,6 +37,13 @@ func New(db *sql.DB, dialect goose.Dialect, files embed.FS, dir string, logger * if err != nil { return nil, fmt.Errorf("migrate: directory %q: %w", dir, err) } + info, err := fs.Stat(root, ".") + if err != nil { + return nil, fmt.Errorf("migrate: directory %q: %w", dir, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("migrate: directory %q: not a directory", dir) + } opts := []goose.ProviderOption{ goose.WithDisableGlobalRegistry(true), goose.WithGoMigrations(migrations...), diff --git a/migrate/migrate_test.go b/migrate/migrate_test.go index 9f77f60..9534981 100644 --- a/migrate/migrate_test.go +++ b/migrate/migrate_test.go @@ -8,6 +8,7 @@ import ( "embed" "errors" "io" + "io/fs" "log/slog" "os" "path/filepath" @@ -414,3 +415,43 @@ func TestConstructor(t *testing.T) { t.Fatal(err) } } + +func TestConstructorDirectory(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + // Directory validation and Go-only construction must remain offline. + if err := db.Close(); err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + for _, test := range []struct { + dir string + wantErr string + }{ + {dir: "missing", wantErr: "directory"}, + {dir: "testdata/migrations/00007_events.sql", wantErr: "not a directory"}, + {dir: "."}, + {dir: "testdata"}, + } { + t.Run(test.dir, func(t *testing.T) { + p, err := migrate.New(db, goose.DialectSQLite3, fixtures, test.dir, logger, goose.NewGoMigration(10, nil, nil)) + if test.wantErr != "" { + if err == nil || p != nil || !strings.Contains(err.Error(), test.wantErr) || !strings.Contains(err.Error(), test.dir) { + t.Fatalf("New = %v, %v; want directory error containing %q and %q", p, err, test.wantErr, test.dir) + } + if test.dir == "missing" && !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("New error = %v; want fs.ErrNotExist", err) + } + return + } + if err != nil { + t.Fatal(err) + } + if sources := p.ListSources(); len(sources) != 1 || sources[0].Version != 10 { + t.Fatalf("ListSources = %v; want Go migration 10 only", sources) + } + }) + } +}