From c31830468c5d43b3ae02d157498c5f2f468f766d Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 13 Jul 2026 21:25:46 +0200 Subject: [PATCH] fix: enforce case-insensitive genre uniqueness genres had only case-sensitive name UNIQUE while authors/tags use UNIQUE (lower(name)); sync_genres could race two case variants into duplicate rows (audit DB-3). Migration 0038 pre-dedups then adds UNIQUE (lower(name)); sync_genres upserts ON CONFLICT (lower(name)). Tested. Co-Authored-By: Claude Opus 4.8 --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- .../0038_genres_name_lower_unique.sql | 45 +++++++++++++++++++ backend/src/repo/crawler.rs | 5 ++- backend/tests/crawler_sync.rs | 17 +++++++ frontend/package.json | 2 +- 6 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 backend/migrations/0038_genres_name_lower_unique.sql diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 94b79d6..dd23e30 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.128.12" +version = "0.128.13" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 0033d4b..50709f9 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.128.12" +version = "0.128.13" edition = "2021" default-run = "mangalord" diff --git a/backend/migrations/0038_genres_name_lower_unique.sql b/backend/migrations/0038_genres_name_lower_unique.sql new file mode 100644 index 0000000..b9096f6 --- /dev/null +++ b/backend/migrations/0038_genres_name_lower_unique.sql @@ -0,0 +1,45 @@ +-- Enforce case-insensitive genre uniqueness, matching `authors` and `tags` +-- (both got `UNIQUE (lower(name))` in 0009). `genres` only had a case-SENSITIVE +-- `name UNIQUE`, but the crawler's `sync_genres` treats genres as +-- case-insensitive (`WHERE lower(name) = lower($1)`) and can INSERT a new row +-- from a source string. Under a race (or across ticks) two different-cased +-- strings — "Isekai" vs "isekai" — could both miss the pre-check and both +-- insert, since the exact-name unique doesn't collide. That leaves duplicate +-- genre rows for one logical genre. + +-- Heal any pre-existing case-variant duplicates before adding the index, so +-- `sqlx::migrate!` can't crash on a dirty table (mirrors 0031's pre-dedup). +-- Keep the lowest id per lower(name) as canonical and repoint manga_genres. +UPDATE manga_genres mg + SET genre_id = d.keep_id + FROM ( + SELECT g.id AS dup_id, k.keep_id + FROM genres g + JOIN (SELECT lower(name) AS lname, (array_agg(id ORDER BY id))[1] AS keep_id + FROM genres GROUP BY lower(name)) k + ON lower(g.name) = k.lname AND g.id <> k.keep_id + ) d + WHERE mg.genre_id = d.dup_id + AND NOT EXISTS ( + SELECT 1 FROM manga_genres x + WHERE x.manga_id = mg.manga_id AND x.genre_id = d.keep_id + ); + +-- Drop links that would have collided with the canonical link (already present). +DELETE FROM manga_genres mg + USING ( + SELECT g.id AS dup_id + FROM genres g + JOIN (SELECT lower(name) AS lname, (array_agg(id ORDER BY id))[1] AS keep_id + FROM genres GROUP BY lower(name)) k + ON lower(g.name) = k.lname AND g.id <> k.keep_id + ) d + WHERE mg.genre_id = d.dup_id; + +-- Remove the now-orphaned duplicate genre rows. +DELETE FROM genres g + USING (SELECT lower(name) AS lname, (array_agg(id ORDER BY id))[1] AS keep_id + FROM genres GROUP BY lower(name)) k + WHERE lower(g.name) = k.lname AND g.id <> k.keep_id; + +CREATE UNIQUE INDEX genres_name_lower_uniq ON genres (lower(name)); diff --git a/backend/src/repo/crawler.rs b/backend/src/repo/crawler.rs index 329949c..36f3dcd 100644 --- a/backend/src/repo/crawler.rs +++ b/backend/src/repo/crawler.rs @@ -247,10 +247,13 @@ async fn sync_genres( let genre_id = match existing { Some((id,)) => id, None => { + // Conflict on lower(name) (0038) not name, so a racing insert of + // a differently-cased variant resolves to the existing row + // instead of raising a unique violation. let (id,): (Uuid,) = sqlx::query_as( r#" INSERT INTO genres (name) VALUES ($1) - ON CONFLICT (name) DO UPDATE SET name = genres.name + ON CONFLICT (lower(name)) DO UPDATE SET name = genres.name RETURNING id "#, ) diff --git a/backend/tests/crawler_sync.rs b/backend/tests/crawler_sync.rs index f57b249..5c4ebc2 100644 --- a/backend/tests/crawler_sync.rs +++ b/backend/tests/crawler_sync.rs @@ -674,6 +674,23 @@ async fn arbitrary_genres_from_source_get_inserted(pool: PgPool) { assert_eq!(webtoons_count.0, 1, "case-insensitive lookup reuses the existing row"); } +#[sqlx::test(migrations = "./migrations")] +async fn genres_reject_case_variant_duplicates_at_the_db(pool: PgPool) { + // The sequential pre-check in sync_genres dedups the common case, but two + // concurrent inserts could each miss it. The lower(name) unique index (0038) + // is the race backstop: a case variant of an existing genre must be rejected + // by the DB itself. "Action" is seeded by 0009. + let dup = sqlx::query("INSERT INTO genres (name) VALUES ('action')") + .execute(&pool) + .await; + let err = dup.expect_err("a case-variant of a seeded genre must violate the unique index"); + let msg = err.to_string(); + assert!( + msg.contains("genres_name_lower_uniq") || msg.contains("unique") || msg.contains("duplicate"), + "expected a unique-violation, got: {msg}" + ); +} + /// User-attached tags (rows with non-NULL `added_by` in `manga_tags`) /// must survive a crawler upsert. The crawler owns source-attached tags /// (added_by IS NULL); user attachments are owned by the user who made diff --git a/frontend/package.json b/frontend/package.json index 19f4117..9b01c0b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.128.12", + "version": "0.128.13", "private": true, "type": "module", "scripts": {