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/components/ui/moderation/GlobalDetailLocalTraceCard.vue b/apps/frontend/src/components/ui/moderation/GlobalDetailLocalTraceCard.vue index a6756ce443..8329b64b33 100644 --- a/apps/frontend/src/components/ui/moderation/GlobalDetailLocalTraceCard.vue +++ b/apps/frontend/src/components/ui/moderation/GlobalDetailLocalTraceCard.vue @@ -5,14 +5,8 @@

{{ trace.project_name }}

-

- {{ trace.version_number }} -

+

@@ -33,9 +27,11 @@ diff --git a/apps/frontend/src/components/ui/moderation/GlobalDetailTracesList.vue b/apps/frontend/src/components/ui/moderation/GlobalDetailTracesList.vue index 8d42abbc1e..1d4fd8cb8a 100644 --- a/apps/frontend/src/components/ui/moderation/GlobalDetailTracesList.vue +++ b/apps/frontend/src/components/ui/moderation/GlobalDetailTracesList.vue @@ -65,7 +65,7 @@

Path - {{ decodeTracePath(getLatestLocalTrace(trace)?.file_path ?? '') }} +

@@ -133,6 +133,7 @@ import { } from '@modrinth/ui' import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue' +import IssueDetailPath from '~/components/ui/moderation/IssueDetailPath.vue' const client = injectModrinthClient() const { addNotification } = injectNotificationManager() @@ -165,14 +166,6 @@ function getLatestLocalTrace(trace: Labrinth.TechReview.Internal.GlobalIssueDeta return trace.local_traces.at(-1) } -function decodeTracePath(path: string): string { - try { - return decodeURIComponent(path) - } catch { - return path - } -} - function getSeverityBadgeColor( severity: Labrinth.TechReview.Internal.DelphiSeverity | undefined, ): string { diff --git a/apps/frontend/src/components/ui/moderation/IssueDetailPath.vue b/apps/frontend/src/components/ui/moderation/IssueDetailPath.vue new file mode 100644 index 0000000000..6289a9cd56 --- /dev/null +++ b/apps/frontend/src/components/ui/moderation/IssueDetailPath.vue @@ -0,0 +1,74 @@ + + + diff --git a/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue b/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue index f91bd67552..3e595510ca 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue +++ b/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue @@ -6,7 +6,6 @@ import { CheckCheckIcon, CheckIcon, ChevronDownIcon, - ChevronRightIcon, ClipboardCopyIcon, CodeIcon, CopyIcon, @@ -49,6 +48,7 @@ import { import dayjs from 'dayjs' import { computed, nextTick, reactive, ref, watch } from 'vue' +import IssueDetailPath from '~/components/ui/moderation/IssueDetailPath.vue' import type { UnsafeFile } from '~/components/ui/moderation/MaliciousSummaryModal.vue' import ThreadView from '~/components/ui/thread/ThreadView.vue' @@ -601,6 +601,29 @@ async function copyToClipboard(code: string, detailId: string) { } } +async function copyDetailCelInput(detailId: string) { + if (copyingCelDetails.has(detailId)) return + + copyingCelDetails.add(detailId) + try { + const input = await client.labrinth.tech_review_internal.getDetailRuleInput(detailId) + await navigator.clipboard.writeText(JSON.stringify(input, null, 2)) + copiedCelDetails.add(detailId) + setTimeout(() => { + copiedCelDetails.delete(detailId) + }, 2000) + } catch (error) { + console.error('Failed to copy CEL input:', error) + addNotification({ + type: 'error', + title: 'Failed to copy CEL input', + text: 'An error occurred while loading the trace rule input.', + }) + } finally { + copyingCelDetails.delete(detailId) + } +} + function getDetailDecision( detailId: string, backendStatus: Labrinth.TechReview.Internal.DelphiReportIssueStatus, @@ -999,6 +1022,8 @@ async function updateGlobalDetailStatus( const expandedClasses = reactive>(new Set()) const autoExpandedFileIds = reactive>(new Set()) const showCopyFeedback = reactive>(new Map()) +const copyingCelDetails = reactive>(new Set()) +const copiedCelDetails = reactive>(new Set()) const highlightedSourceCache = reactive>(new Map()) const LAZY_LOAD_CLASS_SOURCE_MINIMUM = 2 @@ -1025,7 +1050,7 @@ interface JarGroup { function splitJarSegments(jar: string | null, currentFileName: string | null): string[] { if (!jar) return [] const segments = jar - .split(/[/#]/) + .split('#') .map((s) => decodeURIComponent(s.trim())) .filter((s) => s.length > 0) // Skip the first segment if it matches the current file tab (it's already shown in the file list) @@ -1770,27 +1795,12 @@ function copyId() { class="border-b border-solid border-surface-1 px-4 py-3" >
-
- -
+
- {{ - truncateMiddle(classItem.filePath) - }} +
+ + +
diff --git a/apps/labrinth/.sqlx/query-0f3d943e4fc48a94363b77c8a7d36eb1dd626e77331d8278c406df952691be4c.json b/apps/labrinth/.sqlx/query-0f3d943e4fc48a94363b77c8a7d36eb1dd626e77331d8278c406df952691be4c.json deleted file mode 100644 index 4bc87e73a5..0000000000 --- a/apps/labrinth/.sqlx/query-0f3d943e4fc48a94363b77c8a7d36eb1dd626e77331d8278c406df952691be4c.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM payouts_values_notifications WHERE notified = FALSE AND user_id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - null - ] - }, - "hash": "0f3d943e4fc48a94363b77c8a7d36eb1dd626e77331d8278c406df952691be4c" -} diff --git a/apps/labrinth/.sqlx/query-0ffebf149d50149d0c128478e6267a2296efc16763cdbd5d8c295a1a7ca08021.json b/apps/labrinth/.sqlx/query-0ffebf149d50149d0c128478e6267a2296efc16763cdbd5d8c295a1a7ca08021.json deleted file mode 100644 index c8a82d60ef..0000000000 --- a/apps/labrinth/.sqlx/query-0ffebf149d50149d0c128478e6267a2296efc16763cdbd5d8c295a1a7ca08021.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO users (id, username, email, role)\n VALUES\n (1000, 'userland', 'userland@modrinth.com', 'developer'),\n (1001, 'useful', 'useful@modrinth.com', 'developer'),\n (1002, 'Useless', 'useless@modrinth.com', 'developer')\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "0ffebf149d50149d0c128478e6267a2296efc16763cdbd5d8c295a1a7ca08021" -} diff --git a/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json b/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json deleted file mode 100644 index 921f7f92d9..0000000000 --- a/apps/labrinth/.sqlx/query-1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n id,\n status AS \"status: PayoutStatus\"\n FROM payouts\n ORDER BY id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "status: PayoutStatus", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false - ] - }, - "hash": "1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286" -} diff --git a/apps/labrinth/.sqlx/query-20cff8fdf7971e91c9d473b9a4663ce02ca16781e32232ae0fa7a0af1973d3a4.json b/apps/labrinth/.sqlx/query-20cff8fdf7971e91c9d473b9a4663ce02ca16781e32232ae0fa7a0af1973d3a4.json deleted file mode 100644 index 3c99ff3fed..0000000000 --- a/apps/labrinth/.sqlx/query-20cff8fdf7971e91c9d473b9a4663ce02ca16781e32232ae0fa7a0af1973d3a4.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM payouts_values_notifications WHERE notified = FALSE", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "20cff8fdf7971e91c9d473b9a4663ce02ca16781e32232ae0fa7a0af1973d3a4" -} diff --git a/apps/labrinth/.sqlx/query-329c009635ad2a4c214da2f4651b8c14fe329deac983bc2f43ba74e066f9cf8d.json b/apps/labrinth/.sqlx/query-329c009635ad2a4c214da2f4651b8c14fe329deac983bc2f43ba74e066f9cf8d.json deleted file mode 100644 index 2731d395ff..0000000000 --- a/apps/labrinth/.sqlx/query-329c009635ad2a4c214da2f4651b8c14fe329deac983bc2f43ba74e066f9cf8d.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE mods SET status = 'processing' WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "329c009635ad2a4c214da2f4651b8c14fe329deac983bc2f43ba74e066f9cf8d" -} diff --git a/apps/labrinth/.sqlx/query-38ace0abad724d1111b79a72c7c8518f1272690ce7766dbdc41ec1e98f7f1fec.json b/apps/labrinth/.sqlx/query-38ace0abad724d1111b79a72c7c8518f1272690ce7766dbdc41ec1e98f7f1fec.json deleted file mode 100644 index 815e7795ec..0000000000 --- a/apps/labrinth/.sqlx/query-38ace0abad724d1111b79a72c7c8518f1272690ce7766dbdc41ec1e98f7f1fec.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO users (id, username, email, role)\n VALUES (2100, 'prefix_under_score', 'prefix_under_score@modrinth.com', 'developer')\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "38ace0abad724d1111b79a72c7c8518f1272690ce7766dbdc41ec1e98f7f1fec" -} diff --git a/apps/labrinth/.sqlx/query-5cb65218a2a4a7343130d838f8a78df08d25844f1019d539712b0cb686e4ae81.json b/apps/labrinth/.sqlx/query-5cb65218a2a4a7343130d838f8a78df08d25844f1019d539712b0cb686e4ae81.json deleted file mode 100644 index 55ba902620..0000000000 --- a/apps/labrinth/.sqlx/query-5cb65218a2a4a7343130d838f8a78df08d25844f1019d539712b0cb686e4ae81.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO users (id, username, email, role)\n VALUES ($1, $2, $3, 'developer')\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Varchar", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "5cb65218a2a4a7343130d838f8a78df08d25844f1019d539712b0cb686e4ae81" -} diff --git a/apps/labrinth/.sqlx/query-64d02e0fa69fe1cad91d851e4d32dea4a6c830ec1b74ea1e2b71d5f18751ac6c.json b/apps/labrinth/.sqlx/query-64d02e0fa69fe1cad91d851e4d32dea4a6c830ec1b74ea1e2b71d5f18751ac6c.json new file mode 100644 index 0000000000..38c58dbb1c --- /dev/null +++ b/apps/labrinth/.sqlx/query-64d02e0fa69fe1cad91d851e4d32dea4a6c830ec1b74ea1e2b71d5f18751ac6c.json @@ -0,0 +1,62 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tSELECT\n\t\t\tid,\n\t\t\tname,\n\t\t\trule,\n\t\t\trevision,\n\t\t\tcreated_at,\n\t\t\tupdated_at,\n\t\t\tcreated_by,\n\t\t\tupdated_by\n\t\tFROM delphi_rules\n\t\tWHERE NOT delete_on_next_revision\n\t\tORDER BY name, id\n\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "rule", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "revision", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "created_by", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "updated_by", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "64d02e0fa69fe1cad91d851e4d32dea4a6c830ec1b74ea1e2b71d5f18751ac6c" +} diff --git a/apps/labrinth/.sqlx/query-6678cd4c51771cfaae2be8021ba66908ea41a06ba858dc5b523aef6aae27b850.json b/apps/labrinth/.sqlx/query-6678cd4c51771cfaae2be8021ba66908ea41a06ba858dc5b523aef6aae27b850.json deleted file mode 100644 index b4c2e5a56e..0000000000 --- a/apps/labrinth/.sqlx/query-6678cd4c51771cfaae2be8021ba66908ea41a06ba858dc5b523aef6aae27b850.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO payouts_values_notifications (date_available, user_id, notified)\n VALUES ($1, $2, FALSE)\n ON CONFLICT (date_available, user_id) DO NOTHING", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Timestamptz", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "6678cd4c51771cfaae2be8021ba66908ea41a06ba858dc5b523aef6aae27b850" -} diff --git a/apps/labrinth/.sqlx/query-66868d0b35229abffa3f668eaef19dc0cdef502935dd5939a2f3ab89b0766ad2.json b/apps/labrinth/.sqlx/query-66868d0b35229abffa3f668eaef19dc0cdef502935dd5939a2f3ab89b0766ad2.json deleted file mode 100644 index 5d509fdf07..0000000000 --- a/apps/labrinth/.sqlx/query-66868d0b35229abffa3f668eaef19dc0cdef502935dd5939a2f3ab89b0766ad2.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "CREATE SCHEMA public;", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "66868d0b35229abffa3f668eaef19dc0cdef502935dd5939a2f3ab89b0766ad2" -} diff --git a/apps/labrinth/.sqlx/query-69a1cb4b7f1115a990d1fc4805d58541fc78e910111c09ba3d50a12d9ca4a9f8.json b/apps/labrinth/.sqlx/query-69a1cb4b7f1115a990d1fc4805d58541fc78e910111c09ba3d50a12d9ca4a9f8.json deleted file mode 100644 index fc7d2ac98d..0000000000 --- a/apps/labrinth/.sqlx/query-69a1cb4b7f1115a990d1fc4805d58541fc78e910111c09ba3d50a12d9ca4a9f8.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO payouts_values (user_id, mod_id, amount, created, date_available)\n VALUES ($1, NULL, $2, NOW(), $3)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Numeric", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "69a1cb4b7f1115a990d1fc4805d58541fc78e910111c09ba3d50a12d9ca4a9f8" -} diff --git a/apps/labrinth/.sqlx/query-7b4ee9c2fb7ef8da9bce4d86d4271aa078119ed2c60828683f872321fb61bb79.json b/apps/labrinth/.sqlx/query-7b4ee9c2fb7ef8da9bce4d86d4271aa078119ed2c60828683f872321fb61bb79.json new file mode 100644 index 0000000000..9262ec39d7 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7b4ee9c2fb7ef8da9bce4d86d4271aa078119ed2c60828683f872321fb61bb79.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tUPDATE delphi_rules\n\t\tSET\n\t\t\tdelete_on_next_revision = TRUE,\n\t\t\tupdated_at = CURRENT_TIMESTAMP,\n\t\t\tupdated_by = $2\n\t\tWHERE id = $1 AND NOT delete_on_next_revision\n\t\tRETURNING id\n\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "7b4ee9c2fb7ef8da9bce4d86d4271aa078119ed2c60828683f872321fb61bb79" +} diff --git a/apps/labrinth/.sqlx/query-8ca7f46618bd72cfd5fe1f246d41a31da1885cc22bb61be6853950ba52be9aa7.json b/apps/labrinth/.sqlx/query-8ca7f46618bd72cfd5fe1f246d41a31da1885cc22bb61be6853950ba52be9aa7.json deleted file mode 100644 index 444f57bf3b..0000000000 --- a/apps/labrinth/.sqlx/query-8ca7f46618bd72cfd5fe1f246d41a31da1885cc22bb61be6853950ba52be9aa7.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE moderation_locks SET locked_at = NOW() - ($1::bigint * INTERVAL '1 minute') - INTERVAL '1 second' WHERE project_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "8ca7f46618bd72cfd5fe1f246d41a31da1885cc22bb61be6853950ba52be9aa7" -} diff --git a/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json b/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json deleted file mode 100644 index 89bd8147dc..0000000000 --- a/apps/labrinth/.sqlx/query-b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT status AS \"status: PayoutStatus\" FROM payouts WHERE id = 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "status: PayoutStatus", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false - ] - }, - "hash": "b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3" -} diff --git a/apps/labrinth/.sqlx/query-c65295f61b18f7be4fdf30b3e2c2c36b9db3770508be4acbb6b441ed8006d9c7.json b/apps/labrinth/.sqlx/query-c65295f61b18f7be4fdf30b3e2c2c36b9db3770508be4acbb6b441ed8006d9c7.json deleted file mode 100644 index f245ea1c8a..0000000000 --- a/apps/labrinth/.sqlx/query-c65295f61b18f7be4fdf30b3e2c2c36b9db3770508be4acbb6b441ed8006d9c7.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DROP SCHEMA public CASCADE;", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "c65295f61b18f7be4fdf30b3e2c2c36b9db3770508be4acbb6b441ed8006d9c7" -} diff --git a/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json b/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json deleted file mode 100644 index 469c30168a..0000000000 --- a/apps/labrinth/.sqlx/query-cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, $3, $4, $5, 10.0, NOW())\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text", - "Text", - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02" -} diff --git a/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json b/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json deleted file mode 100644 index 52e020ebf2..0000000000 --- a/apps/labrinth/.sqlx/query-cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, NULL, $3, $4, 10.00, NOW())\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text", - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606" -} diff --git a/apps/labrinth/.sqlx/query-e522b3da5f03cafd265929a8489252f7ef678bd0d6b9cee0be7df36bdc92b80a.json b/apps/labrinth/.sqlx/query-e522b3da5f03cafd265929a8489252f7ef678bd0d6b9cee0be7df36bdc92b80a.json new file mode 100644 index 0000000000..ea89bf1e61 --- /dev/null +++ b/apps/labrinth/.sqlx/query-e522b3da5f03cafd265929a8489252f7ef678bd0d6b9cee0be7df36bdc92b80a.json @@ -0,0 +1,66 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tINSERT INTO delphi_rules (\n\t\t\tname,\n\t\t\trule,\n\t\t\tcreated_by,\n\t\t\tupdated_by\n\t\t)\n\t\tVALUES ($1, $2, $3, $3)\n\t\tRETURNING\n\t\t\tid,\n\t\t\tname,\n\t\t\trule,\n\t\t\trevision,\n\t\t\tcreated_at,\n\t\t\tupdated_at,\n\t\t\tcreated_by,\n\t\t\tupdated_by\n\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "rule", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "revision", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "created_by", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "updated_by", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "e522b3da5f03cafd265929a8489252f7ef678bd0d6b9cee0be7df36bdc92b80a" +} diff --git a/apps/labrinth/.sqlx/query-f5b43baf6838a16554aab7282a396540f5b6d265c87af267be0a5c0044d470a9.json b/apps/labrinth/.sqlx/query-f5b43baf6838a16554aab7282a396540f5b6d265c87af267be0a5c0044d470a9.json new file mode 100644 index 0000000000..75717f39b0 --- /dev/null +++ b/apps/labrinth/.sqlx/query-f5b43baf6838a16554aab7282a396540f5b6d265c87af267be0a5c0044d470a9.json @@ -0,0 +1,67 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\tUPDATE delphi_rules\n\t\tSET\n\t\t\tname = $2,\n\t\t\trule = $3,\n\t\t\tupdated_at = CURRENT_TIMESTAMP,\n\t\t\tupdated_by = $4\n\t\tWHERE id = $1 AND NOT delete_on_next_revision\n\t\tRETURNING\n\t\t\tid,\n\t\t\tname,\n\t\t\trule,\n\t\t\trevision,\n\t\t\tcreated_at,\n\t\t\tupdated_at,\n\t\t\tcreated_by,\n\t\t\tupdated_by\n\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "rule", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "revision", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "created_by", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "updated_by", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Varchar", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "f5b43baf6838a16554aab7282a396540f5b6d265c87af267be0a5c0044d470a9" +} diff --git a/apps/labrinth/.sqlx/query-fabba52b34e14196b6305671c060d934b9afc6dccc5fc77454fbe45365bdf429.json b/apps/labrinth/.sqlx/query-fabba52b34e14196b6305671c060d934b9afc6dccc5fc77454fbe45365bdf429.json deleted file mode 100644 index 006563f450..0000000000 --- a/apps/labrinth/.sqlx/query-fabba52b34e14196b6305671c060d934b9afc6dccc5fc77454fbe45365bdf429.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT pg_advisory_unlock(1)", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "pg_advisory_unlock", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "fabba52b34e14196b6305671c060d934b9afc6dccc5fc77454fbe45365bdf429" -} diff --git a/apps/labrinth/.sqlx/query-fd5c773a61d35bcd71503ec4d5f86e8917cfab9679d5064074681663ba467e41.json b/apps/labrinth/.sqlx/query-fd5c773a61d35bcd71503ec4d5f86e8917cfab9679d5064074681663ba467e41.json deleted file mode 100644 index d3e3520bcc..0000000000 --- a/apps/labrinth/.sqlx/query-fd5c773a61d35bcd71503ec4d5f86e8917cfab9679d5064074681663ba467e41.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND body->>'type' = 'payout_available'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - null - ] - }, - "hash": "fd5c773a61d35bcd71503ec4d5f86e8917cfab9679d5064074681663ba467e41" -} 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/20260720105358_delphi_trace_rules.sql b/apps/labrinth/migrations/20260720105358_delphi_trace_rules.sql new file mode 100644 index 0000000000..7cbe599623 --- /dev/null +++ b/apps/labrinth/migrations/20260720105358_delphi_trace_rules.sql @@ -0,0 +1,34 @@ +CREATE TABLE delphi_rules ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by BIGINT REFERENCES users(id) + ON DELETE SET NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT REFERENCES users(id) + ON DELETE SET NULL, + name VARCHAR(256) NOT NULL, + rule VARCHAR(65536) NOT NULL, + revision BIGINT NOT NULL DEFAULT 0, + delete_on_next_revision BOOL NOT NULL DEFAULT FALSE +); + +CREATE TABLE delphi_rule_revisions ( + revision BIGINT PRIMARY KEY +); + +INSERT INTO delphi_rule_revisions (revision) +VALUES (1); + +CREATE TABLE delphi_rule_effects ( + revision BIGINT NOT NULL, + detail_id BIGINT NOT NULL REFERENCES delphi_report_issue_details(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + rule_id BIGINT NOT NULL REFERENCES delphi_rules(id) + ON UPDATE CASCADE, + severity delphi_severity, + hidden BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (revision, detail_id) +); + +CREATE INDEX delphi_rule_effects_rule_id ON delphi_rule_effects(rule_id); diff --git a/apps/labrinth/migrations/20260723120000_delphi_effective_rule_details.sql b/apps/labrinth/migrations/20260723120000_delphi_effective_rule_details.sql new file mode 100644 index 0000000000..65dc4e2536 --- /dev/null +++ b/apps/labrinth/migrations/20260723120000_delphi_effective_rule_details.sql @@ -0,0 +1,37 @@ +DROP VIEW delphi_issue_details_with_statuses; + +CREATE VIEW delphi_issue_details_with_statuses AS +SELECT + drid.id, + drid.issue_id, + drid.key, + drid.jar, + drid.file_path, + drid.decompiled_source, + drid.data, + drid.severity AS original_severity, + COALESCE(dre.severity, drid.severity) AS severity, + COALESCE(dre.hidden, FALSE) AS hidden, + m.id AS project_id, + didv.verdict AS local_status, + dgdv.verdict AS global_status, + COALESCE(dgdv.verdict, didv.verdict, 'pending') AS status +FROM delphi_report_issue_details drid +INNER JOIN delphi_report_issues dri ON dri.id = drid.issue_id +INNER JOIN delphi_reports dr ON dr.id = dri.report_id +INNER JOIN files f ON f.id = dr.file_id +INNER JOIN versions v ON v.id = f.version_id +INNER JOIN mods m ON m.id = v.mod_id +LEFT JOIN delphi_global_detail_verdicts dgdv + ON drid.key = dgdv.detail_key +LEFT JOIN delphi_issue_detail_verdicts didv + ON m.id = didv.project_id + AND drid.key = didv.detail_key +LEFT JOIN ( + SELECT revision + FROM delphi_rule_revisions + LIMIT 1 +) drr ON TRUE +LEFT JOIN delphi_rule_effects dre + ON dre.revision = drr.revision + AND dre.detail_id = drid.id; diff --git a/apps/labrinth/src/routes/internal/delphi/mod.rs b/apps/labrinth/src/routes/internal/delphi/mod.rs index cf92fc312b..ed275dfb6c 100644 --- a/apps/labrinth/src/routes/internal/delphi/mod.rs +++ b/apps/labrinth/src/routes/internal/delphi/mod.rs @@ -210,6 +210,7 @@ async fn ingest_report_deserialized( "Delphi found issues in file", ); + let mut inserted_detail_ids = Vec::new(); for (issue_type, issue_details) in report.issues { let issue_id = DBDelphiReportIssue { id: DelphiReportIssueId(0), // This will be set by the database @@ -229,7 +230,7 @@ async fn ingest_report_deserialized( let decompiled_source = report.decompiled_sources.get(&issue_detail.file); - ReportIssueDetail { + let detail_id = ReportIssueDetail { id: DelphiReportIssueDetailsId(0), // This will be set by the database issue_id, key: issue_detail.key.0, @@ -245,9 +246,17 @@ async fn ingest_report_deserialized( .insert(&mut transaction) .await .wrap_internal_err("failed to insert Delphi issue detail")?; + inserted_detail_ids.push(detail_id); } } + crate::routes::internal::moderation::tech_review::rules_scan::materialize_current_rule_effects( + &inserted_detail_ids, + &mut transaction, + ) + .await + .wrap_internal_err("failed to apply delphi rules to new issue details")?; + tech_review_sync::sync_project_tech_review_state( &[DBProjectId::from(report.project_id)], tech_review_sync::TechReviewExitReason::Resolved, diff --git a/apps/labrinth/src/routes/internal/delphi/rescan.rs b/apps/labrinth/src/routes/internal/delphi/rescan.rs index c7d7fa297b..cbaefd58f7 100644 --- a/apps/labrinth/src/routes/internal/delphi/rescan.rs +++ b/apps/labrinth/src/routes/internal/delphi/rescan.rs @@ -105,6 +105,7 @@ async fn fetch_unreviewed_tech_review_project_ids( WHERE didws.project_id = m.id AND didws.status = 'pending' + AND NOT didws.hidden -- see delphi.rs todo comment AND dri.issue_type != '__dummy' ) @@ -115,6 +116,7 @@ async fn fetch_unreviewed_tech_review_project_ids( WHERE didws.project_id = m.id AND didws.status IN ('safe', 'unsafe') + AND NOT didws.hidden -- see delphi.rs todo comment AND dri.issue_type != '__dummy' ) diff --git a/apps/labrinth/src/routes/internal/delphi/tech_review_sync.rs b/apps/labrinth/src/routes/internal/delphi/tech_review_sync.rs index 23a45d218a..974db515d0 100644 --- a/apps/labrinth/src/routes/internal/delphi/tech_review_sync.rs +++ b/apps/labrinth/src/routes/internal/delphi/tech_review_sync.rs @@ -60,9 +60,11 @@ const DUMMY_ISSUE_TYPE: &str = "__dummy"; pub enum TechReviewExitReason { Resolved, FileDeleted, + RulesChanged, } struct ProjectTechReviewState { + project_id: DBProjectId, has_pending_detail: bool, has_unsafe_detail: bool, has_dummy: bool, @@ -88,36 +90,35 @@ pub async fn sync_project_tech_review_state( r#" WITH project_ids AS ( SELECT unnest($1::bigint[]) AS project_id - ) - SELECT - p.project_id AS "project_id!: DBProjectId", - EXISTS( - SELECT 1 - FROM delphi_issue_details_with_statuses didws - INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id - WHERE - didws.project_id = p.project_id - AND didws.status = 'pending' + ), + project_detail_state AS ( + SELECT + p.project_id, + COALESCE(BOOL_OR( + didws.status = 'pending' + AND NOT didws.hidden AND dri.issue_type != $3 - ) AS "has_pending_detail!", - EXISTS( - SELECT 1 - FROM delphi_issue_details_with_statuses didws - INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id - WHERE - didws.project_id = p.project_id - AND didws.status = 'unsafe' + ), FALSE) AS has_pending_detail, + COALESCE(BOOL_OR( + didws.status = 'unsafe' + AND NOT didws.hidden AND dri.issue_type != $3 - ) AS "has_unsafe_detail!", - EXISTS( - SELECT 1 - FROM delphi_issue_details_with_statuses didws - INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id - WHERE - didws.project_id = p.project_id - AND didws.status = 'pending' + ), FALSE) AS has_unsafe_detail, + COALESCE(BOOL_OR( + didws.status = 'pending' AND dri.issue_type = $3 - ) AS "has_dummy!", + ), FALSE) AS has_dummy + FROM project_ids p + LEFT JOIN delphi_issue_details_with_statuses didws + ON didws.project_id = p.project_id + LEFT JOIN delphi_report_issues dri ON dri.id = didws.issue_id + GROUP BY p.project_id + ) + SELECT + p.project_id AS "project_id!: DBProjectId", + p.has_pending_detail AS "has_pending_detail!", + p.has_unsafe_detail AS "has_unsafe_detail!", + p.has_dummy AS "has_dummy!", ( SELECT t.id FROM threads t @@ -144,7 +145,7 @@ pub async fn sync_project_tech_review_state( ORDER BY dr.created DESC, dr.id DESC LIMIT 1 ) AS "report_id: DelphiReportId" - FROM project_ids p + FROM project_detail_state p "#, &project_ids_raw, &tech_review_message_types, @@ -156,6 +157,7 @@ pub async fn sync_project_tech_review_state( for row in rows { let state = ProjectTechReviewState { + project_id: row.project_id, has_pending_detail: row.has_pending_detail, has_unsafe_detail: row.has_unsafe_detail, has_dummy: row.has_dummy, @@ -250,8 +252,23 @@ async fn sync_one_project_tech_review_state( exit_reason: TechReviewExitReason, txn: &mut PgTransaction<'_>, ) -> Result<(), ApiError> { - let needs_tech_review = - state.has_pending_detail || state.has_unsafe_detail || state.has_dummy; + let has_review_detail = state.has_pending_detail || state.has_unsafe_detail; + + if matches!(exit_reason, TechReviewExitReason::RulesChanged) + && !has_review_detail + { + remove_dummy_issue_details(state.project_id, txn).await?; + + if let Some(thread_id) = state.thread_id + && should_send_exit(state.last_tech_review_message_type.as_deref()) + { + insert_exit_message(thread_id, exit_reason, txn).await?; + } + + return Ok(()); + } + + let needs_tech_review = has_review_detail || state.has_dummy; if needs_tech_review { if (state.has_pending_detail || state.has_unsafe_detail) @@ -325,7 +342,9 @@ async fn insert_exit_message( txn: &mut PgTransaction<'_>, ) -> Result<(), ApiError> { let body = match exit_reason { - TechReviewExitReason::Resolved => MessageBody::TechReviewExited, + TechReviewExitReason::Resolved | TechReviewExitReason::RulesChanged => { + MessageBody::TechReviewExited + } TechReviewExitReason::FileDeleted => { MessageBody::TechReviewExitFileDeleted } @@ -344,6 +363,36 @@ async fn insert_exit_message( Ok(()) } +async fn remove_dummy_issue_details( + project_id: DBProjectId, + txn: &mut PgTransaction<'_>, +) -> Result<(), ApiError> { + sqlx::query!( + r#" + DELETE FROM delphi_report_issue_details detail + USING + delphi_report_issues issue, + delphi_reports report, + files file, + versions version + WHERE + detail.issue_id = issue.id + AND issue.issue_type = $2 + AND issue.report_id = report.id + AND report.file_id = file.id + AND file.version_id = version.id + AND version.mod_id = $1 + "#, + project_id as DBProjectId, + DUMMY_ISSUE_TYPE, + ) + .execute(&mut *txn) + .await + .wrap_internal_err("failed to remove dummy Delphi issue details")?; + + Ok(()) +} + async fn ensure_dummy_issue_detail( report_id: DelphiReportId, txn: &mut PgTransaction<'_>, diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index 8ab03e1d12..c01423a488 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -115,6 +115,15 @@ 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::test_rule, + moderation::tech_review::rules::get_rule_affected_details, + moderation::tech_review::rules::create_rule, + moderation::tech_review::rules::update_rule, + moderation::tech_review::rules::delete_rule, + moderation::tech_review::rules_scan::get_rule_schema, + moderation::tech_review::rules_scan::get_detail_rule_input, + moderation::tech_review::rules_scan::scan_rules, 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/mod.rs b/apps/labrinth/src/routes/internal/moderation/mod.rs index 7b3dac3ca1..e745939f89 100644 --- a/apps/labrinth/src/routes/internal/moderation/mod.rs +++ b/apps/labrinth/src/routes/internal/moderation/mod.rs @@ -295,6 +295,7 @@ pub async fn get_projects_internal( FROM delphi_issue_details_with_statuses didws WHERE didws.project_id = m.id AND didws.status = 'pending' + AND NOT didws.hidden ) ) ), @@ -542,6 +543,7 @@ pub async fn get_projects_internal( FROM delphi_issue_details_with_statuses didws WHERE didws.project_id = m.id AND didws.status = 'pending' + AND NOT didws.hidden ) ) ), @@ -770,6 +772,7 @@ pub async fn get_project_ids( FROM delphi_issue_details_with_statuses didws WHERE didws.project_id = m.id AND didws.status = 'pending' + AND NOT didws.hidden ) ) ), @@ -898,6 +901,7 @@ pub async fn get_project_ids( FROM delphi_issue_details_with_statuses didws WHERE didws.project_id = mods.id AND didws.status = 'pending' + AND NOT didws.hidden ) ) ORDER BY diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review.rs b/apps/labrinth/src/routes/internal/moderation/tech_review.rs index 2bd5097c58..853d0b05fb 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review.rs @@ -44,10 +44,14 @@ use crate::{ use eyre::eyre; pub mod global; +pub mod rules; +pub mod rules_scan; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(search_projects) .configure(global::config) + .configure(rules::config) + .configure(rules_scan::config) .service(get_project_report) .service(get_report) .service(get_issue) @@ -250,7 +254,9 @@ pub async fn get_issue( ) ), '[]'::jsonb) FROM delphi_issue_details_with_statuses didws - WHERE didws.issue_id = dri.id + WHERE + didws.issue_id = dri.id + AND NOT didws.hidden ) ) AS "data!: sqlx::types::Json" FROM delphi_report_issues dri @@ -305,6 +311,16 @@ pub async fn get_report( 'file_size', f.size, 'flag_reason', 'delphi', 'download_url', f.url, + 'severity', COALESCE(( + SELECT MAX(didws.severity) + FROM delphi_report_issues severity_issue + INNER JOIN delphi_issue_details_with_statuses didws + ON didws.issue_id = severity_issue.id + WHERE + severity_issue.report_id = dr.id + AND severity_issue.issue_type != '__dummy' + AND NOT didws.hidden + ), 'low'::delphi_severity), -- TODO: replace with `json_array` in Postgres 16 'issues', ( SELECT coalesce(json_agg( @@ -327,15 +343,24 @@ pub async fn get_report( ) ), '[]'::jsonb) FROM delphi_issue_details_with_statuses didws - WHERE didws.issue_id = dri.id + WHERE + didws.issue_id = dri.id + AND NOT didws.hidden ) ) ), '[]'::json) FROM delphi_report_issues dri WHERE - dri.report_id = dr.id + dri.report_id = dr.id -- see delphi.rs todo comment AND dri.issue_type != '__dummy' + AND EXISTS ( + SELECT 1 + FROM delphi_issue_details_with_statuses visible_detail + WHERE + visible_detail.issue_id = dri.id + AND NOT visible_detail.hidden + ) ) ) AS "data!: sqlx::types::Json" FROM delphi_reports dr @@ -530,7 +555,9 @@ async fn fetch_project_reports( didws.global_status AS "global_status?: DelphiStatus", didws.status AS "status!: DelphiStatus" FROM delphi_issue_details_with_statuses didws - WHERE didws.issue_id = ANY($1::bigint[]) + WHERE + didws.issue_id = ANY($1::bigint[]) + AND NOT didws.hidden ORDER BY didws.issue_id, didws.id "#, &issue_ids.iter().map(|i| i.0).collect::>() @@ -636,6 +663,10 @@ async fn fetch_project_reports( .get(&issue_row.id) .unwrap_or(&empty_details); + if issue_details.is_empty() { + continue; + } + file_issues.push(FileIssue { id: issue_row.id, report_id: issue_row.report_id, @@ -644,12 +675,23 @@ async fn fetch_project_reports( }); } + if file_issues.is_empty() { + continue; + } + + let severity = file_issues + .iter() + .flat_map(|issue| issue.details.iter()) + .map(|detail| detail.severity) + .max() + .unwrap_or(report_row.severity); + file_reports.push(FileReport { report_id: report_row.report_id, file_id: FileId::from(file_row.file_id), created: report_row.created, flag_reason: FlagReason::Delphi, - severity: report_row.severity, + severity, file_name: file_row.filename.clone(), file_size: file_row.size, download_url: file_row.url.clone(), @@ -780,6 +822,7 @@ pub async fn search_projects( AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[])) AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[])) AND didws.status = 'pending' + AND NOT didws.hidden AND ( $5::text IS NULL OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin'))) @@ -789,8 +832,8 @@ pub async fn search_projects( ORDER BY CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC, CASE WHEN $3 = 'created_desc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END DESC, - CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC, - CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC, + CASE WHEN $3 = 'severity_asc' THEN MAX(didws.severity) ELSE 'low'::delphi_severity END ASC, + CASE WHEN $3 = 'severity_desc' THEN MAX(didws.severity) ELSE 'low'::delphi_severity END DESC, -- tie-breaker: oldest reports MIN(dr.created) ASC LIMIT $1 OFFSET $2 @@ -1035,6 +1078,7 @@ pub async fn submit_report( WHERE m.id = $1 AND didws.status = 'pending' + AND NOT didws.hidden -- see delphi.rs todo comment AND dri.issue_type != '__dummy' "#, 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 82aa8055b1..09f47d3ea3 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review/global.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/global.rs @@ -188,6 +188,7 @@ pub async fn search_global_issue_details( FROM delphi_global_detail_verdicts dgdv LEFT JOIN delphi_issue_details_with_statuses didws ON didws.key = dgdv.detail_key + AND NOT didws.hidden LEFT JOIN delphi_report_issues dri ON dri.id = didws.issue_id AND dri.issue_type != '__dummy' @@ -248,6 +249,7 @@ pub async fn search_global_issue_details( AND didv.detail_key = didws.key WHERE didws.key = ANY($1::text[]) + AND NOT didws.hidden AND dri.issue_type != '__dummy' ) SELECT @@ -367,6 +369,7 @@ pub async fn get_global_issue_detail( FROM delphi_global_detail_verdicts dgdv LEFT JOIN delphi_issue_details_with_statuses didws ON didws.key = dgdv.detail_key + AND NOT didws.hidden LEFT JOIN delphi_report_issues dri ON dri.id = didws.issue_id AND dri.issue_type != '__dummy' @@ -412,6 +415,7 @@ pub async fn get_global_issue_detail( WHERE didws.key = $1 AND ($2::bigint IS NULL OR didws.id > $2) + AND NOT didws.hidden AND dri.issue_type != '__dummy' ORDER BY didws.id LIMIT $3 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..00c2f4a91a --- /dev/null +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs @@ -0,0 +1,684 @@ +use std::collections::{BTreeMap, HashMap}; + +use actix_web::{HttpRequest, delete, get, post, put, web}; +use chrono::{DateTime, Utc}; +use eyre::eyre; +use serde::{Deserialize, Serialize}; + +use super::rules_scan::{ + RuleArtifact, RuleInput, RuleScan, RuleScope, RuleTrace, +}; +use crate::{ + auth::check_is_moderator_from_headers, + database::{ + PgPool, ReadOnlyPgPool, + models::{ + DBProjectId, DBVersionId, DelphiReportIssueDetailsId, + DelphiReportIssueId, delphi_report_item::DelphiSeverity, + }, + redis::RedisPool, + }, + models::{ + ids::{ProjectId, VersionId}, + pats::Scopes, + }, + queue::session::AuthQueue, + routes::ApiError, + util::error::Context, +}; + +const MAX_RULE_NAME_LENGTH: usize = 256; +const MAX_RULE_EXPRESSION_LENGTH: usize = 65_536; +const MAX_RULE_TEST_TRACES: usize = 10; + +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(get_rules) + .service(test_rule) + .service(get_rule_affected_details) + .service(create_rule) + .service(update_rule) + .service(delete_rule); +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct DelphiRule { + pub id: i64, + pub name: String, + pub rule: String, + pub revision: i64, + pub created_at: DateTime, + pub updated_at: DateTime, + pub created_by: Option, + pub updated_by: Option, + pub affected_details_count: i64, + pub affected_details: Vec, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct DelphiRuleAffectedDetail { + pub detail_id: DelphiReportIssueDetailsId, + pub issue_id: DelphiReportIssueId, + pub project_id: Option, + pub project_name: Option, + pub project_icon_url: Option, + pub version_id: Option, + pub version_name: Option, + pub version_number: Option, + pub issue_type: String, + pub key: String, + pub jar: Option, + pub file_path: String, + pub original_severity: DelphiSeverity, + pub severity: Option, + pub hidden: bool, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct WriteDelphiRule { + pub name: String, + pub rule: String, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct TestDelphiRule { + pub rule: String, + pub traces: Vec, +} + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct TestDelphiRuleTrace { + pub key: String, + pub issue_type: String, + pub severity: DelphiSeverity, + pub jar: Option, + pub file_path: String, + pub data: HashMap, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct TestDelphiRuleResponse { + pub effects: Vec>, +} + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct DelphiRuleEffect { + #[serde(default)] + pub severity: Option, + #[serde(default)] + pub hidden: bool, +} + +struct ValidatedRule { + name: String, + rule: 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 rule = self.rule.trim().to_string(); + if rule.is_empty() { + return Err(ApiError::Request(eyre!( + "rule expression cannot be empty" + ))); + } + if rule.len() > MAX_RULE_EXPRESSION_LENGTH { + return Err(ApiError::Request(eyre!( + "rule expression cannot exceed {MAX_RULE_EXPRESSION_LENGTH} bytes" + ))); + } + + cel::Program::compile(&rule).map_err(|error| { + ApiError::Request(eyre!("invalid cel expression: {error}")) + })?; + + Ok(ValidatedRule { name, rule }) + } +} + +/// Evaluate a CEL rule against caller-provided issue traces without saving it. +#[utoipa::path( + context_path = "/moderation/tech-review", + tag = "moderation", + security(("bearer_auth" = [])), + request_body = TestDelphiRule, + responses((status = OK, body = TestDelphiRuleResponse)) +)] +#[post("/rules/test")] +pub async fn test_rule( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + body: web::Json, +) -> Result, ApiError> { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await?; + + let request = body.into_inner(); + let rule = request.rule.trim(); + if rule.is_empty() { + return Err(ApiError::Request(eyre!( + "rule expression cannot be empty" + ))); + } + if rule.len() > MAX_RULE_EXPRESSION_LENGTH { + return Err(ApiError::Request(eyre!( + "rule expression cannot exceed {MAX_RULE_EXPRESSION_LENGTH} bytes" + ))); + } + if request.traces.len() > MAX_RULE_TEST_TRACES { + return Err(ApiError::Request(eyre!( + "cannot test more than {MAX_RULE_TEST_TRACES} traces at once" + ))); + } + + let program = cel::Program::compile(rule).map_err(|error| { + ApiError::Request(eyre!("invalid cel expression: {error}")) + })?; + let mut effects = Vec::with_capacity(request.traces.len()); + + for (index, trace) in request.traces.iter().enumerate() { + let input = test_rule_input(trace); + let effect = super::rules_scan::evaluate_rule(&program, input) + .map_err(|error| { + ApiError::Request(eyre!( + "failed to evaluate test trace {index}: {error}" + )) + })?; + effects.push(effect); + } + + Ok(web::Json(TestDelphiRuleResponse { effects })) +} + +fn test_rule_input(trace: &TestDelphiRuleTrace) -> RuleInput { + RuleInput { + schema_version: 1, + trace: RuleTrace { + key: trace.key.clone(), + issue_type: trace.issue_type.clone(), + severity: trace.severity, + jar: trace.jar.clone(), + file_path: trace.file_path.clone(), + data: trace.data.clone(), + }, + scan: RuleScan { delphi_version: 17 }, + artifact: RuleArtifact { + size: Some(412_892), + hashes: BTreeMap::from([ + ("sha1".to_string(), "0123456789abcdef".to_string()), + ("sha512".to_string(), "fedcba9876543210".to_string()), + ]), + }, + scope: RuleScope { + project_id: Some("example-project".to_string()), + version_id: Some("example-version".to_string()), + file_id: Some("example-file".to_string()), + }, + } +} + +/// List all Delphi rules that are not pending deletion. +#[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 rules = sqlx::query!( + r#" + SELECT + delphi_rule.id, + delphi_rule.name, + delphi_rule.rule, + delphi_rule.revision, + delphi_rule.created_at, + delphi_rule.updated_at, + delphi_rule.created_by, + delphi_rule.updated_by, + COALESCE(preview.affected_details_count, 0) + AS "affected_details_count!", + preview.detail_id AS "detail_id?: DelphiReportIssueDetailsId", + preview.issue_id AS "issue_id?: DelphiReportIssueId", + preview.project_id AS "project_id?: DBProjectId", + preview.project_name AS "project_name?", + preview.project_icon_url AS "project_icon_url?", + preview.version_id AS "version_id?: DBVersionId", + preview.version_name AS "version_name?", + preview.version_number AS "version_number?", + preview.issue_type AS "issue_type?", + preview.key AS "key?", + preview.jar AS "jar?", + preview.file_path AS "file_path?", + preview.original_severity AS "original_severity?: DelphiSeverity", + preview.severity AS "effect_severity?: DelphiSeverity", + preview.hidden AS "hidden?" + FROM delphi_rules delphi_rule + LEFT JOIN LATERAL ( + SELECT + effect.detail_id, + detail.issue_id, + version.mod_id AS project_id, + project.name AS project_name, + project.icon_url AS project_icon_url, + version.id AS version_id, + version.name AS version_name, + version.version_number, + issue.issue_type, + detail.key, + detail.jar, + detail.file_path, + detail.severity AS original_severity, + effect.severity, + effect.hidden, + COUNT(*) OVER () AS affected_details_count + FROM delphi_rule_effects effect + INNER JOIN delphi_rule_revisions published + ON published.revision = effect.revision + INNER JOIN delphi_report_issue_details detail + ON detail.id = effect.detail_id + 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 files file ON file.id = report.file_id + LEFT JOIN versions version ON version.id = file.version_id + LEFT JOIN mods project ON project.id = version.mod_id + WHERE effect.rule_id = delphi_rule.id + ORDER BY effect.detail_id DESC + LIMIT 3 + ) preview ON TRUE + WHERE NOT delphi_rule.delete_on_next_revision + ORDER BY delphi_rule.id, preview.detail_id DESC + "#, + ) + .fetch_all(&***ro_pool) + .await + .wrap_internal_err("failed to fetch delphi rules")?; + + let mut response = Vec::::new(); + for rule in rules { + if response + .last() + .is_none_or(|existing| existing.id != rule.id) + { + response.push(DelphiRule { + id: rule.id, + name: rule.name, + rule: rule.rule, + revision: rule.revision, + created_at: rule.created_at, + updated_at: rule.updated_at, + created_by: rule.created_by, + updated_by: rule.updated_by, + affected_details_count: rule.affected_details_count, + affected_details: Vec::new(), + }); + } + + if let ( + Some(detail_id), + Some(issue_id), + Some(issue_type), + Some(key), + Some(file_path), + Some(original_severity), + Some(hidden), + ) = ( + rule.detail_id, + rule.issue_id, + rule.issue_type, + rule.key, + rule.file_path, + rule.original_severity, + rule.hidden, + ) { + response + .last_mut() + .expect("a delphi rule was inserted above") + .affected_details + .push(DelphiRuleAffectedDetail { + detail_id, + issue_id, + project_id: rule.project_id.map(ProjectId::from), + project_name: rule.project_name, + project_icon_url: rule.project_icon_url, + version_id: rule.version_id.map(VersionId::from), + version_name: rule.version_name, + version_number: rule.version_number, + issue_type, + key, + jar: rule.jar, + file_path, + original_severity, + severity: rule.effect_severity, + hidden, + }); + } + } + + Ok(web::Json(response)) +} + +/// List all details affected by a Delphi rule in the published revision. +#[utoipa::path( + context_path = "/moderation/tech-review", + tag = "moderation", + security(("bearer_auth" = [])), + responses((status = OK, body = Vec)) +)] +#[get("/rules/{id}/effects")] +pub async fn get_rule_affected_details( + req: HttpRequest, + pool: web::Data, + ro_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_READ, + ) + .await?; + let (rule_id,) = path.into_inner(); + + let details = sqlx::query!( + r#" + SELECT + effect.detail_id AS "detail_id!: DelphiReportIssueDetailsId", + detail.issue_id AS "issue_id!: DelphiReportIssueId", + version.mod_id AS "project_id?: DBProjectId", + project.name AS "project_name?", + project.icon_url AS "project_icon_url?", + version.id AS "version_id?: DBVersionId", + version.name AS "version_name?", + version.version_number AS "version_number?", + issue.issue_type, + detail.key, + detail.jar, + detail.file_path, + detail.severity AS "original_severity!: DelphiSeverity", + effect.severity AS "effect_severity: DelphiSeverity", + effect.hidden + FROM delphi_rule_effects effect + INNER JOIN delphi_rule_revisions published + ON published.revision = effect.revision + INNER JOIN delphi_report_issue_details detail + ON detail.id = effect.detail_id + 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 files file ON file.id = report.file_id + LEFT JOIN versions version ON version.id = file.version_id + LEFT JOIN mods project ON project.id = version.mod_id + WHERE effect.rule_id = $1 + ORDER BY effect.detail_id DESC + "#, + rule_id, + ) + .fetch_all(&***ro_pool) + .await + .wrap_internal_err("failed to fetch details affected by delphi rule")?; + + Ok(web::Json( + details + .into_iter() + .map(|detail| DelphiRuleAffectedDetail { + detail_id: detail.detail_id, + issue_id: detail.issue_id, + project_id: detail.project_id.map(ProjectId::from), + project_name: detail.project_name, + project_icon_url: detail.project_icon_url, + version_id: detail.version_id.map(VersionId::from), + version_name: detail.version_name, + version_number: detail.version_number, + issue_type: detail.issue_type, + key: detail.key, + jar: detail.jar, + file_path: detail.file_path, + original_severity: detail.original_severity, + severity: detail.effect_severity, + hidden: detail.hidden, + }) + .collect(), + )) +} + +/// Create a Delphi rule. It will be applied by the next manual rule scan. +#[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 rule = sqlx::query!( + r#" + INSERT INTO delphi_rules ( + name, + rule, + revision, + created_by, + updated_by + ) + VALUES ( + $1, + $2, + (SELECT revision + 1 FROM delphi_rule_revisions LIMIT 1), + $3, + $3 + ) + RETURNING + id, + name, + rule, + revision, + created_at, + updated_at, + created_by, + updated_by + "#, + rule.name, + rule.rule, + user_id, + ) + .fetch_one(&**pool) + .await + .wrap_internal_err("failed to create delphi rule")?; + + Ok(web::Json(DelphiRule { + id: rule.id, + name: rule.name, + rule: rule.rule, + revision: rule.revision, + created_at: rule.created_at, + updated_at: rule.updated_at, + created_by: rule.created_by, + updated_by: rule.updated_by, + affected_details_count: 0, + affected_details: Vec::new(), + })) +} + +/// Update a Delphi rule. Its materialized effects remain unchanged until the next scan. +#[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 rule = sqlx::query!( + r#" + UPDATE delphi_rules + SET + name = $2, + rule = $3, + revision = ( + SELECT revision + 1 FROM delphi_rule_revisions LIMIT 1 + ), + updated_at = CURRENT_TIMESTAMP, + updated_by = $4 + WHERE id = $1 AND NOT delete_on_next_revision + RETURNING + id, + name, + rule, + revision, + created_at, + updated_at, + created_by, + updated_by + "#, + id, + rule.name, + rule.rule, + user_id, + ) + .fetch_optional(&**pool) + .await + .wrap_internal_err("failed to update delphi rule")? + .ok_or(ApiError::NotFound)?; + + Ok(web::Json(DelphiRule { + id: rule.id, + name: rule.name, + rule: rule.rule, + revision: rule.revision, + created_at: rule.created_at, + updated_at: rule.updated_at, + created_by: rule.created_by, + updated_by: rule.updated_by, + affected_details_count: 0, + affected_details: Vec::new(), + })) +} + +/// Mark a Delphi rule for deletion when the next rule scan is published. +#[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> { + let user = check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_WRITE, + ) + .await?; + let (id,) = path.into_inner(); + + let deleted = sqlx::query!( + r#" + UPDATE delphi_rules + SET + delete_on_next_revision = TRUE, + revision = ( + SELECT revision + 1 FROM delphi_rule_revisions LIMIT 1 + ), + updated_at = CURRENT_TIMESTAMP, + updated_by = $2 + WHERE id = $1 AND NOT delete_on_next_revision + RETURNING id + "#, + id, + user.id.0 as i64, + ) + .fetch_optional(&**pool) + .await + .wrap_internal_err("failed to mark delphi rule for deletion")?; + + if deleted.is_none() { + return Err(ApiError::NotFound); + } + + Ok(()) +} 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 new file mode 100644 index 0000000000..c3f9d79d21 --- /dev/null +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs @@ -0,0 +1,839 @@ +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 futures_util::{StreamExt, TryStreamExt}; +use serde::Serialize; +use sqlx::types::Json; +use tokio::sync::mpsc; +use tokio_stream::wrappers::UnboundedReceiverStream; +use utoipa::{PartialSchema, ToSchema}; + +use super::rules::DelphiRuleEffect; +use crate::routes::internal::delphi::tech_review_sync::{ + self, TechReviewExitReason, +}; +use crate::{ + auth::check_is_moderator_from_headers, + database::{ + PgPool, PgTransaction, ReadOnlyPgPool, + models::{ + DBProjectId, DelphiReportIssueDetailsId, + delphi_report_item::DelphiSeverity, + }, + redis::RedisPool, + }, + models::pats::Scopes, + queue::session::AuthQueue, + routes::ApiError, +}; + +const RULE_SCAN_LOCK_ID: i64 = 0x6465_6c70_6869_7275; +const PROGRESS_INTERVAL: usize = 50; +const DUMMY_ISSUE_TYPE: &str = "__dummy"; + +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(get_rule_schema) + .service(get_detail_rule_input) + .service(scan_rules); +} + +#[derive(Serialize)] +struct RuleScanEvent<'a> { + phase: &'a str, + revision: i64, + scanned: usize, + total: usize, + effects: usize, +} + +#[derive(Serialize)] +struct RuleScanErrorEvent<'a> { + message: &'a str, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct RuleInput { + pub schema_version: u32, + pub trace: RuleTrace, + pub scan: RuleScan, + pub artifact: RuleArtifact, + pub scope: RuleScope, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct RuleTrace { + pub key: String, + pub issue_type: String, + pub severity: DelphiSeverity, + pub jar: Option, + pub file_path: String, + pub data: HashMap, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct RuleScan { + pub delphi_version: i32, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct RuleArtifact { + pub size: Option, + pub hashes: BTreeMap, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct RuleScope { + pub project_id: Option, + pub version_id: Option, + pub file_id: Option, +} + +struct CompiledRule { + id: i64, + program: cel::Program, +} + +struct MaterializedEffect { + detail_id: i64, + rule_id: i64, + effect: DelphiRuleEffect, +} + +struct ScanSummary { + revision: i64, + scanned: usize, + total: usize, + effects: usize, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct DelphiRuleSchemaResponse { + pub input: serde_json::Value, + pub output: serde_json::Value, + pub components: BTreeMap, +} + +/// Get the schemas for the CEL input and output values. +#[utoipa::path( + context_path = "/moderation/tech-review", + tag = "moderation", + security(("bearer_auth" = [])), + responses((status = OK, body = DelphiRuleSchemaResponse)) +)] +#[get("/rules/schema")] +pub async fn get_rule_schema( + req: HttpRequest, + 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 mut schemas = Vec::new(); + ::schemas(&mut schemas); + as ToSchema>::schemas(&mut schemas); + + Ok(web::Json(DelphiRuleSchemaResponse { + input: schema_to_value(::schema())?, + output: schema_to_value( + as PartialSchema>::schema(), + )?, + components: schemas + .into_iter() + .map(|(name, schema)| Ok((name, schema_to_value(schema)?))) + .collect::>()?, + })) +} + +/// Get the exact CEL input for a Delphi issue detail. +#[utoipa::path( + context_path = "/moderation/tech-review", + tag = "moderation", + security(("bearer_auth" = [])), + responses( + (status = OK, body = RuleInput), + (status = NOT_FOUND, description = "Detail not found") + ) +)] +#[get("/rules/details/{detail_id}/input")] +pub async fn get_detail_rule_input( + req: HttpRequest, + pool: web::Data, + ro_pool: web::Data, + redis: web::Data, + session_queue: web::Data, + path: web::Path<(DelphiReportIssueDetailsId,)>, +) -> Result, ApiError> { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await?; + + let (detail_id,) = path.into_inner(); + let detail = crate::util::error::Context::wrap_internal_err( + sqlx::query!( + r#" + SELECT + detail.key, + issue.issue_type, + detail.severity AS "severity: DelphiSeverity", + detail.jar, + detail.file_path, + detail.data AS "data: Json>", + report.delphi_version, + file.size AS "size?", + file.id AS "file_id?", + version.id AS "version_id?", + version.mod_id AS "project_id?", + COALESCE(file_hashes.hashes, '{}'::jsonb) + AS "hashes!: Json>" + 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 files file ON file.id = report.file_id + LEFT JOIN versions version ON version.id = file.version_id + LEFT JOIN LATERAL ( + SELECT + jsonb_object_agg(algorithm, encode(hash, 'hex')) AS hashes + FROM hashes + WHERE hashes.file_id = file.id + ) file_hashes ON TRUE + WHERE + detail.id = $1 + AND issue.issue_type != $2 + "#, + detail_id as DelphiReportIssueDetailsId, + DUMMY_ISSUE_TYPE, + ) + .fetch_optional(&***ro_pool) + .await, + "failed to fetch delphi rule input", + )? + .ok_or(ApiError::NotFound)?; + + Ok(web::Json(RuleInput { + schema_version: 1, + trace: RuleTrace { + key: detail.key, + issue_type: detail.issue_type, + severity: detail.severity, + jar: detail.jar, + file_path: detail.file_path, + data: detail.data.0, + }, + scan: RuleScan { + delphi_version: detail.delphi_version, + }, + artifact: RuleArtifact { + size: detail.size, + hashes: detail.hashes.0, + }, + scope: RuleScope { + project_id: detail.project_id.map(to_public_id), + version_id: detail.version_id.map(to_public_id), + file_id: detail.file_id.map(to_public_id), + }, + })) +} + +fn schema_to_value( + schema: T, +) -> Result { + serde_json::to_value(schema).map_err(|error| { + ApiError::Internal( + eyre!(error).wrap_err("failed to serialize Delphi rule schema"), + ) + }) +} + +/// Re-evaluate every Delphi issue detail and atomically publish a new rule revision. +#[utoipa::path( + context_path = "/moderation/tech-review", + tag = "moderation", + security(("bearer_auth" = [])), + responses((status = OK), (status = CONFLICT)) +)] +#[post("/rules/scan")] +pub async fn scan_rules( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_WRITE, + ) + .await?; + + let mut transaction = crate::util::error::Context::wrap_internal_err( + pool.begin().await, + "failed to begin delphi rule scan", + )?; + + sqlx::query!("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") + .execute(&mut transaction) + .await + .map_err(|error| { + ApiError::Internal( + eyre!(error) + .wrap_err("failed to set delphi rule scan isolation"), + ) + })?; + + let acquired = sqlx::query_scalar!( + "SELECT pg_try_advisory_xact_lock($1)", + RULE_SCAN_LOCK_ID, + ) + .fetch_one(&mut transaction) + .await + .map_err(|error| { + ApiError::Internal( + eyre!(error).wrap_err("failed to acquire delphi rule scan lock"), + ) + })? + .unwrap_or(false); + + if !acquired { + return Err(ApiError::Conflict( + "a delphi rule scan is already running".to_string(), + )); + } + + let (sender, receiver) = mpsc::unbounded_channel(); + actix_web::rt::spawn(async move { + match run_scan(transaction, &sender).await { + Ok(summary) => { + send_event( + &sender, + "complete", + &RuleScanEvent { + phase: "complete", + revision: summary.revision, + scanned: summary.scanned, + total: summary.total, + effects: summary.effects, + }, + ); + } + Err(error) => { + tracing::error!(error = ?error, "delphi rule scan failed"); + send_event( + &sender, + "failed", + &RuleScanErrorEvent { + message: &error.to_string(), + }, + ); + } + } + }); + + let stream = + UnboundedReceiverStream::new(receiver).map(Ok::<_, std::io::Error>); + + Ok(HttpResponse::Ok() + .insert_header(("Content-Type", "text/event-stream")) + .insert_header(("Cache-Control", "no-cache")) + .insert_header(("X-Accel-Buffering", "no")) + .streaming(stream)) +} + +async fn run_scan( + mut transaction: PgTransaction<'static>, + sender: &mpsc::UnboundedSender, +) -> Result { + sqlx::query!("LOCK TABLE delphi_rules IN SHARE MODE") + .execute(&mut transaction) + .await + .wrap_err("failed to lock delphi rules")?; + sqlx::query!("LOCK TABLE delphi_report_issue_details IN SHARE MODE") + .execute(&mut transaction) + .await + .wrap_err("failed to lock delphi issue details")?; + + let current_revision = sqlx::query_scalar!( + "SELECT revision FROM delphi_rule_revisions LIMIT 1 FOR UPDATE", + ) + .fetch_one(&mut transaction) + .await + .wrap_err("failed to fetch the current delphi rule revision")?; + let revision = current_revision + .checked_add(1) + .ok_or_else(|| eyre!("delphi rule revision overflowed"))?; + + let rules = fetch_compiled_rules(&mut transaction).await?; + + let total = sqlx::query_scalar!( + r#" + SELECT COUNT(*) AS "count!" + FROM delphi_report_issue_details detail + INNER JOIN delphi_report_issues issue ON issue.id = detail.issue_id + WHERE issue.issue_type != $1 + "#, + DUMMY_ISSUE_TYPE, + ) + .fetch_one(&mut transaction) + .await + .wrap_err("failed to count delphi issue details")? as usize; + + let mut details = sqlx::query!( + r#" + SELECT + detail.id, + detail.key, + issue.issue_type, + detail.severity AS "severity: DelphiSeverity", + detail.jar, + detail.file_path, + detail.data AS "data: Json>", + report.delphi_version, + file.size AS "size?", + file.id AS "file_id?", + version.id AS "version_id?", + version.mod_id AS "project_id?", + COALESCE(file_hashes.hashes, '{}'::jsonb) + AS "hashes!: Json>" + 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 files file ON file.id = report.file_id + LEFT JOIN versions version ON version.id = file.version_id + LEFT JOIN ( + SELECT + file_id, + jsonb_object_agg(algorithm, encode(hash, 'hex')) AS hashes + FROM hashes + GROUP BY file_id + ) file_hashes ON file_hashes.file_id = file.id + WHERE issue.issue_type != $1 + ORDER BY detail.id + "#, + DUMMY_ISSUE_TYPE, + ) + .fetch(&mut transaction); + + let mut effects = Vec::new(); + let mut scanned = 0; + send_progress(sender, "scanning", revision, 0, total, 0); + + while let Some(detail) = details + .try_next() + .await + .wrap_err("failed to fetch a delphi issue detail")? + { + let detail_id = detail.id; + let input = RuleInput { + schema_version: 1, + trace: RuleTrace { + key: detail.key, + issue_type: detail.issue_type, + severity: detail.severity, + jar: detail.jar, + file_path: detail.file_path, + data: detail.data.0, + }, + scan: RuleScan { + delphi_version: detail.delphi_version, + }, + artifact: RuleArtifact { + size: detail.size, + hashes: detail.hashes.0, + }, + scope: RuleScope { + project_id: detail.project_id.map(to_public_id), + version_id: detail.version_id.map(to_public_id), + file_id: detail.file_id.map(to_public_id), + }, + }; + + for rule in &rules { + let effect = evaluate_rule(&rule.program, &input) + .wrap_err_with(|| { + format!( + "failed to evaluate delphi rule {} for detail {detail_id}", + rule.id + ) + })?; + if let Some(effect) = effect { + effects.push(MaterializedEffect { + detail_id, + rule_id: rule.id, + effect, + }); + break; + } + } + + scanned += 1; + if scanned % PROGRESS_INTERVAL == 0 || scanned == total { + send_progress( + sender, + "scanning", + revision, + scanned, + total, + effects.len(), + ); + tokio::task::yield_now().await; + } + } + drop(details); + + send_progress(sender, "publishing", revision, total, total, effects.len()); + + insert_materialized_effects(revision, &effects, &mut transaction).await?; + + let affected_projects = sqlx::query!( + r#" + WITH project_membership AS ( + SELECT + detail.project_id, + BOOL_OR( + detail.status IN ('pending', 'unsafe') + AND issue.issue_type != $2 + AND NOT detail.hidden + ) AS old_needs_review, + BOOL_OR( + detail.status IN ('pending', 'unsafe') + AND issue.issue_type != $2 + AND NOT COALESCE(new_effect.hidden, FALSE) + ) AS new_needs_review + FROM delphi_issue_details_with_statuses detail + INNER JOIN delphi_report_issues issue + ON issue.id = detail.issue_id + LEFT JOIN delphi_rule_effects new_effect + ON new_effect.revision = $1 + AND new_effect.detail_id = detail.id + GROUP BY detail.project_id + ) + SELECT project_id AS "project_id!: DBProjectId" + FROM project_membership + WHERE old_needs_review IS DISTINCT FROM new_needs_review + "#, + revision, + DUMMY_ISSUE_TYPE, + ) + .fetch_all(&mut transaction) + .await + .wrap_err("failed to fetch projects affected by delphi rule changes")?; + + sqlx::query!( + "UPDATE delphi_rules SET revision = $1 WHERE NOT delete_on_next_revision", + revision, + ) + .execute(&mut transaction) + .await + .wrap_err("failed to update delphi rule revisions")?; + sqlx::query!("UPDATE delphi_rule_revisions SET revision = $1", revision) + .execute(&mut transaction) + .await + .wrap_err("failed to publish the delphi rule revision")?; + + tech_review_sync::sync_project_tech_review_state( + &affected_projects + .iter() + .map(|project| project.project_id) + .collect::>(), + TechReviewExitReason::RulesChanged, + &mut transaction, + ) + .await + .map_err(|error| { + eyre!(error) + .wrap_err("failed to sync projects affected by delphi rule changes") + })?; + + sqlx::query!( + "DELETE FROM delphi_rule_effects WHERE revision <> $1", + revision, + ) + .execute(&mut transaction) + .await + .wrap_err("failed to delete old delphi rule effects")?; + sqlx::query!("DELETE FROM delphi_rules WHERE delete_on_next_revision") + .execute(&mut transaction) + .await + .wrap_err("failed to delete retired delphi rules")?; + + transaction + .commit() + .await + .wrap_err("failed to commit the delphi rule scan")?; + + Ok(ScanSummary { + revision, + scanned: total, + total, + effects: effects.len(), + }) +} + +pub(crate) async fn materialize_current_rule_effects( + detail_ids: &[DelphiReportIssueDetailsId], + transaction: &mut PgTransaction<'_>, +) -> Result<()> { + if detail_ids.is_empty() { + return Ok(()); + } + + let revision = sqlx::query_scalar!( + "SELECT revision FROM delphi_rule_revisions LIMIT 1", + ) + .fetch_one(&mut *transaction) + .await + .wrap_err("failed to fetch the current delphi rule revision")?; + let rules = fetch_compiled_rules(transaction).await?; + + if rules.is_empty() { + return Ok(()); + } + + let details = sqlx::query!( + r#" + SELECT + detail.id, + detail.key, + issue.issue_type, + detail.severity AS "severity: DelphiSeverity", + detail.jar, + detail.file_path, + detail.data AS "data: Json>", + report.delphi_version, + file.size AS "size?", + file.id AS "file_id?", + version.id AS "version_id?", + version.mod_id AS "project_id?", + COALESCE(file_hashes.hashes, '{}'::jsonb) + AS "hashes!: Json>" + 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 files file ON file.id = report.file_id + LEFT JOIN versions version ON version.id = file.version_id + LEFT JOIN LATERAL ( + SELECT + jsonb_object_agg(algorithm, encode(hash, 'hex')) AS hashes + FROM hashes + WHERE hashes.file_id = file.id + ) file_hashes ON TRUE + WHERE + detail.id = ANY($1::bigint[]) + AND issue.issue_type != $2 + ORDER BY detail.id + "#, + &detail_ids.iter().map(|id| id.0).collect::>(), + DUMMY_ISSUE_TYPE, + ) + .fetch_all(&mut *transaction) + .await + .wrap_err("failed to fetch new delphi issue details")?; + + let mut effects = Vec::new(); + for detail in details { + let input = RuleInput { + schema_version: 1, + trace: RuleTrace { + key: detail.key, + issue_type: detail.issue_type, + severity: detail.severity, + jar: detail.jar, + file_path: detail.file_path, + data: detail.data.0, + }, + scan: RuleScan { + delphi_version: detail.delphi_version, + }, + artifact: RuleArtifact { + size: detail.size, + hashes: detail.hashes.0, + }, + scope: RuleScope { + project_id: detail.project_id.map(to_public_id), + version_id: detail.version_id.map(to_public_id), + file_id: detail.file_id.map(to_public_id), + }, + }; + + for rule in &rules { + let effect = + evaluate_rule(&rule.program, &input).wrap_err_with(|| { + format!( + "failed to evaluate delphi rule {} for detail {}", + rule.id, detail.id + ) + })?; + if let Some(effect) = effect { + effects.push(MaterializedEffect { + detail_id: detail.id, + rule_id: rule.id, + effect, + }); + break; + } + } + } + + insert_materialized_effects(revision, &effects, transaction).await +} + +async fn fetch_compiled_rules( + transaction: &mut PgTransaction<'_>, +) -> Result> { + sqlx::query!( + r#" + SELECT id, rule + FROM delphi_rules + WHERE NOT delete_on_next_revision + ORDER BY id + "#, + ) + .fetch_all(&mut *transaction) + .await + .wrap_err("failed to fetch delphi rules")? + .into_iter() + .map(|rule| { + let program = cel::Program::compile(&rule.rule).map_err(|error| { + eyre!("failed to compile delphi rule {}: {error}", rule.id) + })?; + Ok(CompiledRule { + id: rule.id, + program, + }) + }) + .collect() +} + +async fn insert_materialized_effects( + revision: i64, + effects: &[MaterializedEffect], + transaction: &mut PgTransaction<'_>, +) -> Result<()> { + if effects.is_empty() { + return Ok(()); + } + + let detail_ids = effects + .iter() + .map(|effect| effect.detail_id) + .collect::>(); + let rule_ids = effects + .iter() + .map(|effect| effect.rule_id) + .collect::>(); + let severities = effects + .iter() + .map(|effect| effect.effect.severity) + .collect::>(); + let hidden = effects + .iter() + .map(|effect| effect.effect.hidden) + .collect::>(); + + sqlx::query!( + r#" + INSERT INTO delphi_rule_effects ( + revision, + detail_id, + rule_id, + severity, + hidden + ) + SELECT $1, effect.* + FROM UNNEST( + $2::BIGINT[], + $3::BIGINT[], + $4::delphi_severity[], + $5::BOOLEAN[] + ) AS effect(detail_id, rule_id, severity, hidden) + "#, + revision, + &detail_ids, + &rule_ids, + &severities as &[Option], + &hidden, + ) + .execute(&mut *transaction) + .await + .wrap_err("failed to insert delphi rule effects")?; + + Ok(()) +} + +pub(super) fn evaluate_rule( + program: &cel::Program, + input: impl Serialize, +) -> Result> { + let mut context = cel::Context::default(); + context + .add_variable("input", input) + .wrap_err("failed to build cel input")?; + + let value = program + .execute(&context) + .wrap_err("failed to execute cel expression")?; + let value = value.json().map_err(|error| { + eyre!("failed to convert cel result to json: {error}") + })?; + + match value { + serde_json::Value::Null => Ok(None), + value => serde_json::from_value(value) + .map(Some) + .wrap_err("cel expression returned an invalid rule effect"), + } +} + +fn to_public_id(id: i64) -> String { + to_base62(id as u64) +} + +fn send_progress( + sender: &mpsc::UnboundedSender, + phase: &'static str, + revision: i64, + scanned: usize, + total: usize, + effects: usize, +) { + send_event( + sender, + "progress", + &RuleScanEvent { + phase, + revision, + scanned, + total, + effects, + }, + ); +} + +fn send_event( + sender: &mpsc::UnboundedSender, + event: &str, + data: &impl Serialize, +) { + let Ok(data) = serde_json::to_string(data) else { + return; + }; + let _ = + sender.send(Bytes::from(format!("event: {event}\ndata: {data}\n\n"))); +} 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..2ad32b78ff 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,114 @@ 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 getRuleSchema(): Promise { + return this.client.request( + '/moderation/tech-review/rules/schema', + { + api: 'labrinth', + version: 'internal', + method: 'GET', + }, + ) + } + + public async getDetailRuleInput( + detailId: string, + ): Promise { + return this.client.request( + `/moderation/tech-review/rules/details/${detailId}/input`, + { + api: 'labrinth', + version: 'internal', + method: 'GET', + }, + ) + } + + public async getRuleAffectedDetails( + id: number, + ): Promise { + return this.client.request( + `/moderation/tech-review/rules/${id}/effects`, + { + api: 'labrinth', + version: 'internal', + method: 'GET', + }, + ) + } + + public async testRule( + request: Labrinth.TechReview.Internal.TestDelphiRuleRequest, + ): Promise { + return this.client.request( + '/moderation/tech-review/rules/test', + { + api: 'labrinth', + version: 'internal', + method: 'POST', + body: request, + }, + ) + } + + 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', + }) + } + + public async scanRules(signal?: AbortSignal): Promise> { + return this.client.stream('/moderation/tech-review/rules/scan', { + api: 'labrinth', + version: 'internal', + method: 'POST', + signal, + }) + } + /** * 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..3089099050 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -2226,6 +2226,111 @@ export namespace Labrinth { export namespace TechReview { export namespace Internal { + export type DelphiRule = { + id: number + name: string + rule: string + revision: number + created_at: string + updated_at: string + created_by: number | null + updated_by: number | null + affected_details_count: number + affected_details: DelphiRuleAffectedDetail[] + } + + export type DelphiRuleAffectedDetail = { + detail_id: string + issue_id: string + project_id: string | null + project_name: string | null + project_icon_url: string | null + version_id: string | null + version_name: string | null + version_number: string | null + issue_type: string + key: string + jar: string | null + file_path: string + original_severity: DelphiSeverity + severity: DelphiSeverity | null + hidden: boolean + } + + export type WriteDelphiRule = { + name: string + rule: string + } + + export type TestDelphiRuleTrace = { + key: string + issue_type: string + severity: DelphiSeverity + jar: string | null + file_path: string + data: Record + } + + export type TestDelphiRuleRequest = { + rule: string + traces: TestDelphiRuleTrace[] + } + + export type DelphiRuleEffect = { + severity: DelphiSeverity | null + hidden: boolean + } + + export type DelphiRuleSchema = Record + + export type DelphiRuleSchemaResponse = { + input: DelphiRuleSchema + output: DelphiRuleSchema + components: Record + } + + export type RuleInput = { + schema_version: number + trace: { + key: string + issue_type: string + severity: DelphiSeverity + jar: string | null + file_path: string + data: Record + } + scan: { + delphi_version: number + } + artifact: { + size: number | null + hashes: Record + } + scope: { + project_id: string | null + version_id: string | null + file_id: string | null + } + } + + export type TestDelphiRuleResponse = { + effects: Array + } + + export type DelphiRuleScanPhase = 'scanning' | 'publishing' | 'complete' + + export type DelphiRuleScanEvent = { + phase: DelphiRuleScanPhase + revision: number + scanned: number + total: number + effects: number + } + + export type DelphiRuleScanErrorEvent = { + message: 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")); + } +}