1use anyhow::Result;
4use glob::Pattern;
5use serde::Deserialize;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, Default, Deserialize)]
10pub struct Config {
11 #[serde(default)]
13 pub host_routes: Vec<RouteRule>,
14 #[serde(default)]
16 pub hostpass: Vec<HostPass>,
17}
18
19#[derive(Debug, Clone, Deserialize)]
21pub struct HostPass {
22 pub process: PathBuf,
24}
25
26#[derive(Debug, Clone, Deserialize)]
28pub struct RouteRule {
29 pub destination: String,
31}
32
33impl Config {
34 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 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 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 pub fn matches(&self, destination: &str) -> bool {
71 if let Some(prefix) = self.destination.strip_suffix(".*") {
72 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 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")); assert!(!rule.matches("org.freedesktop.portals")); 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 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 assert!(config.has_hostpass(Path::new("/usr/bin/node")));
178
179 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}