run rule scans

This commit is contained in:
aecsocket
2026-07-22 19:34:18 +01:00
parent 3c77cc1502
commit 28154689f4
7 changed files with 669 additions and 37 deletions
@@ -133,6 +133,14 @@
proceed-label="Delete rule"
@proceed="deleteRule"
/>
<ConfirmModal
ref="scanModal"
title="Run a full Delphi rule scan?"
description="Every stored issue detail will be evaluated against the current rules. Existing effects remain active unless the entire scan succeeds."
:markdown="false"
proceed-label="Run full scan"
@proceed="runFullScan"
/>
<div class="flex flex-col gap-6">
<div class="flex flex-wrap items-center justify-between gap-3">
@@ -148,14 +156,45 @@
</div>
</div>
<ButtonStyled color="brand">
<button type="button" @click="openCreateModal">
<PlusIcon />
Create rule
</button>
</ButtonStyled>
<div class="flex flex-wrap gap-2">
<ButtonStyled>
<button type="button" :disabled="isScanning" @click="scanModal?.show()">
<PlayIcon />
{{ isScanning ? 'Scanning...' : 'Run full scan' }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" :disabled="isScanning" @click="openCreateModal">
<PlusIcon />
Create rule
</button>
</ButtonStyled>
</div>
</div>
<section v-if="isScanning && scanProgress" class="universal-card flex flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 class="m-0 text-base font-bold text-contrast">Scanning Delphi rule effects</h2>
<p class="m-0 text-sm text-secondary">
{{ scanProgress.scanned.toLocaleString() }} of
{{ scanProgress.total.toLocaleString() }} details scanned ·
{{ scanProgress.effects.toLocaleString() }} effects
</p>
</div>
<span class="text-sm font-semibold capitalize text-secondary">
{{ scanProgress.phase }} revision {{ scanProgress.revision }}
</span>
</div>
<ProgressBar
:progress="scanProgress.scanned"
:max="Math.max(scanProgress.total, 1)"
:waiting="scanProgress.total === 0 && scanProgress.phase !== 'complete'"
full-width
show-progress
/>
</section>
<details class="universal-card text-sm">
<summary class="cursor-pointer font-semibold text-contrast">CEL contract and input</summary>
<div class="mt-3 flex flex-col gap-2 text-secondary">
@@ -164,6 +203,9 @@
<code>{ "severity": "low", "hidden": false }</code>. Severity can be <code>low</code>,
<code>medium</code>, <code>high</code>, or <code>severe</code>.
</p>
<p class="m-0">
Rules run in the order shown below. The first rule that returns a non-null effect wins.
</p>
<p class="m-0">
The <code>input</code> object contains <code>schema_version</code>,
<code>trace</code> (<code>key</code>, <code>issue_type</code>, <code>severity</code>,
@@ -195,17 +237,17 @@
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="m-0 text-lg font-bold text-contrast">{{ rule.name }}</h2>
<p class="m-0 text-sm text-secondary">Last applied in revision {{ rule.revision }}</p>
<p class="m-0 text-sm text-secondary">Revision {{ rule.revision }}</p>
</div>
<div class="flex gap-2">
<ButtonStyled>
<button type="button" @click="openEditModal(rule)">
<button type="button" :disabled="isScanning" @click="openEditModal(rule)">
<EditIcon />
Edit
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button type="button" @click="openDeleteModal(rule)">
<button type="button" :disabled="isScanning" @click="openDeleteModal(rule)">
<TrashIcon />
Delete
</button>
@@ -221,12 +263,13 @@
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { type Labrinth, SseParser } from '@modrinth/api-client'
import {
ArrowLeftIcon,
EditIcon,
EyeOffIcon,
LoaderCircleIcon,
PlayIcon,
PlusIcon,
TrashIcon,
} from '@modrinth/assets'
@@ -237,6 +280,7 @@ import {
injectModrinthClient,
injectNotificationManager,
NewModal,
ProgressBar,
StyledInput,
} from '@modrinth/ui'
import { useDebounceFn } from '@vueuse/core'
@@ -282,12 +326,14 @@ const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const ruleModal = useTemplateRef<InstanceType<typeof NewModal>>('ruleModal')
const deleteModal = useTemplateRef<InstanceType<typeof ConfirmModal>>('deleteModal')
const scanModal = useTemplateRef<InstanceType<typeof ConfirmModal>>('scanModal')
const editorComponent = shallowRef<Component | null>(null)
const ruleEditorInstance = shallowRef<Ace.Editor | null>(null)
const rules = ref<Labrinth.TechReview.Internal.DelphiRule[]>([])
const isLoading = ref(true)
const isSaving = ref(false)
const isScanning = ref(false)
const isTestingRule = ref(false)
const isRuleModalOpen = ref(false)
const loadFailed = ref(false)
@@ -295,11 +341,13 @@ const editingRuleId = ref<number | null>(null)
const ruleToDelete = ref<Labrinth.TechReview.Internal.DelphiRule | null>(null)
const ruleTestEffects = ref<Array<Labrinth.TechReview.Internal.DelphiRuleEffect | null>>([])
const ruleTestError = ref<string | null>(null)
const scanProgress = ref<Labrinth.TechReview.Internal.DelphiRuleScanEvent | null>(null)
const form = reactive({
name: '',
rule: DEFAULT_RULE,
})
let ruleTestRequestId = 0
let scanAbortController: AbortController | null = null
onMounted(async () => {
const [{ VAceEditor }] = await Promise.all([
@@ -421,6 +469,7 @@ async function loadRules() {
}
function openCreateModal() {
if (isScanning.value) return
editingRuleId.value = null
form.name = ''
form.rule = DEFAULT_RULE
@@ -431,6 +480,7 @@ function openCreateModal() {
}
function openEditModal(rule: Labrinth.TechReview.Internal.DelphiRule) {
if (isScanning.value) return
editingRuleId.value = rule.id
form.name = rule.name
form.rule = rule.rule
@@ -452,7 +502,7 @@ function handleRuleModalHide() {
}
async function saveRule() {
if (isSaving.value) return
if (isSaving.value || isScanning.value) return
if (!form.name.trim() || !form.rule.trim()) {
addNotification({
@@ -495,11 +545,13 @@ async function saveRule() {
}
function openDeleteModal(rule: Labrinth.TechReview.Internal.DelphiRule) {
if (isScanning.value) return
ruleToDelete.value = rule
deleteModal.value?.show()
}
async function deleteRule() {
if (isScanning.value) return
const rule = ruleToDelete.value
if (!rule) return
@@ -523,5 +575,73 @@ async function deleteRule() {
}
}
async function runFullScan() {
if (isScanning.value) return
isScanning.value = true
scanProgress.value = null
scanAbortController = new AbortController()
let completed = false
try {
const stream = await client.labrinth.tech_review_internal.scanRules(scanAbortController.signal)
const reader = stream.getReader()
const decoder = new TextDecoder()
const parser = new SseParser()
const processItems = (items: ReturnType<SseParser['feed']>) => {
for (const item of items) {
if (item.kind !== 'event') continue
if (item.event === 'failed') {
const error = JSON.parse(
item.data,
) as Labrinth.TechReview.Internal.DelphiRuleScanErrorEvent
throw new Error(error.message)
}
if (item.event === 'progress' || item.event === 'complete') {
scanProgress.value = JSON.parse(
item.data,
) as Labrinth.TechReview.Internal.DelphiRuleScanEvent
completed ||= item.event === 'complete'
}
}
}
while (true) {
const { done, value } = await reader.read()
if (done) break
processItems(parser.feed(decoder.decode(value, { stream: true })))
}
const finalChunk = decoder.decode()
if (finalChunk) processItems(parser.feed(finalChunk))
processItems(parser.end())
if (!completed || !scanProgress.value) {
throw new Error('The scan stream ended before the new revision was published.')
}
addNotification({
type: 'success',
title: 'Rule scan complete',
text: `${scanProgress.value.scanned.toLocaleString()} details were scanned for revision ${scanProgress.value.revision}.`,
})
await loadRules()
} catch (error) {
console.error('Failed to scan Delphi rules', error)
addNotification({
type: 'error',
title: 'Rule scan failed',
text: error instanceof Error ? error.message : 'The previous rule revision remains active.',
})
} finally {
isScanning.value = false
scanAbortController = null
}
}
onMounted(loadRules)
onUnmounted(() => scanAbortController?.abort())
</script>
+1
View File
@@ -120,6 +120,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
moderation::tech_review::rules::create_rule,
moderation::tech_review::rules::update_rule,
moderation::tech_review::rules::delete_rule,
moderation::tech_review::rules_scan::scan_rules,
moderation::tech_review::get_project_report,
moderation::tech_review::submit_report,
moderation::tech_review::update_issue_details,
@@ -45,11 +45,13 @@ 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)
@@ -191,32 +191,12 @@ pub async fn test_rule(
for (index, trace) in request.traces.iter().enumerate() {
let input = test_rule_input(trace);
let mut context = cel::Context::default();
context.add_variable("input", input).map_err(|error| {
ApiError::Request(eyre!(
"failed to build input for test trace {index}: {error}"
))
})?;
let value = program.execute(&context).map_err(|error| {
ApiError::Request(eyre!(
"failed to evaluate test trace {index}: {error}"
))
})?;
let value = value.json().map_err(|error| {
ApiError::Request(eyre!(
"failed to decode result for test trace {index}: {error}"
))
})?;
let effect = match value {
serde_json::Value::Null => None,
value => Some(serde_json::from_value(value).map_err(|error| {
let effect = super::rules_scan::evaluate_rule(&program, input)
.map_err(|error| {
ApiError::Request(eyre!(
"invalid effect for test trace {index}: {error}"
"failed to evaluate test trace {index}: {error}"
))
})?),
};
})?;
effects.push(effect);
}
@@ -280,7 +260,7 @@ pub async fn get_rules(
updated_by
FROM delphi_rules
WHERE NOT delete_on_next_revision
ORDER BY name, id
ORDER BY id
"#,
)
.fetch_all(&***ro_pool)
@@ -336,10 +316,17 @@ pub async fn create_rule(
INSERT INTO delphi_rules (
name,
rule,
revision,
created_by,
updated_by
)
VALUES ($1, $2, $3, $3)
VALUES (
$1,
$2,
(SELECT revision + 1 FROM delphi_rule_revisions LIMIT 1),
$3,
$3
)
RETURNING
id,
name,
@@ -405,6 +392,9 @@ pub async fn update_rule(
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
@@ -470,6 +460,9 @@ pub async fn delete_rule(
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
@@ -0,0 +1,493 @@
use std::collections::{BTreeMap, HashMap};
use actix_web::{HttpRequest, HttpResponse, 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 super::rules::DelphiRuleEffect;
use crate::{
auth::check_is_moderator_from_headers,
database::{
PgPool, PgTransaction, models::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;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.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)]
struct RuleInput {
schema_version: u32,
trace: RuleTrace,
scan: RuleScan,
artifact: RuleArtifact,
scope: RuleScope,
}
#[derive(Serialize)]
struct RuleTrace {
key: String,
issue_type: String,
severity: DelphiSeverity,
jar: Option<String>,
file_path: String,
data: HashMap<String, serde_json::Value>,
}
#[derive(Serialize)]
struct RuleScan {
delphi_version: i32,
}
#[derive(Serialize)]
struct RuleArtifact {
size: Option<i32>,
hashes: BTreeMap<String, String>,
}
#[derive(Serialize)]
struct RuleScope {
project_id: Option<String>,
version_id: Option<String>,
file_id: Option<String>,
}
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,
}
/// 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<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
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<Bytes>,
) -> Result<ScanSummary> {
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 = 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::<Result<Vec<_>>>()?;
let total = sqlx::query_scalar!(
"SELECT COUNT(*) AS \"count!\" FROM delphi_report_issue_details",
)
.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<HashMap<String, serde_json::Value>>",
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<BTreeMap<String, String>>"
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
ORDER BY detail.id
"#,
)
.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());
let detail_ids = effects
.iter()
.map(|effect| effect.detail_id)
.collect::<Vec<_>>();
let rule_ids = effects
.iter()
.map(|effect| effect.rule_id)
.collect::<Vec<_>>();
let severities = effects
.iter()
.map(|effect| effect.effect.severity)
.collect::<Vec<_>>();
let hidden = effects
.iter()
.map(|effect| effect.effect.hidden)
.collect::<Vec<_>>();
if !effects.is_empty() {
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<DelphiSeverity>],
&hidden,
)
.execute(&mut transaction)
.await
.wrap_err("failed to insert delphi rule effects")?;
}
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")?;
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")?;
transaction
.commit()
.await
.wrap_err("failed to commit the delphi rule scan")?;
Ok(ScanSummary {
revision,
scanned: total,
total,
effects: effects.len(),
})
}
pub(super) fn evaluate_rule(
program: &cel::Program,
input: impl Serialize,
) -> Result<Option<DelphiRuleEffect>> {
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<Bytes>,
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<Bytes>,
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")));
}