1pub mod backend;
11mod enforce;
12mod runner;
13pub use enforce::Enforce;
14#[cfg(target_os = "macos")]
15mod enforce_docker;
16#[cfg(target_os = "linux")]
17mod enforce_linux;
18mod mitm;
19
20use std::collections::HashMap;
21use std::sync::Arc;
22
23use bytes::Bytes;
24use http_body_util::{combinators::BoxBody, BodyExt, Full};
25use hyper::service::service_fn;
26use hyper::{Method, Request, Response};
27use hyper_util::rt::TokioIo;
28use regex::Regex;
29use tokio::net::{TcpListener, TcpStream};
30use tokio::sync::mpsc;
31
32#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
37pub struct SecretMapping {
38 pub var: String,
39 pub value: String,
40}
41
42impl SecretMapping {
43 pub fn new(var: impl Into<String>, value: impl Into<String>) -> Self {
45 Self {
46 var: var.into(),
47 value: value.into(),
48 }
49 }
50}
51
52#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
56pub struct StringMapping {
57 pub token: String,
58 pub value: String,
59}
60
61impl StringMapping {
62 pub fn new(token: impl Into<String>, value: impl Into<String>) -> Self {
64 Self {
65 token: token.into(),
66 value: value.into(),
67 }
68 }
69}
70
71#[derive(Clone, Debug)]
73pub enum HostPattern {
74 Exact(String),
76 Regex { pattern: String, re: Regex },
78}
79
80impl HostPattern {
81 pub fn exact(host: impl Into<String>) -> Self {
83 Self::Exact(host.into())
84 }
85
86 pub fn regex(pattern: &str) -> Result<Self, regex::Error> {
88 let re = Regex::new(pattern)?;
89 Ok(Self::Regex {
90 pattern: pattern.to_string(),
91 re,
92 })
93 }
94
95 pub fn matches(&self, host: &str) -> bool {
97 match self {
98 HostPattern::Exact(s) => s == host,
99 HostPattern::Regex { re, .. } => re.is_match(host),
100 }
101 }
102}
103
104#[derive(Clone, Debug)]
107pub struct ConnectionPolicy {
108 pub pattern: HostPattern,
109 pub allow: bool,
111}
112
113impl ConnectionPolicy {
114 pub fn allow(pattern: HostPattern) -> Self {
116 Self {
117 pattern,
118 allow: true,
119 }
120 }
121
122 pub fn deny(pattern: HostPattern) -> Self {
124 Self {
125 pattern,
126 allow: false,
127 }
128 }
129}
130
131#[derive(Clone, Debug)]
133pub struct SandboxConfig {
134 pub secrets: Vec<SecretMapping>,
136 pub strings: Vec<StringMapping>,
138 pub connections: Vec<ConnectionPolicy>,
140 pub allow_private_connect: bool,
142 pub upstream_ca: Option<std::path::PathBuf>,
144 pub force_traffic_through_proxy: bool,
146 pub sandbox_backend: Option<crate::backend::SandboxBackend>,
148}
149
150impl Default for SandboxConfig {
151 fn default() -> Self {
152 Self {
153 secrets: Vec::new(),
154 strings: Vec::new(),
155 connections: Vec::new(),
156 allow_private_connect: false,
157 upstream_ca: None,
158 force_traffic_through_proxy: true,
159 sandbox_backend: None,
160 }
161 }
162}
163
164#[derive(Clone, Debug)]
170pub struct Sandbox {
171 config: SandboxConfig,
172}
173
174impl Sandbox {
175 pub fn new(config: SandboxConfig) -> Self {
177 Self { config }
178 }
179
180 pub async fn run(
184 &self,
185 cmd: &str,
186 ) -> Result<std::process::ExitStatus, Box<dyn std::error::Error + Send + Sync>> {
187 run_impl(
188 cmd,
189 self.config.secrets.clone(),
190 self.config.strings.clone(),
191 self.config.allow_private_connect,
192 self.config.upstream_ca.clone(),
193 self.config.connections.clone(),
194 self.config.force_traffic_through_proxy,
195 self.config.sandbox_backend,
196 )
197 .await
198 }
199}
200
201pub(crate) fn connection_allowed(host: &str, policies: Option<&[ConnectionPolicy]>) -> bool {
204 let Some(policies) = policies else {
205 return true;
206 };
207 if policies.is_empty() {
208 return true;
209 }
210 for p in policies {
211 if p.pattern.matches(host) {
212 return p.allow;
213 }
214 }
215 true
216}
217
218const FORBIDDEN_IN_SECRET: &[u8] = b"\r\n\0";
220
221fn validate_secret(value: &str) -> Result<(), String> {
222 if value.bytes().any(|b| FORBIDDEN_IN_SECRET.contains(&b)) {
223 return Err("secret must not contain CR, LF, or NUL".to_string());
224 }
225 Ok(())
226}
227
228#[cfg(target_os = "linux")]
231pub fn run_as_linux_runner(
232 config_json: String,
233) -> Result<std::process::ExitStatus, Box<dyn std::error::Error + Send + Sync>> {
234 let config: runner::RunnerConfig =
235 serde_json::from_str(&config_json).map_err(|e| format!("runner config JSON: {}", e))?;
236 if !std::path::Path::new("/.dockerenv").exists() {
238 enforce_linux::bring_up_loopback()?;
239 }
240 let connection_policies =
241 runner::runner_rules_to_connection_policies(&config.connection_policies)?;
242 let upstream_ca = config.upstream_ca.map(std::path::PathBuf::from);
243 let rt = tokio::runtime::Runtime::new().map_err(|e| format!("tokio runtime: {}", e))?;
244 rt.block_on(run_impl_inner(
245 &config.cmd,
246 config.secret_mappings,
247 config.string_mappings,
248 config.allow_private_connect,
249 upstream_ca,
250 connection_policies,
251 false, &enforce::ENV_ONLY_ENFORCER,
253 ))
254}
255
256#[allow(clippy::too_many_arguments)]
257async fn run_impl(
258 cmd: &str,
259 secret_mappings: Vec<SecretMapping>,
260 string_mappings: Vec<StringMapping>,
261 allow_private_connect: bool,
262 upstream_ca: Option<std::path::PathBuf>,
263 connection_policies: Vec<ConnectionPolicy>,
264 force_traffic_through_proxy: bool,
265 sandbox_backend: Option<backend::SandboxBackend>,
266) -> Result<std::process::ExitStatus, Box<dyn std::error::Error + Send + Sync>> {
267 let enforcer = enforce::enforcer_for(sandbox_backend, force_traffic_through_proxy)?;
268 if force_traffic_through_proxy {
269 if let Some(status) = enforcer.maybe_spawn_runner(
270 cmd,
271 &secret_mappings,
272 &string_mappings,
273 allow_private_connect,
274 &upstream_ca,
275 &connection_policies,
276 )? {
277 return Ok(status);
278 }
279 }
280
281 run_impl_inner(
282 cmd,
283 secret_mappings,
284 string_mappings,
285 allow_private_connect,
286 upstream_ca,
287 connection_policies,
288 force_traffic_through_proxy,
289 enforcer,
290 )
291 .await
292}
293
294#[allow(clippy::too_many_arguments)]
295async fn run_impl_inner(
296 cmd: &str,
297 secret_mappings: Vec<SecretMapping>,
298 string_mappings: Vec<StringMapping>,
299 allow_private_connect: bool,
300 upstream_ca: Option<std::path::PathBuf>,
301 connection_policies: Vec<ConnectionPolicy>,
302 force_traffic_through_proxy: bool,
303 enforcer: &'static dyn enforce::Enforce,
304) -> Result<std::process::ExitStatus, Box<dyn std::error::Error + Send + Sync>> {
305 if secret_mappings.is_empty() && string_mappings.is_empty() {
306 return Err("sandbox requires at least one secret mapping or string mapping".into());
307 }
308
309 for m in &secret_mappings {
310 validate_secret(&m.value).map_err(|e| format!("{}: {}", m.var, e))?;
311 }
312 for m in &string_mappings {
313 validate_secret(&m.value).map_err(|e| format!("{}: {}", m.token, e))?;
314 }
315
316 let mut env_vars_with_masked: Vec<(String, String)> = Vec::with_capacity(secret_mappings.len());
317 let mut proxy_map: HashMap<String, String> =
318 HashMap::with_capacity(secret_mappings.len() + string_mappings.len());
319
320 for m in &secret_mappings {
321 let masked = format!(
322 "{}-{}",
323 m.var.to_lowercase().replace('_', "-"),
324 uuid::Uuid::new_v4()
325 );
326 env_vars_with_masked.push((m.var.clone(), masked.clone()));
327 proxy_map.insert(masked, m.value.clone());
328 }
329 for m in &string_mappings {
330 proxy_map.insert(m.token.clone(), m.value.clone());
331 }
332
333 let replacement_order: Vec<(String, String)> = {
335 let mut v: Vec<_> = proxy_map.into_iter().collect();
336 v.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
337 v
338 };
339 let token_map = Arc::new(replacement_order);
340
341 let (mitm_config, ssl_cert_file) = {
342 let (config, ca_pem) =
343 mitm::MitmConfig::new(upstream_ca).map_err(|e| format!("MITM config: {}", e))?;
344 let temp = std::env::temp_dir().join(format!("blinders-ca-{}.pem", uuid::Uuid::new_v4()));
345 std::fs::write(&temp, &ca_pem).map_err(|e| format!("write CA cert: {}", e))?;
346 (Arc::new(config), temp)
347 };
348
349 let listener = TcpListener::bind("127.0.0.1:0").await?;
350 let port = listener.local_addr()?.port();
351 let proxy_url = format!("http://127.0.0.1:{}", port);
352
353 let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
354 let server_handle = tokio::spawn(async move {
355 loop {
356 tokio::select! {
357 _ = shutdown_rx.recv() => break,
358 accept_result = listener.accept() => {
359 let (stream, _) = match accept_result {
360 Ok(x) => x,
361 Err(_) => continue,
362 };
363 let token_map = Arc::clone(&token_map);
364 let allow_private = allow_private_connect;
365 let mitm_config = Arc::clone(&mitm_config);
366 let connection_policies = connection_policies.clone();
367 tokio::spawn(async move {
368 let io = TokioIo::new(stream);
369 let service = service_fn(move |req| {
370 let token_map = Arc::clone(&token_map);
371 let mitm_config = Arc::clone(&mitm_config);
372 let connection_policies = connection_policies.clone();
373 async move { proxy_handler(req, token_map, allow_private, mitm_config, connection_policies).await }
374 });
375 let conn = hyper::server::conn::http1::Builder::new()
376 .serve_connection(io, service)
377 .with_upgrades();
378 if let Err(e) = conn.await {
379 eprintln!("proxy connection error: {}", e);
380 }
381 });
382 }
383 }
384 }
385 });
386
387 let cmd = cmd.to_string();
388 let env_vars_with_masked = env_vars_with_masked;
389 let exit_status = tokio::task::spawn_blocking(move || {
390 enforcer.run_child(
391 &cmd,
392 &proxy_url,
393 &env_vars_with_masked,
394 &ssl_cert_file,
395 force_traffic_through_proxy,
396 )
397 })
398 .await
399 .map_err(|e| format!("subprocess join: {}", e))??;
400
401 let _ = shutdown_tx.send(()).await;
402 let _ = server_handle.await;
403
404 Ok(exit_status)
405}
406
407pub(crate) type BoxBodyType = BoxBody<Bytes, hyper::Error>;
409
410fn full_body(chunk: Bytes) -> BoxBodyType {
411 Full::new(chunk).map_err(|never| match never {}).boxed()
412}
413
414pub(crate) fn bad_request(msg: &str) -> Response<BoxBodyType> {
415 Response::builder()
416 .status(http::StatusCode::BAD_REQUEST)
417 .body(full_body(Bytes::from(msg.to_string())))
418 .unwrap()
419}
420
421pub(crate) fn bad_gateway(msg: &str) -> Response<BoxBodyType> {
422 Response::builder()
423 .status(http::StatusCode::BAD_GATEWAY)
424 .body(full_body(Bytes::from(msg.to_string())))
425 .unwrap()
426}
427
428fn replace_bytes(buf: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
430 if from.is_empty() || buf.is_empty() {
431 return buf.to_vec();
432 }
433 let mut out = Vec::with_capacity(buf.len());
434 let mut i = 0;
435 while i <= buf.len().saturating_sub(from.len()) {
436 if buf[i..].starts_with(from) {
437 out.extend_from_slice(to);
438 i += from.len();
439 } else {
440 out.push(buf[i]);
441 i += 1;
442 }
443 }
444 out.extend_from_slice(&buf[i..]);
445 out
446}
447
448pub(crate) fn replace_tokens_in_bytes(
450 buf: &[u8],
451 replacement_order: &[(String, String)],
452) -> Vec<u8> {
453 let mut current = buf.to_vec();
454 for (masked, real) in replacement_order {
455 current = replace_bytes(¤t, masked.as_bytes(), real.as_bytes());
456 }
457 current
458}
459
460pub(crate) fn replace_tokens_in_header_value(
462 value: &str,
463 replacement_order: &[(String, String)],
464) -> Option<String> {
465 let mut s = value.to_string();
466 for (masked, real) in replacement_order {
467 s = s.replace(masked, real);
468 }
469 if s.bytes().any(|b| FORBIDDEN_IN_SECRET.contains(&b)) {
470 return None;
471 }
472 Some(s)
473}
474
475fn is_private_authority(authority: &str) -> bool {
477 let (host, _port) = authority.split_once(':').unwrap_or((authority, "80"));
478 let host = host.trim_start_matches('[').trim_end_matches(']');
479 if host == "localhost" || host.is_empty() {
480 return true;
481 }
482 if let Ok(ip) = host.parse::<std::net::IpAddr>() {
483 return is_private_ip(ip);
484 }
485 false
486}
487
488fn is_private_ip(ip: std::net::IpAddr) -> bool {
489 match ip {
490 std::net::IpAddr::V4(a) => {
491 a.is_loopback()
492 || a.is_private()
493 || a.is_link_local()
494 || a.is_broadcast()
495 || a.is_documentation()
496 }
497 std::net::IpAddr::V6(a) => a.is_loopback() || a.is_unspecified(),
498 }
499}
500
501async fn proxy_handler(
502 req: Request<hyper::body::Incoming>,
503 token_map: Arc<Vec<(String, String)>>,
504 allow_private_connect: bool,
505 mitm_config: Arc<mitm::MitmConfig>,
506 connection_policies: Vec<ConnectionPolicy>,
507) -> Result<Response<BoxBodyType>, hyper::Error> {
508 if req.method() == Method::CONNECT {
509 let authority = match req.uri().authority() {
510 Some(a) => a.to_string(),
511 None => return Ok(bad_request("CONNECT must include authority (host:port)")),
512 };
513 let host = req
514 .uri()
515 .authority()
516 .map(|a| a.host().to_string())
517 .unwrap_or_default();
518 if !connection_allowed(host.as_str(), Some(&connection_policies)) {
519 return Ok(bad_request("CONNECT to this host is not allowed by policy"));
520 }
521 if !allow_private_connect && is_private_authority(&authority) {
522 return Ok(bad_request("CONNECT to private/local address not allowed"));
523 }
524 return mitm::handle_connect_mitm(req, token_map, mitm_config, connection_policies).await;
525 }
526 handle_forward(req, token_map, connection_policies).await
527}
528
529async fn handle_forward(
530 req: Request<hyper::body::Incoming>,
531 token_map: Arc<Vec<(String, String)>>,
532 connection_policies: Vec<ConnectionPolicy>,
533) -> Result<Response<BoxBodyType>, hyper::Error> {
534 let (parts, body) = req.into_parts();
535 let uri = parts.uri.clone();
536 let uri_str = uri.to_string();
537 let modified_uri_bytes = replace_tokens_in_bytes(uri_str.as_bytes(), &token_map);
538 let modified_uri = match String::from_utf8(modified_uri_bytes) {
539 Ok(s) => s.parse().unwrap_or(uri),
540 Err(_) => uri.clone(),
541 };
542 let host = match modified_uri.host() {
543 Some(h) => h.to_string(),
544 None => return Ok(bad_request("Request URI has no host")),
545 };
546 if !connection_allowed(host.as_str(), Some(&connection_policies)) {
547 return Ok(bad_request(
548 "Connection to this host is not allowed by policy",
549 ));
550 }
551 let port = modified_uri.port_u16().unwrap_or(80);
552
553 let body_bytes = body.collect().await?.to_bytes();
554 let modified_body = replace_tokens_in_bytes(&body_bytes, &token_map);
555
556 let mut new_headers = http::HeaderMap::new();
557 for (name, value) in parts.headers.iter() {
558 if name == http::header::CONNECTION
559 || name.as_str().eq_ignore_ascii_case("proxy-connection")
560 || name == http::header::TRANSFER_ENCODING
561 {
562 continue;
563 }
564 let value_str = match value.to_str() {
565 Ok(s) => s,
566 Err(_) => continue,
567 };
568 let new_value = replace_tokens_in_header_value(value_str, &token_map);
569 if let Some(v) = new_value {
570 if let Ok(hv) = v.parse() {
571 new_headers.insert(name.clone(), hv);
572 }
573 }
574 }
575 new_headers.insert(
576 http::header::CONTENT_LENGTH,
577 http::HeaderValue::from_str(&modified_body.len().to_string()).unwrap(),
578 );
579
580 let body = http_body_util::Full::new(bytes::Bytes::from(modified_body));
581 let new_req = match Request::builder()
582 .method(parts.method)
583 .uri(&modified_uri)
584 .body(body)
585 {
586 Ok(r) => r,
587 Err(_) => return Ok(bad_gateway("Invalid request")),
588 };
589 let (mut new_parts, new_body) = new_req.into_parts();
590 new_parts.headers = new_headers;
591 let new_req = Request::from_parts(new_parts, new_body);
592
593 let stream = match TcpStream::connect((host.as_str(), port)).await {
594 Ok(s) => s,
595 Err(e) => {
596 eprintln!("proxy connect error: {}", e);
597 return Ok(bad_gateway("Upstream connection failed"));
598 }
599 };
600 let io = TokioIo::new(stream);
601 let (mut sender, conn) = match hyper::client::conn::http1::Builder::new()
602 .handshake(io)
603 .await
604 {
605 Ok(x) => x,
606 Err(e) => {
607 eprintln!("proxy handshake error: {}", e);
608 return Ok(bad_gateway("Upstream handshake failed"));
609 }
610 };
611 tokio::spawn(async move {
612 let _ = conn.await;
613 });
614
615 let resp = match sender.send_request(new_req).await {
616 Ok(r) => r,
617 Err(e) => {
618 eprintln!("proxy send error: {}", e);
619 return Ok(bad_gateway("Upstream request failed"));
620 }
621 };
622 let (resp_parts, resp_body) = resp.into_parts();
623 let resp_body = resp_body.boxed();
624 let resp = Response::from_parts(resp_parts, resp_body);
625 Ok(resp)
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631
632 #[test]
633 fn secret_mapping_creation() {
634 let m = SecretMapping {
635 var: "API_KEY".to_string(),
636 value: "secret123".to_string(),
637 };
638 assert_eq!(m.var, "API_KEY");
639 assert_eq!(m.value, "secret123");
640 }
641
642 #[test]
643 fn sandbox_new_default_config() {
644 let _sandbox = Sandbox::new(SandboxConfig::default());
645 }
646
647 #[test]
648 fn sandbox_new_one_mapping() {
649 let m = SecretMapping {
650 var: "X".to_string(),
651 value: "y".to_string(),
652 };
653 let config = SandboxConfig {
654 secrets: vec![m],
655 ..SandboxConfig::default()
656 };
657 let _sandbox = Sandbox::new(config);
658 }
659
660 #[test]
661 fn sandbox_new_multiple_mappings() {
662 let mappings = vec![
663 SecretMapping {
664 var: "A".to_string(),
665 value: "a".to_string(),
666 },
667 SecretMapping {
668 var: "B".to_string(),
669 value: "b".to_string(),
670 },
671 ];
672 let config = SandboxConfig {
673 secrets: mappings,
674 ..SandboxConfig::default()
675 };
676 let _sandbox = Sandbox::new(config);
677 }
678
679 #[tokio::test]
680 async fn sandbox_clone() {
681 let m = SecretMapping {
682 var: "K".to_string(),
683 value: "v".to_string(),
684 };
685 let config = SandboxConfig {
686 secrets: vec![m],
687 force_traffic_through_proxy: false,
688 ..SandboxConfig::default()
689 };
690 let sandbox = Sandbox::new(config);
691 let cloned = sandbox.clone();
692 let status = cloned.run("true").await;
693 assert!(status.is_ok());
694 assert!(status.unwrap().success());
695 }
696
697 #[tokio::test]
698 async fn run_requires_at_least_one_mapping() {
699 let config = SandboxConfig {
700 force_traffic_through_proxy: false,
701 ..SandboxConfig::default()
702 };
703 let sandbox = Sandbox::new(config);
704 let result = sandbox.run("true").await;
705 assert!(result.is_err());
706 let err = result.unwrap_err();
707 assert!(
708 err.to_string()
709 .contains("at least one secret mapping or string mapping"),
710 "expected message about mapping, got: {}",
711 err
712 );
713 }
714
715 #[tokio::test]
716 async fn run_with_string_mapping_only() {
717 let config = SandboxConfig {
718 strings: vec![StringMapping {
719 token: "__TOKEN__".to_string(),
720 value: "replaced".to_string(),
721 }],
722 force_traffic_through_proxy: false,
723 ..SandboxConfig::default()
724 };
725 let sandbox = Sandbox::new(config);
726 let result = sandbox.run("true").await;
727 assert!(result.is_ok());
728 assert!(result.unwrap().success());
729 }
730
731 #[tokio::test]
732 async fn run_returns_exit_status_success() {
733 let config = SandboxConfig {
734 secrets: vec![SecretMapping {
735 var: "X".to_string(),
736 value: "x".to_string(),
737 }],
738 force_traffic_through_proxy: false,
739 ..SandboxConfig::default()
740 };
741 let sandbox = Sandbox::new(config);
742 let result = sandbox.run("true").await;
743 assert!(result.is_ok(), "{:?}", result.err());
744 assert!(result.unwrap().success());
745 }
746
747 #[tokio::test]
748 async fn run_returns_exit_status_failure() {
749 let config = SandboxConfig {
750 secrets: vec![SecretMapping {
751 var: "X".to_string(),
752 value: "x".to_string(),
753 }],
754 force_traffic_through_proxy: false,
755 ..SandboxConfig::default()
756 };
757 let sandbox = Sandbox::new(config);
758 let result = sandbox.run("false").await;
759 assert!(result.is_ok());
760 assert!(!result.unwrap().success());
761 }
762
763 #[tokio::test]
764 async fn run_forward_exit_code() {
765 let config = SandboxConfig {
766 secrets: vec![SecretMapping {
767 var: "X".to_string(),
768 value: "x".to_string(),
769 }],
770 force_traffic_through_proxy: false,
771 ..SandboxConfig::default()
772 };
773 let sandbox = Sandbox::new(config);
774 let result = sandbox.run("sh -c 'exit 42'").await;
775 assert!(result.is_ok());
776 assert_eq!(result.unwrap().code(), Some(42));
777 }
778
779 #[tokio::test]
780 async fn run_command_sees_masked_env() {
781 let config = SandboxConfig {
782 secrets: vec![SecretMapping {
783 var: "API_KEY".to_string(),
784 value: "real-secret".to_string(),
785 }],
786 force_traffic_through_proxy: false,
787 ..SandboxConfig::default()
788 };
789 let sandbox = Sandbox::new(config);
790 let result = sandbox.run("sh -c 'v=$API_KEY; if [ \"$v\" = \"real-secret\" ]; then exit 1; fi; case \"$v\" in api-key-*) exit 0;; *) exit 2;; esac'").await;
791 assert!(result.is_ok(), "run failed");
792 assert_eq!(
793 result.unwrap().code(),
794 Some(0),
795 "expected masked token pattern api-key-*"
796 );
797 }
798
799 #[tokio::test]
800 async fn run_sets_http_proxy_env() {
801 let config = SandboxConfig {
802 secrets: vec![SecretMapping {
803 var: "X".to_string(),
804 value: "x".to_string(),
805 }],
806 force_traffic_through_proxy: false,
807 ..SandboxConfig::default()
808 };
809 let sandbox = Sandbox::new(config);
810 let result = sandbox
811 .run("sh -c 'case \"$HTTP_PROXY\" in http://127.0.0.1*) exit 0;; *) exit 1;; esac'")
812 .await;
813 assert!(result.is_ok());
814 assert_eq!(result.unwrap().code(), Some(0));
815 let result = sandbox
816 .run("sh -c 'case \"$HTTPS_PROXY\" in http://127.0.0.1*) exit 0;; *) exit 1;; esac'")
817 .await;
818 assert!(result.is_ok());
819 assert_eq!(result.unwrap().code(), Some(0));
820 }
821
822 #[test]
823 fn replace_bytes_basic() {
824 let buf = b"hello world";
825 let out = replace_bytes(buf, b"o", b"X");
826 assert_eq!(out.as_slice(), b"hellX wXrld");
827 }
828
829 #[test]
830 fn replace_tokens_longest_first() {
831 let order = vec![
832 ("api-key-long".to_string(), "real-long".to_string()),
833 ("api-key".to_string(), "real-short".to_string()),
834 ];
835 let buf = b"prefix api-key-long suffix";
836 let out = replace_tokens_in_bytes(buf, &order);
837 assert_eq!(out.as_slice(), b"prefix real-long suffix");
838 }
839
840 #[test]
841 fn is_private_authority_blocks_localhost() {
842 assert!(is_private_authority("localhost:443"));
843 assert!(is_private_authority("127.0.0.1:8080"));
844 assert!(is_private_authority("10.0.0.1:80"));
845 assert!(!is_private_authority("example.com:443"));
846 }
847
848 #[test]
849 fn validate_secret_rejects_crlf() {
850 assert!(validate_secret("ok").is_ok());
851 assert!(validate_secret("no\rcr").is_err());
852 assert!(validate_secret("no\nlf").is_err());
853 assert!(validate_secret("no\0nul").is_err());
854 }
855
856 #[test]
857 fn string_mapping_creation() {
858 let m = StringMapping {
859 token: "__API_KEY__".to_string(),
860 value: "secret123".to_string(),
861 };
862 assert_eq!(m.token, "__API_KEY__");
863 assert_eq!(m.value, "secret123");
864 }
865
866 #[test]
867 fn replace_tokens_in_bytes_string_mapping() {
868 let order = vec![("__TOKEN__".to_string(), "real-value".to_string())];
869 let buf = b"Bearer __TOKEN__";
870 let out = replace_tokens_in_bytes(buf, &order);
871 assert_eq!(out.as_slice(), b"Bearer real-value");
872 }
873
874 #[test]
875 fn connection_allowed_no_policies() {
876 assert!(connection_allowed("example.com", None));
877 assert!(connection_allowed("evil.com", Some(&[])));
878 }
879
880 #[test]
881 fn connection_allowed_first_match_wins() {
882 let policies = vec![
883 ConnectionPolicy::deny(HostPattern::exact("blocked.com")),
884 ConnectionPolicy::allow(HostPattern::exact("blocked.com")),
885 ];
886 assert!(!connection_allowed("blocked.com", Some(&policies)));
887 }
888
889 #[test]
890 fn connection_allowed_regex() {
891 let policies = vec![
892 ConnectionPolicy::deny(HostPattern::regex(r"^internal\.").unwrap()),
893 ConnectionPolicy::allow(HostPattern::exact("api.example.com")),
894 ];
895 assert!(!connection_allowed("internal.service", Some(&policies)));
896 assert!(connection_allowed("api.example.com", Some(&policies)));
897 assert!(connection_allowed("other.com", Some(&policies))); }
899}