fix(filter): use unique rule ids to prevent silent overwrite #267

Merged
KofTwentyTwo merged 1 commits from story/m6-rule-id-unique into dev 2026-07-31 16:32:10 +00:00
4 changed files with 74 additions and 7 deletions

No files matched your search

Generated
+1
View File
@@ -2544,6 +2544,7 @@ dependencies = [
name = "nuncio-filter"
version = "0.1.0"
dependencies = [
"aes-gcm",
"arc-swap",
"async-trait",
"chrono",
+1
View File
@@ -26,6 +26,7 @@ chrono.workspace = true
reqwest.workspace = true
hex.workspace = true
zeroize.workspace = true
aes-gcm.workspace = true
[dev-dependencies]
tempfile.workspace = true
+13 -7
View File
@@ -65,7 +65,7 @@ impl NsqlParser {
let now = chrono::Utc::now().timestamp();
Ok(FilterRule {
id: format!("rule-{}", uuid_or_simple_hash(&name_str, nsql)),
id: format!("rule-{}", generate_rule_id()),
name: name_str,
target_account,
priority,
@@ -610,12 +610,18 @@ fn split_comma_outside_quotes(text: &str) -> Vec<String> {
result
}
fn uuid_or_simple_hash(name: &str, nsql: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(name.as_bytes());
hasher.update(nsql.as_bytes());
hex::encode(&hasher.finalize()[..8])
/// Generates a fresh, collision-resistant rule id from the same CSPRNG
/// (`aes-gcm`'s `OsRng`) that `nuncio-store` uses to source key material,
/// rather than deriving the id from the rule's name/NSQL text. Two rules
/// created with identical name and NSQL text MUST still get distinct ids --
/// deriving the id from their content would make the second `CreateRule`
/// silently overwrite the first via `save_filter_rule`'s `INSERT OR
/// REPLACE`.
fn generate_rule_id() -> String {
use aes_gcm::aead::{rand_core::RngCore, OsRng};
let mut bytes = [0u8; 16];
OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
#[cfg(test)]
+59
View File
@@ -4333,6 +4333,65 @@ mod tests {
.is_empty());
}
/// Two `CreateRule` calls with the SAME name AND the SAME NSQL text
/// must persist as two distinct rules rather than the second silently
/// overwriting the first. Rule ids used to be derived deterministically
/// from `sha256(name + nsql)`, so identical name+NSQL produced identical
/// ids and `save_filter_rule`'s `INSERT OR REPLACE` clobbered the first
/// rule with no warning.
#[tokio::test]
async fn create_rule_with_duplicate_name_and_nsql_does_not_overwrite_the_first() {
let (addr, _handle, _db, _engine, _dir) = spawn_filters_test_server("correct-token").await;
let mut client = FiltersClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let first = client
.create_rule(authed_bearer_request(CreateRuleRequest {
name: "Duplicate Rule".to_string(),
nsql: SAMPLE_RULE_NSQL.to_string(),
priority: 1,
}))
.await
.expect("first create_rule succeeds")
.into_inner()
.rule
.expect("response carries the rule");
let second = client
.create_rule(authed_bearer_request(CreateRuleRequest {
name: "Duplicate Rule".to_string(),
nsql: SAMPLE_RULE_NSQL.to_string(),
priority: 1,
}))
.await
.expect("second create_rule succeeds")
.into_inner()
.rule
.expect("response carries the rule");
assert_ne!(
first.id, second.id,
"identical name+NSQL must still get distinct ids"
);
let list_response = client
.list_rules(authed_bearer_request(ListRulesRequest {}))
.await
.expect("list_rules succeeds")
.into_inner();
assert_eq!(
list_response.rules.len(),
2,
"both rules must persist, neither overwritten"
);
let ids: std::collections::HashSet<_> =
list_response.rules.iter().map(|r| r.id.clone()).collect();
assert!(ids.contains(&first.id));
assert!(ids.contains(&second.id));
}
#[tokio::test]
async fn delete_rule_rejects_empty_id() {
let (addr, _handle, _db, _engine, _dir) = spawn_filters_test_server("correct-token").await;