From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH pve-cluster 04/12] rust: notify: add subscription matching
Date: Thu, 22 Oct 2026 16:21:57 +0200 [thread overview]
Message-ID: <20261022142205.930536-5-h.laimer@proxmox.com> (raw)
In-Reply-To: <20261022142205.930536-1-h.laimer@proxmox.com>
A subscription is a set of named templates. An event matches when its
path or its rename target matches one of them. A rename carries the
captures of each side that matches. A connection without root
privileges gets no event for a private path, just as IPC and FUSE
hide the content from it.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/rust/pmxcfs-notify/src/conn.rs | 140 +++++++++++++++++++++++++++++
src/rust/pmxcfs-notify/src/lib.rs | 4 +-
2 files changed, 143 insertions(+), 1 deletion(-)
create mode 100644 src/rust/pmxcfs-notify/src/conn.rs
diff --git a/src/rust/pmxcfs-notify/src/conn.rs b/src/rust/pmxcfs-notify/src/conn.rs
new file mode 100644
index 0000000..451504c
--- /dev/null
+++ b/src/rust/pmxcfs-notify/src/conn.rs
@@ -0,0 +1,140 @@
+use std::collections::BTreeMap;
+
+use crate::protocol::Params;
+use crate::template::Template;
+
+pub(crate) struct Conn {
+ privileged: bool,
+ templates: BTreeMap<String, Template>,
+}
+
+impl Conn {
+ pub(crate) fn new(privileged: bool) -> Self {
+ Self {
+ privileged,
+ templates: BTreeMap::new(),
+ }
+ }
+
+ pub(crate) fn matches<'a>(&'a self, path: &'a str, to: Option<&'a str>) -> Option<Params<'a>> {
+ if !self.privileged && (is_private(path) || to.is_some_and(is_private)) {
+ return None;
+ }
+ let mut params = Params::new();
+ for (name, template) in &self.templates {
+ let mut sets = Vec::new();
+ if let Some(captures) = template.matches(path) {
+ sets.push(captures);
+ }
+ // equal captures mean the same path, so a rename onto itself matches once
+ if let Some(to) = to
+ && let Some(captures) = template.matches(to)
+ && sets.first() != Some(&captures)
+ {
+ sets.push(captures);
+ }
+ if !sets.is_empty() {
+ params.insert(name.as_str(), sets);
+ }
+ }
+ (!params.is_empty()).then_some(params)
+ }
+}
+
+// Same rule as path_is_private in the daemon.
+fn is_private(path: &str) -> bool {
+ fn priv_component(path: &str) -> bool {
+ path == "priv" || path.starts_with("priv/")
+ }
+ let path = crate::normalize_path(path);
+ priv_component(path)
+ || path
+ .strip_prefix("nodes/")
+ .and_then(|rest| rest.split_once('/'))
+ .is_some_and(|(_, below)| priv_component(below))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn subscribed(privileged: bool, specs: &[(&str, &str)]) -> Conn {
+ let mut conn = Conn::new(privileged);
+ conn.templates = specs
+ .iter()
+ .map(|(name, text)| ((*name).to_owned(), text.parse().unwrap()))
+ .collect();
+ conn
+ }
+
+ #[test]
+ fn matches_per_template() {
+ let conn = subscribed(
+ true,
+ &[
+ ("guest", "nodes/{node}/qemu-server/{vmid}.conf"),
+ ("dc", "datacenter.cfg"),
+ ],
+ );
+ let params = conn.matches("nodes/n1/qemu-server/100.conf", None).unwrap();
+ assert_eq!(params.len(), 1);
+ assert_eq!(
+ params["guest"],
+ vec![BTreeMap::from([("node", "n1"), ("vmid", "100")])]
+ );
+ assert_eq!(
+ conn.matches("datacenter.cfg", None).unwrap()["dc"],
+ vec![BTreeMap::new()]
+ );
+ assert!(conn.matches("datacenter.cfg.tmp.1", None).is_none());
+ assert!(conn.matches("other", None).is_none());
+ assert!(Conn::new(true).matches("datacenter.cfg", None).is_none());
+ }
+
+ #[test]
+ fn rename_matches_on_either_side() {
+ let conn = subscribed(true, &[("guest", "nodes/{node}/qemu-server/{vmid}.conf")]);
+ let n1 = "nodes/n1/qemu-server/100.conf";
+ let n2 = "nodes/n2/qemu-server/100.conf";
+
+ let params = conn.matches(n1, Some(n2)).unwrap();
+ assert_eq!(params["guest"].len(), 2);
+ assert_eq!(params["guest"][0]["node"], "n1");
+ assert_eq!(params["guest"][1]["node"], "n2");
+
+ let params = conn
+ .matches("nodes/n2/qemu-server/100.conf.tmp.1", Some(n2))
+ .unwrap();
+ assert_eq!(params["guest"].len(), 1);
+ assert_eq!(params["guest"][0]["node"], "n2");
+
+ assert_eq!(conn.matches(n1, Some(n1)).unwrap()["guest"].len(), 1);
+ }
+
+ #[test]
+ fn private_paths_need_privileges() {
+ let conn = subscribed(false, &[("all", "{path...}")]);
+ assert!(conn.matches("datacenter.cfg", None).is_some());
+ assert!(conn.matches("priv/lock/x", None).is_none());
+ assert!(conn.matches("nodes/n1/priv/ssl.key", None).is_none());
+ assert!(conn.matches("datacenter.cfg", Some("priv/moved")).is_none());
+ assert!(conn.matches("priv/old", Some("datacenter.cfg")).is_none());
+
+ let conn = subscribed(true, &[("all", "{path...}")]);
+ assert!(conn.matches("priv/lock/x", None).is_some());
+ assert!(conn.matches("priv/old", Some("datacenter.cfg")).is_some());
+ }
+
+ #[test]
+ fn private_rules() {
+ assert!(is_private("priv"));
+ assert!(is_private("priv/lock/x"));
+ assert!(is_private("/priv/tfa.cfg"));
+ assert!(is_private("nodes/n1/priv"));
+ assert!(is_private("nodes/n1/priv/ssl.key"));
+ assert!(!is_private("private"));
+ assert!(!is_private("nodes/n1/qemu-server/100.conf"));
+ assert!(!is_private("nodes/priv"));
+ assert!(!is_private("datacenter.cfg"));
+ }
+}
diff --git a/src/rust/pmxcfs-notify/src/lib.rs b/src/rust/pmxcfs-notify/src/lib.rs
index d35d3c5..18e7d2f 100644
--- a/src/rust/pmxcfs-notify/src/lib.rs
+++ b/src/rust/pmxcfs-notify/src/lib.rs
@@ -10,10 +10,12 @@
//! A template is a memdb path whose components may each hold one `{name}` placeholder for all or
//! part of that component. The last component may instead be `{name...}` for the rest of the path.
//! `nodes/{node}/qemu-server/{vmid}.conf` captures node and vmid, and `{path...}` matches every
-//! path.
+//! path. Connections admitted by their group id rather than as root never receive events that
+//! mention a private path, just as IPC and FUSE hide private files from them.
#![allow(dead_code)]
+mod conn;
mod protocol;
mod template;
--
2.47.3
next prev parent reply other threads:[~2026-10-22 14:22 UTC|newest]
Thread overview: 13+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-22 14:21 [PATCH cluster 00/12] pmxcfs: add a change notification socket Hannes Laimer
2026-10-22 14:21 ` [PATCH pve-cluster 01/12] buildsys: add rust workspace under src/rust Hannes Laimer
2026-10-22 14:21 ` [PATCH pve-cluster 02/12] rust: notify: add path templates with placeholder capture Hannes Laimer
2026-10-22 14:21 ` [PATCH pve-cluster 03/12] rust: notify: add the wire protocol Hannes Laimer
2026-10-22 14:21 ` Hannes Laimer [this message]
2026-10-22 14:21 ` [PATCH pve-cluster 05/12] rust: notify: add the socket server Hannes Laimer
2026-10-22 14:21 ` [PATCH pve-cluster 06/12] rust: ffi: add C ABI staticlib for pmxcfs Hannes Laimer
2026-10-22 14:22 ` [PATCH pve-cluster 07/12] pmxcfs: memdb: add change notification hook Hannes Laimer
2026-10-22 14:22 ` [PATCH pve-cluster 08/12] buildsys: link pmxcfs against the rust notify staticlib Hannes Laimer
2026-10-22 14:22 ` [PATCH pve-cluster 09/12] pmxcfs: notify: emit change events over the notification socket Hannes Laimer
2026-10-22 14:22 ` [PATCH pve-cluster 10/12] cfs: add perl client for the change " Hannes Laimer
2026-10-22 14:22 ` [PATCH pve-cluster 11/12] cfs: add hook registry for change notification consumers Hannes Laimer
2026-10-22 14:22 ` [PATCH pve-cluster 12/12] cfs: add runner executing cluster change hooks in children Hannes Laimer
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20261022142205.930536-5-h.laimer@proxmox.com \
--to=h.laimer@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox