fix(filter): use unique rule ids to prevent silent overwrite #267
No files matched your search
Generated
+1
@@ -2544,6 +2544,7 @@ dependencies = [
|
||||
name = "nuncio-filter"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user