1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
use std::path::Path;
use std::{
collections::HashMap,
process::{Output, Stdio},
};
use test_rpc::package::{Error, Package, Result};
use tokio::process::Command;
#[cfg(target_os = "linux")]
pub async fn uninstall_app(env: HashMap<String, String>) -> Result<()> {
match get_distribution()? {
Distribution::Debian | Distribution::Ubuntu => {
uninstall_apt("mullvad-vpn", env, true).await
}
Distribution::Fedora => uninstall_rpm("mullvad-vpn", env).await,
}
}
#[cfg(target_os = "macos")]
pub async fn uninstall_app(env: HashMap<String, String>) -> Result<()> {
use tokio::io::AsyncWriteExt;
// Uninstall uses sudo -- patch sudoers to not strip env vars
let mut sudoers = tokio::fs::OpenOptions::new()
.append(true)
.open("/etc/sudoers")
.await
.map_err(|e| strip_error(Error::WriteFile, e))?;
for k in env.keys() {
sudoers
.write_all(format!("\nDefaults env_keep += \"{k}\"").as_bytes())
.await
.map_err(|e| strip_error(Error::WriteFile, e))?;
}
drop(sudoers);
// Run uninstall script, answer yes to everything
let mut cmd = Command::new("zsh");
cmd.arg("-c");
cmd.arg(
"\"/Applications/Mullvad VPN.app/Contents/Resources/uninstall.sh\" << EOF
y
y
y
EOF",
);
cmd.envs(env);
cmd.kill_on_drop(true);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.spawn()
.map_err(|e| strip_error(Error::RunApp, e))?
.wait_with_output()
.await
.map_err(|e| strip_error(Error::RunApp, e))
.and_then(|output| result_from_output("uninstall.sh", output))
}
#[cfg(target_os = "windows")]
pub async fn uninstall_app(env: HashMap<String, String>) -> Result<()> {
// TODO: obtain from registry
// TODO: can this mimic an actual uninstall more closely?
let program_dir = Path::new(r"C:\Program Files\Mullvad VPN");
let uninstall_path = program_dir.join("Uninstall Mullvad VPN.exe");
// To wait for the uninstaller, we must copy it to a temporary directory and
// supply it with the install path.
let temp_uninstaller = std::env::temp_dir().join("mullvad_uninstall.exe");
tokio::fs::copy(uninstall_path, &temp_uninstaller)
.await
.map_err(|e| strip_error(Error::CreateTempUninstaller, e))?;
let mut cmd = Command::new(temp_uninstaller);
cmd.kill_on_drop(true);
cmd.arg("/allusers");
// Silent mode
cmd.arg("/S");
// NSIS doesn't understand that it shouldn't fork itself unless
// there's whitespace prepended to "_?=".
cmd.arg(format!(" _?={}", program_dir.display()));
cmd.envs(env);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.spawn()
.map_err(|e| strip_error(Error::RunApp, e))?
.wait_with_output()
.await
.map_err(|e| strip_error(Error::RunApp, e))
.and_then(|output| result_from_output("uninstall app", output))
}
#[cfg(target_os = "windows")]
pub async fn install_package(package: Package) -> Result<()> {
install_nsis_exe(&package.path).await
}
#[cfg(target_os = "linux")]
pub async fn install_package(package: Package) -> Result<()> {
match get_distribution()? {
Distribution::Debian | Distribution::Ubuntu => install_apt(&package.path).await,
Distribution::Fedora => install_rpm(&package.path).await,
}
}
#[cfg(target_os = "macos")]
pub async fn install_package(package: Package) -> Result<()> {
let mut cmd = Command::new("/usr/sbin/installer");
cmd.arg("-pkg");
cmd.arg(package.path);
cmd.arg("-target");
cmd.arg("/");
cmd.kill_on_drop(true);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.spawn()
.map_err(|e| strip_error(Error::RunApp, e))?
.wait_with_output()
.await
.map_err(|e| strip_error(Error::RunApp, e))
.and_then(|output| result_from_output("installer -pkg", output))
}
#[cfg(target_os = "linux")]
async fn install_apt(path: &Path) -> Result<()> {
let mut cmd = apt_command();
cmd.arg("install");
cmd.arg(path.as_os_str());
cmd.kill_on_drop(true);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.spawn()
.map_err(|e| strip_error(Error::RunApp, e))?
.wait_with_output()
.await
.map_err(|e| strip_error(Error::RunApp, e))
.and_then(|output| result_from_output("apt install", output))
}
#[cfg(target_os = "linux")]
async fn uninstall_apt(name: &str, env: HashMap<String, String>, purge: bool) -> Result<()> {
let action;
let mut cmd = apt_command();
if purge {
action = "apt purge";
cmd.args(["purge", name]);
} else {
action = "apt remove";
cmd.args(["remove", name]);
}
cmd.envs(env);
cmd.kill_on_drop(true);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.spawn()
.map_err(|e| strip_error(Error::RunApp, e))?
.wait_with_output()
.await
.map_err(|e| strip_error(Error::RunApp, e))
.and_then(|output| result_from_output(action, output))
}
#[cfg(target_os = "linux")]
fn apt_command() -> Command {
let mut cmd = Command::new("/usr/bin/apt-get");
// We don't want to fail due to the global apt lock being
// held, which happens sporadically. Wait to acquire the lock
// instead.
cmd.args(["-o", "DPkg::Lock::Timeout=60"]);
cmd.arg("-qy");
// `apt` may consider installing a development build to be a downgrade from the baseline if the
// major version is identical, in which case the ordering is incorrectly based on the git hash
// suffix.
//
// Note that this is only sound if we take precaution to check the installed version after
// running this command.
cmd.arg("--allow-downgrades");
cmd.env("DEBIAN_FRONTEND", "noninteractive");
cmd
}
#[cfg(target_os = "linux")]
async fn install_rpm(path: &Path) -> Result<()> {
use std::time::Duration;
const MAX_INSTALL_ATTEMPTS: usize = 5;
const RETRY_SUBSTRING: &[u8] = b"Failed to download";
const RETRY_WAIT_INTERVAL: Duration = Duration::from_secs(3);
let mut cmd = Command::new("/usr/bin/dnf");
cmd.args(["install", "-y"]);
cmd.arg(path.as_os_str());
cmd.kill_on_drop(true);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut attempt = 0;
let mut output;
loop {
output = cmd
.spawn()
.map_err(|e| strip_error(Error::RunApp, e))?
.wait_with_output()
.await
.map_err(|e| strip_error(Error::RunApp, e))?;
let should_retry = !output.status.success()
&& output
.stderr
.windows(RETRY_SUBSTRING.len())
.any(|slice| slice == RETRY_SUBSTRING);
attempt += 1;
if should_retry && attempt < MAX_INSTALL_ATTEMPTS {
log::debug!("Retrying package install: retry attempt {}", attempt);
tokio::time::sleep(RETRY_WAIT_INTERVAL).await;
continue;
}
return result_from_output("dnf install", output);
}
}
#[cfg(target_os = "linux")]
async fn uninstall_rpm(name: &str, env: HashMap<String, String>) -> Result<()> {
let mut cmd = Command::new("/usr/bin/dnf");
cmd.args(["remove", "-y", name]);
cmd.envs(env);
cmd.kill_on_drop(true);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.spawn()
.map_err(|e| strip_error(Error::RunApp, e))?
.wait_with_output()
.await
.map_err(|e| strip_error(Error::RunApp, e))
.and_then(|output| result_from_output("dnf remove", output))
}
#[cfg(target_os = "windows")]
async fn install_nsis_exe(path: &Path) -> Result<()> {
log::info!("Installing {}", path.display());
let mut cmd = Command::new(path);
cmd.kill_on_drop(true);
// Run the installer in silent mode
cmd.arg("/S");
cmd.spawn()
.map_err(|e| strip_error(Error::RunApp, e))?
.wait_with_output()
.await
.map_err(|e| strip_error(Error::RunApp, e))
.and_then(|output| result_from_output("install app", output))
}
#[cfg(target_os = "linux")]
enum Distribution {
Debian,
Ubuntu,
Fedora,
}
#[cfg(target_os = "linux")]
fn get_distribution() -> Result<Distribution> {
let os_release =
rs_release::get_os_release().map_err(|_error| Error::UnknownOs("unknown".to_string()))?;
match os_release
.get("id")
.or(os_release.get("ID"))
.ok_or(Error::UnknownOs("unknown".to_string()))?
.as_str()
{
"debian" => Ok(Distribution::Debian),
"ubuntu" => Ok(Distribution::Ubuntu),
"fedora" => Ok(Distribution::Fedora),
os => Err(Error::UnknownOs(os.to_string())),
}
}
fn strip_error<T: std::error::Error>(error: Error, source: T) -> Error {
log::error!("Error: {error}\ncause: {source}");
error
}
fn result_from_output(action: &'static str, output: Output) -> Result<()> {
if output.status.success() {
return Ok(());
}
let stdout_str = std::str::from_utf8(&output.stdout).unwrap_or("non-utf8 string");
let stderr_str = std::str::from_utf8(&output.stderr).unwrap_or("non-utf8 string");
log::error!(
"{action} failed:\n\nstdout:\n\n{}\n\nstderr:\n\n{}",
stdout_str,
stderr_str
);
Err(output
.status
.code()
.map(Error::InstallerFailed)
.unwrap_or(Error::InstallerFailedSignal))
}
|