Skip to main content

cloudpub_client/
shell.rs

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
261/// Where an archive entry may be written, or None when it may not be written
262/// at all.
263///
264/// The entry name comes out of the archive, and archives come from the server
265/// and from the self-update download - so it is attacker-controlled the moment
266/// either is malicious or intercepted. This used to be
267/// `Path::new(file.name()).components().skip(skip).collect()` joined onto the
268/// extraction directory, and `components()` keeps `..`: an entry named
269/// `../../bin/clo` walked straight out of the cache directory, and what lands
270/// there is chmod 0755'd and executed afterwards (CLOUDPUB-1298).
271///
272/// Nothing here resolves `..` - it refuses it, along with anything else that
273/// is not a plain name: an absolute path, a Windows prefix, a root component.
274/// `.` is dropped, being a no-op.
275fn 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
356/// An HTTP client carrying the config's TLS setup - custom roots and, where the
357/// config asks for it, the certificate-verification escape hatch.
358///
359/// Extracted from `download` so that everything the client sends over HTTPS
360/// (downloads, and the bug report of CLOUDPUB-577) goes out over the same TLS
361/// setup rather than each call site rebuilding its own.
362pub 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    // Reqwest setup
413    let res = client
414        .get(url)
415        .send()
416        .await
417        .context(format!("Failed to GET from '{}'", url))?;
418
419    // Check if response status is 200 OK
420    if !res.status().is_success() {
421        bail!("HTTP request failed with status: {}", res.status());
422    }
423
424    // Indicatif setup
425    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    // download chunks
456    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                    // Throttle download progress
486                    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    // CLOUDPUB-1298: unzip built the destination with
542    // Path::new(file.name()).components().skip(skip).collect(), and
543    // components() keeps ParentDir. The archives come from the server and from
544    // the self-update download, and what is unpacked is chmod 0755'd and run,
545    // so an entry named ../../bin/clo was arbitrary file write and, in
546    // practice, code execution on the client.
547    // HYPOTHESIS: nothing between the entry name and File::create rejects "..".
548    // SOLUTION: accept only plain name components; refuse the rest.
549    // RESULT: every escaping shape resolves to None instead of a path outside.
550    // STATUS: FIXED - see CLOUDPUB-1298
551
552    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        // skip must not become a way to smuggle "..": the components that
597        // remain are what gets checked.
598        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}