1use std::fmt;
2use std::path::PathBuf;
3
4use crate::command_runner::CommandRunner;
5use crate::inspection::{detect_platform, inspect_system_proxy_with_runner, SystemProxySettings};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum CompatibilityProxyKind {
9 Http,
10 Socks5,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
18pub struct Command {
19 pub program: String,
20 pub args: Vec<String>,
21}
22
23impl Command {
24 pub fn new(program: impl Into<String>, args: Vec<String>) -> Self {
25 Self {
26 program: program.into(),
27 args,
28 }
29 }
30}
31
32impl fmt::Display for Command {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 write!(f, "{}", self.program)?;
35 for arg in &self.args {
36 write!(f, " {}", arg)?;
37 }
38 Ok(())
39 }
40}
41
42#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
44pub struct ApplyPlan {
45 pub platform: String,
47 pub service: Option<String>,
49 pub http_proxy: Option<String>,
51 pub https_proxy: Option<String>,
53 pub socks_proxy: Option<String>,
55 pub no_proxy: Option<String>,
57 pub commands: Vec<Command>,
59 pub previous_settings: Option<SystemProxySettings>,
61}
62
63#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
65pub struct RollbackState {
66 pub timestamp: String,
68 pub platform: String,
70 pub service: Option<String>,
72 pub http_proxy: Option<String>,
74 pub https_proxy: Option<String>,
76 pub socks_proxy: Option<String>,
78 pub no_proxy: Option<String>,
80}
81
82pub struct AppliedProxy {
84 rollback: Option<RollbackState>,
85}
86
87impl AppliedProxy {
88 pub fn restore(&mut self) -> Result<(), String> {
89 let Some(rollback) = self.rollback.take() else {
90 return Ok(());
91 };
92 let runner = crate::command_runner::RealCommandRunner;
93 restore_with_runner(&rollback, &runner)
94 }
95
96 pub fn restore_with_runner(&mut self, runner: &dyn CommandRunner) -> Result<(), String> {
97 let Some(rollback) = self.rollback.take() else {
98 return Ok(());
99 };
100 restore_with_runner(&rollback, runner)
101 }
102}
103
104impl Drop for AppliedProxy {
105 fn drop(&mut self) {
106 let _ = self.restore();
107 }
108}
109
110pub fn apply_compatibility_proxy(
114 kind: CompatibilityProxyKind,
115 address: std::net::SocketAddr,
116) -> Result<AppliedProxy, String> {
117 let runner = crate::command_runner::RealCommandRunner;
118 apply_compatibility_proxy_with_runner(kind, address, &runner)
119}
120
121pub fn apply_compatibility_proxy_with_runner(
122 kind: CompatibilityProxyKind,
123 address: std::net::SocketAddr,
124 runner: &dyn CommandRunner,
125) -> Result<AppliedProxy, String> {
126 let platform = detect_platform();
127 let inspection = inspect_system_proxy_with_runner(runner);
128 if !inspection.apply_supported {
129 return Err(format!(
130 "system proxy apply is unavailable on platform '{platform}'"
131 ));
132 }
133 let settings = inspection
134 .settings
135 .ok_or_else(|| "unable to inspect current system proxy settings".to_string())?;
136 let endpoint = address.to_string();
137 let (http, https, socks) = match kind {
138 CompatibilityProxyKind::Http => (Some(endpoint.clone()), Some(endpoint), None),
139 CompatibilityProxyKind::Socks5 => (None, None, Some(endpoint)),
140 };
141 let plan = plan_apply(
142 &platform,
143 None,
144 http.as_deref(),
145 https.as_deref(),
146 socks.as_deref(),
147 None,
148 Some(&settings),
149 );
150 let rollback = create_rollback(&platform, plan.service.as_deref(), &settings);
151 if let Err(error) = execute_apply(&plan, runner) {
152 let rollback_error = restore_with_runner(&rollback, runner).err();
153 return Err(match rollback_error {
154 Some(rollback_error) => format!("{error}; rollback failed: {rollback_error}"),
155 None => error,
156 });
157 }
158 Ok(AppliedProxy {
159 rollback: Some(rollback),
160 })
161}
162
163fn restore_with_runner(rollback: &RollbackState, runner: &dyn CommandRunner) -> Result<(), String> {
164 for command in generate_revert_commands(rollback) {
165 let args: Vec<&str> = command.args.iter().map(String::as_str).collect();
166 runner
167 .run(&command.program, &args)
168 .map_err(|error| format!("failed to restore '{command}': {error}"))?;
169 }
170 Ok(())
171}
172
173impl RollbackState {
174 pub fn save(&self, path: &PathBuf) -> Result<(), String> {
176 let json = serde_json::to_string_pretty(self)
177 .map_err(|e| format!("failed to serialize rollback state: {e}"))?;
178 std::fs::write(path, json).map_err(|e| format!("failed to write rollback file: {e}"))
179 }
180
181 pub fn load(path: &PathBuf) -> Result<Self, String> {
183 let json = std::fs::read_to_string(path)
184 .map_err(|e| format!("failed to read rollback file: {e}"))?;
185 serde_json::from_str(&json).map_err(|e| format!("failed to parse rollback file: {e}"))
186 }
187}
188
189pub fn plan_apply(
191 platform: &str,
192 service: Option<&str>,
193 http_proxy: Option<&str>,
194 https_proxy: Option<&str>,
195 socks_proxy: Option<&str>,
196 no_proxy: Option<&str>,
197 current_settings: Option<&SystemProxySettings>,
198) -> ApplyPlan {
199 let commands = match platform {
200 "macos" => {
201 let svc = service.unwrap_or("*Wi-Fi");
202 crate::backends::macos::generate_macos_apply_commands(
203 svc,
204 http_proxy,
205 https_proxy,
206 socks_proxy,
207 no_proxy,
208 )
209 }
210 "windows" => crate::backends::windows::generate_windows_apply_commands(
211 http_proxy,
212 https_proxy,
213 socks_proxy,
214 no_proxy,
215 ),
216 "linux" => crate::backends::linux::generate_gnome_apply_commands(
217 http_proxy,
218 https_proxy,
219 socks_proxy,
220 no_proxy,
221 ),
222 _ => Vec::new(),
223 };
224
225 ApplyPlan {
226 platform: platform.to_string(),
227 service: service.map(|s| s.to_string()),
228 http_proxy: http_proxy.map(|s| s.to_string()),
229 https_proxy: https_proxy.map(|s| s.to_string()),
230 socks_proxy: socks_proxy.map(|s| s.to_string()),
231 no_proxy: no_proxy.map(|s| s.to_string()),
232 commands,
233 previous_settings: current_settings.cloned(),
234 }
235}
236
237pub fn create_rollback(
239 platform: &str,
240 service: Option<&str>,
241 settings: &SystemProxySettings,
242) -> RollbackState {
243 RollbackState {
244 timestamp: chrono_timestamp(),
245 platform: platform.to_string(),
246 service: service.map(|s| s.to_string()),
247 http_proxy: settings.http_proxy.clone(),
248 https_proxy: settings.https_proxy.clone(),
249 socks_proxy: settings.socks_proxy.clone(),
250 no_proxy: settings.no_proxy.clone(),
251 }
252}
253
254pub fn execute_apply(plan: &ApplyPlan, runner: &dyn CommandRunner) -> Result<Vec<Command>, String> {
256 let mut executed = Vec::new();
257 for cmd in &plan.commands {
258 let arg_refs: Vec<&str> = cmd.args.iter().map(String::as_str).collect();
259 runner
260 .run(&cmd.program, &arg_refs)
261 .map_err(|e| format!("failed to execute '{cmd}': {e}"))?;
262 executed.push(cmd.clone());
263 }
264 Ok(executed)
265}
266
267pub fn generate_revert_commands(rollback: &RollbackState) -> Vec<Command> {
269 match rollback.platform.as_str() {
270 "macos" => {
271 let svc = rollback.service.as_deref().unwrap_or("*Wi-Fi");
272 let mut commands = crate::backends::macos::generate_macos_disable_commands(svc);
273 if let Some(ref http) = rollback.http_proxy {
274 if !http.is_empty() {
275 commands.push(Command::new(
276 "networksetup",
277 vec!["-setwebproxy".into(), svc.into(), "on".into()],
278 ));
279 commands.push(Command::new(
280 "networksetup",
281 vec!["-setwebproxyservers".into(), svc.into(), http.clone()],
282 ));
283 }
284 }
285 if let Some(ref https) = rollback.https_proxy {
286 if !https.is_empty() {
287 commands.push(Command::new(
288 "networksetup",
289 vec!["-setsecurewebproxy".into(), svc.into(), "on".into()],
290 ));
291 commands.push(Command::new(
292 "networksetup",
293 vec![
294 "-setsecurewebproxyservers".into(),
295 svc.into(),
296 https.clone(),
297 ],
298 ));
299 }
300 }
301 if let Some(ref socks) = rollback.socks_proxy {
302 if !socks.is_empty() {
303 commands.push(Command::new(
304 "networksetup",
305 vec!["-setsocksfirewallproxy".into(), svc.into(), "on".into()],
306 ));
307 commands.push(Command::new(
308 "networksetup",
309 vec![
310 "-setsocksfirewallproxyserver".into(),
311 svc.into(),
312 socks.clone(),
313 ],
314 ));
315 }
316 }
317 commands
318 }
319 "windows" => {
320 let mut commands = crate::backends::windows::generate_windows_disable_commands();
321 let mut parts = Vec::new();
322 if let Some(ref http) = rollback.http_proxy {
323 parts.push(format!("http={http}"));
324 }
325 if let Some(ref https) = rollback.https_proxy {
326 parts.push(format!("https={https}"));
327 }
328 if let Some(ref socks) = rollback.socks_proxy {
329 parts.push(format!("socks={socks}"));
330 }
331 if !parts.is_empty() {
332 let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings";
333 let proxy_value = parts.join(";");
334 commands.push(Command::new(
335 "reg",
336 vec![
337 "add".into(),
338 key.into(),
339 "/v".into(),
340 "ProxyServer".into(),
341 "/t".into(),
342 "REG_SZ".into(),
343 "/d".into(),
344 proxy_value,
345 "/f".into(),
346 ],
347 ));
348 commands.push(Command::new(
349 "reg",
350 vec![
351 "add".into(),
352 key.into(),
353 "/v".into(),
354 "ProxyEnable".into(),
355 "/t".into(),
356 "REG_DWORD".into(),
357 "/d".into(),
358 "1".into(),
359 "/f".into(),
360 ],
361 ));
362 }
363 commands
364 }
365 "linux" => {
366 let mut commands = crate::backends::linux::generate_gnome_disable_commands();
367 let has_any = rollback.http_proxy.is_some()
368 || rollback.https_proxy.is_some()
369 || rollback.socks_proxy.is_some();
370 if has_any {
371 commands.extend(crate::backends::linux::generate_gnome_apply_commands(
372 rollback.http_proxy.as_deref(),
373 rollback.https_proxy.as_deref(),
374 rollback.socks_proxy.as_deref(),
375 rollback.no_proxy.as_deref(),
376 ));
377 }
378 commands
379 }
380 _ => Vec::new(),
381 }
382}
383
384fn chrono_timestamp() -> String {
385 let now = std::time::SystemTime::now()
387 .duration_since(std::time::UNIX_EPOCH)
388 .unwrap_or_default()
389 .as_secs();
390 format!("{now}")
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 #[cfg(unix)]
397 use crate::command_runner::MockCommandRunner;
398 use std::collections::HashMap;
399
400 #[test]
401 fn plan_apply_macos_produces_commands() {
402 let plan = plan_apply(
403 "macos",
404 Some("*Wi-Fi"),
405 Some("proxy:8080"),
406 Some("proxy:8443"),
407 None,
408 None,
409 None,
410 );
411 assert_eq!(plan.platform, "macos");
412 assert!(plan
413 .commands
414 .iter()
415 .any(|c| c.to_string().contains("networksetup")));
416 }
417
418 #[test]
419 fn plan_apply_windows_produces_commands() {
420 let plan = plan_apply("windows", None, Some("proxy:8080"), None, None, None, None);
421 assert_eq!(plan.platform, "windows");
422 assert!(plan
423 .commands
424 .iter()
425 .any(|c| c.to_string().contains("reg add")));
426 }
427
428 #[test]
429 fn plan_apply_linux_produces_commands() {
430 let plan = plan_apply("linux", None, Some("proxy:8080"), None, None, None, None);
431 assert_eq!(plan.platform, "linux");
432 assert!(plan
433 .commands
434 .iter()
435 .any(|c| c.to_string().contains("gsettings")));
436 }
437
438 #[cfg(unix)]
439 #[test]
440 fn execute_apply_preserves_spaces_in_args() {
441 use std::os::unix::process::ExitStatusExt;
442 let runner = MockCommandRunner::new().add_always(
443 "networksetup",
444 Ok(std::process::Output {
445 status: std::process::ExitStatus::from_raw(0),
446 stdout: Vec::new(),
447 stderr: Vec::new(),
448 }),
449 );
450
451 let plan = ApplyPlan {
452 platform: "macos".to_string(),
453 service: Some("USB 10/100/1000 LAN".to_string()),
454 http_proxy: Some("proxy:8080".to_string()),
455 https_proxy: None,
456 socks_proxy: None,
457 no_proxy: None,
458 commands: vec![Command::new(
459 "networksetup",
460 vec![
461 "-setwebproxy".into(),
462 "USB 10/100/1000 LAN".into(),
463 "on".into(),
464 ],
465 )],
466 previous_settings: None,
467 };
468
469 let _ = execute_apply(&plan, &runner).unwrap();
470 let calls = runner.calls();
471 assert_eq!(calls[0].0, "networksetup");
472 assert_eq!(
473 calls[0].1,
474 vec!["-setwebproxy", "USB 10/100/1000 LAN", "on"]
475 );
476 }
477
478 #[test]
479 fn rollback_state_save_and_load() {
480 let state = RollbackState {
481 timestamp: "12345".to_string(),
482 platform: "macos".to_string(),
483 service: Some("*Wi-Fi".to_string()),
484 http_proxy: Some("old-proxy:8080".to_string()),
485 https_proxy: None,
486 socks_proxy: None,
487 no_proxy: None,
488 };
489
490 let dir = tempfile::tempdir().unwrap();
491 let path = dir.path().join("rollback.json");
492 state.save(&path).unwrap();
493
494 let loaded = RollbackState::load(&path).unwrap();
495 assert_eq!(loaded.platform, "macos");
496 assert_eq!(loaded.http_proxy.as_deref(), Some("old-proxy:8080"));
497 }
498
499 #[test]
500 fn create_rollback_from_settings() {
501 let settings = SystemProxySettings {
502 source: "test".to_string(),
503 http_proxy: Some("http://proxy:8080".to_string()),
504 https_proxy: None,
505 socks_proxy: None,
506 no_proxy: None,
507 raw: HashMap::new(),
508 };
509 let rollback = create_rollback("macos", Some("*Wi-Fi"), &settings);
510 assert_eq!(rollback.http_proxy.as_deref(), Some("http://proxy:8080"));
511 assert_eq!(rollback.platform, "macos");
512 }
513
514 #[cfg(unix)]
515 #[test]
516 fn execute_apply_runs_commands() {
517 use std::os::unix::process::ExitStatusExt;
518 let runner = MockCommandRunner::new().add_always(
519 "networksetup",
520 Ok(std::process::Output {
521 status: std::process::ExitStatus::from_raw(0),
522 stdout: Vec::new(),
523 stderr: Vec::new(),
524 }),
525 );
526
527 let plan = ApplyPlan {
528 platform: "macos".to_string(),
529 service: Some("*Wi-Fi".to_string()),
530 http_proxy: Some("proxy:8080".to_string()),
531 https_proxy: None,
532 socks_proxy: None,
533 no_proxy: None,
534 commands: vec![Command::new(
535 "networksetup",
536 vec!["-setwebproxy".into(), "*Wi-Fi".into(), "on".into()],
537 )],
538 previous_settings: None,
539 };
540
541 let executed = execute_apply(&plan, &runner).unwrap();
542 assert_eq!(executed.len(), 1);
543 let calls = runner.calls();
544 assert_eq!(calls[0].0, "networksetup");
545 assert_eq!(calls[0].1, vec!["-setwebproxy", "*Wi-Fi", "on"]);
546 }
547
548 #[test]
549 fn revert_commands_macos() {
550 let rollback = RollbackState {
551 timestamp: "12345".to_string(),
552 platform: "macos".to_string(),
553 service: Some("*Wi-Fi".to_string()),
554 http_proxy: Some("proxy:8080".to_string()),
555 https_proxy: None,
556 socks_proxy: None,
557 no_proxy: None,
558 };
559 let commands = generate_revert_commands(&rollback);
560 assert!(commands.iter().any(|c| c.to_string().contains("off")));
561 assert!(commands
562 .iter()
563 .any(|c| c.to_string().contains("setwebproxy")));
564 }
565
566 #[cfg(unix)]
567 #[test]
568 fn applied_proxy_restore_is_idempotent() {
569 use std::os::unix::process::ExitStatusExt;
570 let runner = MockCommandRunner::new().add_always(
571 "networksetup",
572 Ok(std::process::Output {
573 status: std::process::ExitStatus::from_raw(0),
574 stdout: Vec::new(),
575 stderr: Vec::new(),
576 }),
577 );
578 let rollback = RollbackState {
579 timestamp: "12345".to_string(),
580 platform: "macos".to_string(),
581 service: Some("*Wi-Fi".to_string()),
582 http_proxy: None,
583 https_proxy: None,
584 socks_proxy: None,
585 no_proxy: None,
586 };
587 let mut applied = AppliedProxy {
588 rollback: Some(rollback),
589 };
590 applied.restore_with_runner(&runner).unwrap();
591 applied.restore_with_runner(&runner).unwrap();
592 assert!(!runner.calls().is_empty());
593 }
594}