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 pub fn check_read(&self, path: &str) -> Result<(), PermissionDenied> {
130 match &self.inner {
131 None => Ok(()),
132 Some(perm) => {
133 if perm.is_read_allowed(path) {
134 Ok(())
135 } else {
136 Err(PermissionDenied {
137 category: "read".into(),
138 resource: path.into(),
139 })
140 }
141 }
142 }
143 }
144
145 pub fn check_write(&self, path: &str) -> Result<(), PermissionDenied> {
146 match &self.inner {
147 None => Ok(()),
148 Some(perm) => {
149 if perm.is_write_allowed(path) {
150 Ok(())
151 } else {
152 Err(PermissionDenied {
153 category: "write".into(),
154 resource: path.into(),
155 })
156 }
157 }
158 }
159 }
160
161 pub fn check_net(&self, host: &str) -> Result<(), PermissionDenied> {
162 match &self.inner {
163 None => Ok(()),
164 Some(perm) => {
165 if perm.is_net_allowed(host) {
166 Ok(())
167 } else {
168 Err(PermissionDenied {
169 category: "net".into(),
170 resource: host.into(),
171 })
172 }
173 }
174 }
175 }
176
177 pub fn check_env(&self) -> Result<(), PermissionDenied> {
178 match &self.inner {
179 None => Ok(()),
180 Some(perm) => {
181 if perm.is_env_allowed() {
182 Ok(())
183 } else {
184 Err(PermissionDenied {
185 category: "env".into(),
186 resource: "*".into(),
187 })
188 }
189 }
190 }
191 }
192
193 pub fn check_run(&self) -> Result<(), PermissionDenied> {
194 match &self.inner {
195 None => Ok(()),
196 Some(perm) => {
197 if perm.is_run_allowed() {
198 Ok(())
199 } else {
200 Err(PermissionDenied {
201 category: "run".into(),
202 resource: "*".into(),
203 })
204 }
205 }
206 }
207 }
208}
209
210#[derive(Debug, Clone)]
211pub struct PermissionDenied {
212 pub category: String,
213 pub resource: String,
214}
215
216impl std::fmt::Display for PermissionDenied {
217 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
218 write!(
219 f,
220 "Permission denied: {} on {}",
221 self.category, self.resource
222 )
223 }
224}
225
226impl std::error::Error for PermissionDenied {}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
232
233 #[test]
234 fn permission_default_allows_all() {
235 let perm = Permission::default();
236 assert!(perm.is_read_allowed("/any/path"));
237 assert!(perm.is_write_allowed("/any/path"));
238 assert!(perm.is_net_allowed("any.host.com"));
239 assert!(perm.is_env_allowed());
240 assert!(perm.is_run_allowed());
241 }
242
243 #[test]
244 fn read_prefix_matching() {
245 let perm = Permission {
246 read: Some(vec!["/home".into(), "/tmp/bao".into()]),
247 ..Default::default()
248 };
249 assert!(perm.is_read_allowed("/home"));
250 assert!(perm.is_read_allowed("/home/user/file.txt"));
251 assert!(perm.is_read_allowed("/tmp/bao"));
252 assert!(perm.is_read_allowed("/tmp/bao/cache/data.bin"));
253 assert!(!perm.is_read_allowed("/etc/passwd"));
254 assert!(!perm.is_read_allowed("/tmp/other"));
255 }
256
257 #[test]
258 fn write_prefix_matching() {
259 let perm = Permission {
260 write: Some(vec!["/var/log/bao".into(), "/tmp".into()]),
261 ..Default::default()
262 };
263 assert!(perm.is_write_allowed("/var/log/bao"));
264 assert!(perm.is_write_allowed("/var/log/bao/app.log"));
265 assert!(perm.is_write_allowed("/tmp"));
266 assert!(perm.is_write_allowed("/tmp/session.dat"));
267 assert!(!perm.is_write_allowed("/var/log/other"));
268 assert!(!perm.is_write_allowed("/usr/bin"));
269 }
270
271 #[test]
272 fn net_exact_and_subdomain() {
273 let perm = Permission {
274 net: Some(vec!["example.com".into()]),
275 ..Default::default()
276 };
277 assert!(perm.is_net_allowed("example.com"));
278 assert!(perm.is_net_allowed("sub.example.com"));
279 assert!(perm.is_net_allowed("deep.sub.example.com"));
280 }
281
282 #[test]
283 fn net_partial_mismatch() {
284 let perm = Permission {
285 net: Some(vec!["example.com".into()]),
286 ..Default::default()
287 };
288 assert!(!perm.is_net_allowed("notexample.com"));
289 assert!(!perm.is_net_allowed("xnotexample.com"));
290 assert!(!perm.is_net_allowed("other.com"));
291 }
292
293 #[test]
294 fn env_false_blocks() {
295 let perm = Permission {
296 env: Some(false),
297 ..Default::default()
298 };
299 assert!(!perm.is_env_allowed());
300 }
301
302 #[test]
303 fn run_false_blocks() {
304 let perm = Permission {
305 run: Some(false),
306 ..Default::default()
307 };
308 assert!(!perm.is_run_allowed());
309 }
310
311 #[test]
312 fn guard_none_allows_all() {
313 let guard = PermissionGuard::none();
314 assert!(!guard.is_restricted());
315 assert!(guard.check_read("/secret").is_ok());
316 assert!(guard.check_write("/secret").is_ok());
317 assert!(guard.check_net("evil.com").is_ok());
318 assert!(guard.check_env().is_ok());
319 assert!(guard.check_run().is_ok());
320 }
321
322 #[test]
323 fn guard_new_is_restricted() {
324 let guard = PermissionGuard::new(Permission::default());
325 assert!(guard.is_restricted());
326 }
327
328 #[test]
329 fn guard_check_read_denied_has_correct_category_resource() {
330 let guard = PermissionGuard::new(Permission {
331 read: Some(vec!["/allowed".into()]),
332 ..Default::default()
333 });
334 let err = guard.check_read("/forbidden").unwrap_err();
335 assert_eq!(err.category, "read");
336 assert_eq!(err.resource, "/forbidden");
337 }
338
339 #[test]
340 fn guard_check_net_denied_message() {
341 let guard = PermissionGuard::new(Permission {
342 net: Some(vec!["safe.com".into()]),
343 ..Default::default()
344 });
345 let err = guard.check_net("unsafe.com").unwrap_err();
346 assert_eq!(format!("{err}"), "Permission denied: net on unsafe.com");
347 }
348
349 #[test]
350 fn permission_denied_display_format() {
351 let err = PermissionDenied {
352 category: "env".into(),
353 resource: "*".into(),
354 };
355 assert_eq!(format!("{err}"), "Permission denied: env on *");
356 }
357
358 #[test]
359 fn permission_clone_works() {
360 let perm = Permission {
361 read: Some(vec!["/data".into()]),
362 write: None,
363 net: Some(vec!["api.com".into()]),
364 env: Some(false),
365 run: Some(true),
366 ..Default::default()
367 };
368 let cloned = perm.clone();
369 assert!(cloned.is_read_allowed("/data/file"));
370 assert!(cloned.is_write_allowed("/anything"));
371 assert!(cloned.is_net_allowed("api.com"));
372 assert!(!cloned.is_env_allowed());
373 assert!(cloned.is_run_allowed());
374 }
375
376 #[test]
377 fn permission_guard_clone_works() {
378 let guard = PermissionGuard::new(Permission {
379 read: Some(vec!["/app".into()]),
380 ..Default::default()
381 });
382 let cloned = guard.clone();
383 assert!(cloned.is_restricted());
384 assert!(cloned.check_read("/app/config").is_ok());
385 assert!(cloned.check_read("/other").is_err());
386 }
387
388 #[test]
389 fn empty_allowed_list_denies_all() {
390 let perm = Permission {
391 read: Some(vec![]),
392 write: Some(vec![]),
393 net: Some(vec![]),
394 env: None,
395 run: None,
396 ..Default::default()
397 };
398 assert!(!perm.is_read_allowed("/any"));
399 assert!(!perm.is_write_allowed("/any"));
400 assert!(!perm.is_net_allowed("any.com"));
401 }
402
403 #[test]
407 fn net_exact_match_no_subdomain_false() {
408 let perm = Permission {
410 net: Some(vec!["example.com".into()]),
411 ..Default::default()
412 };
413 assert!(!perm.is_net_allowed("example"));
414 assert!(!perm.is_net_allowed("com"));
415 }
416
417 #[test]
418 fn net_multiple_domains_one_match() {
419 let perm = Permission {
420 net: Some(vec!["safe.com".into(), "trusted.io".into()]),
421 ..Default::default()
422 };
423 assert!(perm.is_net_allowed("safe.com"));
424 assert!(perm.is_net_allowed("sub.safe.com"));
425 assert!(perm.is_net_allowed("trusted.io"));
426 assert!(!perm.is_net_allowed("unsafe.com"));
427 }
428
429 #[test]
430 fn read_path_traversal_not_allowed() {
431 let perm = Permission {
435 read: Some(vec!["/allowed".into()]),
436 ..Default::default()
437 };
438 assert!(
439 !perm.is_read_allowed("/allowed/../secret"),
440 "path traversal must be rejected"
441 );
442 }
443
444 #[test]
445 fn read_path_traversal_url_encoded_rejected() {
446 let perm = Permission {
448 read: Some(vec!["/allowed".into()]),
449 ..Default::default()
450 };
451 assert!(
452 !perm.is_read_allowed("/allowed/%2e%2e/secret"),
453 "URL-encoded traversal must be rejected"
454 );
455 assert!(
456 !perm.is_read_allowed("/allowed/%2E%2Fsecret"),
457 "uppercase URL-encoded traversal must be rejected"
458 );
459 assert!(
460 !perm.is_read_allowed("/allowed/%2f..%2fsecret"),
461 "mixed URL-encoded traversal must be rejected"
462 );
463 }
464
465 #[test]
466 fn read_path_traversal_backslash_rejected() {
467 let perm = Permission {
470 read: Some(vec!["/allowed".into()]),
471 ..Default::default()
472 };
473 assert!(
474 !perm.is_read_allowed("/allowed/..\\secret"),
475 "backslash traversal must be rejected"
476 );
477 }
478
479 #[test]
480 fn read_path_control_byte_rejected() {
481 let perm = Permission {
483 read: Some(vec!["/allowed".into()]),
484 ..Default::default()
485 };
486 assert!(
487 !perm.is_read_allowed("/allowed/\0secret"),
488 "NUL injection must be rejected"
489 );
490 assert!(
491 !perm.is_read_allowed("/allowed/\tsecret"),
492 "control-byte injection must be rejected"
493 );
494 }
495
496 #[test]
497 fn write_path_traversal_rejected() {
498 let perm = Permission {
500 write: Some(vec!["/tmp/bao".into()]),
501 ..Default::default()
502 };
503 assert!(
504 !perm.is_write_allowed("/tmp/bao/../../etc/passwd"),
505 "write path traversal must be rejected"
506 );
507 assert!(
508 !perm.is_write_allowed("/tmp/bao/%2e%2e/%2e%2e/etc/shadow"),
509 "write URL-encoded traversal must be rejected"
510 );
511 }
512
513 #[test]
514 fn net_subdomain_confusion_rejected() {
515 let perm = Permission {
519 net: Some(vec!["safe.com".into()]),
520 ..Default::default()
521 };
522 assert!(
523 !perm.is_net_allowed("safe.com.evil.com"),
524 "subdomain suffix confusion must be rejected"
525 );
526 assert!(
527 !perm.is_net_allowed("notsafe.com"),
528 "prefix-only match must be rejected"
529 );
530 assert!(perm.is_net_allowed("safe.com"), "exact match must pass");
531 assert!(
532 perm.is_net_allowed("sub.safe.com"),
533 "subdomain match must pass"
534 );
535 }
536
537 #[test]
538 fn net_port_userinfo_rejected() {
539 let perm = Permission {
541 net: Some(vec!["safe.com".into()]),
542 ..Default::default()
543 };
544 assert!(
545 !perm.is_net_allowed("safe.com:8080"),
546 "port injection must be rejected"
547 );
548 assert!(
549 !perm.is_net_allowed("user@safe.com"),
550 "userinfo injection must be rejected"
551 );
552 assert!(
553 !perm.is_net_allowed("safe.com/path"),
554 "path injection must be rejected"
555 );
556 }
557
558 #[test]
559 fn net_trailing_dot_normalized() {
560 let perm = Permission {
562 net: Some(vec!["safe.com".into()]),
563 ..Default::default()
564 };
565 assert!(
566 perm.is_net_allowed("safe.com."),
567 "trailing dot must be normalized to bare host"
568 );
569 assert!(
570 perm.is_net_allowed("sub.safe.com."),
571 "trailing dot subdomain must be normalized"
572 );
573 }
574
575 #[test]
576 fn net_case_insensitive() {
577 let perm = Permission {
579 net: Some(vec!["safe.com".into()]),
580 ..Default::default()
581 };
582 assert!(perm.is_net_allowed("SAFE.com"), "uppercase host must match");
583 assert!(
584 perm.is_net_allowed("sub.SAFE.com"),
585 "uppercase subdomain must match"
586 );
587 }
588
589 #[test]
590 fn env_true_allows() {
591 let perm = Permission {
592 env: Some(true),
593 ..Default::default()
594 };
595 assert!(perm.is_env_allowed());
596 }
597
598 #[test]
599 fn run_true_allows() {
600 let perm = Permission {
601 run: Some(true),
602 ..Default::default()
603 };
604 assert!(perm.is_run_allowed());
605 }
606
607 #[test]
608 fn guard_check_write_denied_category() {
609 let guard = PermissionGuard::new(Permission {
610 write: Some(vec!["/tmp".into()]),
611 ..Default::default()
612 });
613 let err = guard.check_write("/etc/passwd").unwrap_err();
614 assert_eq!(err.category, "write");
615 assert_eq!(err.resource, "/etc/passwd");
616 }
617
618 #[test]
619 fn guard_check_env_denied_category() {
620 let guard = PermissionGuard::new(Permission {
621 env: Some(false),
622 ..Default::default()
623 });
624 let err = guard.check_env().unwrap_err();
625 assert_eq!(err.category, "env");
626 assert_eq!(err.resource, "*");
627 }
628
629 #[test]
630 fn guard_check_run_denied_category() {
631 let guard = PermissionGuard::new(Permission {
632 run: Some(false),
633 ..Default::default()
634 });
635 let err = guard.check_run().unwrap_err();
636 assert_eq!(err.category, "run");
637 assert_eq!(err.resource, "*");
638 }
639
640 #[test]
641 fn sys_default_allows() {
642 let perm = Permission::default();
643 assert!(perm.is_sys_allowed());
644 }
645
646 #[test]
647 fn sys_explicit_true_allows() {
648 let perm = Permission {
649 sys: Some(true),
650 ..Default::default()
651 };
652 assert!(perm.is_sys_allowed());
653 }
654
655 #[test]
656 fn sys_explicit_false_denies() {
657 let perm = Permission {
658 sys: Some(false),
659 ..Default::default()
660 };
661 assert!(!perm.is_sys_allowed());
662 }
663}