1use serde::{Deserialize, Serialize};
15use std::path::PathBuf;
16use std::time::Duration;
17
18use crate::cdp_protection::CdpFixMode;
19
20#[cfg(feature = "stealth")]
21use crate::webrtc::WebRtcConfig;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
48#[serde(rename_all = "lowercase")]
49pub enum HeadlessMode {
50 #[default]
53 New,
54 Legacy,
56}
57
58impl HeadlessMode {
59 pub fn from_env() -> Self {
61 match std::env::var("STYGIAN_HEADLESS_MODE")
62 .unwrap_or_default()
63 .to_lowercase()
64 .as_str()
65 {
66 "legacy" => Self::Legacy,
67 _ => Self::New,
68 }
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
87#[serde(rename_all = "lowercase")]
88pub enum StealthLevel {
89 None,
91 Basic,
93 #[default]
95 Advanced,
96}
97
98impl StealthLevel {
99 #[must_use]
101 pub fn is_active(self) -> bool {
102 self != Self::None
103 }
104
105 pub fn from_env() -> Self {
107 match std::env::var("STYGIAN_STEALTH_LEVEL")
108 .unwrap_or_default()
109 .to_lowercase()
110 .as_str()
111 {
112 "none" => Self::None,
113 "basic" => Self::Basic,
114 _ => Self::Advanced,
115 }
116 }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct PoolConfig {
133 pub min_size: usize,
137
138 pub max_size: usize,
142
143 #[serde(with = "duration_secs")]
147 pub idle_timeout: Duration,
148
149 #[serde(with = "duration_secs")]
154 pub acquire_timeout: Duration,
155}
156
157impl Default for PoolConfig {
158 fn default() -> Self {
159 Self {
160 min_size: env_usize("STYGIAN_POOL_MIN", 2),
161 max_size: env_usize("STYGIAN_POOL_MAX", 10),
162 idle_timeout: Duration::from_secs(env_u64("STYGIAN_POOL_IDLE_SECS", 300)),
163 acquire_timeout: Duration::from_secs(env_u64("STYGIAN_POOL_ACQUIRE_SECS", 5)),
164 }
165 }
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct BrowserConfig {
186 pub chrome_path: Option<PathBuf>,
190
191 pub args: Vec<String>,
193
194 pub headless: bool,
198
199 pub user_data_dir: Option<PathBuf>,
201
202 pub headless_mode: HeadlessMode,
208
209 pub window_size: Option<(u32, u32)>,
211
212 pub devtools: bool,
214
215 pub proxy: Option<String>,
217
218 pub proxy_bypass_list: Option<String>,
222
223 #[cfg(feature = "stealth")]
227 pub webrtc: WebRtcConfig,
228
229 pub stealth_level: StealthLevel,
231
232 pub disable_sandbox: bool,
244
245 pub cdp_fix_mode: CdpFixMode,
249
250 pub source_url: Option<String>,
257
258 pub pool: PoolConfig,
260
261 #[serde(with = "duration_secs")]
265 pub launch_timeout: Duration,
266
267 #[serde(with = "duration_secs")]
271 pub cdp_timeout: Duration,
272}
273
274impl Default for BrowserConfig {
275 fn default() -> Self {
276 Self {
277 chrome_path: std::env::var("STYGIAN_CHROME_PATH").ok().map(PathBuf::from),
278 args: vec![],
279 headless: env_bool("STYGIAN_HEADLESS", true),
280 user_data_dir: None,
281 headless_mode: HeadlessMode::from_env(),
282 window_size: Some((1920, 1080)),
283 devtools: false,
284 proxy: std::env::var("STYGIAN_PROXY").ok(),
285 proxy_bypass_list: std::env::var("STYGIAN_PROXY_BYPASS").ok(),
286 #[cfg(feature = "stealth")]
287 webrtc: WebRtcConfig::default(),
288 disable_sandbox: env_bool("STYGIAN_DISABLE_SANDBOX", is_containerized()),
289 stealth_level: StealthLevel::from_env(),
290 cdp_fix_mode: CdpFixMode::from_env(),
291 source_url: std::env::var("STYGIAN_SOURCE_URL").ok(),
292 pool: PoolConfig::default(),
293 launch_timeout: Duration::from_secs(env_u64("STYGIAN_LAUNCH_TIMEOUT_SECS", 10)),
294 cdp_timeout: Duration::from_secs(env_u64("STYGIAN_CDP_TIMEOUT_SECS", 30)),
295 }
296 }
297}
298
299impl BrowserConfig {
300 pub fn builder() -> BrowserConfigBuilder {
302 BrowserConfigBuilder {
303 config: Self::default(),
304 }
305 }
306
307 pub fn effective_args(&self) -> Vec<String> {
312 let mut args = vec![
313 "--disable-blink-features=AutomationControlled".to_string(),
314 "--disable-dev-shm-usage".to_string(),
315 "--disable-infobars".to_string(),
316 "--disable-background-timer-throttling".to_string(),
317 "--disable-backgrounding-occluded-windows".to_string(),
318 "--disable-renderer-backgrounding".to_string(),
319 ];
320
321 if self.disable_sandbox {
322 args.push("--no-sandbox".to_string());
323 }
324
325 if let Some(proxy) = &self.proxy {
326 args.push(format!("--proxy-server={proxy}"));
327 }
328
329 if let Some(bypass) = &self.proxy_bypass_list {
330 args.push(format!("--proxy-bypass-list={bypass}"));
331 }
332
333 #[cfg(feature = "stealth")]
334 args.extend(self.webrtc.chrome_args());
335
336 if let Some((w, h)) = self.window_size {
337 args.push(format!("--window-size={w},{h}"));
338 }
339
340 args.extend_from_slice(&self.args);
341 args
342 }
343
344 pub fn validate(&self) -> Result<(), Vec<String>> {
362 let mut errors: Vec<String> = Vec::new();
363
364 if self.pool.min_size > self.pool.max_size {
365 errors.push(format!(
366 "pool.min_size ({}) must be <= pool.max_size ({})",
367 self.pool.min_size, self.pool.max_size
368 ));
369 }
370 if self.pool.max_size == 0 {
371 errors.push("pool.max_size must be >= 1".to_string());
372 }
373 if self.launch_timeout.is_zero() {
374 errors.push("launch_timeout must be positive".to_string());
375 }
376 if self.cdp_timeout.is_zero() {
377 errors.push("cdp_timeout must be positive".to_string());
378 }
379 if let Some(proxy) = &self.proxy
380 && !proxy.starts_with("http://")
381 && !proxy.starts_with("https://")
382 && !proxy.starts_with("socks4://")
383 && !proxy.starts_with("socks5://")
384 {
385 errors.push(format!(
386 "proxy URL must start with http://, https://, socks4:// or socks5://; got: {proxy}"
387 ));
388 }
389
390 if errors.is_empty() {
391 Ok(())
392 } else {
393 Err(errors)
394 }
395 }
396
397 pub fn to_json(&self) -> Result<String, serde_json::Error> {
412 serde_json::to_string_pretty(self)
413 }
414
415 pub fn from_json_str(s: &str) -> Result<Self, serde_json::Error> {
436 serde_json::from_str(s)
437 }
438
439 pub fn from_json_file(path: impl AsRef<std::path::Path>) -> crate::error::Result<Self> {
453 use crate::error::BrowserError;
454 let content = std::fs::read_to_string(path.as_ref()).map_err(|e| {
455 BrowserError::ConfigError(format!(
456 "cannot read config file {}: {e}",
457 path.as_ref().display()
458 ))
459 })?;
460 serde_json::from_str(&content).map_err(|e| {
461 BrowserError::ConfigError(format!(
462 "invalid JSON in config file {}: {e}",
463 path.as_ref().display()
464 ))
465 })
466 }
467}
468
469pub struct BrowserConfigBuilder {
473 config: BrowserConfig,
474}
475
476impl BrowserConfigBuilder {
477 #[must_use]
479 pub fn chrome_path(mut self, path: PathBuf) -> Self {
480 self.config.chrome_path = Some(path);
481 self
482 }
483
484 #[must_use]
500 pub fn user_data_dir(mut self, path: impl Into<std::path::PathBuf>) -> Self {
501 self.config.user_data_dir = Some(path.into());
502 self
503 }
504
505 #[must_use]
507 pub const fn headless(mut self, headless: bool) -> Self {
508 self.config.headless = headless;
509 self
510 }
511
512 #[must_use]
528 pub const fn headless_mode(mut self, mode: HeadlessMode) -> Self {
529 self.config.headless_mode = mode;
530 self
531 }
532
533 #[must_use]
535 pub const fn window_size(mut self, width: u32, height: u32) -> Self {
536 self.config.window_size = Some((width, height));
537 self
538 }
539
540 #[must_use]
542 pub const fn devtools(mut self, enabled: bool) -> Self {
543 self.config.devtools = enabled;
544 self
545 }
546
547 #[must_use]
549 pub fn proxy(mut self, proxy: String) -> Self {
550 self.config.proxy = Some(proxy);
551 self
552 }
553
554 #[must_use]
566 pub fn proxy_bypass_list(mut self, bypass: String) -> Self {
567 self.config.proxy_bypass_list = Some(bypass);
568 self
569 }
570
571 #[cfg(feature = "stealth")]
583 #[must_use]
584 pub fn webrtc(mut self, webrtc: WebRtcConfig) -> Self {
585 self.config.webrtc = webrtc;
586 self
587 }
588
589 #[must_use]
591 pub fn arg(mut self, arg: String) -> Self {
592 self.config.args.push(arg);
593 self
594 }
595
596 #[cfg(feature = "stealth")]
617 #[must_use]
618 pub fn tls_profile(mut self, profile: &crate::tls::TlsProfile) -> Self {
619 self.config
620 .args
621 .extend(crate::tls::chrome_tls_args(profile));
622 self
623 }
624
625 #[must_use]
627 pub const fn stealth_level(mut self, level: StealthLevel) -> Self {
628 self.config.stealth_level = level;
629 self
630 }
631
632 #[must_use]
646 pub const fn disable_sandbox(mut self, disable: bool) -> Self {
647 self.config.disable_sandbox = disable;
648 self
649 }
650
651 #[must_use]
664 pub const fn cdp_fix_mode(mut self, mode: CdpFixMode) -> Self {
665 self.config.cdp_fix_mode = mode;
666 self
667 }
668
669 #[must_use]
682 pub fn source_url(mut self, url: Option<String>) -> Self {
683 self.config.source_url = url;
684 self
685 }
686
687 #[must_use]
689 pub const fn pool(mut self, pool: PoolConfig) -> Self {
690 self.config.pool = pool;
691 self
692 }
693
694 pub fn build(self) -> BrowserConfig {
696 self.config
697 }
698}
699
700mod duration_secs {
704 use serde::{Deserialize, Deserializer, Serialize, Serializer};
705 use std::time::Duration;
706
707 pub fn serialize<S: Serializer>(d: &Duration, s: S) -> std::result::Result<S::Ok, S::Error> {
708 d.as_secs().serialize(s)
709 }
710
711 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Duration, D::Error> {
712 Ok(Duration::from_secs(u64::deserialize(d)?))
713 }
714}
715
716fn env_bool(key: &str, default: bool) -> bool {
719 std::env::var(key)
720 .map(|v| !matches!(v.to_lowercase().as_str(), "false" | "0" | "no"))
721 .unwrap_or(default)
722}
723
724#[allow(clippy::missing_const_for_fn)] fn is_containerized() -> bool {
736 #[cfg(target_os = "linux")]
737 {
738 if std::path::Path::new("/.dockerenv").exists() {
739 return true;
740 }
741 if let Ok(cgroup) = std::fs::read_to_string("/proc/1/cgroup")
742 && (cgroup.contains("docker") || cgroup.contains("kubepods"))
743 {
744 return true;
745 }
746 false
747 }
748 #[cfg(not(target_os = "linux"))]
749 {
750 false
751 }
752}
753
754fn env_u64(key: &str, default: u64) -> u64 {
755 std::env::var(key)
756 .ok()
757 .and_then(|v| v.parse().ok())
758 .unwrap_or(default)
759}
760
761fn env_usize(key: &str, default: usize) -> usize {
762 std::env::var(key)
763 .ok()
764 .and_then(|v| v.parse().ok())
765 .unwrap_or(default)
766}
767
768#[cfg(test)]
771mod tests {
772 use super::*;
773
774 #[test]
775 fn default_config_is_headless() {
776 let cfg = BrowserConfig::default();
777 assert!(cfg.headless);
778 }
779
780 #[test]
781 fn builder_roundtrip() {
782 let cfg = BrowserConfig::builder()
783 .headless(false)
784 .window_size(1280, 720)
785 .stealth_level(StealthLevel::Basic)
786 .build();
787
788 assert!(!cfg.headless);
789 assert_eq!(cfg.window_size, Some((1280, 720)));
790 assert_eq!(cfg.stealth_level, StealthLevel::Basic);
791 }
792
793 #[test]
794 fn effective_args_include_anti_detection_flag() {
795 let cfg = BrowserConfig::default();
796 let args = cfg.effective_args();
797 assert!(args.iter().any(|a| a.contains("AutomationControlled")));
798 }
799
800 #[test]
801 fn no_sandbox_only_when_explicitly_enabled() {
802 let with_sandbox_disabled = BrowserConfig::builder().disable_sandbox(true).build();
803 assert!(
804 with_sandbox_disabled
805 .effective_args()
806 .iter()
807 .any(|a| a == "--no-sandbox")
808 );
809
810 let with_sandbox_enabled = BrowserConfig::builder().disable_sandbox(false).build();
811 assert!(
812 !with_sandbox_enabled
813 .effective_args()
814 .iter()
815 .any(|a| a == "--no-sandbox")
816 );
817 }
818
819 #[test]
820 fn pool_config_defaults() {
821 let p = PoolConfig::default();
822 assert_eq!(p.min_size, 2);
823 assert_eq!(p.max_size, 10);
824 }
825
826 #[test]
827 fn stealth_level_none_not_active() {
828 assert!(!StealthLevel::None.is_active());
829 assert!(StealthLevel::Basic.is_active());
830 assert!(StealthLevel::Advanced.is_active());
831 }
832
833 #[test]
834 fn config_serialization() -> Result<(), Box<dyn std::error::Error>> {
835 let cfg = BrowserConfig::default();
836 let json = serde_json::to_string(&cfg)?;
837 let back: BrowserConfig = serde_json::from_str(&json)?;
838 assert_eq!(back.headless, cfg.headless);
839 assert_eq!(back.stealth_level, cfg.stealth_level);
840 Ok(())
841 }
842
843 #[test]
844 fn validate_default_config_is_valid() {
845 let cfg = BrowserConfig::default();
846 assert!(cfg.validate().is_ok(), "default config must be valid");
847 }
848
849 #[test]
850 fn validate_detects_pool_size_inversion() {
851 let cfg = BrowserConfig {
852 pool: PoolConfig {
853 min_size: 10,
854 max_size: 5,
855 ..PoolConfig::default()
856 },
857 ..BrowserConfig::default()
858 };
859 let result = cfg.validate();
860 assert!(result.is_err());
861 if let Err(errors) = result {
862 assert!(errors.iter().any(|e| e.contains("min_size")));
863 }
864 }
865
866 #[test]
867 fn validate_detects_zero_max_pool() {
868 let cfg = BrowserConfig {
869 pool: PoolConfig {
870 max_size: 0,
871 ..PoolConfig::default()
872 },
873 ..BrowserConfig::default()
874 };
875 let result = cfg.validate();
876 assert!(result.is_err());
877 if let Err(errors) = result {
878 assert!(errors.iter().any(|e| e.contains("max_size")));
879 }
880 }
881
882 #[test]
883 fn validate_detects_zero_timeouts() {
884 let cfg = BrowserConfig {
885 launch_timeout: std::time::Duration::ZERO,
886 cdp_timeout: std::time::Duration::ZERO,
887 ..BrowserConfig::default()
888 };
889 let result = cfg.validate();
890 assert!(result.is_err());
891 if let Err(errors) = result {
892 assert_eq!(errors.len(), 2);
893 }
894 }
895
896 #[test]
897 fn validate_detects_bad_proxy_scheme() {
898 let cfg = BrowserConfig {
899 proxy: Some("ftp://bad.proxy:1234".to_string()),
900 ..BrowserConfig::default()
901 };
902 let result = cfg.validate();
903 assert!(result.is_err());
904 if let Err(errors) = result {
905 assert!(errors.iter().any(|e| e.contains("proxy URL")));
906 }
907 }
908
909 #[test]
910 fn validate_accepts_valid_proxy() {
911 let cfg = BrowserConfig {
912 proxy: Some("socks5://user:pass@127.0.0.1:1080".to_string()),
913 ..BrowserConfig::default()
914 };
915 assert!(cfg.validate().is_ok());
916 }
917
918 #[test]
919 fn to_json_and_from_json_str_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
920 let cfg = BrowserConfig::builder()
921 .headless(false)
922 .stealth_level(StealthLevel::Basic)
923 .build();
924 let json = cfg.to_json()?;
925 assert!(json.contains("headless"));
926 let back = BrowserConfig::from_json_str(&json)?;
927 assert!(!back.headless);
928 assert_eq!(back.stealth_level, StealthLevel::Basic);
929 Ok(())
930 }
931
932 #[test]
933 fn from_json_str_error_on_invalid_json() {
934 let err = BrowserConfig::from_json_str("not json at all");
935 assert!(err.is_err());
936 }
937
938 #[test]
939 fn builder_cdp_fix_mode_and_source_url() {
940 use crate::cdp_protection::CdpFixMode;
941 let cfg = BrowserConfig::builder()
942 .cdp_fix_mode(CdpFixMode::IsolatedWorld)
943 .source_url(Some("stealth.js".to_string()))
944 .build();
945 assert_eq!(cfg.cdp_fix_mode, CdpFixMode::IsolatedWorld);
946 assert_eq!(cfg.source_url.as_deref(), Some("stealth.js"));
947 }
948
949 #[test]
950 fn builder_source_url_none_disables_sourceurl() {
951 let cfg = BrowserConfig::builder().source_url(None).build();
952 assert!(cfg.source_url.is_none());
953 }
954
955 #[test]
963 fn stealth_level_from_env_none() {
964 temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("none"), || {
967 let level = StealthLevel::from_env();
968 assert_eq!(level, StealthLevel::None);
969 });
970 }
971
972 #[test]
973 fn stealth_level_from_env_basic() {
974 temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("basic"), || {
975 assert_eq!(StealthLevel::from_env(), StealthLevel::Basic);
976 });
977 }
978
979 #[test]
980 fn stealth_level_from_env_advanced_is_default() {
981 temp_env::with_var("STYGIAN_STEALTH_LEVEL", Some("anything_else"), || {
982 assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
983 });
984 }
985
986 #[test]
987 fn stealth_level_from_env_missing_defaults_to_advanced() {
988 temp_env::with_var("STYGIAN_STEALTH_LEVEL", None::<&str>, || {
990 assert_eq!(StealthLevel::from_env(), StealthLevel::Advanced);
991 });
992 }
993
994 #[test]
995 fn cdp_fix_mode_from_env_variants() {
996 use crate::cdp_protection::CdpFixMode;
997 let cases = [
998 ("add_binding", CdpFixMode::AddBinding),
999 ("isolatedworld", CdpFixMode::IsolatedWorld),
1000 ("enable_disable", CdpFixMode::EnableDisable),
1001 ("none", CdpFixMode::None),
1002 ("unknown_value", CdpFixMode::AddBinding), ];
1004 for (val, expected) in cases {
1005 temp_env::with_var("STYGIAN_CDP_FIX_MODE", Some(val), || {
1006 assert_eq!(
1007 CdpFixMode::from_env(),
1008 expected,
1009 "STYGIAN_CDP_FIX_MODE={val}"
1010 );
1011 });
1012 }
1013 }
1014
1015 #[test]
1016 fn pool_config_from_env_min_max() {
1017 temp_env::with_vars(
1018 [
1019 ("STYGIAN_POOL_MIN", Some("3")),
1020 ("STYGIAN_POOL_MAX", Some("15")),
1021 ],
1022 || {
1023 let p = PoolConfig::default();
1024 assert_eq!(p.min_size, 3);
1025 assert_eq!(p.max_size, 15);
1026 },
1027 );
1028 }
1029
1030 #[test]
1031 fn headless_from_env_false() {
1032 temp_env::with_var("STYGIAN_HEADLESS", Some("false"), || {
1033 assert!(!env_bool("STYGIAN_HEADLESS", true));
1035 });
1036 }
1037
1038 #[test]
1039 fn headless_from_env_zero_means_false() {
1040 temp_env::with_var("STYGIAN_HEADLESS", Some("0"), || {
1041 assert!(!env_bool("STYGIAN_HEADLESS", true));
1042 });
1043 }
1044
1045 #[test]
1046 fn headless_from_env_no_means_false() {
1047 temp_env::with_var("STYGIAN_HEADLESS", Some("no"), || {
1048 assert!(!env_bool("STYGIAN_HEADLESS", true));
1049 });
1050 }
1051
1052 #[test]
1053 fn validate_accepts_socks4_proxy() {
1054 let cfg = BrowserConfig {
1055 proxy: Some("socks4://127.0.0.1:1080".to_string()),
1056 ..BrowserConfig::default()
1057 };
1058 assert!(cfg.validate().is_ok());
1059 }
1060
1061 #[test]
1062 fn validate_multiple_errors_returned_together() {
1063 let cfg = BrowserConfig {
1064 pool: PoolConfig {
1065 min_size: 10,
1066 max_size: 5,
1067 ..PoolConfig::default()
1068 },
1069 launch_timeout: std::time::Duration::ZERO,
1070 proxy: Some("ftp://bad".to_string()),
1071 ..BrowserConfig::default()
1072 };
1073 let result = cfg.validate();
1074 assert!(result.is_err());
1075 if let Err(errors) = result {
1076 assert!(errors.len() >= 3, "expected ≥3 errors, got: {errors:?}");
1077 }
1078 }
1079
1080 #[test]
1081 fn json_file_error_on_missing_file() {
1082 let result = BrowserConfig::from_json_file("/nonexistent/path/config.json");
1083 assert!(result.is_err());
1084 if let Err(e) = result {
1085 let err_str = e.to_string();
1086 assert!(err_str.contains("cannot read config file") || err_str.contains("config"));
1087 }
1088 }
1089
1090 #[test]
1091 fn json_roundtrip_preserves_cdp_fix_mode() -> Result<(), Box<dyn std::error::Error>> {
1092 use crate::cdp_protection::CdpFixMode;
1093 let cfg = BrowserConfig::builder()
1094 .cdp_fix_mode(CdpFixMode::EnableDisable)
1095 .build();
1096 let json = cfg.to_json()?;
1097 let back = BrowserConfig::from_json_str(&json)?;
1098 assert_eq!(back.cdp_fix_mode, CdpFixMode::EnableDisable);
1099 Ok(())
1100 }
1101}
1102
1103#[cfg(test)]
1109#[allow(unsafe_code)] mod temp_env {
1111 use std::env;
1112 use std::ffi::OsStr;
1113 use std::sync::Mutex;
1114
1115 static ENV_LOCK: Mutex<()> = Mutex::new(());
1117
1118 pub fn with_var<K, V, F>(key: K, value: Option<V>, f: F)
1121 where
1122 K: AsRef<OsStr>,
1123 V: AsRef<OsStr>,
1124 F: FnOnce(),
1125 {
1126 let _guard = ENV_LOCK
1127 .lock()
1128 .unwrap_or_else(std::sync::PoisonError::into_inner);
1129 let key = key.as_ref();
1130 let prev = env::var_os(key);
1131 match value {
1132 Some(v) => unsafe { env::set_var(key, v.as_ref()) },
1133 None => unsafe { env::remove_var(key) },
1134 }
1135 f();
1136 match prev {
1137 Some(v) => unsafe { env::set_var(key, v) },
1138 None => unsafe { env::remove_var(key) },
1139 }
1140 }
1141
1142 pub fn with_vars<K, V, F>(pairs: impl IntoIterator<Item = (K, Option<V>)>, f: F)
1144 where
1145 K: AsRef<OsStr>,
1146 V: AsRef<OsStr>,
1147 F: FnOnce(),
1148 {
1149 let _guard = ENV_LOCK
1150 .lock()
1151 .unwrap_or_else(std::sync::PoisonError::into_inner);
1152 let pairs: Vec<_> = pairs
1153 .into_iter()
1154 .map(|(k, v)| {
1155 let key = k.as_ref().to_os_string();
1156 let prev = env::var_os(&key);
1157 let new_val = v.map(|v| v.as_ref().to_os_string());
1158 (key, prev, new_val)
1159 })
1160 .collect();
1161
1162 for (key, _, new_val) in &pairs {
1163 match new_val {
1164 Some(v) => unsafe { env::set_var(key, v) },
1165 None => unsafe { env::remove_var(key) },
1166 }
1167 }
1168
1169 f();
1170
1171 for (key, prev, _) in &pairs {
1172 match prev {
1173 Some(v) => unsafe { env::set_var(key, v) },
1174 None => unsafe { env::remove_var(key) },
1175 }
1176 }
1177 }
1178}