diff --git a/internal/jellycompat/playback_sessions_postgres.go b/internal/jellycompat/playback_sessions_postgres.go index 99599521..92318176 100644 --- a/internal/jellycompat/playback_sessions_postgres.go +++ b/internal/jellycompat/playback_sessions_postgres.go @@ -42,6 +42,13 @@ var ( var jsonNULCodePoint = []byte(`\u0000`) +// compatNegotiationLockSQL takes token, device ID, and route item ID as +// separate text parameters and joins them with chr(31) inside PostgreSQL. +const compatNegotiationLockSQL = ` + SELECT pg_advisory_xact_lock( + hashtextextended($1 || chr(31) || $2 || chr(31) || $3, 0) + )` + // marshalPlaybackSession removes NUL code points from every nested string in // the JSON document. PostgreSQL JSONB rejects U+0000 even when Go's encoder // represents it as \u0000. Escaped literal text (\\u0000) remains unchanged. @@ -218,8 +225,14 @@ func (d *DurableCompatPlaybackStore) replaceUnstartedNegotiation( var removed []string if session.CompatToken != "" && session.ClientDeviceID != "" && session.RouteItemID != "" { - scope := session.CompatToken + "\x00" + session.ClientDeviceID + "\x00" + session.RouteItemID - if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, scope); err != nil { + // Join the lock key inside PostgreSQL. A Go-side NUL delimiter cannot + // be bound as text: PostgreSQL rejects U+0000 (SQLSTATE 22021), the + // transaction aborts, and the negotiated session stays cache-only. + if _, err := tx.Exec(ctx, compatNegotiationLockSQL, + stripCompatNUL(session.CompatToken), + stripCompatNUL(session.ClientDeviceID), + stripCompatNUL(session.RouteItemID), + ); err != nil { return nil, err } rows, err := tx.Query(ctx, ` diff --git a/internal/jellycompat/playback_sessions_postgres_test.go b/internal/jellycompat/playback_sessions_postgres_test.go index 265d2efe..dbd0fcb5 100644 --- a/internal/jellycompat/playback_sessions_postgres_test.go +++ b/internal/jellycompat/playback_sessions_postgres_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "testing" "time" @@ -13,6 +14,15 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) +func TestCompatNegotiationLockSQLDoesNotBindNULDelimiter(t *testing.T) { + if strings.Contains(compatNegotiationLockSQL, "\x00") { + t.Fatal("advisory lock SQL must not embed a NUL delimiter") + } + if !strings.Contains(compatNegotiationLockSQL, "chr(31)") { + t.Fatal("advisory lock SQL should join key parts with chr(31) inside PostgreSQL") + } +} + func TestMarshalPlaybackSessionStripsNestedNUL(t *testing.T) { wantLiteral := `literal\u0000text` data, err := marshalPlaybackSession(PlaybackSession{