Skip to main content

dbus_router/
config.rs

1//! Configuration file parsing for routing rules
2
3use anyhow::Result;
4use glob::Pattern;
5use serde::Deserialize;
6use std::path::{Path, PathBuf};
7
8/// Router configuration loaded from TOML file.
9#[derive(Debug, Clone, Default, Deserialize)]
10pub struct Config {
11    /// Routes that should go to the host bus instead of sandbox.
12    #[serde(default)]
13    pub host_routes: Vec<RouteRule>,
14    /// Processes allowed to register services on the host bus.
15    #[serde(default)]
16    pub hostpass: Vec<HostPass>,
17}
18
19/// A process allowed to register services on the host bus.
20#[derive(Debug, Clone, Deserialize)]
21pub struct HostPass {
22    /// Path to the executable.
23    pub process: PathBuf,
24}
25
26/// A routing rule that matches destinations.
27#[derive(Debug, Clone, Deserialize)]
28pub struct RouteRule {
29    /// Destination to match. Supports exact match or "org.foo.*" wildcard.
30    pub destination: String,
31}
32
33impl Config {
34    /// Load configuration from a TOML file.
35    pub fn load(path: &Path) -> Result<Self> {
36        let content = std::fs::read_to_string(path)?;
37        let config: Config = toml::from_str(&content)?;
38        tracing::info!(routes = config.host_routes.len(), "Loaded configuration");
39        for rule in &config.host_routes {
40            tracing::debug!(destination = %rule.destination, "Host route");
41        }
42        Ok(config)
43    }
44
45    /// Check if a destination should be routed to the host bus.
46    pub fn should_route_to_host(&self, destination: &str) -> bool {
47        self.host_routes
48            .iter()
49            .any(|rule| rule.matches(destination))
50    }
51
52    /// Check if a process is allowed to register services on the host bus.
53    ///
54    /// Supports glob pattern matching (e.g., "*/python3*", "/usr/bin/python*").
55    pub fn has_hostpass(&self, exe_path: &Path) -> bool {
56        let exe_str = exe_path.to_string_lossy();
57        self.hostpass.iter().any(|h| {
58            let pattern_str = h.process.to_string_lossy();
59            match Pattern::new(&pattern_str) {
60                Ok(pattern) => pattern.matches(&exe_str),
61                Err(_) => false,
62            }
63        })
64    }
65}
66
67impl RouteRule {
68    /// Check if the destination matches this rule.
69    /// Supports exact match and "org.foo.*" wildcard pattern.
70    pub fn matches(&self, destination: &str) -> bool {
71        if let Some(prefix) = self.destination.strip_suffix(".*") {
72            // Wildcard: destination must start with prefix and either equal it
73            // or have a dot after the prefix
74            if destination == prefix {
75                return true;
76            }
77            if let Some(rest) = destination.strip_prefix(prefix) {
78                return rest.starts_with('.');
79            }
80            false
81        } else {
82            // Exact match
83            self.destination == destination
84        }
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn test_exact_match() {
94        let rule = RouteRule {
95            destination: "org.freedesktop.DBus".to_string(),
96        };
97        assert!(rule.matches("org.freedesktop.DBus"));
98        assert!(!rule.matches("org.freedesktop.DBus.Peer"));
99        assert!(!rule.matches("org.freedesktop"));
100    }
101
102    #[test]
103    fn test_wildcard_match() {
104        let rule = RouteRule {
105            destination: "org.freedesktop.portal.*".to_string(),
106        };
107        assert!(rule.matches("org.freedesktop.portal.Desktop"));
108        assert!(rule.matches("org.freedesktop.portal.FileChooser"));
109        assert!(rule.matches("org.freedesktop.portal")); // prefix itself matches
110        assert!(!rule.matches("org.freedesktop.portals")); // no dot separator
111        assert!(!rule.matches("org.freedesktop.DBus"));
112    }
113
114    #[test]
115    fn test_should_route_to_host() {
116        let config = Config {
117            host_routes: vec![
118                RouteRule {
119                    destination: "org.freedesktop.DBus".to_string(),
120                },
121                RouteRule {
122                    destination: "org.freedesktop.portal.*".to_string(),
123                },
124            ],
125            ..Default::default()
126        };
127
128        assert!(config.should_route_to_host("org.freedesktop.DBus"));
129        assert!(config.should_route_to_host("org.freedesktop.portal.Desktop"));
130        assert!(!config.should_route_to_host("org.example.Test"));
131    }
132
133    #[test]
134    fn test_empty_config() {
135        let config = Config::default();
136        assert!(!config.should_route_to_host("org.freedesktop.DBus"));
137    }
138
139    #[test]
140    fn test_parse_toml() {
141        let toml_str = r#"
142[[host_routes]]
143destination = "org.freedesktop.DBus"
144
145[[host_routes]]
146destination = "org.freedesktop.portal.*"
147"#;
148        let config: Config = toml::from_str(toml_str).unwrap();
149        assert_eq!(config.host_routes.len(), 2);
150        assert_eq!(config.host_routes[0].destination, "org.freedesktop.DBus");
151        assert_eq!(
152            config.host_routes[1].destination,
153            "org.freedesktop.portal.*"
154        );
155    }
156
157    #[test]
158    fn test_has_hostpass_glob_pattern() {
159        let config = Config {
160            host_routes: vec![],
161            hostpass: vec![
162                HostPass {
163                    process: PathBuf::from("*/python3*"),
164                },
165                HostPass {
166                    process: PathBuf::from("/usr/bin/node"),
167                },
168            ],
169        };
170
171        // Glob matching
172        assert!(config.has_hostpass(Path::new("/usr/bin/python3")));
173        assert!(config.has_hostpass(Path::new("/home/user/.venv/bin/python3.12")));
174        assert!(config.has_hostpass(Path::new("/opt/python/bin/python3.11")));
175
176        // Exact matching
177        assert!(config.has_hostpass(Path::new("/usr/bin/node")));
178
179        // No match
180        assert!(!config.has_hostpass(Path::new("/usr/bin/ruby")));
181        assert!(!config.has_hostpass(Path::new("/usr/bin/python2")));
182    }
183
184    #[test]
185    fn test_parse_toml_with_hostpass() {
186        let toml_str = r#"
187[[host_routes]]
188destination = "org.freedesktop.DBus"
189
190[[hostpass]]
191process = "/usr/bin/my-sandbox-app"
192
193[[hostpass]]
194process = "/opt/app/bin/service"
195"#;
196        let config: Config = toml::from_str(toml_str).unwrap();
197        assert_eq!(config.host_routes.len(), 1);
198        assert_eq!(config.hostpass.len(), 2);
199        assert_eq!(
200            config.hostpass[0].process,
201            PathBuf::from("/usr/bin/my-sandbox-app")
202        );
203        assert_eq!(
204            config.hostpass[1].process,
205            PathBuf::from("/opt/app/bin/service")
206        );
207    }
208}