diff --git a/Cargo.toml b/Cargo.toml index 11e02f8..23a453a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["dhttp", "identity", "home", "api", "access", "log"] [workspace.package] -version = "0.6.0" +version = "0.6.1-beta.1" edition = "2024" license = "Apache-2.0" repository = "https://github.com/genmeta/dhttp" @@ -55,6 +55,6 @@ ddns = { package = "dyns", version = "0.7.0", features = [ h3x = { version = "0.6.0", features = [ "dquic", ] } -dhttp = { path = "dhttp", version = "0.6.0" } -dhttp-access = { path = "access", version = "0.4.0" } +dhttp = { path = "dhttp", version = "0.6.1-beta.1" } +dhttp-access = { path = "access", version = "0.4.1-beta.1" } dhttp-log = { path = "log", version = "0.1.0" } diff --git a/access/Cargo.toml b/access/Cargo.toml index f15ca3e..5a59348 100644 --- a/access/Cargo.toml +++ b/access/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dhttp-access" description = "Identity-aware access control primitives for DHttp" -version = "0.4.0" +version = "0.4.1-beta.1" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/access/src/db/mod.rs b/access/src/db/mod.rs index d595c1d..a029c36 100644 --- a/access/src/db/mod.rs +++ b/access/src/db/mod.rs @@ -282,6 +282,47 @@ mod tests { assert!(!tables.contains(&"location_domain_rule_sets".to_string())); } + #[tokio::test] + async fn appending_duplicate_rules_is_idempotent() { + let test_home = TestHome::new("duplicate-rules"); + let home = test_home.home(); + let identity: identity::Name<'static> = "alice.pilot".parse().unwrap(); + let db = init_identity_access_database(&home, identity.borrow()) + .await + .unwrap(); + let service = LocationService::new(&db); + + let first = service + .append_rule_with_id( + &"/api".parse().unwrap(), + RequestAction::Allow, + "alice.pilot~".parse().unwrap(), + ) + .await + .unwrap(); + let first_rules = service + .list_rules_by_pattern(&"/api".parse().unwrap()) + .await + .unwrap(); + assert_eq!(first_rules.rules.len(), 1); + + let duplicate = service + .append_rule_with_id( + &"/api".parse().unwrap(), + RequestAction::Allow, + "alice.pilot.dhttp.net".parse().unwrap(), + ) + .await + .unwrap(); + + assert_eq!(duplicate.id, first.id); + let rules = service + .list_rules_by_pattern(&"/api".parse().unwrap()) + .await + .unwrap(); + assert_eq!(rules.rules.len(), 1); + } + #[tokio::test] async fn location_service_location_only_crud() { let test_home = TestHome::new("location-crud"); diff --git a/access/src/db/service/error.rs b/access/src/db/service/error.rs index ba93ae2..2d553eb 100644 --- a/access/src/db/service/error.rs +++ b/access/src/db/service/error.rs @@ -135,6 +135,8 @@ pub enum AppendRuleError { BeginTransaction { source: DbErr }, #[snafu(display("failed to match or create location rule set before appending rule"))] MatchOrCreateLocation { source: MatchOrCreateLocationError }, + #[snafu(display("failed to load existing location rules before appending rule"))] + LoadExistingRules { source: DbErr }, #[snafu(display("failed to insert location rule"))] InsertRule { source: DbErr }, #[snafu(display("failed to load inserted location rule"))] diff --git a/access/src/db/service/location_service.rs b/access/src/db/service/location_service.rs index 3bdedce..bc449fb 100644 --- a/access/src/db/service/location_service.rs +++ b/access/src/db/service/location_service.rs @@ -45,6 +45,20 @@ pub struct MatchedLocationRules { pub rules: Vec, } +fn deduplicate_rules(rules: &mut Vec) { + let mut unique = Vec::with_capacity(rules.len()); + for candidate in rules.drain(..) { + let duplicate = unique.iter().any(|existing: &rule::Model| { + existing.action == candidate.action + && existing.exprs.polish() == candidate.exprs.polish() + }); + if !duplicate { + unique.push(candidate); + } + } + *rules = unique; +} + impl Display for MatchedLocationRules { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let Self { location, rules } = self; @@ -194,12 +208,13 @@ impl LocationService<'_> { }) .context(list_rules_error::NoMatchedLocationSnafu)?; - let rules = rule::Entity::find() + let mut rules = rule::Entity::find() .filter(rule::Column::LocationId.eq(location_id)) .order_by_asc(rule::Column::CreatedAt) .all(&txn) .await .context(list_rules_error::LoadRulesSnafu)?; + deduplicate_rules(&mut rules); txn.commit().await.context(list_rules_error::CommitSnafu)?; @@ -227,12 +242,13 @@ impl LocationService<'_> { }) .context(list_rules_by_pattern_error::LocationNotExistSnafu)?; - let rules = rule::Entity::find() + let mut rules = rule::Entity::find() .filter(rule::Column::LocationId.eq(location_id)) .order_by_asc(rule::Column::CreatedAt) .all(&txn) .await .context(list_rules_by_pattern_error::LoadRulesSnafu)?; + deduplicate_rules(&mut rules); txn.commit() .await @@ -293,6 +309,7 @@ impl LocationService<'_> { .into_iter() .zip(rules) .map(|(location, mut rules)| { + deduplicate_rules(&mut rules); let pattern_with_time = PatternWithTime::new( location.created_at.timestamp_micros(), location.pattern.clone(), @@ -473,6 +490,21 @@ impl LocationService<'_> { .await .context(append_rule_error::MatchOrCreateLocationSnafu)?; + let existing_rules = rule::Entity::find() + .filter(rule::Column::LocationId.eq(location_id)) + .filter(rule::Column::Action.eq(action)) + .order_by_asc(rule::Column::CreatedAt) + .all(&txn) + .await + .context(append_rule_error::LoadExistingRulesSnafu)?; + if let Some(existing_rule) = existing_rules + .into_iter() + .find(|candidate| candidate.exprs.polish() == expr.polish()) + { + txn.commit().await.context(append_rule_error::CommitSnafu)?; + return Ok(existing_rule); + } + let now = chrono::Utc::now(); let new_rule = rule::ActiveModel { location_id: Set(location_id), diff --git a/access/src/expr/exprs.rs b/access/src/expr/exprs.rs index 2ab255d..2ca6f3c 100644 --- a/access/src/expr/exprs.rs +++ b/access/src/expr/exprs.rs @@ -148,7 +148,14 @@ impl Display for LocationRuleExprs { if let [Part::Expr(AtomicLocationRuleExpr::ClientName(pattern))] = self.polish.parts.as_slice() { - return write!(f, "{}", pattern.as_ref().as_str()); + let pattern = pattern.as_ref().as_str(); + if let Some(prefix) = pattern + .strip_suffix(".dhttp.net") + .filter(|prefix| !prefix.is_empty()) + { + return write!(f, "{prefix}~"); + } + return write!(f, "{pattern}"); } write!(f, "{infix}") } diff --git a/access/src/expr/parse.rs b/access/src/expr/parse.rs index e8a37ed..9e6d977 100644 --- a/access/src/expr/parse.rs +++ b/access/src/expr/parse.rs @@ -515,7 +515,7 @@ mod tests { .parse::() .expect("dhttp shorthand should parse"); - assert_eq!(exprs.to_string(), "alice.dhttp.net"); + assert_eq!(exprs.to_string(), "alice~"); assert!(matches!( &location_invariant("alice~")[0], Expr(AtomicLocationRuleExpr::ClientName(pattern)) @@ -523,6 +523,15 @@ mod tests { )); } + #[test] + fn client_name_pattern_displays_shorthand_for_canonical_name() { + let exprs = "alice.dhttp.net" + .parse::() + .expect("canonical dhttp name should parse"); + + assert_eq!(exprs.to_string(), "alice~"); + } + #[test] fn method_pattern_rejects_unreachable_space() { let error = r#"*? with method "GET POST""#.parse::().unwrap_err(); diff --git a/api/package-lock.json b/api/package-lock.json index 01f49aa..527b8fa 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "@genmeta/dhttp", - "version": "0.6.0", + "version": "0.6.1-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@genmeta/dhttp", - "version": "0.6.0", + "version": "0.6.1-beta.1", "devDependencies": { "@napi-rs/cli": "^3.3.5" } diff --git a/api/package.json b/api/package.json index a8bf8a3..13ec16b 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@genmeta/dhttp", - "version": "0.6.0", + "version": "0.6.1-beta.1", "description": "The True Internet", "license": "Apache-2.0", "homepage": "https://dhttp.net/",