1fn normalize_path_for_check(path: &str) -> Option<String> {
10 if path.bytes().any(|b| b == 0 || b < 0x20) {
12 return None;
13 }
14 let lower = path.to_ascii_lowercase();
17 let decoded = lower
18 .replace("%2e", ".")
19 .replace("%2f", "/")
20 .replace("%5c", "/");
21 let decoded = decoded.replace('\\', "/");
23 for seg in decoded.split('/') {
26 if seg == ".." || seg == "." {
27 return None;
28 }
29 }
30 Some(decoded)
31}
32
33fn normalize_host_for_check(host: &str) -> Option<String> {
36 let trimmed = host.trim_end_matches('.').to_ascii_lowercase();
38 if trimmed
42 .chars()
43 .any(|c| c == '@' || c == '/' || c == ':' || c.is_whitespace())
44 {
45 return None;
46 }
47 Some(trimmed)
48}
49
50#[derive(Debug, Clone, Default)]
51pub struct Permission {
52 pub read: Option<Vec<String>>,
53 pub write: Option<Vec<String>>,
54 pub net: Option<Vec<String>>,
55 pub env: Option<bool>,
56 pub run: Option<bool>,
57 pub sys: Option<bool>,
58}
59
60impl Permission {
61 pub fn is_read_allowed(&self, path: &str) -> bool {
62 match &self.read {
63 None => true,
64 Some(allowed) => match normalize_path_for_check(path) {
65 None => false,
66 Some(normalized) => allowed
67 .iter()
68 .any(|prefix| normalized.starts_with(prefix.as_str())),
69 },
70 }
71 }
72
73 pub fn is_write_allowed(&self, path: &str) -> bool {
74 match &self.write {
75 None => true,
76 Some(allowed) => match normalize_path_for_check(path) {
77 None => false,
78 Some(normalized) => allowed
79 .iter()
80 .any(|prefix| normalized.starts_with(prefix.as_str())),
81 },
82 }
83 }
84
85 pub fn is_net_allowed(&self, host: &str) -> bool {
86 match &self.net {
87 None => true,
88 Some(allowed) => match normalize_host_for_check(host) {
89 None => false,
90 Some(normalized) => allowed.iter().any(|domain| {
91 let d = domain.to_ascii_lowercase();
92 normalized == d || normalized.ends_with(&format!(".{d}"))
93 }),
94 },
95 }
96 }
97
98 pub fn is_env_allowed(&self) -> bool {
99 self.env.unwrap_or(true)
100 }
101
102 pub fn is_run_allowed(&self) -> bool {
103 self.run.unwrap_or(true)
104 }
105
106 pub fn is_sys_allowed(&self) -> bool {
107 self.sys.unwrap_or(true)
108 }
109}
110
111#[derive(Debug, Clone, Default)]
112pub struct PermissionGuard {
113 inner: Option<Permission>,
114}
115
116impl PermissionGuard {
117 pub fn none() -> Self {
118 PermissionGuard { inner: None }
119 }
120
121 pub fn new(perm: Permission) -> Self {
122 PermissionGuard { inner: Some(perm) }
123 }
124
125 pub fn is_restricted(&self) -> bool {
126 self.inner.is_some()
127 }
128
129 fn check_inner(
133 inner: &Option<Permission>,
134 allowed: bool,
135 category: &str,
136 resource: &str,
137 ) -> Result<(), PermissionDenied> {
138 match inner {
139 None => Ok(()),
140 Some(_) if allowed => Ok(()),
141 Some(_) => Err(PermissionDenied {
142 category: category.into(),
143 resource: resource.into(),
144 }),
145 }
146 }
147
148 pub fn check_read(&self, path: &str) -> Result<(), PermissionDenied> {
149 let allowed = self
150 .inner
151 .as_ref()
152 .map_or(true, |perm| perm.is_read_allowed(path));
153 Self::check_inner(&self.inner, allowed, "read", path)
154 }
155
156 pub fn check_write(&self, path: &str) -> Result<(), PermissionDenied> {
157 let allowed = self
158 .inner
159 .as_ref()
160 .map_or(true, |perm| perm.is_write_allowed(path));
161 Self::check_inner(&self.inner, allowed, "write", path)
162 }
163
164 pub fn check_net(&self, host: &str) -> Result<(), PermissionDenied> {
165 let allowed = self
166 .inner
167 .as_ref()
168 .map_or(true, |perm| perm.is_net_allowed(host));
169 Self::check_inner(&self.inner, allowed, "net", host)
170 }
171
172 fn check_flag(
177 &self,
178 predicate: fn(&Permission) -> bool,
179 category: &str,
180 ) -> Result<(), PermissionDenied> {
181 let allowed = self.inner.as_ref().map_or(true, predicate);
182 Self::check_inner(&self.inner, allowed, category, "*")
183 }
184
185 pub fn check_env(&self) -> Result<(), PermissionDenied> {
186 self.check_flag(Permission::is_env_allowed, "env")
187 }
188
189 pub fn check_run(&self) -> Result<(), PermissionDenied> {
190 self.check_flag(Permission::is_run_allowed, "run")
191 }
192}
193
194#[derive(Debug, Clone)]
195pub struct PermissionDenied {
196 pub category: String,
197 pub resource: String,
198}
199
200impl std::fmt::Display for PermissionDenied {
201 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
202 write!(
203 f,
204 "Permission denied: {} on {}",
205 self.category, self.resource
206 )
207 }
208}
209
210impl std::error::Error for PermissionDenied {}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
216
217 #[test]
218 fn permission_default_allows_all() {
219 let perm = Permission::default();
220 assert!(perm.is_read_allowed("/any/path"));
221 assert!(perm.is_write_allowed("/any/path"));
222 assert!(perm.is_net_allowed("any.host.com"));
223 assert!(perm.is_env_allowed());
224 assert!(perm.is_run_allowed());
225 }
226
227 #[test]
228 fn read_prefix_matching() {
229 let perm = Permission {
230 read: Some(vec!["/home".into(), "/tmp/bao".into()]),
231 ..Default::default()
232 };
233 assert!(perm.is_read_allowed("/home"));
234 assert!(perm.is_read_allowed("/home/user/file.txt"));
235 assert!(perm.is_read_allowed("/tmp/bao"));
236 assert!(perm.is_read_allowed("/tmp/bao/cache/data.bin"));
237 assert!(!perm.is_read_allowed("/etc/passwd"));
238 assert!(!perm.is_read_allowed("/tmp/other"));
239 }
240
241 #[test]
242 fn write_prefix_matching() {
243 let perm = Permission {
244 write: Some(vec!["/var/log/bao".into(), "/tmp".into()]),
245 ..Default::default()
246 };
247 assert!(perm.is_write_allowed("/var/log/bao"));
248 assert!(perm.is_write_allowed("/var/log/bao/app.log"));
249 assert!(perm.is_write_allowed("/tmp"));
250 assert!(perm.is_write_allowed("/tmp/session.dat"));
251 assert!(!perm.is_write_allowed("/var/log/other"));
252 assert!(!perm.is_write_allowed("/usr/bin"));
253 }
254
255 #[test]
256 fn net_exact_and_subdomain() {
257 let perm = Permission {
258 net: Some(vec!["example.com".into()]),
259 ..Default::default()
260 };
261 assert!(perm.is_net_allowed("example.com"));
262 assert!(perm.is_net_allowed("sub.example.com"));
263 assert!(perm.is_net_allowed("deep.sub.example.com"));
264 }
265
266 #[test]
267 fn net_partial_mismatch() {
268 let perm = Permission {
269 net: Some(vec!["example.com".into()]),
270 ..Default::default()
271 };
272 assert!(!perm.is_net_allowed("notexample.com"));
273 assert!(!perm.is_net_allowed("xnotexample.com"));
274 assert!(!perm.is_net_allowed("other.com"));
275 }
276
277 #[test]
278 fn env_false_blocks() {
279 let perm = Permission {
280 env: Some(false),
281 ..Default::default()
282 };
283 assert!(!perm.is_env_allowed());
284 }
285
286 #[test]
287 fn run_false_blocks() {
288 let perm = Permission {
289 run: Some(false),
290 ..Default::default()
291 };
292 assert!(!perm.is_run_allowed());
293 }
294
295 #[test]
296 fn guard_none_allows_all() {
297 let guard = PermissionGuard::none();
298 assert!(!guard.is_restricted());
299 assert!(guard.check_read("/secret").is_ok());
300 assert!(guard.check_write("/secret").is_ok());
301 assert!(guard.check_net("evil.com").is_ok());
302 assert!(guard.check_env().is_ok());
303 assert!(guard.check_run().is_ok());
304 }
305
306 #[test]
307 fn guard_new_is_restricted() {
308 let guard = PermissionGuard::new(Permission::default());
309 assert!(guard.is_restricted());
310 }
311
312 #[test]
313 fn guard_check_read_denied_has_correct_category_resource() {
314 let guard = PermissionGuard::new(Permission {
315 read: Some(vec!["/allowed".into()]),
316 ..Default::default()
317 });
318 let err = guard.check_read("/forbidden").unwrap_err();
319 assert_eq!(err.category, "read");
320 assert_eq!(err.resource, "/forbidden");
321 }
322
323 #[test]
324 fn guard_check_net_denied_message() {
325 let guard = PermissionGuard::new(Permission {
326 net: Some(vec!["safe.com".into()]),
327 ..Default::default()
328 });
329 let err = guard.check_net("unsafe.com").unwrap_err();
330 assert_eq!(format!("{err}"), "Permission denied: net on unsafe.com");
331 }
332
333 #[test]
334 fn permission_denied_display_format() {
335 let err = PermissionDenied {
336 category: "env".into(),
337 resource: "*".into(),
338 };
339 assert_eq!(format!("{err}"), "Permission denied: env on *");
340 }
341
342 #[test]
343 fn permission_clone_works() {
344 let perm = Permission {
345 read: Some(vec!["/data".into()]),
346 write: None,
347 net: Some(vec!["api.com".into()]),
348 env: Some(false),
349 run: Some(true),
350 ..Default::default()
351 };
352 let cloned = perm.clone();
353 assert!(cloned.is_read_allowed("/data/file"));
354 assert!(cloned.is_write_allowed("/anything"));
355 assert!(cloned.is_net_allowed("api.com"));
356 assert!(!cloned.is_env_allowed());
357 assert!(cloned.is_run_allowed());
358 }
359
360 #[test]
361 fn permission_guard_clone_works() {
362 let guard = PermissionGuard::new(Permission {
363 read: Some(vec!["/app".into()]),
364 ..Default::default()
365 });
366 let cloned = guard.clone();
367 assert!(cloned.is_restricted());
368 assert!(cloned.check_read("/app/config").is_ok());
369 assert!(cloned.check_read("/other").is_err());
370 }
371
372 #[test]
373 fn empty_allowed_list_denies_all() {
374 let perm = Permission {
375 read: Some(vec![]),
376 write: Some(vec![]),
377 net: Some(vec![]),
378 env: None,
379 run: None,
380 ..Default::default()
381 };
382 assert!(!perm.is_read_allowed("/any"));
383 assert!(!perm.is_write_allowed("/any"));
384 assert!(!perm.is_net_allowed("any.com"));
385 }
386
387 #[test]
391 fn net_exact_match_no_subdomain_false() {
392 let perm = Permission {
394 net: Some(vec!["example.com".into()]),
395 ..Default::default()
396 };
397 assert!(!perm.is_net_allowed("example"));
398 assert!(!perm.is_net_allowed("com"));
399 }
400
401 #[test]
402 fn net_multiple_domains_one_match() {
403 let perm = Permission {
404 net: Some(vec!["safe.com".into(), "trusted.io".into()]),
405 ..Default::default()
406 };
407 assert!(perm.is_net_allowed("safe.com"));
408 assert!(perm.is_net_allowed("sub.safe.com"));
409 assert!(perm.is_net_allowed("trusted.io"));
410 assert!(!perm.is_net_allowed("unsafe.com"));
411 }
412
413 #[test]
414 fn read_path_traversal_not_allowed() {
415 let perm = Permission {
419 read: Some(vec!["/allowed".into()]),
420 ..Default::default()
421 };
422 assert!(
423 !perm.is_read_allowed("/allowed/../secret"),
424 "path traversal must be rejected"
425 );
426 }
427
428 #[test]
429 fn read_path_traversal_url_encoded_rejected() {
430 let perm = Permission {
432 read: Some(vec!["/allowed".into()]),
433 ..Default::default()
434 };
435 assert!(
436 !perm.is_read_allowed("/allowed/%2e%2e/secret"),
437 "URL-encoded traversal must be rejected"
438 );
439 assert!(
440 !perm.is_read_allowed("/allowed/%2E%2Fsecret"),
441 "uppercase URL-encoded traversal must be rejected"
442 );
443 assert!(
444 !perm.is_read_allowed("/allowed/%2f..%2fsecret"),
445 "mixed URL-encoded traversal must be rejected"
446 );
447 }
448
449 #[test]
450 fn read_path_traversal_backslash_rejected() {
451 let perm = Permission {
454 read: Some(vec!["/allowed".into()]),
455 ..Default::default()
456 };
457 assert!(
458 !perm.is_read_allowed("/allowed/..\\secret"),
459 "backslash traversal must be rejected"
460 );
461 }
462
463 #[test]
464 fn read_path_control_byte_rejected() {
465 let perm = Permission {
467 read: Some(vec!["/allowed".into()]),
468 ..Default::default()
469 };
470 assert!(
471 !perm.is_read_allowed("/allowed/\0secret"),
472 "NUL injection must be rejected"
473 );
474 assert!(
475 !perm.is_read_allowed("/allowed/\tsecret"),
476 "control-byte injection must be rejected"
477 );
478 }
479
480 #[test]
481 fn write_path_traversal_rejected() {
482 let perm = Permission {
484 write: Some(vec!["/tmp/bao".into()]),
485 ..Default::default()
486 };
487 assert!(
488 !perm.is_write_allowed("/tmp/bao/../../etc/passwd"),
489 "write path traversal must be rejected"
490 );
491 assert!(
492 !perm.is_write_allowed("/tmp/bao/%2e%2e/%2e%2e/etc/shadow"),
493 "write URL-encoded traversal must be rejected"
494 );
495 }
496
497 #[test]
498 fn net_subdomain_confusion_rejected() {
499 let perm = Permission {
503 net: Some(vec!["safe.com".into()]),
504 ..Default::default()
505 };
506 assert!(
507 !perm.is_net_allowed("safe.com.evil.com"),
508 "subdomain suffix confusion must be rejected"
509 );
510 assert!(
511 !perm.is_net_allowed("notsafe.com"),
512 "prefix-only match must be rejected"
513 );
514 assert!(perm.is_net_allowed("safe.com"), "exact match must pass");
515 assert!(
516 perm.is_net_allowed("sub.safe.com"),
517 "subdomain match must pass"
518 );
519 }
520
521 #[test]
522 fn net_port_userinfo_rejected() {
523 let perm = Permission {
525 net: Some(vec!["safe.com".into()]),
526 ..Default::default()
527 };
528 assert!(
529 !perm.is_net_allowed("safe.com:8080"),
530 "port injection must be rejected"
531 );
532 assert!(
533 !perm.is_net_allowed("user@safe.com"),
534 "userinfo injection must be rejected"
535 );
536 assert!(
537 !perm.is_net_allowed("safe.com/path"),
538 "path injection must be rejected"
539 );
540 }
541
542 #[test]
543 fn net_trailing_dot_normalized() {
544 let perm = Permission {
546 net: Some(vec!["safe.com".into()]),
547 ..Default::default()
548 };
549 assert!(
550 perm.is_net_allowed("safe.com."),
551 "trailing dot must be normalized to bare host"
552 );
553 assert!(
554 perm.is_net_allowed("sub.safe.com."),
555 "trailing dot subdomain must be normalized"
556 );
557 }
558
559 #[test]
560 fn net_case_insensitive() {
561 let perm = Permission {
563 net: Some(vec!["safe.com".into()]),
564 ..Default::default()
565 };
566 assert!(perm.is_net_allowed("SAFE.com"), "uppercase host must match");
567 assert!(
568 perm.is_net_allowed("sub.SAFE.com"),
569 "uppercase subdomain must match"
570 );
571 }
572
573 #[test]
574 fn env_true_allows() {
575 let perm = Permission {
576 env: Some(true),
577 ..Default::default()
578 };
579 assert!(perm.is_env_allowed());
580 }
581
582 #[test]
583 fn run_true_allows() {
584 let perm = Permission {
585 run: Some(true),
586 ..Default::default()
587 };
588 assert!(perm.is_run_allowed());
589 }
590
591 #[test]
592 fn guard_check_write_denied_category() {
593 let guard = PermissionGuard::new(Permission {
594 write: Some(vec!["/tmp".into()]),
595 ..Default::default()
596 });
597 let err = guard.check_write("/etc/passwd").unwrap_err();
598 assert_eq!(err.category, "write");
599 assert_eq!(err.resource, "/etc/passwd");
600 }
601
602 #[test]
603 fn guard_check_env_denied_category() {
604 let guard = PermissionGuard::new(Permission {
605 env: Some(false),
606 ..Default::default()
607 });
608 let err = guard.check_env().unwrap_err();
609 assert_eq!(err.category, "env");
610 assert_eq!(err.resource, "*");
611 }
612
613 #[test]
614 fn guard_check_run_denied_category() {
615 let guard = PermissionGuard::new(Permission {
616 run: Some(false),
617 ..Default::default()
618 });
619 let err = guard.check_run().unwrap_err();
620 assert_eq!(err.category, "run");
621 assert_eq!(err.resource, "*");
622 }
623
624 #[test]
625 fn sys_default_allows() {
626 let perm = Permission::default();
627 assert!(perm.is_sys_allowed());
628 }
629
630 #[test]
631 fn sys_explicit_true_allows() {
632 let perm = Permission {
633 sys: Some(true),
634 ..Default::default()
635 };
636 assert!(perm.is_sys_allowed());
637 }
638
639 #[test]
640 fn sys_explicit_false_denies() {
641 let perm = Permission {
642 sys: Some(false),
643 ..Default::default()
644 };
645 assert!(!perm.is_sys_allowed());
646 }
647}