1use crate::config::ClientConfig;
2use anyhow::{bail, Context, Result};
3use std::cmp::min;
4use std::fs::File;
5use std::io::Write;
6use std::path::PathBuf;
7
8use cloudpub_common::protocol::message::Message;
9use cloudpub_common::protocol::{Break, ErrorInfo, ErrorKind, ProgressInfo};
10use cloudpub_common::transport::rustls::load_roots;
11use dirs::cache_dir;
12use futures::stream::StreamExt;
13use parking_lot::RwLock;
14use reqwest::{Certificate, ClientBuilder};
15use std::collections::HashMap;
16use std::io;
17use std::path::{Component, Path};
18use std::process::Stdio;
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::Arc;
21use tokio::io::{AsyncBufReadExt, BufReader};
22use tokio::process::Command;
23use tokio::sync::mpsc;
24use tracing::{error, info, warn};
25use walkdir::WalkDir;
26use zip::read::ZipArchive;
27
28pub const DOWNLOAD_SUBDIR: &str = "download";
29
30pub struct SubProcess {
31 shutdown_tx: mpsc::Sender<Message>,
32 command: PathBuf,
33 args: Vec<String>,
34 pub port: u16,
35 canceled: Arc<AtomicBool>,
36 pub result: Arc<RwLock<Result<()>>>,
37}
38
39impl SubProcess {
40 pub fn new(
41 command: PathBuf,
42 args: Vec<String>,
43 chdir: Option<PathBuf>,
44 envs: HashMap<String, String>,
45 result_tx: mpsc::Sender<Message>,
46 port: u16,
47 ) -> Self {
48 let canceled = Arc::new(AtomicBool::new(false));
49 let (shutdown_tx, mut shutdown_rx) = mpsc::channel(1);
50 let command2 = command.clone();
51 let args2 = args.clone();
52 let canceled2 = canceled.clone();
53 let result = Arc::new(RwLock::new(Ok(())));
54 let result_clone = result.clone();
55 tokio::spawn(async move {
56 if let Err(err) = execute(command2, args2, chdir, envs, None, &mut shutdown_rx).await {
57 if !canceled2.load(Ordering::Relaxed) {
58 error!("Failed to execute command: {:?}", err);
59 result_tx
60 .send(Message::Error(ErrorInfo {
61 kind: ErrorKind::ExecuteFailed.into(),
62 message: crate::t!("error-execute-failed", "err" => err.to_string()),
63 guid: String::new(),
64 }))
65 .await
66 .ok();
67 }
68 *result_clone.write() = Err(err);
69 }
70 });
71 Self {
72 shutdown_tx,
73 port,
74 command,
75 args,
76 canceled,
77 result,
78 }
79 }
80
81 pub fn stop(&mut self) {
82 self.canceled.store(true, Ordering::Relaxed);
83 self.shutdown_tx
84 .try_send(Message::Break(Break {
85 ..Default::default()
86 }))
87 .ok();
88 }
89}
90
91impl Drop for SubProcess {
92 fn drop(&mut self) {
93 info!("Drop subprocess: {:?} {:?}", self.command, self.args);
94 self.stop();
95 }
96}
97
98pub async fn send_progress(
99 message: &str,
100 template: &str,
101 total: u64,
102 current: u64,
103 progress_tx: &mpsc::Sender<Message>,
104) {
105 let progress = ProgressInfo {
106 message: message.to_string(),
107 template: template.to_string(),
108 total: total as u32,
109 current: current as u32,
110 ..Default::default()
111 };
112 progress_tx.send(Message::Progress(progress)).await.ok();
113}
114
115pub async fn execute(
116 command: PathBuf,
117 args: Vec<String>,
118 chdir: Option<PathBuf>,
119 envs: HashMap<String, String>,
120 progress: Option<(String, mpsc::Sender<Message>, u64)>,
121 shutdown_rx: &mut mpsc::Receiver<Message>,
122) -> Result<()> {
123 let argv = format!("{} {}", command.to_str().unwrap(), args.join(" "));
124 info!("Executing command: {}", argv);
125
126 info!("Environment: {:?}", envs);
127
128 let template = crate::t!("progress-files-eta");
129 let chdir = chdir.as_deref().unwrap_or(Path::new("."));
130
131 if let Some((message, tx, total)) = progress.as_ref() {
132 send_progress(message, &template, *total, 0, tx).await;
133 send_progress(message, &template, *total, 1, tx).await;
134 }
135
136 #[cfg(windows)]
137 let mut child = Command::new(command.clone())
138 .args(args.clone())
139 .kill_on_drop(true)
140 .current_dir(&chdir)
141 .stdout(Stdio::piped())
142 .stderr(Stdio::piped())
143 .creation_flags(0x08000000)
144 .envs(envs)
145 .spawn()
146 .context(format!(
147 "Failed to execute command: {:?} {:?}",
148 command, args
149 ))?;
150
151 #[cfg(not(windows))]
152 let mut child = Command::new(command.clone())
153 .args(args.clone())
154 .kill_on_drop(true)
155 .current_dir(chdir)
156 .stdout(Stdio::piped())
157 .stderr(Stdio::piped())
158 .envs(envs)
159 .spawn()
160 .context(format!(
161 "Failed to execute command: {:?} {:?}",
162 command, args
163 ))?;
164
165 let stdout = child.stdout.take().context("Failed to get stdout")?;
166 let stderr = child.stderr.take().context("Failed to get stderr")?;
167
168 let stdout_reader = BufReader::new(stdout).lines();
169 let stderr_reader = BufReader::new(stderr).lines();
170
171 let progress1 = progress.clone();
172 let template_clone = template.clone();
173 let last_stderr = Arc::new(RwLock::new(String::new()));
174 let last_stderr_clone = last_stderr.clone();
175 tokio::spawn(async move {
176 tokio::pin!(stdout_reader);
177 tokio::pin!(stderr_reader);
178 let mut current = 0;
179 loop {
180 let progress = progress1.clone();
181 tokio::select! {
182 line = stdout_reader.next_line() => match line {
183 Ok(Some(line)) => {
184 info!("STDOUT: {}", line);
185 current += 1;
186 if let Some((message, tx, total)) = progress.as_ref() {
187 send_progress(message, &template_clone, *total, current, tx).await;
188 }
189 }
190 Err(e) => {
191 bail!("Error reading stdout: {}", e);
192 },
193 Ok(None) => {
194 info!("STDOUT EOF");
195 break;
196 }
197 },
198 line = stderr_reader.next_line() => match line {
199 Ok(Some(line)) => {
200 warn!("STDERR: {}", line);
201 {
202 let mut last = last_stderr.write();
203 if !last.is_empty() {
204 *last += " ";
205 }
206 if (*last).len() > 2000 {
207 *last = (*last).split_off(1000);
208 }
209 *last += &line;
210 }
211 current += 1;
212 if let Some((message, tx, total)) = progress.as_ref() {
213 send_progress(message, &template_clone, *total, current, tx).await;
214 }
215 },
216 Err(e) => {
217 bail!("Error reading stderr: {}", e);
218 },
219 Ok(None) => {
220 info!("STDERR EOF");
221 break;
222 }
223 },
224 }
225 }
226 Ok(())
227 });
228
229 tokio::select! {
230 status = child.wait() => {
231 let status = status.context("Failed to wait on child")?;
232 if !status.success() {
233 if let Some((message, tx, total)) = progress.as_ref() {
234 send_progress(message, &template, *total, *total, tx).await;
235 }
236 bail!("{}: exit code {} ({})", command.file_name().unwrap().to_string_lossy(), status.code().unwrap_or(-1), *last_stderr_clone.read());
237 }
238 }
239
240 cmd = shutdown_rx.recv() => match cmd {
241 Some(Message::Stop(_)) | Some(Message::Break(_)) => {
242 info!("Received break command, killing child process: {}", argv);
243 child.kill().await.ok();
244 }
245 None => {
246 info!("Command channel closed, killing child process: {}", argv);
247 child.kill().await.ok();
248 }
249 _ => {}
250 }
251 }
252
253 if let Some((message, tx, total)) = progress.as_ref() {
254 send_progress(message, &template, *total, *total, tx).await;
255 }
256 info!("Command executed successfully");
257
258 Ok(())
259}
260
261fn extract_target(extract_dir: &Path, name: &str, skip: usize) -> Option<PathBuf> {
276 let mut relative = PathBuf::new();
277 for component in Path::new(name).components().skip(skip) {
278 match component {
279 Component::Normal(part) => relative.push(part),
280 Component::CurDir => {}
281 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
282 warn!("Refusing archive entry outside the extraction directory: {name}");
283 return None;
284 }
285 }
286 }
287 if relative.as_os_str().is_empty() {
288 return None;
289 }
290 Some(extract_dir.join(relative))
291}
292
293pub async fn unzip(
294 message: &str,
295 zip_file_path: &Path,
296 extract_dir: &Path,
297 skip: usize,
298 result_tx: &mpsc::Sender<Message>,
299) -> Result<()> {
300 info!("Unzipping {:?} to {:?}", zip_file_path, extract_dir);
301 let file = File::open(zip_file_path)?;
302 let mut archive = ZipArchive::new(file)?;
303
304 std::fs::create_dir_all(extract_dir)
305 .context(format!("Failed to create dir '{:?}'", extract_dir))?;
306
307 let template = crate::t!("progress-files-eta");
308
309 let mut progress = ProgressInfo {
310 message: message.to_string(),
311 template,
312 total: archive.len() as u32,
313 current: 0,
314 ..Default::default()
315 };
316
317 result_tx
318 .send(Message::Progress(progress.clone()))
319 .await
320 .ok();
321
322 for i in 0..archive.len() {
323 let mut file = archive
324 .by_index(i)
325 .context("Failed to get file from archive")?;
326 let Some(target_path) = extract_target(extract_dir, file.name(), skip) else {
327 continue;
328 };
329 info!("Extracting {:?}", target_path);
330 if file.is_dir() {
331 std::fs::create_dir_all(target_path.clone())
332 .context(format!("unzip failed to create dir '{:?}'", target_path))?;
333 } else {
334 let mut output_file = File::create(target_path.clone())
335 .context(format!("unzip failed to create file '{:?}'", target_path))?;
336 io::copy(&mut file, &mut output_file).context("unzip failed to copy file")?;
337 }
338
339 progress.current = (i + 1) as u32;
340 if progress.current.is_multiple_of(100) {
341 result_tx
342 .send(Message::Progress(progress.clone()))
343 .await
344 .ok();
345 }
346 }
347
348 progress.current = progress.total;
349 result_tx
350 .send(Message::Progress(progress.clone()))
351 .await
352 .ok();
353 Ok(())
354}
355
356pub fn build_http_client(config: &Arc<RwLock<ClientConfig>>) -> Result<reqwest::Client> {
363 let mut client = ClientBuilder::default();
364
365 let danger_accept_invalid_certs = config
366 .read()
367 .transport
368 .tls
369 .as_ref()
370 .and_then(|tls| tls.danger_ignore_certificate_verification)
371 .unwrap_or(false);
372
373 if let Some(tls) = &config.read().transport.tls {
374 let roots = load_roots(tls).context("Failed to load client config")?;
375 for cert_der in roots {
376 let cert = Certificate::from_der(&cert_der)?;
377 client = client.add_root_certificate(cert);
378 }
379 if tls.danger_ignore_certificate_verification.unwrap_or(false) {
380 client = client.danger_accept_invalid_certs(danger_accept_invalid_certs);
381 }
382 }
383
384 match client.build() {
385 Ok(client) => Ok(client),
386 Err(e) => {
387 error!(
388 "Failed to create reqwest client with system certificates: {:?}",
389 e
390 );
391 warn!("Using default reqwest client");
392 reqwest::Client::builder()
393 .danger_accept_invalid_certs(danger_accept_invalid_certs)
394 .build()
395 .context("Failed to create defaut reqwest client")
396 }
397 }
398}
399
400pub async fn download(
401 message: &str,
402 config: Arc<RwLock<ClientConfig>>,
403 url: &str,
404 path: &Path,
405 command_rx: &mut mpsc::Receiver<Message>,
406 result_tx: &mpsc::Sender<Message>,
407) -> Result<()> {
408 info!("Downloading {} to {:?}", url, path);
409
410 let client = build_http_client(&config)?;
411
412 let res = client
414 .get(url)
415 .send()
416 .await
417 .context(format!("Failed to GET from '{}'", url))?;
418
419 if !res.status().is_success() {
421 bail!("HTTP request failed with status: {}", res.status());
422 }
423
424 let total_size = res
426 .content_length()
427 .context(format!("Failed to get content length from '{}'", url))?;
428
429 if let Ok(file) = File::open(path) {
430 if file
431 .metadata()
432 .context(format!("Failed to get metadata from '{:?}'", path))?
433 .len()
434 == total_size
435 {
436 return Ok(());
437 }
438 }
439
440 let template = crate::t!("progress-bytes");
441
442 let mut progress = ProgressInfo {
443 message: message.to_string(),
444 template,
445 total: total_size as u32,
446 current: 0,
447 ..Default::default()
448 };
449
450 result_tx
451 .send(Message::Progress(progress.clone()))
452 .await
453 .ok();
454
455 let mut file = File::create(path).context(format!("Failed to create file '{:?}'", path))?;
457 let mut stream = res.bytes_stream();
458
459 loop {
460 tokio::select! {
461 cmd = command_rx.recv() => {
462 match cmd {
463 Some(Message::Stop(_)) | Some(Message::Break(_)) => {
464 info!("Download cancelled");
465 progress.total = total_size as u32;
466 result_tx.send(Message::Progress(progress.clone())).await.ok();
467 bail!("Download cancelled");
468 }
469 None => {
470 progress.total = total_size as u32;
471 result_tx.send(Message::Progress(progress.clone())).await.ok();
472 bail!("Command channel closed");
473 }
474 _ => {}
475 }
476 }
477
478 item = stream.next() => {
479 if let Some(item) = item {
480 let chunk = item.context("Failed to get chunk")?;
481 file.write_all(&chunk).context("Error while writing to file")?;
482 let kb_current = progress.current / 1024;
483 progress.current = min(progress.current + (chunk.len() as u32), total_size as u32);
484 let kb_new = progress.current / 1024;
485 if kb_new > kb_current {
487 result_tx.send(Message::Progress(progress.clone())).await.ok();
488 }
489 } else {
490 break;
491 }
492 }
493 }
494 }
495
496 progress.current = total_size as u32;
497 result_tx
498 .send(Message::Progress(progress.clone()))
499 .await
500 .ok();
501 Ok(())
502}
503
504pub fn compare_filenames(path1: &Path, path2: &Path) -> bool {
505 if let (Some(file_name1), Some(file_name2)) = (path1.file_name(), path2.file_name()) {
506 let filename1 = file_name1.to_string_lossy();
507 let filename2 = file_name2.to_string_lossy();
508 #[cfg(windows)]
509 return filename1.eq_ignore_ascii_case(&filename2);
510 #[cfg(not(windows))]
511 filename1.eq(&filename2)
512 } else {
513 false
514 }
515}
516
517pub fn find(dir: &Path, file: &Path) -> Result<Option<PathBuf>> {
518 info!("Searching for {:?} in {:?}", file, dir);
519 for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
520 if compare_filenames(entry.path(), file) {
521 return Ok(Some(entry.path().to_path_buf()));
522 }
523 }
524 Ok(None)
525}
526
527pub fn get_cache_dir(subdir: &str) -> Result<PathBuf> {
528 let mut cache_dir = cache_dir().context("Can't get cache dir")?;
529 cache_dir.push("cloudpub");
530 if !subdir.is_empty() {
531 cache_dir.push(subdir);
532 }
533 std::fs::create_dir_all(cache_dir.clone()).context("Can't create cache dir")?;
534 Ok(cache_dir)
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 fn dir() -> PathBuf {
553 PathBuf::from("/home/user/.cache/cloudpub")
554 }
555
556 #[test]
557 fn plain_entry_lands_inside_the_extraction_directory() {
558 assert_eq!(
559 extract_target(&dir(), "httpd/bin/httpd", 0),
560 Some(dir().join("httpd/bin/httpd"))
561 );
562 }
563
564 #[test]
565 fn skip_drops_the_leading_components() {
566 assert_eq!(
567 extract_target(&dir(), "httpd-2.4.63/bin/httpd", 1),
568 Some(dir().join("bin/httpd"))
569 );
570 }
571
572 #[test]
573 fn current_directory_components_are_dropped() {
574 assert_eq!(
575 extract_target(&dir(), "./bin/./httpd", 0),
576 Some(dir().join("bin/httpd"))
577 );
578 }
579
580 #[test]
581 fn escaping_entries_are_refused() {
582 for name in [
583 "../evil",
584 "../../bin/clo",
585 "httpd/../../../../etc/systemd/system/cloudpub.service",
586 "/etc/passwd",
587 "/../etc/passwd",
588 "..",
589 ] {
590 assert_eq!(extract_target(&dir(), name, 0), None, "must refuse {name}");
591 }
592 }
593
594 #[test]
595 fn escaping_after_skip_is_refused() {
596 assert_eq!(extract_target(&dir(), "prefix/../../evil", 1), None);
599 }
600
601 #[test]
602 fn an_entry_naming_the_root_itself_is_skipped() {
603 assert_eq!(extract_target(&dir(), "prefix", 1), None);
604 assert_eq!(extract_target(&dir(), ".", 0), None);
605 assert_eq!(extract_target(&dir(), "", 0), None);
606 }
607}