From 98507d4466481e9a833deaa1c62284305308aa9f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Jul 2026 22:30:26 +0000 Subject: [PATCH 1/5] d1 import: stop emitting untrusted DEFAULT/REFERENCES SQL verbatim Unrecognized DEFAULT expressions on typed columns were spliced into CREATE TABLE as-is, so a payload like DEFAULT (0)); DROP TABLE users; -- could escape the statement and run via psql. Restrict those defaults to validated numeric/boolean/quoted literals (or omit them), harden UUID and quoted-literal handling, and drop unsafe REFERENCES action tails the same way. Co-authored-by: Mike Coutermarsh --- internal/import/d1/constraints.go | 37 +++++- internal/import/d1/constraints_test.go | 37 ++++++ internal/import/d1/convert.go | 164 ++++++++++++++++++++++--- internal/import/d1/convert_test.go | 83 +++++++++++++ 4 files changed, 298 insertions(+), 23 deletions(-) diff --git a/internal/import/d1/constraints.go b/internal/import/d1/constraints.go index d1e26af6..22824ce5 100644 --- a/internal/import/d1/constraints.go +++ b/internal/import/d1/constraints.go @@ -397,10 +397,14 @@ func convertUniqueConstraint(clause string, table TableSchema) string { // quoted identifiers case-sensitively, so the referenced table/columns are canonicalized // to the actual declared case from all (the full set of parsed tables) rather than quoting // whatever case happens to appear in this clause. +// +// Unrecognized REFERENCES text and unsafe action tails (statement terminators, extra +// parentheses, or tokens outside the FK-action grammar) are dropped rather than emitted +// verbatim into executed DDL. func convertReferencesClause(refs string, all []TableSchema) string { m := referencesClauseRe.FindStringSubmatch(refs) if m == nil { - return refs + return "" } rawTable := firstNonEmpty(m[1], m[2], m[3], m[4]) refTable := tableByName(all, rawTable) @@ -410,11 +414,34 @@ func convertReferencesClause(refs string, all []TableSchema) string { } table := postgres.QuoteIdentifier(tableName) refCols := quoteColumnListFor(m[5], refTable) - tail := strings.TrimSpace(m[6]) - if tail != "" { - return "REFERENCES " + table + " (" + refCols + ") " + tail + if refCols == "" { + return "" + } + out := "REFERENCES " + table + " (" + refCols + ")" + if tail := sanitizeReferencesTail(strings.TrimSpace(m[6])); tail != "" { + return out + " " + tail + } + return out +} + +// fkActionTailRe matches a sequence of SQLite/Postgres foreign-key action clauses only. +// Anything outside this grammar (including ";" / extra ")" that could escape CREATE TABLE) +// is rejected by sanitizeReferencesTail. +var fkActionTailRe = regexp.MustCompile(`(?is)^(?:\s*(?:ON\s+(?:DELETE|UPDATE)\s+(?:CASCADE|SET\s+NULL|SET\s+DEFAULT|RESTRICT|NO\s+ACTION)|MATCH\s+(?:SIMPLE|FULL|PARTIAL)|NOT\s+DEFERRABLE|DEFERRABLE(?:\s+INITIALLY\s+(?:DEFERRED|IMMEDIATE))?|INITIALLY\s+(?:DEFERRED|IMMEDIATE)))+$`) + +// sanitizeReferencesTail returns tail when it is a safe FK action clause list, otherwise "". +func sanitizeReferencesTail(tail string) string { + tail = strings.TrimSpace(tail) + if tail == "" { + return "" + } + if strings.ContainsAny(tail, ";()") { + return "" + } + if !fkActionTailRe.MatchString(tail) { + return "" } - return "REFERENCES " + table + " (" + refCols + ")" + return strings.Join(strings.Fields(tail), " ") } // quoteColumnList quotes a comma-separated column list, stripping SQLite indexed-column diff --git a/internal/import/d1/constraints_test.go b/internal/import/d1/constraints_test.go index ad577041..b202c314 100644 --- a/internal/import/d1/constraints_test.go +++ b/internal/import/d1/constraints_test.go @@ -86,6 +86,43 @@ CREATE TABLE Posts (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES USERS(ID) assertValidPostgresDDL(t, verifyDDL) } +func TestConvertReferencesClauseKeepsSafeActionTail(t *testing.T) { + sql := `CREATE TABLE users (id INTEGER PRIMARY KEY); +CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE ON UPDATE SET NULL); +` + ddl := convertTablesDDL(t, sql) + if !strings.Contains(ddl, `REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE SET NULL`) { + t.Fatalf("expected safe FK action tail preserved:\n%s", ddl) + } + assertValidPostgresDDL(t, ddl) +} + +func TestConvertReferencesClauseDropsInjectionTail(t *testing.T) { + got := convertReferencesClause(`REFERENCES users(id)); DROP TABLE users; --`, nil) + if strings.Contains(strings.ToUpper(got), "DROP TABLE") { + t.Fatalf("injected DROP must not appear in REFERENCES clause: %q", got) + } + if strings.Contains(got, ");") { + t.Fatalf("escaping parentheses must not appear in REFERENCES clause: %q", got) + } + if got != `REFERENCES "users" ("id")` { + t.Fatalf("got %q, want REFERENCES with action tail stripped", got) + } + + got = convertReferencesClause(`REFERENCES users(id) ON DELETE CASCADE); DROP TABLE users; --`, nil) + if strings.Contains(strings.ToUpper(got), "DROP TABLE") || strings.Contains(got, "CASCADE") { + // Hostile tail must be dropped entirely (not partially trusted). + t.Fatalf("hostile mixed tail must be dropped: %q", got) + } + if got != `REFERENCES "users" ("id")` { + t.Fatalf("got %q, want REFERENCES without hostile tail", got) + } + + if got := convertReferencesClause(`NOT A REFERENCES CLAUSE); DROP TABLE users; --`, nil); got != "" { + t.Fatalf("unparseable REFERENCES must be omitted, got %q", got) + } +} + func TestQuoteColumnListStripsIndexedColumnModifiers(t *testing.T) { got := quoteColumnList("a, b DESC") want := `"a", "b"` diff --git a/internal/import/d1/convert.go b/internal/import/d1/convert.go index 34e510db..34804530 100644 --- a/internal/import/d1/convert.go +++ b/internal/import/d1/convert.go @@ -166,7 +166,9 @@ func convertColumn(col ColumnSchema, table TableSchema, all []TableSchema, ctx * } if col.ForeignKey != "" { - parts = append(parts, convertReferencesClause(col.ForeignKey, all)) + if refs := convertReferencesClause(col.ForeignKey, all); refs != "" { + parts = append(parts, refs) + } } return strings.Join(parts, " ") @@ -254,26 +256,163 @@ func convertDefault(def, pgType string) (string, bool) { return mapped, true } if pgType == "BOOLEAN" { - if lit := parseBooleanDefaultLiteral(def); lit != "" { + if lit := parseBooleanDefaultLiteral(unwrapOuterParens(def)); lit != "" { return lit, true } + if bu := strings.ToUpper(unwrapOuterParens(def)); bu == "TRUE" || bu == "FALSE" { + return bu, true + } } if pgType == "UUID" { - return "'" + strings.Trim(def, "'\"") + "'", true + return convertUUIDDefault(def) } if strings.HasPrefix(def, "'") { - // Already a valid (SQLite and Postgres share '' escaping) single-quoted literal. - return def, true + if lit, ok := validatedSingleQuotedLiteral(def); ok { + return lit, true + } + return "", false } if strings.HasPrefix(def, `"`) { + inner, ok := validatedDoubleQuotedLiteral(def) + if !ok { + return "", false + } // SQLite's double-quoted string-literal fallback: Postgres always treats double // quotes as identifiers, so re-quote as a proper single-quoted string literal. - return quotePostgresLiteral(unwrapDoubleQuotedLiteral(def)), true + return quotePostgresLiteral(inner), true } if pgType == "TEXT" || pgType == "TIMESTAMPTZ" { return quotePostgresLiteral(strings.Trim(def, "'\"")), true } - return def, true + // Typed non-text columns (BIGINT, NUMERIC, BYTEA, …): only emit validated scalar + // literals. Never pass an unrecognized attacker-controlled expression through + // verbatim into executed DDL — that lets unbalanced ")" / ";" escape CREATE TABLE. + if lit, ok := safeNumericDefaultLiteral(def); ok { + return lit, true + } + return "", false +} + +// convertUUIDDefault emits a safely quoted UUID default, or omits unrecognized input. +func convertUUIDDefault(def string) (string, bool) { + def = strings.TrimSpace(def) + if strings.HasPrefix(def, "'") { + return validatedSingleQuotedLiteral(def) + } + if strings.HasPrefix(def, `"`) { + inner, ok := validatedDoubleQuotedLiteral(def) + if !ok { + return "", false + } + return quotePostgresLiteral(inner), true + } + if isSafeBareUUIDDefault(def) { + return quotePostgresLiteral(def), true + } + return "", false +} + +// validatedSingleQuotedLiteral accepts a complete SQLite/Postgres single-quoted string +// literal (with '' escapes) and rejects unclosed quotes or trailing junk after the +// closing quote — both of which can break out of DEFAULT into adjacent SQL. +func validatedSingleQuotedLiteral(s string) (string, bool) { + s = strings.TrimSpace(s) + if len(s) < 2 || s[0] != '\'' { + return "", false + } + for i := 1; i < len(s); i++ { + if s[i] != '\'' { + continue + } + if i+1 < len(s) && s[i+1] == '\'' { + i++ + continue + } + if i != len(s)-1 { + return "", false + } + return s, true + } + return "", false +} + +// validatedDoubleQuotedLiteral accepts a complete double-quoted SQLite string-literal +// fallback (with "" escapes) and returns the unescaped inner text. Trailing junk after +// the closing quote is rejected. +func validatedDoubleQuotedLiteral(s string) (string, bool) { + s = strings.TrimSpace(s) + if len(s) < 2 || s[0] != '"' { + return "", false + } + var inner strings.Builder + for i := 1; i < len(s); i++ { + if s[i] != '"' { + inner.WriteByte(s[i]) + continue + } + if i+1 < len(s) && s[i+1] == '"' { + inner.WriteByte('"') + i++ + continue + } + if i != len(s)-1 { + return "", false + } + return inner.String(), true + } + return "", false +} + +// isSafeBareUUIDDefault reports whether s is a bare UUID-shaped token safe to quote as a +// default (hex digits and hyphens only). +func isSafeBareUUIDDefault(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F', c == '-': + default: + return false + } + } + return true +} + +// safeNumericDefaultLiteral accepts a simple numeric literal, optionally wrapped in +// balanced outer parentheses (e.g. "(0)", "((-1))"). Anything else — expressions, +// statement terminators, unbalanced parentheses — is rejected so it cannot be spliced +// into CREATE TABLE. +func safeNumericDefaultLiteral(def string) (string, bool) { + unwrapped := strings.TrimSpace(unwrapOuterParens(def)) + if unwrapped == "" { + return "", false + } + if _, err := strconv.ParseInt(unwrapped, 10, 64); err == nil { + return unwrapped, true + } + if _, err := strconv.ParseFloat(unwrapped, 64); err == nil { + upper := strings.ToUpper(unwrapped) + if strings.Contains(upper, "INF") || strings.Contains(upper, "NAN") { + return "", false + } + return unwrapped, true + } + lower := strings.ToLower(unwrapped) + if strings.HasPrefix(lower, "0x") { + hex := unwrapped[2:] + if n, err := strconv.ParseUint(hex, 16, 64); err == nil { + return strconv.FormatUint(n, 10), true + } + } + if strings.HasPrefix(lower, "-0x") { + hex := unwrapped[3:] + if n, err := strconv.ParseUint(hex, 16, 63); err == nil { + return strconv.FormatInt(-int64(n), 10), true + } + } + return "", false } // quotePostgresLiteral wraps s as a Postgres single-quoted string literal, doubling any @@ -282,17 +421,6 @@ func quotePostgresLiteral(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" } -// unwrapDoubleQuotedLiteral strips the surrounding double quotes from SQLite's -// double-quoted string-literal fallback syntax (e.g. `"active"`), un-escaping any doubled -// internal quotes. Returns s unchanged if it isn't double-quoted. -func unwrapDoubleQuotedLiteral(s string) string { - if len(s) >= 2 && strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) { - inner := s[1 : len(s)-1] - return strings.ReplaceAll(inner, `""`, `"`) - } - return s -} - // unwrapOuterParens repeatedly strips a single matching outer paren pair that wraps the // entire expression, e.g. "((random()))" -> "random()". func unwrapOuterParens(s string) string { diff --git a/internal/import/d1/convert_test.go b/internal/import/d1/convert_test.go index 5b4d6ebe..8ae1ab16 100644 --- a/internal/import/d1/convert_test.go +++ b/internal/import/d1/convert_test.go @@ -651,3 +651,86 @@ func TestConvertGeneratedColumnHasNoDefault(t *testing.T) { t.Fatalf("generated column must not also emit DEFAULT: %q", got) } } + +// Unrecognized DEFAULT expressions on typed columns must never be emitted verbatim: +// an unbalanced ")" plus ";" escapes CREATE TABLE and injects arbitrary SQL into psql. +func TestConvertDefaultRejectsSQLInjectionOnIntegerColumn(t *testing.T) { + cases := []string{ + `(0)); DROP TABLE users; --`, + `0); DROP TABLE users; --`, + `1; DROP TABLE users; --`, + `(1) + (SELECT 1)`, + } + for _, def := range cases { + got, ok := convertDefault(def, "BIGINT") + if ok || got != "" { + t.Fatalf("convertDefault(%q, BIGINT) = %q, ok=%v; must omit unrecognized/hostile defaults", def, got, ok) + } + } + + ddl := convertTablesDDL(t, `CREATE TABLE evil (n INTEGER DEFAULT (0));`) + if strings.Contains(strings.ToUpper(ddl), "DROP TABLE") { + t.Fatalf("injected DROP must not appear in converted DDL:\n%s", ddl) + } + assertValidPostgresDDL(t, ddl) + + // Direct regression for the reported payload shape on a BIGINT column. + col := ColumnSchema{Name: "n", Type: "INTEGER", DefaultValue: `(0)); DROP TABLE users; --`} + table := TableSchema{Name: "t", Columns: []ColumnSchema{col}} + got := convertColumn(col, table, []TableSchema{table}, nil) + if strings.Contains(strings.ToUpper(got), "DROP TABLE") || strings.Contains(got, ");") { + t.Fatalf("hostile default must not appear in column DDL: %q", got) + } + if strings.Contains(got, "DEFAULT") { + t.Fatalf("hostile default must be omitted entirely: %q", got) + } +} + +func TestConvertDefaultKeepsSafeNumericLiterals(t *testing.T) { + cases := map[string]struct { + def string + pgType string + want string + }{ + "bare zero": {"0", "BIGINT", "0"}, + "parenthesized zero": {"(0)", "BIGINT", "0"}, + "nested parens": {"((-1))", "BIGINT", "-1"}, + "float": {"1.5", "DOUBLE PRECISION", "1.5"}, + "parenthesized float": {"(3.14)", "NUMERIC", "3.14"}, + } + for name, tc := range cases { + got, ok := convertDefault(tc.def, tc.pgType) + if !ok || got != tc.want { + t.Fatalf("%s: convertDefault(%q, %q) = %q, ok=%v; want %q", name, tc.def, tc.pgType, got, ok, tc.want) + } + } +} + +func TestConvertDefaultRejectsBrokenQuotedLiteral(t *testing.T) { + cases := []string{ + `'foo'); DROP TABLE users; --`, + `'unclosed`, + `"active"); DROP TABLE users; --`, + } + for _, def := range cases { + got, ok := convertDefault(def, "TEXT") + if ok || got != "" { + t.Fatalf("convertDefault(%q, TEXT) = %q, ok=%v; must reject broken quotes", def, got, ok) + } + got, ok = convertDefault(def, "BIGINT") + if ok || got != "" { + t.Fatalf("convertDefault(%q, BIGINT) = %q, ok=%v; must reject broken quotes", def, got, ok) + } + } +} + +func TestConvertDefaultUUIDRejectsInjection(t *testing.T) { + got, ok := convertDefault(`x'); DROP TABLE users; --`, "UUID") + if ok || got != "" { + t.Fatalf("UUID default must not quote hostile bare input: got %q ok=%v", got, ok) + } + got, ok = convertDefault(`550e8400-e29b-41d4-a716-446655440000`, "UUID") + if !ok || got != `'550e8400-e29b-41d4-a716-446655440000'` { + t.Fatalf("safe bare UUID = %q ok=%v", got, ok) + } +} From 6bf3a549e4b229da4f18e4aa1d6cbfeb0d716859 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Jul 2026 22:32:56 +0000 Subject: [PATCH 2/5] d1 import: stop parseTableBody at balanced CREATE TABLE close parseTableBody used the last ')' in RawDDL, so a dump that smuggled "); DROP ...; CREATE TABLE ..." after a real REFERENCES close pulled attacker SQL into the column/FK fragment. Match the opening paren instead, truncate RawDDL at that close during ParseDump, and cover the reported REFERENCES-tail injection end-to-end. Co-authored-by: Mike Coutermarsh --- internal/import/d1/constraints_test.go | 25 +++++++++++++++++++++++++ internal/import/d1/parse.go | 23 ++++++++++++++++++++--- internal/import/d1/parse_test.go | 17 +++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/internal/import/d1/constraints_test.go b/internal/import/d1/constraints_test.go index b202c314..7bc12cba 100644 --- a/internal/import/d1/constraints_test.go +++ b/internal/import/d1/constraints_test.go @@ -123,6 +123,31 @@ func TestConvertReferencesClauseDropsInjectionTail(t *testing.T) { } } +// End-to-end: attacker dumps smuggle "); DROP ...; CREATE TABLE ..." after a real +// REFERENCES ... ON DELETE CASCADE close. Parse must stop at the balanced CREATE TABLE +// close, and conversion must never emit the injected statements into executed DDL. +func TestConvertReferencesTailInjectionEndToEnd(t *testing.T) { + sql := `CREATE TABLE a (id INTEGER PRIMARY KEY); +CREATE TABLE b ( x INTEGER REFERENCES a(id) ON DELETE CASCADE); DROP TABLE users; CREATE TABLE dummy (z int ); +` + ddl := convertTablesDDL(t, sql) + upper := strings.ToUpper(ddl) + if strings.Contains(upper, "DROP TABLE") { + t.Fatalf("injected DROP must not appear in converted DDL:\n%s", ddl) + } + if strings.Contains(upper, `"DUMMY"`) || strings.Contains(ddl, `"dummy"`) { + t.Fatalf("smuggled CREATE TABLE must not appear in converted DDL:\n%s", ddl) + } + if !strings.Contains(ddl, `REFERENCES "a" ("id")`) { + t.Fatalf("expected safe REFERENCES clause:\n%s", ddl) + } + // Legitimate action should survive once the trailing junk is excluded from the body. + if !strings.Contains(ddl, `ON DELETE CASCADE`) { + t.Fatalf("expected ON DELETE CASCADE preserved after stripping smuggled SQL:\n%s", ddl) + } + assertValidPostgresDDL(t, ddl) +} + func TestQuoteColumnListStripsIndexedColumnModifiers(t *testing.T) { got := quoteColumnList("a, b DESC") want := `"a", "b"` diff --git a/internal/import/d1/parse.go b/internal/import/d1/parse.go index 3b9a073b..e97d72d1 100644 --- a/internal/import/d1/parse.go +++ b/internal/import/d1/parse.go @@ -69,7 +69,18 @@ func ParseDump(path string) ([]TableSchema, error) { if current == nil { return } - current.RawDDL = strings.Join(ddlLines, "\n") + raw := strings.Join(ddlLines, "\n") + // Truncate at the CREATE TABLE's balanced column-list close so trailing + // attacker statements on the same line never become part of RawDDL. + if start := strings.Index(raw, "("); start >= 0 { + if end, ok := matchingParenEnd(raw, start); ok { + raw = strings.TrimSpace(raw[:end+1]) + if !strings.HasSuffix(raw, ";") { + raw += ";" + } + } + } + current.RawDDL = raw current.Columns, current.Constraints = parseTableBody(current.RawDDL) tables = append(tables, *current) current = nil @@ -131,8 +142,14 @@ func ParseDump(path string) ([]TableSchema, error) { func parseTableBody(ddl string) ([]ColumnSchema, []string) { start := strings.Index(ddl, "(") - end := strings.LastIndex(ddl, ")") - if start < 0 || end <= start { + if start < 0 { + return nil, nil + } + // Match the opening paren of the column list — never strings.LastIndex. + // A dump that smuggles "); DROP ...; CREATE TABLE ..." after the real close + // would otherwise pull attacker SQL into a column/REFERENCES fragment. + end, ok := matchingParenEnd(ddl, start) + if !ok { return nil, nil } body := stripSQLComments(ddl[start+1 : end]) diff --git a/internal/import/d1/parse_test.go b/internal/import/d1/parse_test.go index 3b8f6429..e136f78c 100644 --- a/internal/import/d1/parse_test.go +++ b/internal/import/d1/parse_test.go @@ -146,3 +146,20 @@ func TestExtractCheckClausesMultiple(t *testing.T) { t.Fatalf("cleaned still contains CHECK: %q", cleaned) } } + +func TestParseTableBodyIgnoresSmuggledSQLAfterClose(t *testing.T) { + ddl := `CREATE TABLE b ( x INTEGER REFERENCES a(id) ON DELETE CASCADE); DROP TABLE users; CREATE TABLE dummy (z int );` + cols, constraints := parseTableBody(ddl) + if len(constraints) != 0 { + t.Fatalf("constraints = %#v", constraints) + } + if len(cols) != 1 { + t.Fatalf("cols = %#v", cols) + } + if cols[0].ForeignKey != `REFERENCES a(id) ON DELETE CASCADE` { + t.Fatalf("ForeignKey = %q, want REFERENCES without smuggled SQL", cols[0].ForeignKey) + } + if strings.Contains(strings.ToUpper(cols[0].ForeignKey), "DROP") { + t.Fatalf("ForeignKey must not include injected DROP: %q", cols[0].ForeignKey) + } +} From e1bc8c9b21949144b02b3cd2f2ed728ba3c4e6c5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Jul 2026 22:33:18 +0000 Subject: [PATCH 3/5] d1 import: run injection assertions before optional Postgres skip assertValidPostgresDDL calls t.Skip when no local Postgres is available, which skipped later unit assertions in the DEFAULT injection regression. Co-authored-by: Mike Coutermarsh --- internal/import/d1/convert_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/import/d1/convert_test.go b/internal/import/d1/convert_test.go index 8ae1ab16..7ad9e5b0 100644 --- a/internal/import/d1/convert_test.go +++ b/internal/import/d1/convert_test.go @@ -672,7 +672,6 @@ func TestConvertDefaultRejectsSQLInjectionOnIntegerColumn(t *testing.T) { if strings.Contains(strings.ToUpper(ddl), "DROP TABLE") { t.Fatalf("injected DROP must not appear in converted DDL:\n%s", ddl) } - assertValidPostgresDDL(t, ddl) // Direct regression for the reported payload shape on a BIGINT column. col := ColumnSchema{Name: "n", Type: "INTEGER", DefaultValue: `(0)); DROP TABLE users; --`} @@ -684,6 +683,7 @@ func TestConvertDefaultRejectsSQLInjectionOnIntegerColumn(t *testing.T) { if strings.Contains(got, "DEFAULT") { t.Fatalf("hostile default must be omitted entirely: %q", got) } + assertValidPostgresDDL(t, ddl) } func TestConvertDefaultKeepsSafeNumericLiterals(t *testing.T) { From 8762d166ab37cd090140d19cfa72159c206fbc35 Mon Sep 17 00:00:00 2001 From: Mike Coutermarsh Date: Thu, 23 Jul 2026 07:51:21 -0400 Subject: [PATCH 4/5] d1 import: keep literal comment gofmt-stable Co-authored-by: Cursor --- internal/import/d1/convert.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/import/d1/convert.go b/internal/import/d1/convert.go index 34804530..9cec42ae 100644 --- a/internal/import/d1/convert.go +++ b/internal/import/d1/convert.go @@ -313,7 +313,7 @@ func convertUUIDDefault(def string) (string, bool) { } // validatedSingleQuotedLiteral accepts a complete SQLite/Postgres single-quoted string -// literal (with '' escapes) and rejects unclosed quotes or trailing junk after the +// literal (with doubled-apostrophe escapes) and rejects unclosed quotes or trailing junk after the // closing quote — both of which can break out of DEFAULT into adjacent SQL. func validatedSingleQuotedLiteral(s string) (string, bool) { s = strings.TrimSpace(s) From 67d681a2acceac9f0cdb46f40c0ffa003c27cb90 Mon Sep 17 00:00:00 2001 From: Mike Coutermarsh Date: Thu, 23 Jul 2026 07:55:12 -0400 Subject: [PATCH 5/5] d1 import: omit invalid table foreign keys Co-authored-by: Cursor --- internal/import/d1/constraints.go | 5 ++++- internal/import/d1/constraints_test.go | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/internal/import/d1/constraints.go b/internal/import/d1/constraints.go index 22824ce5..0f4f8f3a 100644 --- a/internal/import/d1/constraints.go +++ b/internal/import/d1/constraints.go @@ -84,7 +84,7 @@ func convertNamedConstraint(clause string, table TableSchema, all []TableSchema, } converted := convertTableConstraint(body, table, all, ctx) if converted == "" { - return clause + return "" } return "CONSTRAINT " + postgres.QuoteIdentifier(name) + " " + converted } @@ -373,6 +373,9 @@ func convertForeignKeyConstraint(clause string, table TableSchema, all []TableSc } cols := quoteColumnListFor(m[1], &table) refs := convertReferencesClause(strings.TrimSpace(m[2]), all) + if cols == "" || refs == "" { + return "" + } return "FOREIGN KEY (" + cols + ") " + refs } diff --git a/internal/import/d1/constraints_test.go b/internal/import/d1/constraints_test.go index 7bc12cba..6cc7fee2 100644 --- a/internal/import/d1/constraints_test.go +++ b/internal/import/d1/constraints_test.go @@ -123,6 +123,17 @@ func TestConvertReferencesClauseDropsInjectionTail(t *testing.T) { } } +func TestConvertTableForeignKeyDropsUnparseableReferences(t *testing.T) { + for _, clause := range []string{ + `FOREIGN KEY (user_id) REFERENCES invalid`, + `CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES invalid`, + } { + if got := convertTableConstraint(clause, TableSchema{}, nil, nil); got != "" { + t.Errorf("unparseable table REFERENCES must be omitted, got %q", got) + } + } +} + // End-to-end: attacker dumps smuggle "); DROP ...; CREATE TABLE ..." after a real // REFERENCES ... ON DELETE CASCADE close. Parse must stop at the balanced CREATE TABLE // close, and conversion must never emit the injected statements into executed DDL.