From 023c789a3190a92e49c30b3385ade0e983008f41 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:46:12 +0100 Subject: [PATCH 01/37] wip: delphi trace transform rules --- Cargo.lock | 91 ++++- Cargo.toml | 2 + apps/frontend/src/pages/moderation.vue | 25 +- .../moderation/technical-review/index.vue | 9 + .../moderation/technical-review/rules.vue | 288 +++++++++++++ ...1078cb48ccd645f059e257cd204de024955b8.json | 14 + ...05f834dbb9b88b834186b1353f116a07c3421.json | 60 +++ ...a9ff55fc6dda4b154dc3136478d3772e06020.json | 61 +++ ...6f7de5ddc18e2014470d2b88626a9b37d30fe.json | 42 ++ ...70a71b32d7fe35bf5f11654e1653c63245078.json | 80 ++++ ...37aa1023b436d06b4412a559553dd5452029e.json | 22 + apps/labrinth/Cargo.toml | 1 + .../20260715120000_delphi_rules.sql | 50 +++ apps/labrinth/src/routes/internal/mod.rs | 4 + .../routes/internal/moderation/tech_review.rs | 2 + .../internal/moderation/tech_review/rules.rs | 379 ++++++++++++++++++ .../modules/labrinth/tech-review/internal.ts | 48 +++ .../api-client/src/modules/labrinth/types.ts | 20 + packages/delphi-cel-experiment/Cargo.toml | 10 + packages/delphi-cel-experiment/src/main.rs | 173 ++++++++ 20 files changed, 1376 insertions(+), 5 deletions(-) create mode 100644 apps/frontend/src/pages/moderation/technical-review/rules.vue create mode 100644 apps/labrinth/.sqlx/query-2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8.json create mode 100644 apps/labrinth/.sqlx/query-3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421.json create mode 100644 apps/labrinth/.sqlx/query-3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020.json create mode 100644 apps/labrinth/.sqlx/query-882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe.json create mode 100644 apps/labrinth/.sqlx/query-90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078.json create mode 100644 apps/labrinth/.sqlx/query-9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e.json create mode 100644 apps/labrinth/migrations/20260715120000_delphi_rules.sql create mode 100644 apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs create mode 100644 packages/delphi-cel-experiment/Cargo.toml create mode 100644 packages/delphi-cel-experiment/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 1c3493e8e0..c7b1fff036 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -437,6 +437,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "antlr4rust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093d520274bfff7278d776f7ea12981a0a0a6f96db90964658e0f38fc6e9a6a6" +dependencies = [ + "better_any", + "bit-set 0.8.0", + "byteorder", + "lazy_static", + "murmur3", + "once_cell", + "parking_lot", + "typed-arena", + "uuid 1.23.3", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -1415,10 +1432,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c" dependencies = [ "base64 0.21.7", - "pastey", + "pastey 0.1.1", "serde", ] +[[package]] +name = "better_any" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4372b9543397a4b86050cc5e7ee36953edf4bac9518e8a774c2da694977fb6e4" + [[package]] name = "bindgen" version = "0.72.1" @@ -1445,7 +1468,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -1454,6 +1486,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit_field" version = "0.10.3" @@ -1883,6 +1921,22 @@ dependencies = [ "shlex", ] +[[package]] +name = "cel" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed39583e427bf41d93c28c7f27c943a93bcb8697220ff3575fd53a9e13f3814" +dependencies = [ + "antlr4rust", + "base64 0.22.1", + "lazy_static", + "nom 7.1.3", + "pastey 0.2.3", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "censor" version = "0.3.0" @@ -2843,6 +2897,15 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" +[[package]] +name = "delphi-cel-experiment" +version = "0.1.0" +dependencies = [ + "cel", + "serde", + "serde_json", +] + [[package]] name = "der" version = "0.7.10" @@ -3508,7 +3571,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex-automata", "regex-syntax", ] @@ -5433,6 +5496,7 @@ dependencies = [ "base64 0.22.1", "bitflags 2.9.4", "bytes", + "cel", "censor", "chrono", "clap 4.5.48", @@ -6170,6 +6234,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb585ade2549a017db2e35978b77c319214fa4b37cede841e27954dd6e8f3ca8" +[[package]] +name = "murmur3" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a198f9589efc03f544388dfc4a19fe8af4323662b62f598b8dcfdac62c14771c" +dependencies = [ + "byteorder", +] + [[package]] name = "mutually_exclusive_features" version = "0.1.0" @@ -7178,6 +7251,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "path-util" version = "0.0.0" @@ -11671,6 +11750,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typed-path" version = "0.12.0" diff --git a/Cargo.toml b/Cargo.toml index a5aed6cf76..5d1fa375b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "packages/app-lib", "packages/ariadne", "packages/daedalus", + "packages/delphi-cel-experiment", "packages/labrinth-derive", "packages/modrinth-content-management", "packages/modrinth-log", @@ -55,6 +56,7 @@ bon = "3.9.3" bytemuck = "1.24.0" bytes = "1.10.1" censor = "0.3.0" +cel = { version = "0.14.0", default-features = false, features = ["json"] } chardetng = "0.1.17" chrono = "0.4.42" cidre = { version = "0.15.0", default-features = false, features = [ diff --git a/apps/frontend/src/pages/moderation.vue b/apps/frontend/src/pages/moderation.vue index 9352acabba..dc4a051390 100644 --- a/apps/frontend/src/pages/moderation.vue +++ b/apps/frontend/src/pages/moderation.vue @@ -15,7 +15,14 @@ diff --git a/apps/labrinth/.sqlx/query-2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8.json b/apps/labrinth/.sqlx/query-2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8.json new file mode 100644 index 0000000000..5c2a54af63 --- /dev/null +++ b/apps/labrinth/.sqlx/query-2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tUPDATE delphi_rule_revisions\n\t\tSET active = FALSE\n\t\tWHERE rule_id = $1 AND active\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8" +} diff --git a/apps/labrinth/.sqlx/query-3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421.json b/apps/labrinth/.sqlx/query-3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421.json new file mode 100644 index 0000000000..e22ef8fa7c --- /dev/null +++ b/apps/labrinth/.sqlx/query-3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tINSERT INTO delphi_rules (\n\t\t\tname,\n\t\t\tpriority,\n\t\t\tcreated_by,\n\t\t\tupdated_by\n\t\t)\n\t\tVALUES ($1, $2, $3, $3)\n\t\tRETURNING id, name, priority, created, updated, created_by, updated_by\n\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "created_by", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "updated_by", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421" +} diff --git a/apps/labrinth/.sqlx/query-3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020.json b/apps/labrinth/.sqlx/query-3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020.json new file mode 100644 index 0000000000..0caf56d981 --- /dev/null +++ b/apps/labrinth/.sqlx/query-3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020.json @@ -0,0 +1,61 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tUPDATE delphi_rules\n\t\tSET\n\t\t\tname = $2,\n\t\t\tpriority = $3,\n\t\t\tupdated = CURRENT_TIMESTAMP,\n\t\t\tupdated_by = $4\n\t\tWHERE id = $1\n\t\tRETURNING id, name, priority, created, updated, created_by, updated_by\n\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "created_by", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "updated_by", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Int4", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020" +} diff --git a/apps/labrinth/.sqlx/query-882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe.json b/apps/labrinth/.sqlx/query-882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe.json new file mode 100644 index 0000000000..de87121fdd --- /dev/null +++ b/apps/labrinth/.sqlx/query-882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tINSERT INTO delphi_rule_revisions (rule_id, expression, created_by)\n\t\tVALUES ($1, $2, $3)\n\t\tRETURNING id, expression, created, created_by\n\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "expression", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_by", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe" +} diff --git a/apps/labrinth/.sqlx/query-90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078.json b/apps/labrinth/.sqlx/query-90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078.json new file mode 100644 index 0000000000..80d5ade693 --- /dev/null +++ b/apps/labrinth/.sqlx/query-90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078.json @@ -0,0 +1,80 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tSELECT\n\t\t\tr.id,\n\t\t\tr.name,\n\t\t\tr.priority,\n\t\t\tr.created,\n\t\t\tr.updated,\n\t\t\tr.created_by,\n\t\t\tr.updated_by,\n\t\t\trr.id AS revision_id,\n\t\t\trr.expression,\n\t\t\trr.created AS revision_created,\n\t\t\trr.created_by AS revision_created_by\n\t\tFROM delphi_rules r\n\t\tINNER JOIN delphi_rule_revisions rr\n\t\t\tON rr.rule_id = r.id\n\t\t\tAND rr.active\n\t\tORDER BY r.priority DESC, r.id\n\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "created_by", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "updated_by", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "revision_id", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "expression", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "revision_created", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "revision_created_by", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + false, + false, + false, + true + ] + }, + "hash": "90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078" +} diff --git a/apps/labrinth/.sqlx/query-9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e.json b/apps/labrinth/.sqlx/query-9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e.json new file mode 100644 index 0000000000..97b8370fb4 --- /dev/null +++ b/apps/labrinth/.sqlx/query-9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM delphi_rules WHERE id = $1 RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e" +} diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 3da97e6fa8..50ac869f4a 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -34,6 +34,7 @@ base64 = { workspace = true } bitflags = { workspace = true } bytes = { workspace = true } censor = { workspace = true } +cel = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } clickhouse = { workspace = true, features = ["time", "uuid"] } diff --git a/apps/labrinth/migrations/20260715120000_delphi_rules.sql b/apps/labrinth/migrations/20260715120000_delphi_rules.sql new file mode 100644 index 0000000000..527528dcaa --- /dev/null +++ b/apps/labrinth/migrations/20260715120000_delphi_rules.sql @@ -0,0 +1,50 @@ +CREATE TABLE delphi_rules ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + name TEXT NOT NULL CHECK (BTRIM(name) <> ''), + priority INTEGER NOT NULL DEFAULT 0, + created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by BIGINT REFERENCES users (id) + ON DELETE SET NULL + ON UPDATE CASCADE, + updated_by BIGINT REFERENCES users (id) + ON DELETE SET NULL + ON UPDATE CASCADE +); + +CREATE TABLE delphi_rule_revisions ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + rule_id BIGINT NOT NULL REFERENCES delphi_rules (id) + ON DELETE CASCADE + ON UPDATE CASCADE, + expression TEXT NOT NULL CHECK (BTRIM(expression) <> ''), + active BOOLEAN NOT NULL DEFAULT TRUE, + created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by BIGINT REFERENCES users (id) + ON DELETE SET NULL + ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX delphi_rule_revisions_active + ON delphi_rule_revisions (rule_id) + WHERE active; + +CREATE INDEX delphi_rule_revisions_rule_id + ON delphi_rule_revisions (rule_id, created DESC); + +CREATE TABLE delphi_rule_effects ( + rule_revision_id BIGINT NOT NULL REFERENCES delphi_rule_revisions (id) + ON DELETE CASCADE + ON UPDATE CASCADE, + issue_detail_id BIGINT NOT NULL REFERENCES delphi_report_issue_details (id) + ON DELETE CASCADE + ON UPDATE CASCADE, + severity delphi_severity, + hidden BOOLEAN NOT NULL DEFAULT FALSE, + created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (rule_revision_id, issue_detail_id), + CHECK (severity IS NOT NULL OR hidden) +); + +CREATE INDEX delphi_rule_effects_issue_detail_id + ON delphi_rule_effects (issue_detail_id); diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index 8ab03e1d12..acb31a4f36 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -115,6 +115,10 @@ pub fn config(cfg: &mut web::ServiceConfig) { moderation::tech_review::search_projects, moderation::tech_review::global::search_global_issue_details, moderation::tech_review::global::get_global_issue_detail, + moderation::tech_review::rules::get_rules, + moderation::tech_review::rules::create_rule, + moderation::tech_review::rules::update_rule, + moderation::tech_review::rules::delete_rule, moderation::tech_review::get_project_report, moderation::tech_review::submit_report, moderation::tech_review::update_issue_details, diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review.rs b/apps/labrinth/src/routes/internal/moderation/tech_review.rs index 2bd5097c58..fedf2288f2 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review.rs @@ -44,10 +44,12 @@ use crate::{ use eyre::eyre; pub mod global; +pub mod rules; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(search_projects) .configure(global::config) + .configure(rules::config) .service(get_project_report) .service(get_report) .service(get_issue) diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs new file mode 100644 index 0000000000..f0946c3de6 --- /dev/null +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs @@ -0,0 +1,379 @@ +use actix_web::{HttpRequest, delete, get, post, put, web}; +use chrono::{DateTime, Utc}; +use eyre::eyre; +use serde::{Deserialize, Serialize}; + +use crate::{ + auth::check_is_moderator_from_headers, + database::{PgPool, ReadOnlyPgPool, redis::RedisPool}, + models::pats::Scopes, + queue::session::AuthQueue, + routes::ApiError, + util::error::Context, +}; + +const MAX_RULE_NAME_LENGTH: usize = 128; +const MAX_RULE_EXPRESSION_LENGTH: usize = 65_536; + +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(get_rules) + .service(create_rule) + .service(update_rule) + .service(delete_rule); +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct DelphiRule { + pub id: i64, + pub name: String, + pub priority: i32, + pub expression: String, + pub revision_id: i64, + pub created: DateTime, + pub updated: DateTime, + pub revision_created: DateTime, + pub created_by: Option, + pub updated_by: Option, + pub revision_created_by: Option, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct WriteDelphiRule { + pub name: String, + pub priority: i32, + pub expression: String, +} + +struct ValidatedRule { + name: String, + priority: i32, + expression: String, +} + +impl WriteDelphiRule { + fn validate(self) -> Result { + let name = self.name.trim().to_string(); + if name.is_empty() { + return Err(ApiError::Request(eyre!("rule name cannot be empty"))); + } + if name.chars().count() > MAX_RULE_NAME_LENGTH { + return Err(ApiError::Request(eyre!( + "rule name cannot exceed {MAX_RULE_NAME_LENGTH} characters" + ))); + } + + let expression = self.expression.trim().to_string(); + if expression.is_empty() { + return Err(ApiError::Request(eyre!( + "rule expression cannot be empty" + ))); + } + if expression.len() > MAX_RULE_EXPRESSION_LENGTH { + return Err(ApiError::Request(eyre!( + "rule expression cannot exceed {MAX_RULE_EXPRESSION_LENGTH} bytes" + ))); + } + + cel::Program::compile(&expression).map_err(|error| { + ApiError::Request(eyre!("invalid cel expression: {error}")) + })?; + + Ok(ValidatedRule { + name, + priority: self.priority, + expression, + }) + } +} + +/// List the current revision of every Delphi rule. +#[utoipa::path( + context_path = "/moderation/tech-review", + tag = "moderation", + security(("bearer_auth" = [])), + responses((status = OK, body = Vec)) +)] +#[get("/rules")] +pub async fn get_rules( + req: HttpRequest, + pool: web::Data, + ro_pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result>, ApiError> { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await?; + + let rows = sqlx::query!( + r#" + SELECT + r.id, + r.name, + r.priority, + r.created, + r.updated, + r.created_by, + r.updated_by, + rr.id AS revision_id, + rr.expression, + rr.created AS revision_created, + rr.created_by AS revision_created_by + FROM delphi_rules r + INNER JOIN delphi_rule_revisions rr + ON rr.rule_id = r.id + AND rr.active + ORDER BY r.priority DESC, r.id + "#, + ) + .fetch_all(&***ro_pool) + .await + .wrap_internal_err("failed to fetch delphi rules")?; + + Ok(web::Json( + rows.into_iter() + .map(|row| DelphiRule { + id: row.id, + name: row.name, + priority: row.priority, + expression: row.expression, + revision_id: row.revision_id, + created: row.created, + updated: row.updated, + revision_created: row.revision_created, + created_by: row.created_by, + updated_by: row.updated_by, + revision_created_by: row.revision_created_by, + }) + .collect(), + )) +} + +/// Create a Delphi rule and its first revision. +#[utoipa::path( + context_path = "/moderation/tech-review", + tag = "moderation", + security(("bearer_auth" = [])), + request_body = WriteDelphiRule, + responses((status = OK, body = DelphiRule)) +)] +#[post("/rules")] +pub async fn create_rule( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + body: web::Json, +) -> Result, ApiError> { + let user = check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_WRITE, + ) + .await?; + let rule = body.into_inner().validate()?; + let user_id = user.id.0 as i64; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("failed to begin delphi rule transaction")?; + + let row = sqlx::query!( + r#" + INSERT INTO delphi_rules ( + name, + priority, + created_by, + updated_by + ) + VALUES ($1, $2, $3, $3) + RETURNING id, name, priority, created, updated, created_by, updated_by + "#, + rule.name, + rule.priority, + user_id, + ) + .fetch_one(&mut transaction) + .await + .wrap_internal_err("failed to create delphi rule")?; + + let revision = sqlx::query!( + r#" + INSERT INTO delphi_rule_revisions (rule_id, expression, created_by) + VALUES ($1, $2, $3) + RETURNING id, expression, created, created_by + "#, + row.id, + rule.expression, + user_id, + ) + .fetch_one(&mut transaction) + .await + .wrap_internal_err("failed to create delphi rule revision")?; + + transaction + .commit() + .await + .wrap_internal_err("failed to commit delphi rule transaction")?; + + Ok(web::Json(DelphiRule { + id: row.id, + name: row.name, + priority: row.priority, + expression: revision.expression, + revision_id: revision.id, + created: row.created, + updated: row.updated, + revision_created: revision.created, + created_by: row.created_by, + updated_by: row.updated_by, + revision_created_by: revision.created_by, + })) +} + +/// Replace a Delphi rule and create a new current revision. +#[utoipa::path( + context_path = "/moderation/tech-review", + tag = "moderation", + security(("bearer_auth" = [])), + request_body = WriteDelphiRule, + responses((status = OK, body = DelphiRule), (status = NOT_FOUND)) +)] +#[put("/rules/{id}")] +pub async fn update_rule( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + path: web::Path<(i64,)>, + body: web::Json, +) -> Result, ApiError> { + let user = check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_WRITE, + ) + .await?; + let (id,) = path.into_inner(); + let rule = body.into_inner().validate()?; + let user_id = user.id.0 as i64; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("failed to begin delphi rule transaction")?; + + let row = sqlx::query!( + r#" + UPDATE delphi_rules + SET + name = $2, + priority = $3, + updated = CURRENT_TIMESTAMP, + updated_by = $4 + WHERE id = $1 + RETURNING id, name, priority, created, updated, created_by, updated_by + "#, + id, + rule.name, + rule.priority, + user_id, + ) + .fetch_optional(&mut transaction) + .await + .wrap_internal_err("failed to update delphi rule")? + .ok_or(ApiError::NotFound)?; + + sqlx::query!( + r#" + UPDATE delphi_rule_revisions + SET active = FALSE + WHERE rule_id = $1 AND active + "#, + id, + ) + .execute(&mut transaction) + .await + .wrap_internal_err("failed to deactivate delphi rule revision")?; + + let revision = sqlx::query!( + r#" + INSERT INTO delphi_rule_revisions (rule_id, expression, created_by) + VALUES ($1, $2, $3) + RETURNING id, expression, created, created_by + "#, + id, + rule.expression, + user_id, + ) + .fetch_one(&mut transaction) + .await + .wrap_internal_err("failed to create delphi rule revision")?; + + transaction + .commit() + .await + .wrap_internal_err("failed to commit delphi rule transaction")?; + + Ok(web::Json(DelphiRule { + id: row.id, + name: row.name, + priority: row.priority, + expression: revision.expression, + revision_id: revision.id, + created: row.created, + updated: row.updated, + revision_created: revision.created, + created_by: row.created_by, + updated_by: row.updated_by, + revision_created_by: revision.created_by, + })) +} + +/// Delete a Delphi rule and all its revisions and materialized effects. +#[utoipa::path( + context_path = "/moderation/tech-review", + tag = "moderation", + security(("bearer_auth" = [])), + responses((status = OK), (status = NOT_FOUND)) +)] +#[delete("/rules/{id}")] +pub async fn delete_rule( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + path: web::Path<(i64,)>, +) -> Result<(), ApiError> { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_WRITE, + ) + .await?; + let (id,) = path.into_inner(); + + let deleted = sqlx::query!( + "DELETE FROM delphi_rules WHERE id = $1 RETURNING id", + id, + ) + .fetch_optional(&**pool) + .await + .wrap_internal_err("failed to delete delphi rule")?; + + if deleted.is_none() { + return Err(ApiError::NotFound); + } + + Ok(()) +} diff --git a/packages/api-client/src/modules/labrinth/tech-review/internal.ts b/packages/api-client/src/modules/labrinth/tech-review/internal.ts index 0012474334..ef2af98c29 100644 --- a/packages/api-client/src/modules/labrinth/tech-review/internal.ts +++ b/packages/api-client/src/modules/labrinth/tech-review/internal.ts @@ -6,6 +6,54 @@ export class LabrinthTechReviewInternalModule extends AbstractModule { return 'labrinth_tech_review_internal' } + public async getRules(): Promise { + return this.client.request( + '/moderation/tech-review/rules', + { + api: 'labrinth', + version: 'internal', + method: 'GET', + }, + ) + } + + public async createRule( + rule: Labrinth.TechReview.Internal.WriteDelphiRule, + ): Promise { + return this.client.request( + '/moderation/tech-review/rules', + { + api: 'labrinth', + version: 'internal', + method: 'POST', + body: rule, + }, + ) + } + + public async updateRule( + id: number, + rule: Labrinth.TechReview.Internal.WriteDelphiRule, + ): Promise { + return this.client.request( + `/moderation/tech-review/rules/${id}`, + { + api: 'labrinth', + version: 'internal', + method: 'PUT', + body: rule, + }, + ) + } + + public async deleteRule(id: number): Promise { + return this.client.request(`/moderation/tech-review/rules/${id}`, { + api: 'labrinth', + version: 'internal', + method: 'DELETE', + }) + } + /** * Search for projects awaiting technical review. * diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index cbe00e4f90..4c586cb44f 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -2226,6 +2226,26 @@ export namespace Labrinth { export namespace TechReview { export namespace Internal { + export type DelphiRule = { + id: number + name: string + priority: number + expression: string + revision_id: number + created: string + updated: string + revision_created: string + created_by: number | null + updated_by: number | null + revision_created_by: number | null + } + + export type WriteDelphiRule = { + name: string + priority: number + expression: string + } + export type SearchProjectsRequest = { limit?: number page?: number diff --git a/packages/delphi-cel-experiment/Cargo.toml b/packages/delphi-cel-experiment/Cargo.toml new file mode 100644 index 0000000000..34ea4460b0 --- /dev/null +++ b/packages/delphi-cel-experiment/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "delphi-cel-experiment" +version = "0.1.0" +edition.workspace = true +publish = false + +[dependencies] +cel = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } diff --git a/packages/delphi-cel-experiment/src/main.rs b/packages/delphi-cel-experiment/src/main.rs new file mode 100644 index 0000000000..92fe06bd33 --- /dev/null +++ b/packages/delphi-cel-experiment/src/main.rs @@ -0,0 +1,173 @@ +use std::{collections::BTreeMap, error::Error, io}; + +use cel::{Context, Program}; +use serde::{Deserialize, Serialize}; + +const EFFECT_RULE: &str = r#" + input.trace.issue_type == "OBFUSCATED_NAMES" + && input.trace.severity == "high" + && "confidence" in input.trace.data + && input.trace.data.confidence >= 0.9 + ? { + "severity": "low", + "hidden": false + } + : null +"#; + +#[derive(Debug, Serialize)] +struct RuleInput { + schema_version: u32, + trace: TraceInput, + scan: ScanInput, + artifact: ArtifactInput, + scope: ScopeInput, +} + +#[derive(Debug, Serialize)] +struct TraceInput { + key: String, + issue_type: String, + severity: DelphiSeverity, + jar: Option, + file_path: String, + data: BTreeMap, +} + +#[derive(Debug, Serialize)] +struct ScanInput { + delphi_version: i32, +} + +#[derive(Debug, Serialize)] +struct ArtifactInput { + size: u32, + hashes: BTreeMap, +} + +#[derive(Debug, Serialize)] +struct ScopeInput { + project_id: String, + version_id: String, + file_id: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +enum DelphiSeverity { + Low, + Medium, + High, + Severe, +} + +#[derive(Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuleEffect { + #[serde(default)] + severity: Option, + #[serde(default)] + hidden: bool, +} + +fn main() -> Result<(), Box> { + let input = example_input(); + println!( + "CEL input context:\n{}", + serde_json::to_string_pretty(&input)? + ); + + let effect = evaluate_rule(EFFECT_RULE, &input)?; + println!("\nCEL map decoded as RuleEffect:\n{effect:#?}"); + + Ok(()) +} + +fn example_input() -> RuleInput { + RuleInput { + schema_version: 1, + trace: TraceInput { + key: "known-safe:obfuscated-bootstrap".to_string(), + issue_type: "OBFUSCATED_NAMES".to_string(), + severity: DelphiSeverity::High, + jar: Some("META-INF/jars/embedded.jar".to_string()), + file_path: "com/example/Bootstrap.class".to_string(), + data: BTreeMap::from([ + ("confidence".to_string(), serde_json::json!(0.97)), + ("symbol_count".to_string(), serde_json::json!(42)), + ]), + }, + scan: ScanInput { delphi_version: 17 }, + artifact: ArtifactInput { + size: 412_892, + hashes: BTreeMap::from([ + ("sha1".to_string(), "0123456789abcdef".to_string()), + ("sha512".to_string(), "fedcba9876543210".to_string()), + ]), + }, + scope: ScopeInput { + project_id: "AANobbMI".to_string(), + version_id: "IIJJKKLL".to_string(), + file_id: "XXYYZZ00".to_string(), + }, + } +} + +fn evaluate_rule( + expression: &str, + input: &RuleInput, +) -> Result, Box> { + let mut context = Context::default(); + context.add_variable("input", input)?; + + let value = Program::compile(expression)?.execute(&context)?; + let json = value + .json() + .map_err(|error| invalid_data(error.to_string()))?; + match json { + serde_json::Value::Null => Ok(None), + value => Ok(Some(serde_json::from_value(value)?)), + } +} + +fn invalid_data(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_a_map_as_an_effect() { + let effect = evaluate_rule(EFFECT_RULE, &example_input()) + .expect("rule should evaluate"); + + assert_eq!( + effect, + Some(RuleEffect { + severity: Some(DelphiSeverity::Low), + hidden: false, + }) + ); + } + + #[test] + fn returns_none_when_a_rule_does_not_match() { + let mut input = example_input(); + input.trace.issue_type = "NETWORK_ACCESS".to_string(); + + let effect = + evaluate_rule(EFFECT_RULE, &input).expect("rule should evaluate"); + + assert_eq!(effect, None); + } + + #[test] + fn rejects_unknown_map_fields() { + let error = evaluate_rule(r#"{"unknown": true}"#, &example_input()) + .expect_err("unknown fields should be rejected"); + + assert!(error.to_string().contains("unknown field")); + } +} From b6b19e543c41c47f7411d7557b587330d05c759e Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:43:03 +0100 Subject: [PATCH 02/37] add demo delphi rules ui --- .../moderation/technical-review/rules.vue | 256 +++++++++-- ...36eb1dd626e77331d8278c406df952691be4c.json | 22 - ...67a2296efc16763cdbd5d8c295a1a7ca08021.json | 12 - ...f4eeff66ab4165a9f4980032e114db4dc1286.json | 26 -- ...63ce02ca16781e32232ae0fa7a0af1973d3a4.json | 20 - ...1078cb48ccd645f059e257cd204de024955b8.json | 14 - ...b8c14fe329deac983bc2f43ba74e066f9cf8d.json | 14 - ...8518f1272690ce7766dbdc41ec1e98f7f1fec.json | 12 - ...78df08d25844f1019d539712b0cb686e4ae81.json | 16 - ...2dea4a6c830ec1b74ea1e2b71d5f18751ac6c.json | 62 +++ ...66908ea41a06ba858dc5b523aef6aae27b850.json | 15 - ...19dc0cdef502935dd5939a2f3ab89b0766ad2.json | 12 - ...58541fc78e910111c09ba3d50a12d9ca4a9f8.json | 16 - ...71aa078119ed2c60828683f872321fb61bb79.json | 23 + ...6f7de5ddc18e2014470d2b88626a9b37d30fe.json | 42 -- ...1a31da1885cc22bb61be6853950ba52be9aa7.json | 15 - ...70a71b32d7fe35bf5f11654e1653c63245078.json | 80 ---- ...37aa1023b436d06b4412a559553dd5452029e.json | 22 - ...d2402f52fea71e27b08e7926fcc2a9e62c0f3.json | 20 - ...2c36b9db3770508be4acbb6b441ed8006d9c7.json | 12 - ...afedb074492b4ec7f2457c14113f5fd13aa02.json | 18 - ...e5c93783c7641b019fdb698a1ec0be1393606.json | 17 - ...52f7ef678bd0d6b9cee0be7df36bdc92b80a.json} | 28 +- ...6540f5b6d265c87af267be0a5c0044d470a9.json} | 28 +- ...0d934b9afc6dccc5fc77454fbe45365bdf429.json | 20 - ...86e8917cfab9679d5064074681663ba467e41.json | 22 - .../20260715120000_delphi_rules.sql | 50 --- .../20260720105358_delphi_trace_rules.sql | 34 ++ apps/labrinth/src/routes/internal/mod.rs | 1 + .../internal/moderation/tech_review/rules.rs | 413 +++++++++++------- .../modules/labrinth/tech-review/internal.ts | 14 + .../api-client/src/modules/labrinth/types.ts | 37 +- 32 files changed, 685 insertions(+), 708 deletions(-) delete mode 100644 apps/labrinth/.sqlx/query-0f3d943e4fc48a94363b77c8a7d36eb1dd626e77331d8278c406df952691be4c.json delete mode 100644 apps/labrinth/.sqlx/query-0ffebf149d50149d0c128478e6267a2296efc16763cdbd5d8c295a1a7ca08021.json delete mode 100644 apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json delete mode 100644 apps/labrinth/.sqlx/query-20cff8fdf7971e91c9d473b9a4663ce02ca16781e32232ae0fa7a0af1973d3a4.json delete mode 100644 apps/labrinth/.sqlx/query-2dd8132c42c883381fe2782b28e1078cb48ccd645f059e257cd204de024955b8.json delete mode 100644 apps/labrinth/.sqlx/query-329c009635ad2a4c214da2f4651b8c14fe329deac983bc2f43ba74e066f9cf8d.json delete mode 100644 apps/labrinth/.sqlx/query-38ace0abad724d1111b79a72c7c8518f1272690ce7766dbdc41ec1e98f7f1fec.json delete mode 100644 apps/labrinth/.sqlx/query-5cb65218a2a4a7343130d838f8a78df08d25844f1019d539712b0cb686e4ae81.json create mode 100644 apps/labrinth/.sqlx/query-64d02e0fa69fe1cad91d851e4d32dea4a6c830ec1b74ea1e2b71d5f18751ac6c.json delete mode 100644 apps/labrinth/.sqlx/query-6678cd4c51771cfaae2be8021ba66908ea41a06ba858dc5b523aef6aae27b850.json delete mode 100644 apps/labrinth/.sqlx/query-66868d0b35229abffa3f668eaef19dc0cdef502935dd5939a2f3ab89b0766ad2.json delete mode 100644 apps/labrinth/.sqlx/query-69a1cb4b7f1115a990d1fc4805d58541fc78e910111c09ba3d50a12d9ca4a9f8.json create mode 100644 apps/labrinth/.sqlx/query-7b4ee9c2fb7ef8da9bce4d86d4271aa078119ed2c60828683f872321fb61bb79.json delete mode 100644 apps/labrinth/.sqlx/query-882090c323a15f717e9481a84ae6f7de5ddc18e2014470d2b88626a9b37d30fe.json delete mode 100644 apps/labrinth/.sqlx/query-8ca7f46618bd72cfd5fe1f246d41a31da1885cc22bb61be6853950ba52be9aa7.json delete mode 100644 apps/labrinth/.sqlx/query-90f61453157c8a9cec0a360430170a71b32d7fe35bf5f11654e1653c63245078.json delete mode 100644 apps/labrinth/.sqlx/query-9476659afe6432102c6c35cf04b37aa1023b436d06b4412a559553dd5452029e.json delete mode 100644 apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json delete mode 100644 apps/labrinth/.sqlx/query-c65295f61b18f7be4fdf30b3e2c2c36b9db3770508be4acbb6b441ed8006d9c7.json delete mode 100644 apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json delete mode 100644 apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json rename apps/labrinth/.sqlx/{query-3e6c4e0a778f7f06bace43cbce705f834dbb9b88b834186b1353f116a07c3421.json => query-e522b3da5f03cafd265929a8489252f7ef678bd0d6b9cee0be7df36bdc92b80a.json} (58%) rename apps/labrinth/.sqlx/{query-3fd6d574c4feaec4b5116a43432a9ff55fc6dda4b154dc3136478d3772e06020.json => query-f5b43baf6838a16554aab7282a396540f5b6d265c87af267be0a5c0044d470a9.json} (57%) delete mode 100644 apps/labrinth/.sqlx/query-fabba52b34e14196b6305671c060d934b9afc6dccc5fc77454fbe45365bdf429.json delete mode 100644 apps/labrinth/.sqlx/query-fd5c773a61d35bcd71503ec4d5f86e8917cfab9679d5064074681663ba467e41.json delete mode 100644 apps/labrinth/migrations/20260715120000_delphi_rules.sql create mode 100644 apps/labrinth/migrations/20260720105358_delphi_trace_rules.sql diff --git a/apps/frontend/src/pages/moderation/technical-review/rules.vue b/apps/frontend/src/pages/moderation/technical-review/rules.vue index 1a9ac93c98..3668b93201 100644 --- a/apps/frontend/src/pages/moderation/technical-review/rules.vue +++ b/apps/frontend/src/pages/moderation/technical-review/rules.vue @@ -1,34 +1,106 @@ + + diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review.rs b/apps/labrinth/src/routes/internal/moderation/tech_review.rs index 0f8f2e973d..e448647bc6 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review.rs @@ -1417,7 +1417,7 @@ pub async fn update_issue_details( security(("bearer_auth" = [])), responses((status = NO_CONTENT)) )] -#[post("/global-issue-detail")] +#[post("/global-traces")] pub async fn update_global_issue_details( req: HttpRequest, pool: web::Data, diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/global.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/global.rs index fd576cfb42..3ca452aea9 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review/global.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/global.rs @@ -133,7 +133,7 @@ pub struct GlobalIssueDetailTrace { security(("bearer_auth" = [])), responses((status = OK, body = SearchGlobalIssueDetailsResponse)) )] -#[post("/global-issue-detail/search")] +#[post("/global-traces/search")] pub async fn search_global_issue_details( req: HttpRequest, pool: web::Data, @@ -357,7 +357,7 @@ pub async fn search_global_issue_details( security(("bearer_auth" = [])), responses((status = OK, body = GetGlobalIssueDetailResponse)) )] -#[post("/global-issue-detail/local-traces")] +#[post("/global-traces/local-traces")] pub async fn get_global_issue_detail( req: HttpRequest, pool: web::Data, diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs index 81f1474251..ad6de058ed 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs @@ -115,7 +115,9 @@ impl WriteDelphiRule { .await .wrap_internal_err("failed to join cel compilation task")? .map_err(|error| { - ApiError::Request(eyre!("invalid cel expression: {error}")) + ApiError::Request( + eyre!(error).wrap_err("invalid cel expression"), + ) })?; Ok(ValidatedRule { @@ -165,7 +167,9 @@ pub async fn test_rule( .await .wrap_internal_err("failed to join cel compilation task")? .map_err(|error| { - ApiError::Request(eyre!("invalid cel expression: {error}")) + ApiError::Request( + eyre!(error).wrap_err("invalid cel expression"), + ) })?; let mut effects = Vec::with_capacity(request.inputs.len()); diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index fc5f72e75c..3713066342 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -235,6 +235,23 @@ impl ApiError { } pub fn as_api_error<'a>(&self) -> crate::models::error::ApiError<'a> { + let report = match self { + Self::Internal(report) + | Self::Request(report) + | Self::Auth(report) + | Self::NotFound(report) + | Self::Conflict(report) + | Self::FailedDependency(report) + | Self::PreconditionRequired(report) + | Self::PreconditionFailed(report) + | Self::RateLimit(report) => report, + }; + let details = report + .chain() + .skip(1) + .map(ToString::to_string) + .collect::>(); + crate::models::error::ApiError { error: match self { Self::Internal(..) => "internal_error", @@ -247,8 +264,8 @@ impl ApiError { Self::PreconditionFailed(..) => "precondition_failed", Self::RateLimit(..) => "ratelimit_error", }, - description: format!("{self:#}"), - details: None, + description: report.to_string(), + details: (!details.is_empty()).then(|| serde_json::json!(details)), } } } @@ -272,3 +289,25 @@ impl actix_web::ResponseError for ApiError { HttpResponse::build(self.status_code()).json(self.as_api_error()) } } + +#[cfg(test)] +mod tests { + use super::ApiError; + + #[test] + fn api_error_serializes_source_chain_as_details() { + let error = ApiError::Request( + eyre::eyre!("root cause") + .wrap_err("intermediate context") + .wrap_err("request failed"), + ); + + let response = error.as_api_error(); + + assert_eq!(response.description, "request failed"); + assert_eq!( + response.details, + Some(serde_json::json!(["intermediate context", "root cause"])), + ); + } +} diff --git a/packages/api-client/src/modules/labrinth/tech-review/internal.ts b/packages/api-client/src/modules/labrinth/tech-review/internal.ts index 2ad32b78ff..5e7c60f7c8 100644 --- a/packages/api-client/src/modules/labrinth/tech-review/internal.ts +++ b/packages/api-client/src/modules/labrinth/tech-review/internal.ts @@ -227,7 +227,7 @@ export class LabrinthTechReviewInternalModule extends AbstractModule { public async updateGlobalIssueDetails( data: Labrinth.TechReview.Internal.UpdateGlobalIssueRequest[], ): Promise { - return this.client.request('/moderation/tech-review/global-issue-detail', { + return this.client.request('/moderation/tech-review/global-traces', { api: 'labrinth', version: 'internal', method: 'POST', @@ -239,7 +239,7 @@ export class LabrinthTechReviewInternalModule extends AbstractModule { params: Labrinth.TechReview.Internal.SearchGlobalIssueDetailsRequest, ): Promise { return this.client.request( - '/moderation/tech-review/global-issue-detail/search', + '/moderation/tech-review/global-traces/search', { api: 'labrinth', version: 'internal', @@ -253,7 +253,7 @@ export class LabrinthTechReviewInternalModule extends AbstractModule { params: Labrinth.TechReview.Internal.GetGlobalIssueDetailRequest, ): Promise { return this.client.request( - '/moderation/tech-review/global-issue-detail/local-traces', + '/moderation/tech-review/global-traces/local-traces', { api: 'labrinth', version: 'internal', diff --git a/packages/api-client/src/types/errors.ts b/packages/api-client/src/types/errors.ts index 6e33b6d9a2..a1fd55c837 100644 --- a/packages/api-client/src/types/errors.ts +++ b/packages/api-client/src/types/errors.ts @@ -38,6 +38,11 @@ export type ModrinthErrorResponse = { */ description: string + /** + * Structured details about the error + */ + details?: unknown + /** * Optional context about where the error occurred */ diff --git a/scripts/seed-db.sh b/scripts/seed-db.sh new file mode 100755 index 0000000000..13b5305dc6 --- /dev/null +++ b/scripts/seed-db.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +source .env +psql $DATABASE_URL < fixtures/labrinth-seed-data-202508052143.sql From 26e1d645b88ce5a7119b36c93838159a65e86f39 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:27:21 +0000 Subject: [PATCH 18/37] live/outdated label on rules --- .../moderation/technical-review/rules.vue | 21 ++++++++++++++++--- .../internal/moderation/tech_review/rules.rs | 7 +++++++ .../api-client/src/modules/labrinth/types.ts | 1 + 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/frontend/src/pages/moderation/technical-review/rules.vue b/apps/frontend/src/pages/moderation/technical-review/rules.vue index e9649b5999..bf106a13ef 100644 --- a/apps/frontend/src/pages/moderation/technical-review/rules.vue +++ b/apps/frontend/src/pages/moderation/technical-review/rules.vue @@ -269,9 +269,20 @@

{{ rule.name }}

-

- Priority {{ rule.priority }} · Revision {{ rule.revision }} -

+
+ Priority {{ rule.priority }} + + + {{ isRuleLive(rule) ? 'Live' : 'Outdated' }} + +

- Return null when the rule does not match, or a map containing - severity when it does. + Return null when the rule does not match, or return a severity string such as + "low". A map containing severity is also supported.

@@ -433,7 +433,7 @@ import type { Component } from 'vue' import IssueDetailPath from '~/components/ui/moderation/IssueDetailPath.vue' const DEFAULT_RULE = `trace.issue_type == "OBFUSCATED_NAMES" - ? {"severity": "low"} + ? "low" : null` const RULE_EDITOR_OPTIONS: Partial = { useWorker: false, diff --git a/apps/labrinth/fixtures/delphi-trace-rules.sql b/apps/labrinth/fixtures/delphi-trace-rules.sql index 03d7ab790d..4e9baa13cb 100644 --- a/apps/labrinth/fixtures/delphi-trace-rules.sql +++ b/apps/labrinth/fixtures/delphi-trace-rules.sql @@ -216,7 +216,7 @@ INSERT INTO delphi_rules (name, rule, priority, revision) VALUES ( '[DEV trace-rule fixture] Escalate known malware host', - 'trace.issue_type == "SUSPICIOUS_NETWORK_ACCESS" && trace.data.host == "evil.example" ? {"severity": "malware"} : null', + 'trace.issue_type == "SUSPICIOUS_NETWORK_ACCESS" && trace.data.host == "evil.example" ? "malware" : null', 1000, (SELECT revision FROM delphi_rule_revisions LIMIT 1) ), @@ -234,7 +234,7 @@ VALUES ), ( '[DEV trace-rule fixture] Hide known-safe obfuscation', - 'trace.issue_type == "OBFUSCATED_NAMES" && trace.data.confidence >= 0.95 ? {"severity": "hidden"} : null', + 'trace.issue_type == "OBFUSCATED_NAMES" && trace.data.confidence >= 0.95 ? "hidden" : null', 700, (SELECT revision FROM delphi_rule_revisions LIMIT 1) ), @@ -252,7 +252,7 @@ VALUES ), ( '[DEV trace-rule fixture] Downgrade bundled libraries', - 'trace.issue_type == "BUNDLED_LIBRARY" ? {"severity": "low"} : null', + 'trace.issue_type == "BUNDLED_LIBRARY" ? "low" : null', 400, (SELECT revision FROM delphi_rule_revisions LIMIT 1) ), diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs index 58b4aebeba..ff5ec950d3 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs @@ -97,6 +97,13 @@ pub struct DelphiRuleEffect { pub severity: DelphiSeverity, } +#[derive(Serialize, utoipa::ToSchema)] +#[serde(untagged)] +pub enum DelphiRuleOutput { + Severity(DelphiSeverity), + Effect(DelphiRuleEffect), +} + struct ValidatedRule { name: String, rule: String, diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs index 4cd7f6d6ba..b8ad0d999c 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs @@ -12,7 +12,7 @@ use tokio_stream::wrappers::UnboundedReceiverStream; use utoipa::{PartialSchema, ToSchema}; use xredis::RedisPool; -use super::rules::DelphiRuleEffect; +use super::rules::{DelphiRuleEffect, DelphiRuleOutput}; use crate::routes::internal::delphi::tech_review_queue::{ self, TechReviewRemovalReason, }; @@ -156,12 +156,12 @@ pub async fn get_rule_schema( let mut schemas = Vec::new(); ::schemas(&mut schemas); - as ToSchema>::schemas(&mut schemas); + as ToSchema>::schemas(&mut schemas); Ok(web::Json(DelphiRuleSchemaResponse { input: schema_to_value(::schema())?, output: schema_to_value( - as PartialSchema>::schema(), + as PartialSchema>::schema(), )?, components: schemas .into_iter() @@ -972,6 +972,12 @@ fn evaluate_rule_inner( match value { serde_json::Value::Null => Ok(None), + serde_json::Value::String(severity) => { + let severity = + serde_json::from_value(serde_json::Value::String(severity)) + .wrap_err("cel expression returned an invalid severity")?; + Ok(Some(DelphiRuleEffect { severity })) + } value => serde_json::from_value(value) .map(Some) .wrap_err("cel expression returned an invalid rule effect"), From 5cf9d423ad0a4565f00ddb7cc51971dd41ee1b90 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:27:30 +0000 Subject: [PATCH 22/37] fix up wrap err calls --- .../moderation/tech_review/rules_scan.rs | 91 +++++++++---------- 1 file changed, 41 insertions(+), 50 deletions(-) diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs index b8ad0d999c..f1571f11d9 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, HashMap}; use actix_web::{HttpRequest, HttpResponse, get, post, web}; use ariadne::ids::base62_impl::to_base62; use bytes::Bytes; -use eyre::{Context as _, Result, eyre}; +use eyre::{Result, eyre}; use futures_util::{StreamExt, TryStreamExt}; use serde::{Deserialize, Serialize}; use sqlx::types::Json; @@ -28,6 +28,7 @@ use crate::{ models::pats::Scopes, queue::session::AuthQueue, routes::ApiError, + util::error::Context, }; const RULE_SCAN_LOCK_ID: i64 = 0x6465_6c70_6869_7275; @@ -142,17 +143,15 @@ pub async fn get_rule_schema( redis: web::Data, session_queue: web::Data, ) -> Result, ApiError> { - crate::util::error::Context::wrap_auth_err( - check_is_moderator_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Scopes::PROJECT_READ, - ) - .await, - "authenticating API request", - )?; + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await + .wrap_auth_err("authenticating API request")?; let mut schemas = Vec::new(); ::schemas(&mut schemas); @@ -189,22 +188,18 @@ pub async fn get_detail_rule_input( session_queue: web::Data, path: web::Path<(DelphiReportIssueDetailsId,)>, ) -> Result, ApiError> { - crate::util::error::Context::wrap_auth_err( - check_is_moderator_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Scopes::PROJECT_READ, - ) - .await, - "authenticating API request", - )?; + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await + .wrap_auth_err("authenticating API request")?; let (detail_id,) = path.into_inner(); - let detail = crate::util::error::Context::wrap_not_found_err( - crate::util::error::Context::wrap_internal_err( - sqlx::query!( + let detail = sqlx::query!( r#" SELECT detail.key, @@ -257,14 +252,12 @@ pub async fn get_detail_rule_input( ) file_hashes ON TRUE WHERE detail.id = $1 "#, - detail_id as DelphiReportIssueDetailsId, - ) - .fetch_optional(&***ro_pool) - .await, - "failed to fetch delphi rule input", - )?, - "delphi rule input not found", - )?; + detail_id as DelphiReportIssueDetailsId, + ) + .fetch_optional(&***ro_pool) + .await + .wrap_internal_err("failed to fetch delphi rule input")? + .wrap_not_found_err("delphi rule input not found")?; Ok(web::Json(RuleInput { schema_version: 1, @@ -321,22 +314,20 @@ pub async fn scan_rules( redis: web::Data, session_queue: web::Data, ) -> Result { - crate::util::error::Context::wrap_auth_err( - check_is_moderator_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Scopes::PROJECT_WRITE, - ) - .await, - "authenticating API request", - )?; - - let mut transaction = crate::util::error::Context::wrap_internal_err( - pool.begin().await, - "failed to begin delphi rule scan", - )?; + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_WRITE, + ) + .await + .wrap_auth_err("authenticating API request")?; + + let mut transaction = pool + .begin() + .await + .wrap_internal_err("failed to begin delphi rule scan")?; sqlx::query!("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") .execute(&mut transaction) From c204e00ebcfc182b1e4a46e2f94c869d7be934fe Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:56:47 +0900 Subject: [PATCH 23/37] add sibling traces --- .../moderation/technical-review/rules.vue | 2 + ...2ad488032235981bc118532ad1094701eae2d.json | 120 +++++++++++++++++ ...3526bf321a861f42b59c9b21c6f50196d3158.json | 124 +++++++++++++++++ ...65fd5165d02ebcc0f8454554cbd870e4ddb42.json | 120 ----------------- ...04666378d1d24981ab8113badded16fb63413.json | 118 ---------------- ...ba93c7016b2a6f18aa4f22ea57f604d82c1da.json | 114 ---------------- ...306aad5e2d79f1bb4bf144d48933f7fa1ee83.json | 126 ++++++++++++++++++ .../moderation/tech_review/rules_scan.rs | 72 +++++++++- .../api-client/src/modules/labrinth/types.ts | 19 +-- 9 files changed, 454 insertions(+), 361 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-448ba47493dc3b83b33ce83316c2ad488032235981bc118532ad1094701eae2d.json create mode 100644 apps/labrinth/.sqlx/query-6cbc61717a4ac5dd6d1b07137643526bf321a861f42b59c9b21c6f50196d3158.json delete mode 100644 apps/labrinth/.sqlx/query-7e7e2e9f67158b94eb62834cbfd65fd5165d02ebcc0f8454554cbd870e4ddb42.json delete mode 100644 apps/labrinth/.sqlx/query-8537362d9c11e437ada9ce97bb604666378d1d24981ab8113badded16fb63413.json delete mode 100644 apps/labrinth/.sqlx/query-b536d7870ba77ace55e309938c3ba93c7016b2a6f18aa4f22ea57f604d82c1da.json create mode 100644 apps/labrinth/.sqlx/query-e8ea2268b7d3e4ec1741bfc0312306aad5e2d79f1bb4bf144d48933f7fa1ee83.json diff --git a/apps/frontend/src/pages/moderation/technical-review/rules.vue b/apps/frontend/src/pages/moderation/technical-review/rules.vue index 325e29cc44..71120d0725 100644 --- a/apps/frontend/src/pages/moderation/technical-review/rules.vue +++ b/apps/frontend/src/pages/moderation/technical-review/rules.vue @@ -460,6 +460,7 @@ const TEST_INPUTS: Labrinth.TechReview.Internal.RuleInput[] = [ symbol_count: 42, }, }, + sibling_traces: [], scan: { delphi_version: 17, }, @@ -494,6 +495,7 @@ const TEST_INPUTS: Labrinth.TechReview.Internal.RuleInput[] = [ host: 'telemetry.example.com', }, }, + sibling_traces: [], scan: { delphi_version: 18, }, diff --git a/apps/labrinth/.sqlx/query-448ba47493dc3b83b33ce83316c2ad488032235981bc118532ad1094701eae2d.json b/apps/labrinth/.sqlx/query-448ba47493dc3b83b33ce83316c2ad488032235981bc118532ad1094701eae2d.json new file mode 100644 index 0000000000..750071379b --- /dev/null +++ b/apps/labrinth/.sqlx/query-448ba47493dc3b83b33ce83316c2ad488032235981bc118532ad1094701eae2d.json @@ -0,0 +1,120 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n detail.key,\n issue.issue_type,\n detail.severity AS \"severity: DelphiSeverity\",\n detail.jar,\n detail.file_path,\n detail.data AS \"data: Json>\",\n COALESCE(sibling_traces.traces, '[]'::jsonb)\n AS \"sibling_traces!: Json>\",\n report.delphi_version,\n file.size AS \"size?\",\n file.id AS \"file_id?\",\n version.id AS \"version_id?\",\n version.mod_id AS \"project_id?\",\n COALESCE(version_metadata.project_types, ARRAY[]::text[])\n AS \"project_types!: Vec\",\n COALESCE(version_metadata.loaders, ARRAY[]::text[])\n AS \"loaders!: Vec\",\n COALESCE(file_hashes.hashes, '{}'::jsonb)\n AS \"hashes!: Json>\"\n FROM delphi_report_issue_details detail\n INNER JOIN delphi_report_issues issue ON issue.id = detail.issue_id\n INNER JOIN delphi_reports report ON report.id = issue.report_id\n LEFT JOIN LATERAL (\n SELECT\n jsonb_agg(\n jsonb_build_object(\n 'key', sibling_detail.key,\n 'issue_type', sibling_issue.issue_type,\n 'severity', sibling_detail.severity,\n 'jar', sibling_detail.jar,\n 'file_path', sibling_detail.file_path,\n 'data', sibling_detail.data\n )\n ORDER BY sibling_detail.id\n ) AS traces\n FROM delphi_report_issues sibling_issue\n INNER JOIN delphi_report_issue_details sibling_detail\n ON sibling_detail.issue_id = sibling_issue.id\n WHERE sibling_issue.report_id = issue.report_id\n AND sibling_detail.id != detail.id\n ) sibling_traces ON TRUE\n LEFT JOIN files file ON file.id = report.file_id\n LEFT JOIN versions version ON version.id = file.version_id\n LEFT JOIN LATERAL (\n SELECT\n ARRAY_AGG(\n DISTINCT project_type.name::text\n ORDER BY project_type.name::text\n ) FILTER (WHERE project_type.name IS NOT NULL)\n AS project_types,\n ARRAY_AGG(\n DISTINCT loader.loader\n ORDER BY loader.loader\n ) AS loaders\n FROM loaders_versions loader_version\n INNER JOIN loaders loader\n ON loader.id = loader_version.loader_id\n LEFT JOIN loaders_project_types loader_project_type\n ON loader_project_type.joining_loader_id = loader_version.loader_id\n LEFT JOIN project_types project_type\n ON project_type.id = loader_project_type.joining_project_type_id\n WHERE loader_version.version_id = version.id\n ) version_metadata ON TRUE\n LEFT JOIN LATERAL (\n SELECT\n jsonb_object_agg(algorithm, encode(hash, 'hex')) AS hashes\n FROM hashes\n WHERE hashes.file_id = file.id\n ) file_hashes ON TRUE\n WHERE detail.id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "key", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "issue_type", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "severity: DelphiSeverity", + "type_info": { + "Custom": { + "name": "delphi_severity", + "kind": { + "Enum": [ + "hidden", + "low", + "medium", + "high", + "severe", + "malware" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "jar", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "file_path", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "data: Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "sibling_traces!: Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "delphi_version", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "size?", + "type_info": "Int4" + }, + { + "ordinal": 9, + "name": "file_id?", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "version_id?", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "project_id?", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "project_types!: Vec", + "type_info": "TextArray" + }, + { + "ordinal": 13, + "name": "loaders!: Vec", + "type_info": "VarcharArray" + }, + { + "ordinal": 14, + "name": "hashes!: Json>", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + null, + false, + false, + false, + false, + false, + null, + null, + null + ] + }, + "hash": "448ba47493dc3b83b33ce83316c2ad488032235981bc118532ad1094701eae2d" +} diff --git a/apps/labrinth/.sqlx/query-6cbc61717a4ac5dd6d1b07137643526bf321a861f42b59c9b21c6f50196d3158.json b/apps/labrinth/.sqlx/query-6cbc61717a4ac5dd6d1b07137643526bf321a861f42b59c9b21c6f50196d3158.json new file mode 100644 index 0000000000..674e9196aa --- /dev/null +++ b/apps/labrinth/.sqlx/query-6cbc61717a4ac5dd6d1b07137643526bf321a861f42b59c9b21c6f50196d3158.json @@ -0,0 +1,124 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n detail.id,\n detail.key,\n issue.issue_type,\n detail.severity AS \"severity: DelphiSeverity\",\n detail.jar,\n detail.file_path,\n detail.data AS \"data: Json>\",\n COALESCE(sibling_traces.traces, '[]'::jsonb)\n AS \"sibling_traces!: Json>\",\n report.delphi_version,\n file.size AS \"size?\",\n file.id AS \"file_id?\",\n version.id AS \"version_id?\",\n version.mod_id AS \"project_id?\",\n COALESCE(version_metadata.project_types, ARRAY[]::text[])\n AS \"project_types!: Vec\",\n COALESCE(version_metadata.loaders, ARRAY[]::text[])\n AS \"loaders!: Vec\",\n COALESCE(file_hashes.hashes, '{}'::jsonb)\n AS \"hashes!: Json>\"\n FROM delphi_report_issue_details detail\n INNER JOIN delphi_report_issues issue ON issue.id = detail.issue_id\n INNER JOIN delphi_reports report ON report.id = issue.report_id\n LEFT JOIN LATERAL (\n SELECT\n jsonb_agg(\n jsonb_build_object(\n 'key', sibling_detail.key,\n 'issue_type', sibling_issue.issue_type,\n 'severity', sibling_detail.severity,\n 'jar', sibling_detail.jar,\n 'file_path', sibling_detail.file_path,\n 'data', sibling_detail.data\n )\n ORDER BY sibling_detail.id\n ) AS traces\n FROM delphi_report_issues sibling_issue\n INNER JOIN delphi_report_issue_details sibling_detail\n ON sibling_detail.issue_id = sibling_issue.id\n WHERE sibling_issue.report_id = issue.report_id\n AND sibling_detail.id != detail.id\n ) sibling_traces ON TRUE\n LEFT JOIN files file ON file.id = report.file_id\n LEFT JOIN versions version ON version.id = file.version_id\n LEFT JOIN (\n SELECT\n loader_version.version_id,\n ARRAY_AGG(\n DISTINCT project_type.name::text\n ORDER BY project_type.name::text\n ) FILTER (WHERE project_type.name IS NOT NULL)\n AS project_types,\n ARRAY_AGG(\n DISTINCT loader.loader\n ORDER BY loader.loader\n ) AS loaders\n FROM loaders_versions loader_version\n INNER JOIN loaders loader\n ON loader.id = loader_version.loader_id\n LEFT JOIN loaders_project_types loader_project_type\n ON loader_project_type.joining_loader_id = loader_version.loader_id\n LEFT JOIN project_types project_type\n ON project_type.id = loader_project_type.joining_project_type_id\n GROUP BY loader_version.version_id\n ) version_metadata\n ON version_metadata.version_id = version.id\n LEFT JOIN (\n SELECT\n file_id,\n jsonb_object_agg(algorithm, encode(hash, 'hex')) AS hashes\n FROM hashes\n GROUP BY file_id\n ) file_hashes ON file_hashes.file_id = file.id\n ORDER BY detail.id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "key", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "issue_type", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "severity: DelphiSeverity", + "type_info": { + "Custom": { + "name": "delphi_severity", + "kind": { + "Enum": [ + "hidden", + "low", + "medium", + "high", + "severe", + "malware" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "jar", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "file_path", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "data: Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "sibling_traces!: Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "delphi_version", + "type_info": "Int4" + }, + { + "ordinal": 9, + "name": "size?", + "type_info": "Int4" + }, + { + "ordinal": 10, + "name": "file_id?", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "version_id?", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "project_id?", + "type_info": "Int8" + }, + { + "ordinal": 13, + "name": "project_types!: Vec", + "type_info": "TextArray" + }, + { + "ordinal": 14, + "name": "loaders!: Vec", + "type_info": "VarcharArray" + }, + { + "ordinal": 15, + "name": "hashes!: Json>", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + null, + false, + false, + false, + false, + false, + null, + null, + null + ] + }, + "hash": "6cbc61717a4ac5dd6d1b07137643526bf321a861f42b59c9b21c6f50196d3158" +} diff --git a/apps/labrinth/.sqlx/query-7e7e2e9f67158b94eb62834cbfd65fd5165d02ebcc0f8454554cbd870e4ddb42.json b/apps/labrinth/.sqlx/query-7e7e2e9f67158b94eb62834cbfd65fd5165d02ebcc0f8454554cbd870e4ddb42.json deleted file mode 100644 index 14a9377c81..0000000000 --- a/apps/labrinth/.sqlx/query-7e7e2e9f67158b94eb62834cbfd65fd5165d02ebcc0f8454554cbd870e4ddb42.json +++ /dev/null @@ -1,120 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n detail.id,\n detail.key,\n issue.issue_type,\n detail.severity AS \"severity: DelphiSeverity\",\n detail.jar,\n detail.file_path,\n detail.data AS \"data: Json>\",\n report.delphi_version,\n file.size AS \"size?\",\n file.id AS \"file_id?\",\n version.id AS \"version_id?\",\n version.mod_id AS \"project_id?\",\n COALESCE(version_metadata.project_types, ARRAY[]::text[])\n AS \"project_types!: Vec\",\n COALESCE(version_metadata.loaders, ARRAY[]::text[])\n AS \"loaders!: Vec\",\n COALESCE(file_hashes.hashes, '{}'::jsonb)\n AS \"hashes!: Json>\"\n FROM delphi_report_issue_details detail\n INNER JOIN delphi_report_issues issue ON issue.id = detail.issue_id\n INNER JOIN delphi_reports report ON report.id = issue.report_id\n LEFT JOIN files file ON file.id = report.file_id\n LEFT JOIN versions version ON version.id = file.version_id\n LEFT JOIN LATERAL (\n SELECT\n ARRAY_AGG(\n DISTINCT project_type.name::text\n ORDER BY project_type.name::text\n ) FILTER (WHERE project_type.name IS NOT NULL)\n AS project_types,\n ARRAY_AGG(\n DISTINCT loader.loader\n ORDER BY loader.loader\n ) AS loaders\n FROM loaders_versions loader_version\n INNER JOIN loaders loader\n ON loader.id = loader_version.loader_id\n LEFT JOIN loaders_project_types loader_project_type\n ON loader_project_type.joining_loader_id = loader_version.loader_id\n LEFT JOIN project_types project_type\n ON project_type.id = loader_project_type.joining_project_type_id\n WHERE loader_version.version_id = version.id\n ) version_metadata ON TRUE\n LEFT JOIN LATERAL (\n SELECT\n jsonb_object_agg(algorithm, encode(hash, 'hex')) AS hashes\n FROM hashes\n WHERE hashes.file_id = file.id\n ) file_hashes ON TRUE\n WHERE detail.id = ANY($1::bigint[])\n ORDER BY detail.id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "key", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "issue_type", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "severity: DelphiSeverity", - "type_info": { - "Custom": { - "name": "delphi_severity", - "kind": { - "Enum": [ - "hidden", - "low", - "medium", - "high", - "severe", - "malware" - ] - } - } - } - }, - { - "ordinal": 4, - "name": "jar", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "file_path", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "data: Json>", - "type_info": "Jsonb" - }, - { - "ordinal": 7, - "name": "delphi_version", - "type_info": "Int4" - }, - { - "ordinal": 8, - "name": "size?", - "type_info": "Int4" - }, - { - "ordinal": 9, - "name": "file_id?", - "type_info": "Int8" - }, - { - "ordinal": 10, - "name": "version_id?", - "type_info": "Int8" - }, - { - "ordinal": 11, - "name": "project_id?", - "type_info": "Int8" - }, - { - "ordinal": 12, - "name": "project_types!: Vec", - "type_info": "TextArray" - }, - { - "ordinal": 13, - "name": "loaders!: Vec", - "type_info": "VarcharArray" - }, - { - "ordinal": 14, - "name": "hashes!: Json>", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "Int8Array" - ] - }, - "nullable": [ - false, - false, - false, - false, - true, - false, - false, - false, - true, - true, - true, - true, - null, - null, - null - ] - }, - "hash": "7e7e2e9f67158b94eb62834cbfd65fd5165d02ebcc0f8454554cbd870e4ddb42" -} diff --git a/apps/labrinth/.sqlx/query-8537362d9c11e437ada9ce97bb604666378d1d24981ab8113badded16fb63413.json b/apps/labrinth/.sqlx/query-8537362d9c11e437ada9ce97bb604666378d1d24981ab8113badded16fb63413.json deleted file mode 100644 index 1102be54f7..0000000000 --- a/apps/labrinth/.sqlx/query-8537362d9c11e437ada9ce97bb604666378d1d24981ab8113badded16fb63413.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n detail.id,\n detail.key,\n issue.issue_type,\n detail.severity AS \"severity: DelphiSeverity\",\n detail.jar,\n detail.file_path,\n detail.data AS \"data: Json>\",\n report.delphi_version,\n file.size AS \"size?\",\n file.id AS \"file_id?\",\n version.id AS \"version_id?\",\n version.mod_id AS \"project_id?\",\n COALESCE(version_metadata.project_types, ARRAY[]::text[])\n AS \"project_types!: Vec\",\n COALESCE(version_metadata.loaders, ARRAY[]::text[])\n AS \"loaders!: Vec\",\n COALESCE(file_hashes.hashes, '{}'::jsonb)\n AS \"hashes!: Json>\"\n FROM delphi_report_issue_details detail\n INNER JOIN delphi_report_issues issue ON issue.id = detail.issue_id\n INNER JOIN delphi_reports report ON report.id = issue.report_id\n LEFT JOIN files file ON file.id = report.file_id\n LEFT JOIN versions version ON version.id = file.version_id\n LEFT JOIN (\n SELECT\n loader_version.version_id,\n ARRAY_AGG(\n DISTINCT project_type.name::text\n ORDER BY project_type.name::text\n ) FILTER (WHERE project_type.name IS NOT NULL)\n AS project_types,\n ARRAY_AGG(\n DISTINCT loader.loader\n ORDER BY loader.loader\n ) AS loaders\n FROM loaders_versions loader_version\n INNER JOIN loaders loader\n ON loader.id = loader_version.loader_id\n LEFT JOIN loaders_project_types loader_project_type\n ON loader_project_type.joining_loader_id = loader_version.loader_id\n LEFT JOIN project_types project_type\n ON project_type.id = loader_project_type.joining_project_type_id\n GROUP BY loader_version.version_id\n ) version_metadata\n ON version_metadata.version_id = version.id\n LEFT JOIN (\n SELECT\n file_id,\n jsonb_object_agg(algorithm, encode(hash, 'hex')) AS hashes\n FROM hashes\n GROUP BY file_id\n ) file_hashes ON file_hashes.file_id = file.id\n ORDER BY detail.id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "key", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "issue_type", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "severity: DelphiSeverity", - "type_info": { - "Custom": { - "name": "delphi_severity", - "kind": { - "Enum": [ - "hidden", - "low", - "medium", - "high", - "severe", - "malware" - ] - } - } - } - }, - { - "ordinal": 4, - "name": "jar", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "file_path", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "data: Json>", - "type_info": "Jsonb" - }, - { - "ordinal": 7, - "name": "delphi_version", - "type_info": "Int4" - }, - { - "ordinal": 8, - "name": "size?", - "type_info": "Int4" - }, - { - "ordinal": 9, - "name": "file_id?", - "type_info": "Int8" - }, - { - "ordinal": 10, - "name": "version_id?", - "type_info": "Int8" - }, - { - "ordinal": 11, - "name": "project_id?", - "type_info": "Int8" - }, - { - "ordinal": 12, - "name": "project_types!: Vec", - "type_info": "TextArray" - }, - { - "ordinal": 13, - "name": "loaders!: Vec", - "type_info": "VarcharArray" - }, - { - "ordinal": 14, - "name": "hashes!: Json>", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - false, - true, - false, - false, - false, - false, - false, - false, - false, - null, - null, - null - ] - }, - "hash": "8537362d9c11e437ada9ce97bb604666378d1d24981ab8113badded16fb63413" -} diff --git a/apps/labrinth/.sqlx/query-b536d7870ba77ace55e309938c3ba93c7016b2a6f18aa4f22ea57f604d82c1da.json b/apps/labrinth/.sqlx/query-b536d7870ba77ace55e309938c3ba93c7016b2a6f18aa4f22ea57f604d82c1da.json deleted file mode 100644 index 1d7013755a..0000000000 --- a/apps/labrinth/.sqlx/query-b536d7870ba77ace55e309938c3ba93c7016b2a6f18aa4f22ea57f604d82c1da.json +++ /dev/null @@ -1,114 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n detail.key,\n issue.issue_type,\n detail.severity AS \"severity: DelphiSeverity\",\n detail.jar,\n detail.file_path,\n detail.data AS \"data: Json>\",\n report.delphi_version,\n file.size AS \"size?\",\n file.id AS \"file_id?\",\n version.id AS \"version_id?\",\n version.mod_id AS \"project_id?\",\n COALESCE(version_metadata.project_types, ARRAY[]::text[])\n AS \"project_types!: Vec\",\n COALESCE(version_metadata.loaders, ARRAY[]::text[])\n AS \"loaders!: Vec\",\n COALESCE(file_hashes.hashes, '{}'::jsonb)\n AS \"hashes!: Json>\"\n FROM delphi_report_issue_details detail\n INNER JOIN delphi_report_issues issue ON issue.id = detail.issue_id\n INNER JOIN delphi_reports report ON report.id = issue.report_id\n LEFT JOIN files file ON file.id = report.file_id\n LEFT JOIN versions version ON version.id = file.version_id\n LEFT JOIN LATERAL (\n SELECT\n ARRAY_AGG(\n DISTINCT project_type.name::text\n ORDER BY project_type.name::text\n ) FILTER (WHERE project_type.name IS NOT NULL)\n AS project_types,\n ARRAY_AGG(\n DISTINCT loader.loader\n ORDER BY loader.loader\n ) AS loaders\n FROM loaders_versions loader_version\n INNER JOIN loaders loader\n ON loader.id = loader_version.loader_id\n LEFT JOIN loaders_project_types loader_project_type\n ON loader_project_type.joining_loader_id = loader_version.loader_id\n LEFT JOIN project_types project_type\n ON project_type.id = loader_project_type.joining_project_type_id\n WHERE loader_version.version_id = version.id\n ) version_metadata ON TRUE\n LEFT JOIN LATERAL (\n SELECT\n jsonb_object_agg(algorithm, encode(hash, 'hex')) AS hashes\n FROM hashes\n WHERE hashes.file_id = file.id\n ) file_hashes ON TRUE\n WHERE detail.id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "key", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "issue_type", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "severity: DelphiSeverity", - "type_info": { - "Custom": { - "name": "delphi_severity", - "kind": { - "Enum": [ - "hidden", - "low", - "medium", - "high", - "severe", - "malware" - ] - } - } - } - }, - { - "ordinal": 3, - "name": "jar", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "file_path", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "data: Json>", - "type_info": "Jsonb" - }, - { - "ordinal": 6, - "name": "delphi_version", - "type_info": "Int4" - }, - { - "ordinal": 7, - "name": "size?", - "type_info": "Int4" - }, - { - "ordinal": 8, - "name": "file_id?", - "type_info": "Int8" - }, - { - "ordinal": 9, - "name": "version_id?", - "type_info": "Int8" - }, - { - "ordinal": 10, - "name": "project_id?", - "type_info": "Int8" - }, - { - "ordinal": 11, - "name": "project_types!: Vec", - "type_info": "TextArray" - }, - { - "ordinal": 12, - "name": "loaders!: Vec", - "type_info": "VarcharArray" - }, - { - "ordinal": 13, - "name": "hashes!: Json>", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false, - false, - false, - true, - false, - false, - false, - false, - false, - false, - false, - null, - null, - null - ] - }, - "hash": "b536d7870ba77ace55e309938c3ba93c7016b2a6f18aa4f22ea57f604d82c1da" -} diff --git a/apps/labrinth/.sqlx/query-e8ea2268b7d3e4ec1741bfc0312306aad5e2d79f1bb4bf144d48933f7fa1ee83.json b/apps/labrinth/.sqlx/query-e8ea2268b7d3e4ec1741bfc0312306aad5e2d79f1bb4bf144d48933f7fa1ee83.json new file mode 100644 index 0000000000..1a7cc4ae34 --- /dev/null +++ b/apps/labrinth/.sqlx/query-e8ea2268b7d3e4ec1741bfc0312306aad5e2d79f1bb4bf144d48933f7fa1ee83.json @@ -0,0 +1,126 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n detail.id,\n detail.key,\n issue.issue_type,\n detail.severity AS \"severity: DelphiSeverity\",\n detail.jar,\n detail.file_path,\n detail.data AS \"data: Json>\",\n COALESCE(sibling_traces.traces, '[]'::jsonb)\n AS \"sibling_traces!: Json>\",\n report.delphi_version,\n file.size AS \"size?\",\n file.id AS \"file_id?\",\n version.id AS \"version_id?\",\n version.mod_id AS \"project_id?\",\n COALESCE(version_metadata.project_types, ARRAY[]::text[])\n AS \"project_types!: Vec\",\n COALESCE(version_metadata.loaders, ARRAY[]::text[])\n AS \"loaders!: Vec\",\n COALESCE(file_hashes.hashes, '{}'::jsonb)\n AS \"hashes!: Json>\"\n FROM delphi_report_issue_details detail\n INNER JOIN delphi_report_issues issue ON issue.id = detail.issue_id\n INNER JOIN delphi_reports report ON report.id = issue.report_id\n LEFT JOIN LATERAL (\n SELECT\n jsonb_agg(\n jsonb_build_object(\n 'key', sibling_detail.key,\n 'issue_type', sibling_issue.issue_type,\n 'severity', sibling_detail.severity,\n 'jar', sibling_detail.jar,\n 'file_path', sibling_detail.file_path,\n 'data', sibling_detail.data\n )\n ORDER BY sibling_detail.id\n ) AS traces\n FROM delphi_report_issues sibling_issue\n INNER JOIN delphi_report_issue_details sibling_detail\n ON sibling_detail.issue_id = sibling_issue.id\n WHERE sibling_issue.report_id = issue.report_id\n AND sibling_detail.id != detail.id\n ) sibling_traces ON TRUE\n LEFT JOIN files file ON file.id = report.file_id\n LEFT JOIN versions version ON version.id = file.version_id\n LEFT JOIN LATERAL (\n SELECT\n ARRAY_AGG(\n DISTINCT project_type.name::text\n ORDER BY project_type.name::text\n ) FILTER (WHERE project_type.name IS NOT NULL)\n AS project_types,\n ARRAY_AGG(\n DISTINCT loader.loader\n ORDER BY loader.loader\n ) AS loaders\n FROM loaders_versions loader_version\n INNER JOIN loaders loader\n ON loader.id = loader_version.loader_id\n LEFT JOIN loaders_project_types loader_project_type\n ON loader_project_type.joining_loader_id = loader_version.loader_id\n LEFT JOIN project_types project_type\n ON project_type.id = loader_project_type.joining_project_type_id\n WHERE loader_version.version_id = version.id\n ) version_metadata ON TRUE\n LEFT JOIN LATERAL (\n SELECT\n jsonb_object_agg(algorithm, encode(hash, 'hex')) AS hashes\n FROM hashes\n WHERE hashes.file_id = file.id\n ) file_hashes ON TRUE\n WHERE detail.id = ANY($1::bigint[])\n ORDER BY detail.id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "key", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "issue_type", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "severity: DelphiSeverity", + "type_info": { + "Custom": { + "name": "delphi_severity", + "kind": { + "Enum": [ + "hidden", + "low", + "medium", + "high", + "severe", + "malware" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "jar", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "file_path", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "data: Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "sibling_traces!: Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "delphi_version", + "type_info": "Int4" + }, + { + "ordinal": 9, + "name": "size?", + "type_info": "Int4" + }, + { + "ordinal": 10, + "name": "file_id?", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "version_id?", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "project_id?", + "type_info": "Int8" + }, + { + "ordinal": 13, + "name": "project_types!: Vec", + "type_info": "TextArray" + }, + { + "ordinal": 14, + "name": "loaders!: Vec", + "type_info": "VarcharArray" + }, + { + "ordinal": 15, + "name": "hashes!: Json>", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + null, + false, + true, + true, + true, + true, + null, + null, + null + ] + }, + "hash": "e8ea2268b7d3e4ec1741bfc0312306aad5e2d79f1bb4bf144d48933f7fa1ee83" +} diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs index f1571f11d9..a8b3ed3766 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs @@ -57,6 +57,7 @@ struct RuleScanErrorEvent<'a> { pub struct RuleInput { pub schema_version: u32, pub trace: RuleTrace, + pub sibling_traces: Vec, pub scan: RuleScan, pub artifact: RuleArtifact, pub project: RuleProject, @@ -64,7 +65,7 @@ pub struct RuleInput { pub file: RuleFile, } -#[derive(Deserialize, Serialize, utoipa::ToSchema)] +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] pub struct RuleTrace { pub key: String, pub issue_type: String, @@ -208,6 +209,8 @@ pub async fn get_detail_rule_input( detail.jar, detail.file_path, detail.data AS "data: Json>", + COALESCE(sibling_traces.traces, '[]'::jsonb) + AS "sibling_traces!: Json>", report.delphi_version, file.size AS "size?", file.id AS "file_id?", @@ -222,6 +225,25 @@ pub async fn get_detail_rule_input( FROM delphi_report_issue_details detail INNER JOIN delphi_report_issues issue ON issue.id = detail.issue_id INNER JOIN delphi_reports report ON report.id = issue.report_id + LEFT JOIN LATERAL ( + SELECT + jsonb_agg( + jsonb_build_object( + 'key', sibling_detail.key, + 'issue_type', sibling_issue.issue_type, + 'severity', sibling_detail.severity, + 'jar', sibling_detail.jar, + 'file_path', sibling_detail.file_path, + 'data', sibling_detail.data + ) + ORDER BY sibling_detail.id + ) AS traces + FROM delphi_report_issues sibling_issue + INNER JOIN delphi_report_issue_details sibling_detail + ON sibling_detail.issue_id = sibling_issue.id + WHERE sibling_issue.report_id = issue.report_id + AND sibling_detail.id != detail.id + ) sibling_traces ON TRUE LEFT JOIN files file ON file.id = report.file_id LEFT JOIN versions version ON version.id = file.version_id LEFT JOIN LATERAL ( @@ -269,6 +291,7 @@ pub async fn get_detail_rule_input( file_path: detail.file_path, data: detail.data.0, }, + sibling_traces: detail.sibling_traces.0, scan: RuleScan { delphi_version: detail.delphi_version, }, @@ -443,6 +466,8 @@ async fn run_scan( detail.jar, detail.file_path, detail.data AS "data: Json>", + COALESCE(sibling_traces.traces, '[]'::jsonb) + AS "sibling_traces!: Json>", report.delphi_version, file.size AS "size?", file.id AS "file_id?", @@ -457,6 +482,25 @@ async fn run_scan( FROM delphi_report_issue_details detail INNER JOIN delphi_report_issues issue ON issue.id = detail.issue_id INNER JOIN delphi_reports report ON report.id = issue.report_id + LEFT JOIN LATERAL ( + SELECT + jsonb_agg( + jsonb_build_object( + 'key', sibling_detail.key, + 'issue_type', sibling_issue.issue_type, + 'severity', sibling_detail.severity, + 'jar', sibling_detail.jar, + 'file_path', sibling_detail.file_path, + 'data', sibling_detail.data + ) + ORDER BY sibling_detail.id + ) AS traces + FROM delphi_report_issues sibling_issue + INNER JOIN delphi_report_issue_details sibling_detail + ON sibling_detail.issue_id = sibling_issue.id + WHERE sibling_issue.report_id = issue.report_id + AND sibling_detail.id != detail.id + ) sibling_traces ON TRUE LEFT JOIN files file ON file.id = report.file_id LEFT JOIN versions version ON version.id = file.version_id LEFT JOIN ( @@ -523,6 +567,7 @@ async fn run_scan( file_path: detail.file_path, data: detail.data.0, }, + sibling_traces: detail.sibling_traces.0, scan: RuleScan { delphi_version: detail.delphi_version, }, @@ -719,6 +764,8 @@ pub(crate) async fn materialize_current_rule_effects( detail.jar, detail.file_path, detail.data AS "data: Json>", + COALESCE(sibling_traces.traces, '[]'::jsonb) + AS "sibling_traces!: Json>", report.delphi_version, file.size AS "size?", file.id AS "file_id?", @@ -733,6 +780,25 @@ pub(crate) async fn materialize_current_rule_effects( FROM delphi_report_issue_details detail INNER JOIN delphi_report_issues issue ON issue.id = detail.issue_id INNER JOIN delphi_reports report ON report.id = issue.report_id + LEFT JOIN LATERAL ( + SELECT + jsonb_agg( + jsonb_build_object( + 'key', sibling_detail.key, + 'issue_type', sibling_issue.issue_type, + 'severity', sibling_detail.severity, + 'jar', sibling_detail.jar, + 'file_path', sibling_detail.file_path, + 'data', sibling_detail.data + ) + ORDER BY sibling_detail.id + ) AS traces + FROM delphi_report_issues sibling_issue + INNER JOIN delphi_report_issue_details sibling_detail + ON sibling_detail.issue_id = sibling_issue.id + WHERE sibling_issue.report_id = issue.report_id + AND sibling_detail.id != detail.id + ) sibling_traces ON TRUE LEFT JOIN files file ON file.id = report.file_id LEFT JOIN versions version ON version.id = file.version_id LEFT JOIN LATERAL ( @@ -782,6 +848,7 @@ pub(crate) async fn materialize_current_rule_effects( file_path: detail.file_path, data: detail.data.0, }, + sibling_traces: detail.sibling_traces.0, scan: RuleScan { delphi_version: detail.delphi_version, }, @@ -938,6 +1005,9 @@ fn evaluate_rule_inner( context .add_variable("trace", &input.trace) .wrap_err("failed to add `trace` to cel context")?; + context + .add_variable("sibling_traces", &input.sibling_traces) + .wrap_err("failed to add `sibling_traces` to cel context")?; context .add_variable("scan", &input.scan) .wrap_err("failed to add `scan` to cel context")?; diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 18713f53b8..b71ad57819 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -2398,14 +2398,8 @@ export namespace Labrinth { export type RuleInput = { schema_version: number - trace: { - key: string - issue_type: string - severity: DelphiSeverity - jar: string | null - file_path: string - data: Record - } + trace: RuleTrace + sibling_traces: RuleTrace[] scan: { delphi_version: number } @@ -2426,6 +2420,15 @@ export namespace Labrinth { } } + export type RuleTrace = { + key: string + issue_type: string + severity: DelphiSeverity + jar: string | null + file_path: string + data: Record + } + export type TestDelphiRuleResponse = { effects: Array } From e9200c748327c3fa526b88b388c59077820ed929 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:23:13 +0900 Subject: [PATCH 24/37] update frontend --- .../moderation/technical-review/rules.vue | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/apps/frontend/src/pages/moderation/technical-review/rules.vue b/apps/frontend/src/pages/moderation/technical-review/rules.vue index 71120d0725..870fa02e3f 100644 --- a/apps/frontend/src/pages/moderation/technical-review/rules.vue +++ b/apps/frontend/src/pages/moderation/technical-review/rules.vue @@ -45,10 +45,31 @@ -

- Return null when the rule does not match, or return a severity string such as - "low". A map containing severity is also supported. -

+ + + + CEL API reference +
@@ -92,23 +113,6 @@ - -
Date: Tue, 25 Aug 2026 13:16:59 +0100 Subject: [PATCH 25/37] allow modifying test trace in rules.vue --- .../moderation/technical-review/rules.vue | 319 +++++++++++------- 1 file changed, 195 insertions(+), 124 deletions(-) diff --git a/apps/frontend/src/pages/moderation/technical-review/rules.vue b/apps/frontend/src/pages/moderation/technical-review/rules.vue index 870fa02e3f..5c73571168 100644 --- a/apps/frontend/src/pages/moderation/technical-review/rules.vue +++ b/apps/frontend/src/pages/moderation/technical-review/rules.vue @@ -105,38 +105,73 @@
-

Test traces

+

Test trace

- These results are evaluated from the current expression. + Change the trace details to evaluate the current expression.

-

Original

-
- - {{ example.original.severity }} - - {{ example.original.issue_type }} +

+ Trace details +

+
+ + + +
-
-
Key
-
{{ example.original.key }}
-
File
-
- {{ example.original.file_path }} -
-
+ + +

+ {{ traceDataError }} +

- New state + Rule result +

+
+ + Evaluating… +
+

+ Preview unavailable.

Hidden from reports
+
+ No match +

{{ testTracePreview.summary }}

+
@@ -451,79 +500,67 @@ type RuleTestError = { details: string[] } -const TEST_INPUTS: Labrinth.TechReview.Internal.RuleInput[] = [ - { - schema_version: 1, - trace: { - key: 'known-safe:obfuscated-bootstrap', - issue_type: 'OBFUSCATED_NAMES', - severity: 'high', - jar: 'META-INF/jars/embedded.jar', - file_path: 'com/example/Bootstrap.class', - data: { - confidence: 0.97, - symbol_count: 42, - }, - }, - sibling_traces: [], - scan: { - delphi_version: 17, - }, - artifact: { - size: 412_892, - hashes: { - sha1: '0123456789abcdef', - sha512: 'fedcba9876543210', - }, - }, - project: { - id: 'example-project', - types: ['mod'], - }, - version: { - id: 'example-version', - loaders: ['fabric'], - }, - file: { - id: 'example-file', - }, +type TestTraceForm = { + key: string + issueType: string + severity: Labrinth.TechReview.Internal.DelphiSeverity + jar: string + filePath: string + data: string +} + +const TRACE_SEVERITIES: Labrinth.TechReview.Internal.DelphiSeverity[] = [ + 'low', + 'medium', + 'high', + 'severe', + 'malware', + 'hidden', +] + +const TEST_INPUT_METADATA: Omit = { + schema_version: 1, + sibling_traces: [], + scan: { + delphi_version: 17, }, - { - schema_version: 1, - trace: { - key: 'network/known-telemetry-host', - issue_type: 'SUSPICIOUS_NETWORK_ACCESS', - severity: 'medium', - jar: null, - file_path: 'com/example/Telemetry.class', - data: { - host: 'telemetry.example.com', - }, - }, - sibling_traces: [], - scan: { - delphi_version: 18, - }, - artifact: { - size: 98_304, - hashes: { - sha1: 'abcdef0123456789', - sha512: '0123456789abcdef', - }, - }, - project: { - id: 'telemetry-project', - types: ['mod'], - }, - version: { - id: 'telemetry-version', - loaders: ['neoforge'], - }, - file: { - id: 'telemetry-file', + artifact: { + size: 412_892, + hashes: { + sha1: '0123456789abcdef', + sha512: 'fedcba9876543210', }, }, -] + project: { + id: 'example-project', + types: ['mod'], + }, + version: { + id: 'example-version', + loaders: ['fabric'], + }, + file: { + id: 'example-file', + }, +} + +function createTestTraceForm(): TestTraceForm { + return { + key: 'known-safe:obfuscated-bootstrap', + issueType: 'OBFUSCATED_NAMES', + severity: 'high', + jar: 'META-INF/jars/embedded.jar', + filePath: 'com/example/Bootstrap.class', + data: JSON.stringify( + { + confidence: 0.97, + symbol_count: 42, + }, + null, + 2, + ), + } +} useHead({ title: 'Delphi rules - Modrinth' }) @@ -549,6 +586,7 @@ const editingRuleId = ref(null) const ruleToDelete = ref(null) const ruleTestEffects = ref>([]) const ruleTestError = ref(null) +const traceDataError = ref(null) const scanProgress = ref(null) const expandedAffectedDetails = reactive( new Map(), @@ -559,6 +597,7 @@ const form = reactive({ priority: 0 as number | undefined, rule: DEFAULT_RULE, }) +const testTraceForm = reactive(createTestTraceForm()) let ruleTestRequestId = 0 let scanAbortController: AbortController | null = null @@ -577,32 +616,25 @@ const ruleInputSchemaText = computed(() => const ruleOutputSchemaText = computed(() => ruleSchema.value ? formatRuleSchema(ruleSchema.value.output, ruleSchema.value.components) : '', ) -const previewExamples = computed(() => - TEST_INPUTS.map(({ trace: original }, index) => { - const effect = ruleTestEffects.value[index] ?? null - const effectiveSeverity = effect?.severity ?? original.severity - let summary: string - - if (isTestingRule.value) { - summary = 'Evaluating the current expression...' - } else if (ruleTestError.value) { - summary = 'Preview unavailable.' - } else if (!effect) { - summary = 'Rule does not match; no change.' - } else if (effect.severity && effect.severity !== original.severity) { - summary = `Severity changed from ${original.severity} to ${effect.severity}.` - } else { - summary = 'Rule matched; no visible change.' - } +const testTracePreview = computed(() => { + const effect = ruleTestEffects.value[0] ?? null + const effectiveSeverity = effect?.severity ?? testTraceForm.severity + let summary: string + + if (!effect) { + summary = 'This rule would not change the trace.' + } else if (effect.severity !== testTraceForm.severity) { + summary = `Severity changed from ${testTraceForm.severity} to ${effect.severity}.` + } else { + summary = 'The rule matched without changing the severity.' + } - return { - original, - effect, - effectiveSeverity, - summary, - } - }), -) + return { + effect, + effectiveSeverity, + summary, + } +}) function isRuleLive(rule: Labrinth.TechReview.Internal.DelphiRule): boolean { return rule.current_revision === undefined || rule.revision === rule.current_revision @@ -721,6 +753,34 @@ function handleRuleInput(rule: string) { queueRuleTest() } +function getTestRuleInput(): Labrinth.TechReview.Internal.RuleInput | null { + let data: unknown + try { + data = JSON.parse(testTraceForm.data) + } catch { + traceDataError.value = 'Enter valid JSON data for the trace.' + return null + } + + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + traceDataError.value = 'Trace data must be a JSON object.' + return null + } + + traceDataError.value = null + return { + ...TEST_INPUT_METADATA, + trace: { + key: testTraceForm.key, + issue_type: testTraceForm.issueType, + severity: testTraceForm.severity, + jar: testTraceForm.jar.trim() || null, + file_path: testTraceForm.filePath, + data: data as Record, + }, + } +} + async function testRule() { if (!isRuleModalOpen.value) return @@ -728,6 +788,7 @@ async function testRule() { const rule = form.rule.trim() ruleTestEffects.value = [] ruleTestError.value = null + traceDataError.value = null if (!rule) { isTestingRule.value = false @@ -738,11 +799,17 @@ async function testRule() { return } + const input = getTestRuleInput() + if (!input) { + isTestingRule.value = false + return + } + isTestingRule.value = true try { const response = await client.labrinth.tech_review_internal.testRule({ rule, - inputs: TEST_INPUTS, + inputs: [input], }) if (requestId !== ruleTestRequestId) return @@ -777,6 +844,8 @@ function queueRuleTest() { void testRuleDebounced() } +watch(testTraceForm, queueRuleTest, { flush: 'sync' }) + async function loadRules() { isLoading.value = true loadFailed.value = false @@ -855,6 +924,7 @@ function openCreateModal() { form.name = '' form.priority = 0 form.rule = DEFAULT_RULE + Object.assign(testTraceForm, createTestTraceForm()) isRuleModalOpen.value = true ruleModal.value?.show() nextTick(() => ruleEditorInstance.value?.resize(true)) @@ -868,6 +938,7 @@ function openEditModal(rule: Labrinth.TechReview.Internal.DelphiRule) { form.name = rule.name form.priority = rule.priority form.rule = rule.rule + Object.assign(testTraceForm, createTestTraceForm()) isRuleModalOpen.value = true ruleModal.value?.show() nextTick(() => ruleEditorInstance.value?.resize(true)) From 9f28a9ba5faea78c3c36a45a0414eaf4cecc0501 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:28:42 +0100 Subject: [PATCH 26/37] enable cel regex --- Cargo.lock | 1 + Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 5b10537334..7bb6313426 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1938,6 +1938,7 @@ dependencies = [ "lazy_static", "nom 7.1.3", "pastey 0.2.3", + "regex", "serde", "serde_json", "thiserror 1.0.69", diff --git a/Cargo.toml b/Cargo.toml index a1fa25deea..22f303fba4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,7 +57,7 @@ bitflags = "2.9.4" bon = "3.9.3" bytemuck = "1.24.0" bytes = "1.10.1" -cel = { version = "0.14.0", default-features = false, features = ["json"] } +cel = { version = "0.14.0", default-features = false, features = ["json", "regex"] } censor = "0.3.0" chardetng = "0.1.17" chrono = "0.4.42" From a7196deb982b912d7cdbb22373ec756910b53ed3 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:57:14 +0100 Subject: [PATCH 27/37] cel #define preprocessor, regex ext --- .../moderation/technical-review/rules.vue | 24 +- .../internal/moderation/tech_review/rules.rs | 2 +- .../moderation/tech_review/rules_scan.rs | 2 +- apps/labrinth/src/util/cel.rs | 413 ++++++++++++++++++ apps/labrinth/src/util/mod.rs | 1 + 5 files changed, 439 insertions(+), 3 deletions(-) create mode 100644 apps/labrinth/src/util/cel.rs diff --git a/apps/frontend/src/pages/moderation/technical-review/rules.vue b/apps/frontend/src/pages/moderation/technical-review/rules.vue index 5c73571168..95ca2931c5 100644 --- a/apps/frontend/src/pages/moderation/technical-review/rules.vue +++ b/apps/frontend/src/pages/moderation/technical-review/rules.vue @@ -98,6 +98,26 @@
{{ ruleOutputSchemaText }}
+

+ Extensions +

+ +
#define ISSUE_TYPE "OBFUSCATED_NAMES"
+
+trace.issue_type == ISSUE_TYPE
@@ -486,7 +506,9 @@ import type { Component } from 'vue' import IssueDetailPath from '~/components/ui/moderation/IssueDetailPath.vue' -const DEFAULT_RULE = `trace.issue_type == "OBFUSCATED_NAMES" +const DEFAULT_RULE = `#define ISSUE_TYPE "OBFUSCATED_NAMES" + +trace.issue_type == ISSUE_TYPE ? "low" : null` const RULE_EDITOR_OPTIONS: Partial = { diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs index ff5ec950d3..c9beebef34 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs @@ -22,7 +22,7 @@ use crate::{ }, queue::session::AuthQueue, routes::ApiError, - util::{error::Context, validate::validation_errors_to_string}, + util::{cel, error::Context, validate::validation_errors_to_string}, }; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs index a8b3ed3766..aa5da3d68b 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs @@ -28,7 +28,7 @@ use crate::{ models::pats::Scopes, queue::session::AuthQueue, routes::ApiError, - util::error::Context, + util::{cel, error::Context}, }; const RULE_SCAN_LOCK_ID: i64 = 0x6465_6c70_6869_7275; diff --git a/apps/labrinth/src/util/cel.rs b/apps/labrinth/src/util/cel.rs new file mode 100644 index 0000000000..eb1eda7db3 --- /dev/null +++ b/apps/labrinth/src/util/cel.rs @@ -0,0 +1,413 @@ +use std::collections::BTreeMap; + +pub use ::cel::Context; +use ::cel::{ExecutionError, Program as CelProgram, Value}; +use chumsky::{Parser, prelude::*}; +use eyre::{Result, WrapErr, eyre}; +use thiserror::Error; + +const MAX_PREPROCESSED_SIZE: usize = 1_048_576; + +pub struct Program { + inner: CelProgram, +} + +impl Program { + pub fn compile(source: &str) -> Result { + let source = preprocess(source) + .wrap_err("failed to preprocess cel expression")?; + let inner = + CelProgram::compile(&source).map_err(|error| eyre!(error))?; + + Ok(Self { inner }) + } + + pub fn execute<'a>( + &self, + context: &Context<'a>, + ) -> std::result::Result { + self.inner.execute(context) + } +} + +#[derive(Debug)] +struct Definition { + name: String, + replacement: String, +} + +#[derive(Debug, Error)] +enum PreprocessorError { + #[error("invalid `#define` on line {line}, column {column}: {message}")] + InvalidDefinition { + line: usize, + column: usize, + message: String, + }, + #[error("macro `{name}` is defined more than once")] + DuplicateDefinition { name: String }, + #[error("recursive macro expansion: {path}")] + RecursiveExpansion { path: String }, + #[error("preprocessed expression exceeds {MAX_PREPROCESSED_SIZE} bytes")] + ExpressionTooLarge, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum LexState { + #[default] + Normal, + String { + quote: u8, + triple: bool, + raw: bool, + }, + BlockComment, +} + +fn definition_parser() -> impl Parser> { + let horizontal_whitespace = one_of(" \t").repeated(); + let identifier = filter(|character: &char| { + character.is_ascii_alphabetic() || *character == '_' + }) + .then( + filter(|character: &char| { + character.is_ascii_alphanumeric() || *character == '_' + }) + .repeated(), + ) + .map(|(first, rest)| { + std::iter::once(first).chain(rest).collect::() + }); + let replacement = any().repeated().at_least(1).collect::(); + + horizontal_whitespace + .clone() + .ignore_then(just('#')) + .then_ignore(horizontal_whitespace.clone()) + .then_ignore(just("define")) + .then_ignore(horizontal_whitespace.clone().at_least(1)) + .ignore_then(identifier) + .then_ignore(horizontal_whitespace.at_least(1)) + .then(replacement) + .map(|(name, replacement)| Definition { + name, + replacement: replacement.trim().to_string(), + }) + .then_ignore(end()) +} + +fn preprocess(source: &str) -> Result { + let mut definitions = BTreeMap::new(); + let mut expression = String::with_capacity(source.len()); + let mut state = LexState::Normal; + + for (line_index, line) in source.split_inclusive('\n').enumerate() { + let directive = state == LexState::Normal + && line.trim_start_matches([' ', '\t']).starts_with('#'); + + if directive { + let directive = line.trim_end_matches(['\r', '\n']); + let definition = + definition_parser().parse(directive).map_err(|errors| { + let error = + errors.into_iter().next().unwrap_or_else(|| { + Simple::custom(0..0, "invalid directive") + }); + PreprocessorError::InvalidDefinition { + line: line_index + 1, + column: error.span().start + 1, + message: error.to_string(), + } + })?; + + if definitions + .insert(definition.name.clone(), definition.replacement) + .is_some() + { + return Err(PreprocessorError::DuplicateDefinition { + name: definition.name, + }); + } + + if line.ends_with('\n') { + expression.push('\n'); + } + } else { + expression.push_str(line); + advance_lex_state(line, &mut state); + } + } + + let mut expanded = String::with_capacity(expression.len()); + expand(&expression, &definitions, &mut Vec::new(), &mut expanded)?; + Ok(expanded) +} + +fn expand( + source: &str, + definitions: &BTreeMap, + stack: &mut Vec, + output: &mut String, +) -> Result<(), PreprocessorError> { + let bytes = source.as_bytes(); + let mut index = 0; + let mut state = LexState::Normal; + + while index < bytes.len() { + match state { + LexState::Normal => { + if bytes[index..].starts_with(b"//") { + push(output, "//")?; + index += 2; + while index < bytes.len() { + let character = source[index..] + .chars() + .next() + .expect("the index is within the string"); + let mut encoded = [0; 4]; + push(output, character.encode_utf8(&mut encoded))?; + index += character.len_utf8(); + if character == '\n' { + break; + } + } + continue; + } + + if bytes[index..].starts_with(b"/*") { + push(output, "/*")?; + index += 2; + state = LexState::BlockComment; + continue; + } + + if let Some((length, string_state)) = string_start(bytes, index) + { + push(output, &source[index..index + length])?; + index += length; + state = string_state; + continue; + } + + if bytes[index].is_ascii_alphabetic() || bytes[index] == b'_' { + let start = index; + index += 1; + while index < bytes.len() + && (bytes[index].is_ascii_alphanumeric() + || bytes[index] == b'_') + { + index += 1; + } + + let identifier = &source[start..index]; + if let Some(replacement) = definitions.get(identifier) { + if let Some(cycle_start) = + stack.iter().position(|name| name == identifier) + { + let mut path = stack[cycle_start..].to_vec(); + path.push(identifier.to_string()); + return Err( + PreprocessorError::RecursiveExpansion { + path: path.join(" -> "), + }, + ); + } + + stack.push(identifier.to_string()); + expand(replacement, definitions, stack, output)?; + stack.pop(); + } else { + push(output, identifier)?; + } + continue; + } + } + LexState::String { quote, triple, raw } => { + if !raw && bytes[index] == b'\\' { + let end = (index + 2).min(bytes.len()); + push(output, &source[index..end])?; + index = end; + continue; + } + + let closing_length = if triple { 3 } else { 1 }; + if bytes[index] == quote + && bytes[index..].len() >= closing_length + && bytes[index..index + closing_length] + .iter() + .all(|character| *character == quote) + { + push(output, &source[index..index + closing_length])?; + index += closing_length; + state = LexState::Normal; + continue; + } + } + LexState::BlockComment => { + if bytes[index..].starts_with(b"*/") { + push(output, "*/")?; + index += 2; + state = LexState::Normal; + continue; + } + } + } + + let character = source[index..] + .chars() + .next() + .expect("the index is within the string"); + let mut encoded = [0; 4]; + push(output, character.encode_utf8(&mut encoded))?; + index += character.len_utf8(); + } + + Ok(()) +} + +fn push(output: &mut String, value: &str) -> Result<(), PreprocessorError> { + if output.len().saturating_add(value.len()) > MAX_PREPROCESSED_SIZE { + return Err(PreprocessorError::ExpressionTooLarge); + } + output.push_str(value); + Ok(()) +} + +fn advance_lex_state(source: &str, state: &mut LexState) { + let bytes = source.as_bytes(); + let mut index = 0; + + while index < bytes.len() { + match *state { + LexState::Normal => { + if bytes[index..].starts_with(b"//") { + return; + } + if bytes[index..].starts_with(b"/*") { + *state = LexState::BlockComment; + index += 2; + continue; + } + if let Some((length, string_state)) = string_start(bytes, index) + { + *state = string_state; + index += length; + continue; + } + } + LexState::String { quote, triple, raw } => { + if !raw && bytes[index] == b'\\' { + index = (index + 2).min(bytes.len()); + continue; + } + + let closing_length = if triple { 3 } else { 1 }; + if bytes[index] == quote + && bytes[index..].len() >= closing_length + && bytes[index..index + closing_length] + .iter() + .all(|character| *character == quote) + { + *state = LexState::Normal; + index += closing_length; + continue; + } + } + LexState::BlockComment => { + if bytes[index..].starts_with(b"*/") { + *state = LexState::Normal; + index += 2; + continue; + } + } + } + + index += source[index..] + .chars() + .next() + .expect("the index is within the string") + .len_utf8(); + } + + if matches!(state, LexState::String { triple: false, .. }) { + *state = LexState::Normal; + } +} + +fn string_start(bytes: &[u8], index: usize) -> Option<(usize, LexState)> { + let remaining = &bytes[index..]; + let (prefix_length, raw) = if remaining.len() >= 3 + && matches!(remaining[0].to_ascii_lowercase(), b'b' | b'r') + && matches!(remaining[1].to_ascii_lowercase(), b'b' | b'r') + && remaining[0].to_ascii_lowercase() + != remaining[1].to_ascii_lowercase() + { + (2, true) + } else if remaining.len() >= 2 + && matches!(remaining[0].to_ascii_lowercase(), b'b' | b'r') + { + (1, remaining[0].eq_ignore_ascii_case(&b'r')) + } else { + (0, false) + }; + + let quote = *remaining.get(prefix_length)?; + if !matches!(quote, b'\'' | b'"') { + return None; + } + + let triple = + remaining + .get(prefix_length..prefix_length + 3) + .is_some_and(|quotes| { + quotes.iter().all(|character| *character == quote) + }); + let quote_length = if triple { 3 } else { 1 }; + + Some(( + prefix_length + quote_length, + LexState::String { quote, triple, raw }, + )) +} + +#[cfg(test)] +mod tests { + use super::preprocess; + + #[test] + fn expands_object_macros() { + let source = + "#define ISSUE OBFUSCATED_NAMES\ntrace.issue_type == ISSUE"; + assert_eq!( + preprocess(source).unwrap(), + "\ntrace.issue_type == OBFUSCATED_NAMES" + ); + } + + #[test] + fn recursively_expands_macros() { + let source = + "#define RESULT SEVERITY\n#define SEVERITY \"low\"\nRESULT"; + assert_eq!(preprocess(source).unwrap(), "\n\n\"low\""); + } + + #[test] + fn does_not_expand_strings_or_comments() { + let source = + "#define VALUE expanded\nVALUE == \"VALUE\" // VALUE\n/* VALUE */"; + assert_eq!( + preprocess(source).unwrap(), + "\nexpanded == \"VALUE\" // VALUE\n/* VALUE */" + ); + } + + #[test] + fn reports_recursive_macros() { + let source = "#define FIRST SECOND\n#define SECOND FIRST\nFIRST"; + let error = preprocess(source).unwrap_err(); + assert_eq!( + error.to_string(), + "recursive macro expansion: FIRST -> SECOND -> FIRST" + ); + } +} diff --git a/apps/labrinth/src/util/mod.rs b/apps/labrinth/src/util/mod.rs index dc17c4eeb6..7a9b0de91c 100644 --- a/apps/labrinth/src/util/mod.rs +++ b/apps/labrinth/src/util/mod.rs @@ -4,6 +4,7 @@ pub mod archon; pub mod avalara1099; pub mod bitflag; pub mod captcha; +pub mod cel; pub mod cors; pub mod date; pub mod error; From 30477f3316b8ee5b6ac59797a74cc31a98b64ef4 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:03:06 +0100 Subject: [PATCH 28/37] simple autocomplete --- .../moderation/technical-review/rules.vue | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/apps/frontend/src/pages/moderation/technical-review/rules.vue b/apps/frontend/src/pages/moderation/technical-review/rules.vue index 95ca2931c5..98e07141ee 100644 --- a/apps/frontend/src/pages/moderation/technical-review/rules.vue +++ b/apps/frontend/src/pages/moderation/technical-review/rules.vue @@ -511,10 +511,76 @@ const DEFAULT_RULE = `#define ISSUE_TYPE "OBFUSCATED_NAMES" trace.issue_type == ISSUE_TYPE ? "low" : null` + +const CEL_LANGUAGE_COMPLETIONS: Ace.Completion[] = [ + ...['true', 'false', 'null', 'in'].map((value) => ({ + value, + score: 900, + meta: 'keyword', + })), + { caption: 'has()', snippet: 'has(${1:field})', score: 850, meta: 'macro' }, + { caption: 'size()', snippet: 'size(${1:value})', score: 850, meta: 'function' }, + { caption: 'string()', snippet: 'string(${1:value})', score: 850, meta: 'function' }, + { caption: 'int()', snippet: 'int(${1:value})', score: 850, meta: 'function' }, + { caption: '#define', snippet: '#define ${1:NAME} ${2:value}', score: 800, meta: 'preprocessor' }, +] +const CEL_MEMBER_COMPLETIONS: Ace.Completion[] = [ + { caption: 'contains()', snippet: 'contains("${1:value}")', score: 800, meta: 'string' }, + { caption: 'startsWith()', snippet: 'startsWith("${1:prefix}")', score: 800, meta: 'string' }, + { caption: 'endsWith()', snippet: 'endsWith("${1:suffix}")', score: 800, meta: 'string' }, + { caption: 'matches()', snippet: 'matches("${1:pattern}")', score: 800, meta: 'regex' }, + { caption: 'all()', snippet: 'all(${1:item}, ${2:predicate})', score: 750, meta: 'list macro' }, + { + caption: 'exists()', + snippet: 'exists(${1:item}, ${2:predicate})', + score: 750, + meta: 'list macro', + }, + { + caption: 'exists_one()', + snippet: 'exists_one(${1:item}, ${2:predicate})', + score: 750, + meta: 'list macro', + }, + { + caption: 'filter()', + snippet: 'filter(${1:item}, ${2:predicate})', + score: 750, + meta: 'list macro', + }, + { caption: 'map()', snippet: 'map(${1:item}, ${2:expression})', score: 750, meta: 'list macro' }, +] +const CEL_COMPLETER: Ace.Completer = { + id: 'delphi-cel', + triggerCharacters: ['.'], + getCompletions(_editor, session, position, prefix, callback) { + const beforePrefix = session + .getLine(position.row) + .slice(0, Math.max(0, position.column - prefix.length)) + const receiver = beforePrefix.match(/([A-Za-z_][\w.]*)\.$/)?.[1] + if (!receiver) { + callback(null, [...getCelSchemaCompletions(), ...CEL_LANGUAGE_COMPLETIONS]) + return + } + + const schemaCompletions = getCelSchemaCompletions(receiver) + if (schemaCompletions.length > 0) { + callback(null, schemaCompletions) + return + } + + callback(null, CEL_MEMBER_COMPLETIONS) + }, +} const RULE_EDITOR_OPTIONS: Partial = { useWorker: false, tabSize: 2, useSoftTabs: true, + enableBasicAutocompletion: [CEL_COMPLETER], + enableLiveAutocompletion: [CEL_COMPLETER], + liveAutocompletionDelay: 150, + liveAutocompletionThreshold: 1, + enableSnippets: true, } type RuleTestError = { @@ -628,6 +694,7 @@ onMounted(async () => { import('vue3-ace-editor'), import('@modrinth/ui/src/utils/ace-theme'), ]) + await import('ace-builds/src-noconflict/ext-language_tools') editorComponent.value = VAceEditor }) @@ -683,6 +750,70 @@ function isSchema(value: unknown): value is Labrinth.TechReview.Internal.DelphiR return typeof value === 'object' && value !== null && !Array.isArray(value) } +function resolveRuleSchema( + schema: Labrinth.TechReview.Internal.DelphiRuleSchema, + components: Record, +): Labrinth.TechReview.Internal.DelphiRuleSchema { + let resolved = schema + const visited = new Set() + + while (typeof resolved.$ref === 'string') { + const name = decodeURIComponent(resolved.$ref.split('/').at(-1) ?? '') + if (!name || visited.has(name) || !components[name]) break + + visited.add(name) + resolved = components[name] + } + + return resolved +} + +function getRuleSchemaLabel( + schema: Labrinth.TechReview.Internal.DelphiRuleSchema, + components: Record, +): string { + if (typeof schema.$ref === 'string') { + return decodeURIComponent(schema.$ref.split('/').at(-1) ?? '') || 'context' + } + + const resolved = resolveRuleSchema(schema, components) + const types = Array.isArray(resolved.type) + ? resolved.type.filter((type): type is string => typeof type === 'string') + : typeof resolved.type === 'string' + ? [resolved.type] + : [] + return types.join(' | ') || 'context' +} + +function getCelSchemaCompletions(receiver?: string): Ace.Completion[] { + const response = ruleSchema.value + if (!response) return [] + + let schema = response.input + for (const segment of receiver?.split('.') ?? []) { + const resolved = resolveRuleSchema(schema, response.components) + if (!isSchema(resolved.properties)) return [] + + const property = resolved.properties[segment] + if (!isSchema(property)) return [] + schema = property + } + + const resolved = resolveRuleSchema(schema, response.components) + if (!isSchema(resolved.properties)) return [] + + return Object.entries(resolved.properties) + .filter( + (entry): entry is [string, Labrinth.TechReview.Internal.DelphiRuleSchema] => + isSchema(entry[1]), + ) + .map(([value, property]) => ({ + value, + score: 1_000, + meta: getRuleSchemaLabel(property, response.components), + })) +} + function formatRuleSchema( schema: Labrinth.TechReview.Internal.DelphiRuleSchema, components: Record, From 58f89d9940fbc88ff0e29699263fd2b9438f810b Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:06:31 +0100 Subject: [PATCH 29/37] prepr --- .../moderation/technical-review/rules.vue | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/apps/frontend/src/pages/moderation/technical-review/rules.vue b/apps/frontend/src/pages/moderation/technical-review/rules.vue index 98e07141ee..ec4e25ed5c 100644 --- a/apps/frontend/src/pages/moderation/technical-review/rules.vue +++ b/apps/frontend/src/pages/moderation/technical-review/rules.vue @@ -62,7 +62,7 @@ -
+

Trace details @@ -147,10 +145,7 @@ trace.issue_type == ISSUE_TYPE

@@ -512,6 +513,7 @@ const CEL_LANGUAGE_COMPLETIONS: Ace.Completion[] = [ { caption: 'string()', snippet: 'string(${1:value})', score: 850, meta: 'function' }, { caption: 'int()', snippet: 'int(${1:value})', score: 850, meta: 'function' }, { caption: '#define', snippet: '#define ${1:NAME} ${2:value}', score: 800, meta: 'preprocessor' }, + { caption: '#bind', snippet: '#bind ${1:NAME} ${2:expression}', score: 800, meta: 'preprocessor' }, ] const CEL_MEMBER_COMPLETIONS: Ace.Completion[] = [ { caption: 'contains()', snippet: 'contains("${1:value}")', score: 800, meta: 'string' }, diff --git a/apps/labrinth/src/util/cel.rs b/apps/labrinth/src/util/cel.rs index eb1eda7db3..a4ee71cf73 100644 --- a/apps/labrinth/src/util/cel.rs +++ b/apps/labrinth/src/util/cel.rs @@ -10,42 +10,104 @@ const MAX_PREPROCESSED_SIZE: usize = 1_048_576; pub struct Program { inner: CelProgram, + bindings: Vec, } impl Program { pub fn compile(source: &str) -> Result { - let source = preprocess(source) + let preprocessed = preprocess(source) .wrap_err("failed to preprocess cel expression")?; - let inner = - CelProgram::compile(&source).map_err(|error| eyre!(error))?; - - Ok(Self { inner }) + let bindings = preprocessed + .bindings + .into_iter() + .map(|binding| { + let inner = CelProgram::compile(&binding.expression) + .map_err(|error| eyre!(error)) + .wrap_err_with(|| { + format!( + "failed to compile `#bind {}` on line {}", + binding.name, binding.line + ) + })?; + + Ok(CompiledBinding { + name: binding.name, + inner, + }) + }) + .collect::>>()?; + let inner = CelProgram::compile(&preprocessed.expression) + .map_err(|error| eyre!(error))?; + + Ok(Self { inner, bindings }) } pub fn execute<'a>( &self, context: &Context<'a>, ) -> std::result::Result { - self.inner.execute(context) + let mut context = context.new_inner_scope(); + + for binding in &self.bindings { + let value = binding.inner.execute(&context).map_err(|error| { + ExecutionError::function_error( + &format!("#bind {}", binding.name), + error, + ) + })?; + context.add_variable_from_value(&binding.name, value); + } + + self.inner.execute(&context) } } +struct CompiledBinding { + name: String, + inner: CelProgram, +} + +#[derive(Debug)] +struct Preprocessed { + expression: String, + bindings: Vec, +} + #[derive(Debug)] struct Definition { name: String, replacement: String, } +#[derive(Debug)] +struct Binding { + name: String, + expression: String, + line: usize, +} + +#[derive(Debug)] +enum Directive { + Define(Definition), + Bind { name: String, expression: String }, +} + #[derive(Debug, Error)] enum PreprocessorError { - #[error("invalid `#define` on line {line}, column {column}: {message}")] - InvalidDefinition { + #[error( + "invalid preprocessor directive on line {line}, column {column}: {message}" + )] + InvalidDirective { line: usize, column: usize, message: String, }, #[error("macro `{name}` is defined more than once")] DuplicateDefinition { name: String }, + #[error("binding `{name}` is declared more than once")] + DuplicateBinding { name: String }, + #[error("`{name}` cannot be both a macro and a binding")] + ConflictingDirective { name: String }, #[error("recursive macro expansion: {path}")] RecursiveExpansion { path: String }, #[error("preprocessed expression exceeds {MAX_PREPROCESSED_SIZE} bytes")] @@ -64,7 +126,7 @@ enum LexState { BlockComment, } -fn definition_parser() -> impl Parser> { +fn directive_parser() -> impl Parser> { let horizontal_whitespace = one_of(" \t").repeated(); let identifier = filter(|character: &char| { character.is_ascii_alphabetic() || *character == '_' @@ -78,26 +140,37 @@ fn definition_parser() -> impl Parser> { .map(|(first, rest)| { std::iter::once(first).chain(rest).collect::() }); - let replacement = any().repeated().at_least(1).collect::(); + let expression = any().repeated().at_least(1).collect::(); + let kind = just("define").to(true).or(just("bind").to(false)); horizontal_whitespace .clone() .ignore_then(just('#')) .then_ignore(horizontal_whitespace.clone()) - .then_ignore(just("define")) + .ignore_then(kind) .then_ignore(horizontal_whitespace.clone().at_least(1)) - .ignore_then(identifier) + .then(identifier) .then_ignore(horizontal_whitespace.at_least(1)) - .then(replacement) - .map(|(name, replacement)| Definition { - name, - replacement: replacement.trim().to_string(), + .then(expression) + .map(|((define, name), expression)| { + if define { + Directive::Define(Definition { + name, + replacement: expression.trim().to_string(), + }) + } else { + Directive::Bind { + name, + expression: expression.trim().to_string(), + } + } }) .then_ignore(end()) } -fn preprocess(source: &str) -> Result { +fn preprocess(source: &str) -> Result { let mut definitions = BTreeMap::new(); + let mut bindings = Vec::new(); let mut expression = String::with_capacity(source.len()); let mut state = LexState::Normal; @@ -107,26 +180,57 @@ fn preprocess(source: &str) -> Result { if directive { let directive = line.trim_end_matches(['\r', '\n']); - let definition = - definition_parser().parse(directive).map_err(|errors| { + let directive = + directive_parser().parse(directive).map_err(|errors| { let error = errors.into_iter().next().unwrap_or_else(|| { Simple::custom(0..0, "invalid directive") }); - PreprocessorError::InvalidDefinition { + PreprocessorError::InvalidDirective { line: line_index + 1, column: error.span().start + 1, message: error.to_string(), } })?; - if definitions - .insert(definition.name.clone(), definition.replacement) - .is_some() - { - return Err(PreprocessorError::DuplicateDefinition { - name: definition.name, - }); + match directive { + Directive::Define(definition) => { + if bindings.iter().any(|binding: &Binding| { + binding.name == definition.name + }) { + return Err(PreprocessorError::ConflictingDirective { + name: definition.name, + }); + } + if definitions + .insert(definition.name.clone(), definition.replacement) + .is_some() + { + return Err(PreprocessorError::DuplicateDefinition { + name: definition.name, + }); + } + } + Directive::Bind { name, expression } => { + if definitions.contains_key(&name) { + return Err(PreprocessorError::ConflictingDirective { + name, + }); + } + if bindings + .iter() + .any(|binding: &Binding| binding.name == name) + { + return Err(PreprocessorError::DuplicateBinding { + name, + }); + } + bindings.push(Binding { + name, + expression, + line: line_index + 1, + }); + } } if line.ends_with('\n') { @@ -140,7 +244,21 @@ fn preprocess(source: &str) -> Result { let mut expanded = String::with_capacity(expression.len()); expand(&expression, &definitions, &mut Vec::new(), &mut expanded)?; - Ok(expanded) + for binding in &mut bindings { + let mut expanded = String::with_capacity(binding.expression.len()); + expand( + &binding.expression, + &definitions, + &mut Vec::new(), + &mut expanded, + )?; + binding.expression = expanded; + } + + Ok(Preprocessed { + expression: expanded, + bindings, + }) } fn expand( @@ -372,14 +490,14 @@ fn string_start(bytes: &[u8], index: usize) -> Option<(usize, LexState)> { #[cfg(test)] mod tests { - use super::preprocess; + use super::{Context, Program, Value, preprocess}; #[test] fn expands_object_macros() { let source = "#define ISSUE OBFUSCATED_NAMES\ntrace.issue_type == ISSUE"; assert_eq!( - preprocess(source).unwrap(), + preprocess(source).unwrap().expression, "\ntrace.issue_type == OBFUSCATED_NAMES" ); } @@ -388,7 +506,7 @@ mod tests { fn recursively_expands_macros() { let source = "#define RESULT SEVERITY\n#define SEVERITY \"low\"\nRESULT"; - assert_eq!(preprocess(source).unwrap(), "\n\n\"low\""); + assert_eq!(preprocess(source).unwrap().expression, "\n\n\"low\""); } #[test] @@ -396,7 +514,7 @@ mod tests { let source = "#define VALUE expanded\nVALUE == \"VALUE\" // VALUE\n/* VALUE */"; assert_eq!( - preprocess(source).unwrap(), + preprocess(source).unwrap().expression, "\nexpanded == \"VALUE\" // VALUE\n/* VALUE */" ); } @@ -410,4 +528,44 @@ mod tests { "recursive macro expansion: FIRST -> SECOND -> FIRST" ); } + + #[test] + fn evaluates_runtime_bindings_in_order() { + let source = "#bind DOUBLE input * 2\n#bind RESULT DOUBLE + 1\nRESULT"; + let program = Program::compile(source).unwrap(); + let mut context = Context::default(); + context.add_variable("input", 4).unwrap(); + + assert_eq!(program.execute(&context).unwrap(), Value::Int(9)); + } + + #[test] + fn expands_definitions_in_runtime_bindings() { + let source = + "#define MULTIPLIER 3\n#bind RESULT input * MULTIPLIER\nRESULT"; + let program = Program::compile(source).unwrap(); + let mut context = Context::default(); + context.add_variable("input", 4).unwrap(); + + assert_eq!(program.execute(&context).unwrap(), Value::Int(12)); + } + + #[test] + fn bindings_shadow_context_variables_after_initialization() { + let source = "#bind VALUE VALUE + 1\nVALUE"; + let program = Program::compile(source).unwrap(); + let mut context = Context::default(); + context.add_variable("VALUE", 4).unwrap(); + + assert_eq!(program.execute(&context).unwrap(), Value::Int(5)); + } + + #[test] + fn reports_runtime_binding_errors_with_the_binding_name() { + let program = + Program::compile("#bind RESULT missing + 1\nRESULT").unwrap(); + let error = program.execute(&Context::default()).unwrap_err(); + + assert!(error.to_string().contains("#bind RESULT")); + } } From e9f245cacd7c47116d7732f97533e28dad47f44b Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:32:56 +0100 Subject: [PATCH 31/37] impl url parsing stuff --- .../moderation/technical-review/rules.vue | 20 +++ apps/labrinth/src/util/cel.rs | 161 +++++++++++++++++- 2 files changed, 176 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/pages/moderation/technical-review/rules.vue b/apps/frontend/src/pages/moderation/technical-review/rules.vue index 9270d102a3..4e0008a790 100644 --- a/apps/frontend/src/pages/moderation/technical-review/rules.vue +++ b/apps/frontend/src/pages/moderation/technical-review/rules.vue @@ -112,6 +112,24 @@ Regex +
  • + + URL parsing + + with url.parse(string) and + url.is_valid(string) +
    + Parsed URLs expose href, scheme, host, + domain, port, path, + path_segments, query, query_pairs, + fragment, credentials, and origin fields. +
    +
  •  {
    +    inner: CelContext<'a>,
    +}
    +
    +impl Default for Context<'_> {
    +    fn default() -> Self {
    +        let mut inner = CelContext::default();
    +        inner.add_function("url.parse", parse_url);
    +        inner.add_function("url.is_valid", is_valid_url);
    +
    +        Self { inner }
    +    }
    +}
    +
    +impl<'a> Deref for Context<'a> {
    +    type Target = CelContext<'a>;
    +
    +    fn deref(&self) -> &Self::Target {
    +        &self.inner
    +    }
    +}
    +
    +impl DerefMut for Context<'_> {
    +    fn deref_mut(&mut self) -> &mut Self::Target {
    +        &mut self.inner
    +    }
    +}
    +
    +#[derive(Serialize)]
    +struct ParsedUrl<'a> {
    +    href: &'a str,
    +    scheme: &'a str,
    +    has_authority: bool,
    +    cannot_be_a_base: bool,
    +    username: &'a str,
    +    password: Option<&'a str>,
    +    host: Option<&'a str>,
    +    host_type: Option<&'static str>,
    +    domain: Option<&'a str>,
    +    port: Option,
    +    port_or_known_default: Option,
    +    path: &'a str,
    +    path_segments: Option>,
    +    query: Option<&'a str>,
    +    query_pairs: Vec,
    +    fragment: Option<&'a str>,
    +    origin: String,
    +    origin_is_tuple: bool,
    +}
    +
    +#[derive(Serialize)]
    +struct QueryPair {
    +    key: String,
    +    value: String,
    +}
    +
    +fn parse_url(value: Arc) -> std::result::Result {
    +    let parsed = Url::parse(&value)
    +        .map_err(|error| ExecutionError::function_error("url.parse", error))?;
    +    let origin = parsed.origin();
    +    let host_type = parsed.host().map(|host| match host {
    +        Host::Domain(_) => "domain",
    +        Host::Ipv4(_) => "ipv4",
    +        Host::Ipv6(_) => "ipv6",
    +    });
    +    let query_pairs = parsed
    +        .query_pairs()
    +        .map(|(key, value)| QueryPair {
    +            key: key.into_owned(),
    +            value: value.into_owned(),
    +        })
    +        .collect();
    +    let value = ParsedUrl {
    +        href: parsed.as_str(),
    +        scheme: parsed.scheme(),
    +        has_authority: parsed.has_authority(),
    +        cannot_be_a_base: parsed.cannot_be_a_base(),
    +        username: parsed.username(),
    +        password: parsed.password(),
    +        host: parsed.host_str(),
    +        host_type,
    +        domain: parsed.domain(),
    +        port: parsed.port().map(i64::from),
    +        port_or_known_default: parsed.port_or_known_default().map(i64::from),
    +        path: parsed.path(),
    +        path_segments: parsed.path_segments().map(Iterator::collect),
    +        query: parsed.query(),
    +        query_pairs,
    +        fragment: parsed.fragment(),
    +        origin: origin.ascii_serialization(),
    +        origin_is_tuple: origin.is_tuple(),
    +    };
    +
    +    ::cel::to_value(value)
    +        .map_err(|error| ExecutionError::function_error("url.parse", error))
    +}
    +
    +fn is_valid_url(value: Arc) -> bool {
    +    Url::parse(&value).is_ok()
    +}
    +
     pub struct Program {
         inner: CelProgram,
         bindings: Vec,
    @@ -46,7 +154,7 @@ impl Program {
             &self,
             context: &Context<'a>,
         ) -> std::result::Result {
    -        let mut context = context.new_inner_scope();
    +        let mut context = context.inner.new_inner_scope();
     
             for binding in &self.bindings {
                 let value = binding.inner.execute(&context).map_err(|error| {
    @@ -568,4 +676,47 @@ mod tests {
     
             assert!(error.to_string().contains("#bind RESULT"));
         }
    +
    +    #[test]
    +    fn parses_urls_with_namespaced_functions() {
    +        let source = r#"
    +#bind URL url.parse("https://user:password@example.com:8443/a/b?first=one&first=two#fragment")
    +URL.scheme == "https" &&
    +URL.host == "example.com" &&
    +URL.host_type == "domain" &&
    +URL.port == 8443 &&
    +URL.path == "/a/b" &&
    +URL.path_segments == ["a", "b"] &&
    +URL.query_pairs[1].key == "first" &&
    +URL.query_pairs[1].value == "two" &&
    +URL.fragment == "fragment"
    +"#;
    +        let program = Program::compile(source).unwrap();
    +
    +        assert_eq!(
    +            program.execute(&Context::default()).unwrap(),
    +            Value::Bool(true)
    +        );
    +    }
    +
    +    #[test]
    +    fn checks_url_validity() {
    +        let program = Program::compile(
    +            r#"url.is_valid("https://modrinth.com/") && !url.is_valid("not a URL")"#,
    +        )
    +        .unwrap();
    +
    +        assert_eq!(
    +            program.execute(&Context::default()).unwrap(),
    +            Value::Bool(true)
    +        );
    +    }
    +
    +    #[test]
    +    fn reports_url_parse_errors() {
    +        let program = Program::compile(r#"url.parse("not a URL")"#).unwrap();
    +        let error = program.execute(&Context::default()).unwrap_err();
    +
    +        assert!(error.to_string().contains("url.parse"));
    +    }
     }
    
    From 94d8cc11331a36a219366ca37a1b8e06096dd11f Mon Sep 17 00:00:00 2001
    From: aecsocket <43144841+aecsocket@users.noreply.github.com>
    Date: Sat, 29 Aug 2026 20:49:22 +0100
    Subject: [PATCH 32/37] switch to file_details
    
    ---
     .../moderation/technical-review/rules.vue     |  22 ++--
     .../moderation/tech_review/rules_scan.rs      | 102 +++++++++---------
     .../api-client/src/modules/labrinth/types.ts  |   2 +-
     3 files changed, 64 insertions(+), 62 deletions(-)
    
    diff --git a/apps/frontend/src/pages/moderation/technical-review/rules.vue b/apps/frontend/src/pages/moderation/technical-review/rules.vue
    index 592fb6eee1..37ed49a8d1 100644
    --- a/apps/frontend/src/pages/moderation/technical-review/rules.vue
    +++ b/apps/frontend/src/pages/moderation/technical-review/rules.vue
    @@ -619,9 +619,8 @@ const TRACE_SEVERITIES: Labrinth.TechReview.Internal.DelphiSeverity[] = [
     	'hidden',
     ]
     
    -const TEST_INPUT_METADATA: Omit = {
    +const TEST_INPUT_METADATA: Omit = {
     	schema_version: 1,
    -	sibling_traces: [],
     	scan: {
     		delphi_version: 17,
     	},
    @@ -933,16 +932,19 @@ function getTestRuleInput(): Labrinth.TechReview.Internal.RuleInput | null {
     	}
     
     	traceDataError.value = null
    +	const trace: Labrinth.TechReview.Internal.RuleTrace = {
    +		key: testTraceForm.key,
    +		issue_type: testTraceForm.issueType,
    +		severity: testTraceForm.severity,
    +		jar: testTraceForm.jar.trim() || null,
    +		file_path: testTraceForm.filePath,
    +		data: data as Record,
    +	}
    +
     	return {
     		...TEST_INPUT_METADATA,
    -		trace: {
    -			key: testTraceForm.key,
    -			issue_type: testTraceForm.issueType,
    -			severity: testTraceForm.severity,
    -			jar: testTraceForm.jar.trim() || null,
    -			file_path: testTraceForm.filePath,
    -			data: data as Record,
    -		},
    +		trace,
    +		file_traces: [trace],
     	}
     }
     
    diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs
    index aa5da3d68b..02e628d203 100644
    --- a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs
    +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs
    @@ -57,7 +57,7 @@ struct RuleScanErrorEvent<'a> {
     pub struct RuleInput {
         pub schema_version: u32,
         pub trace: RuleTrace,
    -    pub sibling_traces: Vec,
    +    pub file_traces: Vec,
         pub scan: RuleScan,
         pub artifact: RuleArtifact,
         pub project: RuleProject,
    @@ -209,8 +209,8 @@ pub async fn get_detail_rule_input(
                 detail.jar,
                 detail.file_path,
                 detail.data AS "data: Json>",
    -            COALESCE(sibling_traces.traces, '[]'::jsonb)
    -                AS "sibling_traces!: Json>",
    +            COALESCE(file_traces.traces, '[]'::jsonb)
    +                AS "file_traces!: Json>",
                 report.delphi_version,
                 file.size AS "size?",
                 file.id AS "file_id?",
    @@ -229,21 +229,21 @@ pub async fn get_detail_rule_input(
                 SELECT
                     jsonb_agg(
                         jsonb_build_object(
    -                        'key', sibling_detail.key,
    -                        'issue_type', sibling_issue.issue_type,
    -                        'severity', sibling_detail.severity,
    -                        'jar', sibling_detail.jar,
    -                        'file_path', sibling_detail.file_path,
    -                        'data', sibling_detail.data
    +                        'key', file_detail.key,
    +                        'issue_type', file_issue.issue_type,
    +                        'severity', file_detail.severity,
    +                        'jar', file_detail.jar,
    +                        'file_path', file_detail.file_path,
    +                        'data', file_detail.data
                         )
    -                    ORDER BY sibling_detail.id
    +                    ORDER BY file_detail.id
                     ) AS traces
    -            FROM delphi_report_issues sibling_issue
    -            INNER JOIN delphi_report_issue_details sibling_detail
    -                ON sibling_detail.issue_id = sibling_issue.id
    -            WHERE sibling_issue.report_id = issue.report_id
    -                AND sibling_detail.id != detail.id
    -        ) sibling_traces ON TRUE
    +            FROM delphi_report_issues file_issue
    +            INNER JOIN delphi_report_issue_details file_detail
    +                ON file_detail.issue_id = file_issue.id
    +            WHERE file_issue.report_id = issue.report_id
    +                AND file_detail.file_path = detail.file_path
    +        ) file_traces ON TRUE
             LEFT JOIN files file ON file.id = report.file_id
             LEFT JOIN versions version ON version.id = file.version_id
             LEFT JOIN LATERAL (
    @@ -291,7 +291,7 @@ pub async fn get_detail_rule_input(
                 file_path: detail.file_path,
                 data: detail.data.0,
             },
    -        sibling_traces: detail.sibling_traces.0,
    +        file_traces: detail.file_traces.0,
             scan: RuleScan {
                 delphi_version: detail.delphi_version,
             },
    @@ -466,8 +466,8 @@ async fn run_scan(
                 detail.jar,
                 detail.file_path,
                 detail.data AS "data: Json>",
    -            COALESCE(sibling_traces.traces, '[]'::jsonb)
    -                AS "sibling_traces!: Json>",
    +            COALESCE(file_traces.traces, '[]'::jsonb)
    +                AS "file_traces!: Json>",
                 report.delphi_version,
                 file.size AS "size?",
                 file.id AS "file_id?",
    @@ -486,21 +486,21 @@ async fn run_scan(
                 SELECT
                     jsonb_agg(
                         jsonb_build_object(
    -                        'key', sibling_detail.key,
    -                        'issue_type', sibling_issue.issue_type,
    -                        'severity', sibling_detail.severity,
    -                        'jar', sibling_detail.jar,
    -                        'file_path', sibling_detail.file_path,
    -                        'data', sibling_detail.data
    +                        'key', file_detail.key,
    +                        'issue_type', file_issue.issue_type,
    +                        'severity', file_detail.severity,
    +                        'jar', file_detail.jar,
    +                        'file_path', file_detail.file_path,
    +                        'data', file_detail.data
                         )
    -                    ORDER BY sibling_detail.id
    +                    ORDER BY file_detail.id
                     ) AS traces
    -            FROM delphi_report_issues sibling_issue
    -            INNER JOIN delphi_report_issue_details sibling_detail
    -                ON sibling_detail.issue_id = sibling_issue.id
    -            WHERE sibling_issue.report_id = issue.report_id
    -                AND sibling_detail.id != detail.id
    -        ) sibling_traces ON TRUE
    +            FROM delphi_report_issues file_issue
    +            INNER JOIN delphi_report_issue_details file_detail
    +                ON file_detail.issue_id = file_issue.id
    +            WHERE file_issue.report_id = issue.report_id
    +                AND file_detail.file_path = detail.file_path
    +        ) file_traces ON TRUE
             LEFT JOIN files file ON file.id = report.file_id
             LEFT JOIN versions version ON version.id = file.version_id
             LEFT JOIN (
    @@ -567,7 +567,7 @@ async fn run_scan(
                     file_path: detail.file_path,
                     data: detail.data.0,
                 },
    -            sibling_traces: detail.sibling_traces.0,
    +            file_traces: detail.file_traces.0,
                 scan: RuleScan {
                     delphi_version: detail.delphi_version,
                 },
    @@ -764,8 +764,8 @@ pub(crate) async fn materialize_current_rule_effects(
                 detail.jar,
                 detail.file_path,
                 detail.data AS "data: Json>",
    -            COALESCE(sibling_traces.traces, '[]'::jsonb)
    -                AS "sibling_traces!: Json>",
    +            COALESCE(file_traces.traces, '[]'::jsonb)
    +                AS "file_traces!: Json>",
                 report.delphi_version,
                 file.size AS "size?",
                 file.id AS "file_id?",
    @@ -784,21 +784,21 @@ pub(crate) async fn materialize_current_rule_effects(
                 SELECT
                     jsonb_agg(
                         jsonb_build_object(
    -                        'key', sibling_detail.key,
    -                        'issue_type', sibling_issue.issue_type,
    -                        'severity', sibling_detail.severity,
    -                        'jar', sibling_detail.jar,
    -                        'file_path', sibling_detail.file_path,
    -                        'data', sibling_detail.data
    +                        'key', file_detail.key,
    +                        'issue_type', file_issue.issue_type,
    +                        'severity', file_detail.severity,
    +                        'jar', file_detail.jar,
    +                        'file_path', file_detail.file_path,
    +                        'data', file_detail.data
                         )
    -                    ORDER BY sibling_detail.id
    +                    ORDER BY file_detail.id
                     ) AS traces
    -            FROM delphi_report_issues sibling_issue
    -            INNER JOIN delphi_report_issue_details sibling_detail
    -                ON sibling_detail.issue_id = sibling_issue.id
    -            WHERE sibling_issue.report_id = issue.report_id
    -                AND sibling_detail.id != detail.id
    -        ) sibling_traces ON TRUE
    +            FROM delphi_report_issues file_issue
    +            INNER JOIN delphi_report_issue_details file_detail
    +                ON file_detail.issue_id = file_issue.id
    +            WHERE file_issue.report_id = issue.report_id
    +                AND file_detail.file_path = detail.file_path
    +        ) file_traces ON TRUE
             LEFT JOIN files file ON file.id = report.file_id
             LEFT JOIN versions version ON version.id = file.version_id
             LEFT JOIN LATERAL (
    @@ -848,7 +848,7 @@ pub(crate) async fn materialize_current_rule_effects(
                     file_path: detail.file_path,
                     data: detail.data.0,
                 },
    -            sibling_traces: detail.sibling_traces.0,
    +            file_traces: detail.file_traces.0,
                 scan: RuleScan {
                     delphi_version: detail.delphi_version,
                 },
    @@ -1006,8 +1006,8 @@ fn evaluate_rule_inner(
             .add_variable("trace", &input.trace)
             .wrap_err("failed to add `trace` to cel context")?;
         context
    -        .add_variable("sibling_traces", &input.sibling_traces)
    -        .wrap_err("failed to add `sibling_traces` to cel context")?;
    +        .add_variable("file_traces", &input.file_traces)
    +        .wrap_err("failed to add `file_traces` to cel context")?;
         context
             .add_variable("scan", &input.scan)
             .wrap_err("failed to add `scan` to cel context")?;
    diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts
    index eb647a8251..bce9116d27 100644
    --- a/packages/api-client/src/modules/labrinth/types.ts
    +++ b/packages/api-client/src/modules/labrinth/types.ts
    @@ -2471,7 +2471,7 @@ export namespace Labrinth {
     			export type RuleInput = {
     				schema_version: number
     				trace: RuleTrace
    -				sibling_traces: RuleTrace[]
    +				file_traces: RuleTrace[]
     				scan: {
     					delphi_version: number
     				}
    
    From 352205eca9a8eccd990dd515f8339a3d149c0fdc Mon Sep 17 00:00:00 2001
    From: aecsocket <43144841+aecsocket@users.noreply.github.com>
    Date: Sun, 30 Aug 2026 17:17:29 +0100
    Subject: [PATCH 33/37] paginate affected traces
    
    ---
     apps/frontend/src/layouts/default.vue         |   8 ++
     .../moderation/technical-review/rules.vue     | 102 +++++++++++++-----
     .../internal/moderation/tech_review/rules.rs  |  60 ++++++++++-
     .../modules/labrinth/tech-review/internal.ts  |   6 +-
     .../api-client/src/modules/labrinth/types.ts  |  10 ++
     5 files changed, 151 insertions(+), 35 deletions(-)
    
    diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue
    index 8670de42a0..9fd6130af8 100644
    --- a/apps/frontend/src/layouts/default.vue
    +++ b/apps/frontend/src/layouts/default.vue
    @@ -410,6 +410,14 @@
     							to: '/moderation/global-traces',
     							tone: 'orange',
     						},
    +						{
    +							id: 'delphi-rules',
    +							label: 'Delphi rules',
    +							icon: SettingsIcon,
    +							type: 'link',
    +							to: '/moderation/technical-review/rules',
    +							tone: 'orange',
    +						},
     						{ type: 'divider' },
     						{
     							id: 'file-lookup',
    diff --git a/apps/frontend/src/pages/moderation/technical-review/rules.vue b/apps/frontend/src/pages/moderation/technical-review/rules.vue
    index 37ed49a8d1..9363af1b95 100644
    --- a/apps/frontend/src/pages/moderation/technical-review/rules.vue
    +++ b/apps/frontend/src/pages/moderation/technical-review/rules.vue
    @@ -383,9 +383,9 @@ IS_MATCH ? "low" : null

    - Affected details ({{ rule.affected_details_count.toLocaleString() }}) + Affected details ({{ getAffectedDetailsTotal(rule).toLocaleString() }})

    -

    +

    No details are affected in the current revision.

    @@ -455,25 +455,27 @@ IS_MATCH ? "low" : null
    - +

    + + Showing {{ getAffectedDetailsPageStart(rule).toLocaleString() }}–{{ + getAffectedDetailsPageEnd(rule).toLocaleString() + }} + of {{ getAffectedDetailsTotal(rule).toLocaleString() }} +

    +
    - @@ -502,6 +504,7 @@ import { injectModrinthClient, injectNotificationManager, NewModal, + Pagination, ProgressBar, Textarea, } from '@modrinth/ui' @@ -688,10 +691,18 @@ const ruleTestEffects = ref(null) const traceDataError = ref(null) const scanProgress = ref(null) -const expandedAffectedDetails = reactive( - new Map(), +const affectedDetailsPages = reactive( + new Map< + number, + { + page: number + total: number + details: Labrinth.TechReview.Internal.DelphiRuleAffectedDetail[] + } + >(), ) const loadingAffectedRuleIds = reactive(new Set()) +const AFFECTED_DETAILS_PAGE_SIZE = 3 const form = reactive({ name: '', priority: 0 as number | undefined, @@ -1018,7 +1029,7 @@ async function loadRules() { loadFailed.value = false try { rules.value = await client.labrinth.tech_review_internal.getRules() - expandedAffectedDetails.clear() + affectedDetailsPages.clear() } catch (error) { console.error('Failed to load Delphi rules', error) loadFailed.value = true @@ -1045,7 +1056,32 @@ async function loadRuleSchema() { function getVisibleRuleDetails( rule: Labrinth.TechReview.Internal.DelphiRule, ): Labrinth.TechReview.Internal.DelphiRuleAffectedDetail[] { - return expandedAffectedDetails.get(rule.id) ?? rule.affected_details + return affectedDetailsPages.get(rule.id)?.details ?? rule.affected_details +} + +function getAffectedDetailsPage(rule: Labrinth.TechReview.Internal.DelphiRule): number { + return affectedDetailsPages.get(rule.id)?.page ?? 1 +} + +function getAffectedDetailsTotal(rule: Labrinth.TechReview.Internal.DelphiRule): number { + return affectedDetailsPages.get(rule.id)?.total ?? rule.affected_details_count +} + +function getAffectedDetailsPageCount(rule: Labrinth.TechReview.Internal.DelphiRule): number { + return Math.max(Math.ceil(getAffectedDetailsTotal(rule) / AFFECTED_DETAILS_PAGE_SIZE), 1) +} + +function getAffectedDetailsPageStart(rule: Labrinth.TechReview.Internal.DelphiRule): number { + const total = getAffectedDetailsTotal(rule) + if (total === 0) return 0 + return (getAffectedDetailsPage(rule) - 1) * AFFECTED_DETAILS_PAGE_SIZE + 1 +} + +function getAffectedDetailsPageEnd(rule: Labrinth.TechReview.Internal.DelphiRule): number { + return Math.min( + getAffectedDetailsPage(rule) * AFFECTED_DETAILS_PAGE_SIZE, + getAffectedDetailsTotal(rule), + ) } function getAffectedDetailLink( @@ -1062,23 +1098,33 @@ function getVersionLink(detail: Labrinth.TechReview.Internal.DelphiRuleAffectedD return `/project/${detail.project_id}/version/${detail.version_id}` } -async function toggleAffectedDetails(rule: Labrinth.TechReview.Internal.DelphiRule) { - if (expandedAffectedDetails.has(rule.id)) { - expandedAffectedDetails.delete(rule.id) +async function switchAffectedDetailsPage( + rule: Labrinth.TechReview.Internal.DelphiRule, + page: number, +) { + if (loadingAffectedRuleIds.has(rule.id)) return + if (page === 1) { + affectedDetailsPages.delete(rule.id) return } - if (loadingAffectedRuleIds.has(rule.id)) return loadingAffectedRuleIds.add(rule.id) try { - const details = await client.labrinth.tech_review_internal.getRuleAffectedDetails(rule.id) - expandedAffectedDetails.set(rule.id, details) + const response = await client.labrinth.tech_review_internal.getRuleAffectedDetails(rule.id, { + limit: AFFECTED_DETAILS_PAGE_SIZE, + page: page - 1, + }) + affectedDetailsPages.set(rule.id, { + page, + total: response.total, + details: response.details, + }) } catch (error) { console.error('Failed to load details affected by Delphi rule', error) addNotification({ type: 'error', title: 'Failed to load affected details', - text: 'The complete list of affected details could not be loaded.', + text: 'The requested page of affected details could not be loaded.', }) } finally { loadingAffectedRuleIds.delete(rule.id) diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs index c9beebef34..0071b493df 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs @@ -68,6 +68,26 @@ pub struct DelphiRuleAffectedDetail { pub severity: DelphiSeverity, } +fn default_affected_details_limit() -> u64 { + 5 +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct GetRuleAffectedDetailsRequest { + #[serde(default = "default_affected_details_limit")] + #[schema(default = 3)] + pub limit: u64, + #[serde(default)] + #[schema(default = 0)] + pub page: u64, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct GetRuleAffectedDetailsResponse { + pub total: i64, + pub details: Vec, +} + #[derive(Debug, Deserialize, Validate, utoipa::ToSchema)] pub struct WriteDelphiRule { #[validate(length(min = 1, max = 256))] @@ -366,7 +386,12 @@ pub async fn get_rules( context_path = "/moderation/tech-review", tag = "moderation", security(("bearer_auth" = [])), - responses((status = OK, body = Vec)) + params( + ("id" = DelphiRuleId, Path), + ("limit" = Option, Query), + ("page" = Option, Query) + ), + responses((status = OK, body = GetRuleAffectedDetailsResponse)) )] #[get("/rules/{id}/effects")] pub async fn get_rule_affected_details( @@ -376,7 +401,8 @@ pub async fn get_rule_affected_details( redis: web::Data, session_queue: web::Data, path: web::Path<(DelphiRuleId,)>, -) -> Result>, ApiError> { + query: web::Query, +) -> Result, ApiError> { check_is_moderator_from_headers( &req, &**pool, @@ -387,6 +413,26 @@ pub async fn get_rule_affected_details( .await .wrap_auth_err("authenticating API request")?; let (rule_id,) = path.into_inner(); + let limit = query.limit.clamp(1, 100); + let offset = limit.saturating_mul(query.page); + let limit = + i64::try_from(limit).wrap_request_err("limit cannot fit into `i64`")?; + let offset = i64::try_from(offset) + .wrap_request_err("offset cannot fit into `i64`")?; + + let total = sqlx::query_scalar!( + r#" + SELECT COUNT(*) AS "count!" + FROM delphi_rule_effects effect + INNER JOIN delphi_rule_revisions published + ON published.revision = effect.revision + WHERE effect.rule_id = $1 + "#, + rule_id as DelphiRuleId, + ) + .fetch_one(&***ro_pool) + .await + .wrap_internal_err("failed to count details affected by delphi rule")?; let details = sqlx::query!( r#" @@ -417,15 +463,19 @@ pub async fn get_rule_affected_details( LEFT JOIN mods project ON project.id = version.mod_id WHERE effect.rule_id = $1 ORDER BY effect.detail_id DESC + LIMIT $2 OFFSET $3 "#, rule_id as DelphiRuleId, + limit, + offset, ) .fetch_all(&***ro_pool) .await .wrap_internal_err("failed to fetch details affected by delphi rule")?; - Ok(web::Json( - details + Ok(web::Json(GetRuleAffectedDetailsResponse { + total, + details: details .into_iter() .map(|detail| DelphiRuleAffectedDetail { detail_id: detail.detail_id, @@ -444,7 +494,7 @@ pub async fn get_rule_affected_details( severity: detail.effect_severity, }) .collect(), - )) + })) } /// Create a Delphi rule. It will be applied by the next manual rule scan. diff --git a/packages/api-client/src/modules/labrinth/tech-review/internal.ts b/packages/api-client/src/modules/labrinth/tech-review/internal.ts index 5e7c60f7c8..bdc4c129c4 100644 --- a/packages/api-client/src/modules/labrinth/tech-review/internal.ts +++ b/packages/api-client/src/modules/labrinth/tech-review/internal.ts @@ -43,13 +43,15 @@ export class LabrinthTechReviewInternalModule extends AbstractModule { public async getRuleAffectedDetails( id: number, - ): Promise { - return this.client.request( + params: Labrinth.TechReview.Internal.GetRuleAffectedDetailsRequest = {}, + ): Promise { + return this.client.request( `/moderation/tech-review/rules/${id}/effects`, { api: 'labrinth', version: 'internal', method: 'GET', + params, }, ) } diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index bce9116d27..5acaec6a57 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -2445,6 +2445,16 @@ export namespace Labrinth { severity: DelphiSeverity } + export type GetRuleAffectedDetailsRequest = { + limit?: number + page?: number + } + + export type GetRuleAffectedDetailsResponse = { + total: number + details: DelphiRuleAffectedDetail[] + } + export type WriteDelphiRule = { name: string rule: string From e2dfe64b38caecf7211cc88c88baf064c385dd10 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:59:16 +0100 Subject: [PATCH 34/37] filter rule runs by specific issue details --- .../moderation/technical-review/rules.vue | 64 +++++++++++++++++++ ...20260830120000_delphi_rule_issue_types.sql | 2 + .../internal/moderation/tech_review/rules.rs | 32 +++++++++- .../moderation/tech_review/rules_scan.rs | 22 ++++++- .../modules/labrinth/tech-review/internal.ts | 11 ++++ .../api-client/src/modules/labrinth/types.ts | 4 ++ 6 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 apps/labrinth/migrations/20260830120000_delphi_rule_issue_types.sql diff --git a/apps/frontend/src/pages/moderation/technical-review/rules.vue b/apps/frontend/src/pages/moderation/technical-review/rules.vue index 9363af1b95..807dc0b062 100644 --- a/apps/frontend/src/pages/moderation/technical-review/rules.vue +++ b/apps/frontend/src/pages/moderation/technical-review/rules.vue @@ -23,6 +23,35 @@ Higher-priority rules run first. Rules with the same priority run in creation order.

    +
    + +

    + Choose which issue types this rule evaluates. Leave empty to run it against every issue + type. +

    + +
    +

    The available issue types could not be loaded.

    + +
    +
    +
    {{ isRuleLive(rule) ? 'Live' : 'Outdated' }}
    +
    + Applies to + All issue types + +