This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-rs.git


The following commit(s) were added to refs/heads/master by this push:
     new 12bf6a7  fix: support subjectPriority, policy priority sorting and in 
operator like Go
12bf6a7 is described below

commit 12bf6a709d7d2c086b8698795441ea17ec8ae0b8
Author: Yang Luo <[email protected]>
AuthorDate: Wed Oct 21 01:49:32 2026 +0800

    fix: support subjectPriority, policy priority sorting and in operator like 
Go
---
 .github/workflows/ci.yml   |   8 +++
 src/config.rs              |  24 ++++++++-
 src/effector.rs            |   8 ++-
 src/enforcer.rs            | 121 ++++++++++++++++++++++++++++++++++++++++++---
 src/macros.rs              |   2 +-
 src/model/assertion.rs     |  28 ++++++++---
 src/model/default_model.rs |   3 ++
 src/util.rs                |  22 ++++++++-
 tests/conformance.rs       | 101 +++++++++++++++++++++++++++++++++++++
 9 files changed, 296 insertions(+), 21 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7d52b0d..19c4188 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,6 +18,12 @@ jobs:
     steps:
       - name: Checkout Repository
         uses: actions/checkout@v4
+      - name: Checkout Conformance Cases
+        uses: actions/checkout@v4
+        with:
+          repository: apache/casbin
+          path: casbin-conformance
+          sparse-checkout: conformance
       - name: Install Rust toolchain ${{ matrix.rust }}
         run: |
           rustup toolchain install ${{ matrix.rust }} --profile minimal 
--component rustfmt,clippy --target wasm32-unknown-unknown
@@ -62,6 +68,8 @@ jobs:
         run: cargo test --no-default-features --features 
runtime-async-std,cached,glob,ip,watcher,logging,incremental,explain
       - name: Cargo Test For All Features Using tokio
         run: cargo test --no-default-features --features 
runtime-tokio,cached,glob,ip,watcher,logging,incremental,explain
+        env:
+          CASBIN_CONFORMANCE_DIR: ${{ github.workspace 
}}/casbin-conformance/conformance
       - name: Cargo Check Wasm
         env:
           RUSTFLAGS: '--cfg getrandom_backend="wasm_js"'
diff --git a/src/config.rs b/src/config.rs
index 99bf191..5522381 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -26,6 +26,27 @@ const DEFAULT_COMMENT: &str = "#";
 const DEFAULT_COMMENT_SEM: &str = ";";
 const DEFAULT_MULTI_LINE_SEPARATOR: &str = "\\";
 
+fn remove_inline_comment(line: &str) -> String {
+    let (body, multi_line) =
+        match line.strip_suffix(DEFAULT_MULTI_LINE_SEPARATOR) {
+            Some(body) => (body, true),
+            None => (line, false),
+        };
+    let body = match body.find(|c| {
+        DEFAULT_COMMENT.contains(c) || DEFAULT_COMMENT_SEM.contains(c)
+    }) {
+        Some(idx) => &body[..idx],
+        None => body,
+    }
+    .trim_end();
+
+    if multi_line {
+        format!("{}{}", body, DEFAULT_MULTI_LINE_SEPARATOR)
+    } else {
+        body.to_owned()
+    }
+}
+
 pub(crate) struct Config {
     data: HashMap<String, HashMap<String, String>>,
 }
@@ -87,6 +108,7 @@ impl Config {
                 section = line[1..line.len() - 1].to_string();
             } else {
                 let mut next_section = String::new();
+                line = remove_inline_comment(&line);
                 while line.ends_with(DEFAULT_MULTI_LINE_SEPARATOR) {
                     line = line[..line.len() - 1].trim_end().to_string();
 
@@ -109,7 +131,7 @@ impl Config {
                         next_section =
                             inner_line[1..inner_line.len() - 1].to_string();
                     } else {
-                        line.push_str(&inner_line);
+                        line.push_str(&remove_inline_comment(&inner_line));
                     }
                 }
 
diff --git a/src/effector.rs b/src/effector.rs
index 1c0082a..43e286e 100644
--- a/src/effector.rs
+++ b/src/effector.rs
@@ -39,7 +39,8 @@ impl Effector for DefaultEffector {
         let res = match expr {
             "some(where (p_eft == allow))"
             | "some(where (p_eft == allow)) && !some(where (p_eft == deny))"
-            | "priority(p_eft) || deny" => false,
+            | "priority(p_eft) || deny"
+            | "subjectPriority(p_eft) || deny" => false,
             "!some(where (p_eft == deny))" => true,
             _ => panic!("unsupported effect: `{}`", expr),
         };
@@ -93,6 +94,8 @@ impl EffectorStream for DefaultEffectStream {
                 self.done = true;
                 self.res = false;
 
+                #[cfg(feature = "explain")]
+                self.expl.clear();
                 push_index_if_explain!(self)
             }
         } else if self.expr == "!some(where (p_eft == deny))" {
@@ -102,7 +105,8 @@ impl EffectorStream for DefaultEffectStream {
 
                 push_index_if_explain!(self)
             }
-        } else if self.expr == "priority(p_eft) || deny"
+        } else if (self.expr == "priority(p_eft) || deny"
+            || self.expr == "subjectPriority(p_eft) || deny")
             && eft != EffectKind::Indeterminate
         {
             self.res = eft == EffectKind::Allow;
diff --git a/src/enforcer.rs b/src/enforcer.rs
index b4f8afd..28ddcde 100644
--- a/src/enforcer.rs
+++ b/src/enforcer.rs
@@ -7,7 +7,7 @@ use crate::{
     error::{ModelError, PolicyError, RequestError},
     get_or_err, get_or_err_with_context,
     management_api::MgmtApi,
-    model::{FunctionMap, Model, OperatorFunction},
+    model::{key_match, FunctionMap, Model, OperatorFunction},
     rbac::{DefaultRoleManager, RoleManager},
     register_g_function,
     util::{escape_assertion, escape_eval},
@@ -26,6 +26,7 @@ use crate::watcher::Watcher;
 use crate::{DefaultLogger, Logger};
 
 use async_trait::async_trait;
+use hashlink::LinkedHashSet;
 use once_cell::sync::Lazy;
 use parking_lot::RwLock;
 use rhai::{
@@ -52,7 +53,41 @@ def_package! {
 
 static CASBIN_PACKAGE: Lazy<CasbinPackage> = Lazy::new(CasbinPackage::new);
 
-use std::{cmp::max, collections::HashMap, sync::Arc};
+use std::{
+    cmp::{max, Reverse},
+    collections::{HashMap, HashSet, VecDeque},
+    sync::Arc,
+};
+
+fn subject_hierarchy(
+    policies: &LinkedHashSet<Vec<String>>,
+) -> HashMap<(String, String), usize> {
+    let mut children: HashMap<(String, String), Vec<(String, String)>> =
+        HashMap::new();
+    let mut parents = Vec::new();
+    for rule in policies.iter().filter(|rule| rule.len() >= 2) {
+        let domain = rule.get(2).cloned().unwrap_or_default();
+        let child = (domain.clone(), rule[0].clone());
+        let parent = (domain, rule[1].clone());
+        parents.push(parent.clone());
+        children.entry(parent).or_default().push(child);
+    }
+
+    let nested: HashSet<_> = children.values().flatten().collect();
+    let mut levels = HashMap::new();
+    for root in parents.iter().filter(|x| !nested.contains(x)) {
+        let mut queue = VecDeque::from([(root, 0)]);
+        while let Some((node, level)) = queue.pop_front() {
+            if let Some(nodes) =
+                children.get(node).filter(|_| level < policies.len())
+            {
+                queue.extend(nodes.iter().map(|x| (x, level + 1)));
+            }
+            levels.insert(node.clone(), level);
+        }
+    }
+    levels
+}
 
 type EventCallback = fn(&mut Enforcer, EventData);
 
@@ -413,14 +448,84 @@ impl Enforcer {
     }
 
     fn init_rm_map(&mut self) {
+        let model = self.model.get_model();
+        let token = |sec: &str| {
+            model
+                .get(sec)
+                .and_then(|asts| asts.get(sec))
+                .and_then(|ast| ast.tokens.get(1))
+        };
+        let domain_key_match = match (token("r"), token("p")) {
+            (Some(r_dom), Some(p_dom)) => model
+                .get("m")
+                .and_then(|asts| asts.get("m"))
+                .is_some_and(|ast| {
+                    ast.value
+                        .contains(&format!("keyMatch({}, {})", r_dom, p_dom))
+                }),
+            _ => false,
+        };
+        if let Some(ast_map) = model.get("g") {
+            for (ptype, ast) in ast_map {
+                self.rm_map.entry(ptype.to_owned()).or_insert_with(|| {
+                    let mut rm = DefaultRoleManager::new(10);
+                    if domain_key_match && ast.role_token_count() > 2 {
+                        rm.matching_fn(None, Some(key_match));
+                    }
+                    Arc::new(RwLock::new(rm))
+                });
+            }
+        }
         self.rm_map.entry("g".to_owned()).or_insert_with(|| {
             Arc::new(RwLock::new(DefaultRoleManager::new(10)))
         });
-        if let Some(ast_map) = self.model.get_model().get("g") {
-            for ptype in ast_map.keys() {
-                self.rm_map.entry(ptype.to_owned()).or_insert_with(|| {
-                    Arc::new(RwLock::new(DefaultRoleManager::new(10)))
-                });
+    }
+
+    fn sort_policies(&mut self) {
+        let model = self.model.get_mut_model();
+        let hierarchy = model
+            .get("e")
+            .and_then(|asts| asts.get("e"))
+            .filter(|ast| ast.value == "subjectPriority(p_eft) || deny")
+            .and_then(|_| model.get("g").and_then(|asts| asts.get("g")))
+            .map(|ast| subject_hierarchy(&ast.policy));
+
+        if let Some(asts) = model.get_mut("p") {
+            for (ptype, ast) in asts.iter_mut() {
+                let dom_index = ast
+                    .tokens
+                    .iter()
+                    .position(|t| *t == format!("{}_dom", ptype));
+                let priority_index = ast
+                    .tokens
+                    .iter()
+                    .position(|t| *t == format!("{}_priority", ptype));
+                if hierarchy.is_none() && priority_index.is_none() {
+                    continue;
+                }
+
+                let mut policies: Vec<Vec<String>> =
+                    ast.policy.drain().collect();
+                if let Some(hierarchy) = &hierarchy {
+                    policies.sort_by_key(|rule| {
+                        let domain = dom_index
+                            .and_then(|i| rule.get(i))
+                            .map_or("", String::as_str);
+                        let name = rule.first().map_or("", String::as_str);
+                        Reverse(
+                            hierarchy
+                                .get(&(domain.to_owned(), name.to_owned()))
+                                .copied()
+                                .unwrap_or(0),
+                        )
+                    });
+                }
+                if let Some(i) = priority_index {
+                    policies.sort_by_key(|rule| {
+                        rule.get(i).and_then(|x| x.parse::<i64>().ok())
+                    });
+                }
+                ast.policy = policies.into_iter().collect();
             }
         }
     }
@@ -802,6 +907,7 @@ impl CoreApi for Enforcer {
     async fn load_policy(&mut self) -> Result<()> {
         self.model.clear_policy();
         self.adapter.load_policy(&mut *self.model).await?;
+        self.sort_policies();
 
         if self.auto_build_role_links {
             self.build_role_links()?;
@@ -815,6 +921,7 @@ impl CoreApi for Enforcer {
         self.adapter
             .load_filtered_policy(&mut *self.model, f)
             .await?;
+        self.sort_policies();
 
         if self.auto_build_role_links {
             self.build_role_links()?;
diff --git a/src/macros.rs b/src/macros.rs
index 8721655..358413f 100644
--- a/src/macros.rs
+++ b/src/macros.rs
@@ -48,7 +48,7 @@ macro_rules! get_or_err_with_context {
 macro_rules! register_g_function {
     ($enforcer:ident, $fname:ident, $ast:ident) => {{
         let rm = Arc::clone(&$enforcer.rm_map[$fname]);
-        let count = $ast.value.matches('_').count();
+        let count = $ast.role_token_count();
 
         if count == 2 {
             $enforcer.engine.register_fn(
diff --git a/src/model/assertion.rs b/src/model/assertion.rs
index 1041630..9065246 100644
--- a/src/model/assertion.rs
+++ b/src/model/assertion.rs
@@ -36,6 +36,16 @@ impl Default for Assertion {
 }
 
 impl Assertion {
+    pub(crate) fn role_token_count(&self) -> usize {
+        let params = match (self.value.find('('), self.value.find(')')) {
+            (Some(start), Some(end)) if start < end => {
+                self.value[start..end].matches('_').count()
+            }
+            _ => 0,
+        };
+        self.value.matches('_').count() - params
+    }
+
     #[inline]
     pub fn get_policy(&self) -> &LinkedHashSet<Vec<String>> {
         &self.policy
@@ -51,7 +61,8 @@ impl Assertion {
         rm: Arc<RwLock<dyn RoleManager>>,
     ) -> Result<()> {
         let count = self.value.matches('_').count();
-        if count < 2 {
+        let tokens = self.role_token_count();
+        if tokens < 2 {
             return Err(ModelError::P(
                 r#"the number of "_" in role definition should be at least 2"#
                     .to_owned(),
@@ -66,11 +77,11 @@ impl Assertion {
                 )
                 .into());
             }
-            if count == 2 {
+            if tokens == 2 {
                 rm.write().add_link(&rule[0], &rule[1], None);
-            } else if count == 3 {
+            } else if tokens == 3 {
                 rm.write().add_link(&rule[0], &rule[1], Some(&rule[2]));
-            } else if count >= 4 {
+            } else if tokens >= 4 {
                 return Err(ModelError::P(
                     "Multiple domains are not supported".to_owned(),
                 )
@@ -88,7 +99,8 @@ impl Assertion {
         d: EventData,
     ) -> Result<()> {
         let count = self.value.matches('_').count();
-        if count < 2 {
+        let tokens = self.role_token_count();
+        if tokens < 2 {
             return Err(ModelError::P(
                 r#"the number of "_" in role definition should be at least 2"#
                     .to_owned(),
@@ -114,13 +126,13 @@ impl Assertion {
                     )
                     .into());
                 }
-                if count == 2 {
+                if tokens == 2 {
                     if insert {
                         rm.write().add_link(&rule[0], &rule[1], None);
                     } else {
                         rm.write().delete_link(&rule[0], &rule[1], None)?;
                     }
-                } else if count == 3 {
+                } else if tokens == 3 {
                     if insert {
                         rm.write().add_link(&rule[0], &rule[1], 
Some(&rule[2]));
                     } else {
@@ -130,7 +142,7 @@ impl Assertion {
                             Some(&rule[2]),
                         )?;
                     }
-                } else if count >= 4 {
+                } else if tokens >= 4 {
                     return Err(ModelError::P(
                         "Multiple domains are not supported".to_owned(),
                     )
diff --git a/src/model/default_model.rs b/src/model/default_model.rs
index 166d526..2223738 100644
--- a/src/model/default_model.rs
+++ b/src/model/default_model.rs
@@ -169,6 +169,9 @@ impl Model for DefaultModel {
                 .collect();
         } else {
             ast.value = escape_assertion(&ast.value);
+            if sec == "m" {
+                ast.value = escape_in_operator(&ast.value).into_owned();
+            }
         }
 
         if let Some(new_model) = self.model.get_mut(sec) {
diff --git a/src/util.rs b/src/util.rs
index 24cab15..9826b81 100644
--- a/src/util.rs
+++ b/src/util.rs
@@ -15,6 +15,7 @@ static ESC_G: Lazy<Regex> = Lazy::new(|| {
     
regex!(r"\b(g\d*)\(((?:\s*[r|p]\d*\.\w+\s*,\s*){1,2}\s*[r|p]\d*\.\w+\s*)\)")
 });
 static ESC_C: Lazy<Regex> = Lazy::new(|| regex!(r#"(\s*"[^"]*"?|\s*[^,]*)"#));
+static ESC_IN: Lazy<Regex> = Lazy::new(|| regex!(r"\bin\s*\(([^()]*)\)"));
 pub(crate) static ESC_E: Lazy<Regex> =
     Lazy::new(|| regex!(r"\beval\(([^)]*)\)"));
 
@@ -38,7 +39,20 @@ pub fn escape_assertion(s: &str) -> String {
         }
         let end = (end + 1).min(bytes.len());
         res.push_str(&ESC_A.replace_all(&s[start..i], "${1}_"));
-        res.push_str(&s[i..end]);
+        if quote == b'\'' && end - i >= 2 && bytes[end - 1] == quote {
+            res.push('"');
+            let mut escaped = false;
+            for c in s[i + 1..end - 1].chars() {
+                if c == '"' && !escaped {
+                    res.push('\\');
+                }
+                escaped = c == '\\' && !escaped;
+                res.push(c);
+            }
+            res.push('"');
+        } else {
+            res.push_str(&s[i..end]);
+        }
         start = end;
         i = end;
     }
@@ -46,6 +60,10 @@ pub fn escape_assertion(s: &str) -> String {
     res
 }
 
+pub fn escape_in_operator(m: &str) -> Cow<'_, str> {
+    ESC_IN.replace_all(m, "in [${1}]")
+}
+
 pub fn remove_comment(s: &str) -> String {
     let s = if let Some(idx) = s.find('#') {
         &s[..idx]
@@ -117,7 +135,7 @@ mod tests {
             )
         );
         assert_eq!(
-            r#"r_obj == "a\"p.b" || p_obj == 'r'"#,
+            r#"r_obj == "a\"p.b" || p_obj == "r""#,
             escape_assertion(r#"r.obj == "a\"p.b" || p.obj == 'r'"#)
         );
     }
diff --git a/tests/conformance.rs b/tests/conformance.rs
new file mode 100644
index 0000000..2aa502e
--- /dev/null
+++ b/tests/conformance.rs
@@ -0,0 +1,101 @@
+#![cfg(all(
+    feature = "explain",
+    feature = "runtime-tokio",
+    not(target_arch = "wasm32")
+))]
+
+use casbin::prelude::*;
+use serde::Deserialize;
+use std::{fs, path::PathBuf};
+
+#[derive(Deserialize)]
+struct ConformanceCase {
+    model: String,
+    policy: String,
+    tests: Vec<ConformanceTest>,
+}
+
+#[derive(Deserialize)]
+struct ConformanceTest {
+    request: Vec<String>,
+    allow: bool,
+    explain: Vec<String>,
+}
+
+async fn run_case(dir: PathBuf, file: PathBuf) -> Vec<String> {
+    let name = file.file_name().unwrap().to_string_lossy().to_string();
+    let case: ConformanceCase =
+        serde_json::from_str(&fs::read_to_string(&file).unwrap()).unwrap();
+    let model =
+        DefaultModel::from_file(dir.join("data").join(&case.model)).await;
+    let e = match model {
+        Ok(model) => {
+            Enforcer::new(
+                model,
+                FileAdapter::new(dir.join("data").join(&case.policy)),
+            )
+            .await
+        }
+        Err(err) => Err(err),
+    };
+    let e = match e {
+        Ok(e) => e,
+        Err(err) => return vec![format!("{}: {}", name, err)],
+    };
+
+    let mut failures = Vec::new();
+    for test in case.tests {
+        match e.enforce_ex(test.request.clone()) {
+            Ok((allow, rules)) => {
+                let explain = rules.into_iter().next().unwrap_or_default();
+                if allow != test.allow || explain != test.explain {
+                    failures.push(format!(
+                        "{} {:?}: got ({}, {:?}), want ({}, {:?})",
+                        name,
+                        test.request,
+                        allow,
+                        explain,
+                        test.allow,
+                        test.explain
+                    ));
+                }
+            }
+            Err(err) => {
+                failures.push(format!("{} {:?}: {}", name, test.request, err))
+            }
+        }
+    }
+    failures
+}
+
+#[tokio::test]
+async fn test_conformance() {
+    let dir = match std::env::var("CASBIN_CONFORMANCE_DIR") {
+        Ok(dir) if !dir.is_empty() => PathBuf::from(dir),
+        _ => return,
+    };
+
+    let mut files: Vec<_> = fs::read_dir(dir.join("cases"))
+        .unwrap()
+        .map(|entry| entry.unwrap().path())
+        .filter(|path| path.extension().map_or(false, |ext| ext == "json"))
+        .collect();
+    files.sort();
+    assert!(!files.is_empty(), "no conformance cases found in {:?}", dir);
+
+    let mut failures = Vec::new();
+    for file in files {
+        let name = file.file_name().unwrap().to_string_lossy().to_string();
+        match tokio::spawn(run_case(dir.clone(), file)).await {
+            Ok(case_failures) => failures.extend(case_failures),
+            Err(err) => failures.push(format!("{}: {}", name, err)),
+        }
+    }
+
+    assert!(
+        failures.is_empty(),
+        "{} conformance failures:\n{}",
+        failures.len(),
+        failures.join("\n")
+    );
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to