summaryrefslogtreecommitdiffstats
path: root/toolkit/crashreporter/client/app/src/net/report.rs
blob: 46be952547362d9af9fba05cbf08983bacfec322 (plain)
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
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */

//! Support for crash report creation and upload.
//!
//! Upload currently uses the system libcurl or curl binary rather than a rust network stack (as
//! curl is more mature, albeit the code to interact with it must be a bit more careful).

use crate::std::{ffi::OsStr, path::Path, process::Child};
use anyhow::Context;

#[cfg(mock)]
use crate::std::mock::{mock_key, MockKey};

#[cfg(mock)]
mock_key! {
    pub struct MockLibCurl => Box<dyn Fn(&CrashReport) -> std::io::Result<std::io::Result<String>> + Send + Sync>
}

pub const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));

/// A crash report to upload.
///
/// Post a multipart form payload to the report URL.
///
/// The form data contains:
/// | name | filename | content | mime |
/// ====================================
/// | `extra` | `extra.json` | extra json object | `application/json`|
/// | `upload_file_minidump` | dump file name | dump file contents | derived (probably application/binary) |
/// if present:
/// | `memory_report` | memory file name | memory file contents | derived (probably gzipped json) |
pub struct CrashReport<'a> {
    pub extra: &'a serde_json::Value,
    pub dump_file: &'a Path,
    pub memory_file: Option<&'a Path>,
    pub url: &'a OsStr,
}

impl CrashReport<'_> {
    /// Send the crash report.
    pub fn send(&self) -> std::io::Result<CrashReportSender> {
        // Windows 10+ and macOS 10.15+ contain `curl` 7.64.1+ as a system-provided binary, so
        // `send_with_curl_binary` should not fail.
        //
        // Linux distros generally do not contain `curl`, but `libcurl` is very likely to be
        // incidentally installed (if not outright part of the distro base packages). Based on a
        // cursory look at the debian repositories as an examplar, the curl binary is much less
        // likely to be incidentally installed.
        //
        // For uniformity, we always will try the curl binary first, then try libcurl if that
        // fails.

        let extra_json_data = serde_json::to_string(self.extra)?;

        self.send_with_curl_binary(extra_json_data.clone())
            .or_else(|e| {
                log::info!("failed to invoke curl ({e}), trying libcurl");
                self.send_with_libcurl(extra_json_data.clone())
            })
    }

    /// Send the crash report using the `curl` binary.
    fn send_with_curl_binary(&self, extra_json_data: String) -> std::io::Result<CrashReportSender> {
        let mut cmd = crate::process::background_command("curl");

        cmd.args(["--user-agent", USER_AGENT]);

        cmd.arg("--form");
        // `@-` causes the data to be read from stdin, which is desirable to not have to worry
        // about process argument string length limitations (though they are generally pretty high
        // limits).
        cmd.arg("extra=@-;filename=extra.json;type=application/json");

        cmd.arg("--form");
        cmd.arg(format!(
            "upload_file_minidump=@{}",
            CurlQuote(&self.dump_file.display().to_string())
        ));

        if let Some(path) = self.memory_file {
            cmd.arg("--form");
            cmd.arg(format!(
                "memory_report=@{}",
                CurlQuote(&path.display().to_string())
            ));
        }

        cmd.arg(self.url);

        cmd.stdin(std::process::Stdio::piped());
        cmd.stdout(std::process::Stdio::piped());
        cmd.stderr(std::process::Stdio::piped());

        cmd.spawn().map(move |child| CrashReportSender::CurlChild {
            child,
            extra_json_data,
        })
    }

    /// Send the crash report using the `curl` library.
    fn send_with_libcurl(&self, extra_json_data: String) -> std::io::Result<CrashReportSender> {
        #[cfg(mock)]
        if !crate::std::mock::try_hook(false, "use_system_libcurl") {
            return self.send_with_mock_libcurl(extra_json_data);
        }

        let curl = super::libcurl::load()?;
        let mut easy = curl.easy()?;

        easy.set_url(&self.url.to_string_lossy())?;
        easy.set_user_agent(USER_AGENT)?;
        easy.set_max_redirs(30)?;

        let mut mime = easy.mime()?;
        {
            let mut part = mime.add_part()?;
            part.set_name("extra")?;
            part.set_filename("extra.json")?;
            part.set_type("application/json")?;
            part.set_data(extra_json_data.as_bytes())?;
        }
        {
            let mut part = mime.add_part()?;
            part.set_name("upload_file_minidump")?;
            part.set_filename(&self.dump_file.display().to_string())?;
            part.set_filedata(self.dump_file)?;
        }
        if let Some(path) = self.memory_file {
            let mut part = mime.add_part()?;
            part.set_name("memory_report")?;
            part.set_filename(&path.display().to_string())?;
            part.set_filedata(path)?;
        }
        easy.set_mime_post(mime)?;

        Ok(CrashReportSender::LibCurl { easy })
    }

    #[cfg(mock)]
    fn send_with_mock_libcurl(
        &self,
        _extra_json_data: String,
    ) -> std::io::Result<CrashReportSender> {
        MockLibCurl
            .get(|f| f(&self))
            .map(|response| CrashReportSender::MockLibCurl { response })
    }
}

pub enum CrashReportSender {
    CurlChild {
        child: Child,
        extra_json_data: String,
    },
    LibCurl {
        easy: super::libcurl::Easy<'static>,
    },
    #[cfg(mock)]
    MockLibCurl {
        response: std::io::Result<String>,
    },
}

impl CrashReportSender {
    pub fn finish(self) -> anyhow::Result<Response> {
        let response = match self {
            Self::CurlChild {
                mut child,
                extra_json_data,
            } => {
                {
                    let mut stdin = child
                        .stdin
                        .take()
                        .context("failed to get curl process stdin")?;
                    std::io::copy(&mut std::io::Cursor::new(extra_json_data), &mut stdin)
                        .context("failed to write extra file data to stdin of curl process")?;
                    // stdin is dropped at the end of this scope so that the stream gets an EOF,
                    // otherwise curl will wait for more input.
                }
                let output = child
                    .wait_with_output()
                    .context("failed to wait on curl process")?;
                anyhow::ensure!(
                    output.status.success(),
                    "process failed (exit status {}) with stderr: {}",
                    output.status,
                    String::from_utf8_lossy(&output.stderr)
                );
                String::from_utf8_lossy(&output.stdout).into_owned()
            }
            Self::LibCurl { easy } => {
                let response = easy.perform()?;
                let response_code = easy.get_response_code()?;

                let response = String::from_utf8_lossy(&response).into_owned();
                dbg!(&response, &response_code);

                anyhow::ensure!(
                    response_code == 200,
                    "unexpected response code ({response_code}): {response}"
                );

                response
            }
            #[cfg(mock)]
            Self::MockLibCurl { response } => response?.into(),
        };

        log::debug!("received response from sending report: {:?}", &*response);
        Ok(Response::parse(response))
    }
}

/// A parsed response from submitting a crash report.
#[derive(Default, Debug)]
pub struct Response {
    pub crash_id: Option<String>,
    pub stop_sending_reports_for: Option<String>,
    pub view_url: Option<String>,
    pub discarded: bool,
}

impl Response {
    /// Parse a server response.
    ///
    /// The response should be newline-separated `<key>=<value>` pairs.
    fn parse<S: AsRef<str>>(response: S) -> Self {
        let mut ret = Self::default();
        // Fields may be omitted, and parsing is best-effort but will not produce any errors (just
        // a default Response struct).
        for line in response.as_ref().lines() {
            if let Some((key, value)) = line.split_once('=') {
                match key {
                    "StopSendingReportsFor" => {
                        ret.stop_sending_reports_for = Some(value.to_owned())
                    }
                    "Discarded" => ret.discarded = true,
                    "CrashID" => ret.crash_id = Some(value.to_owned()),
                    "ViewURL" => ret.view_url = Some(value.to_owned()),
                    _ => (),
                }
            }
        }
        ret
    }
}

/// Quote a string per https://curl.se/docs/manpage.html#-F.
/// That is, add quote characters and escape " and \ with backslashes.
struct CurlQuote<'a>(&'a str);
impl std::fmt::Display for CurlQuote<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        use std::fmt::Write;

        f.write_char('"')?;
        const ESCAPE_CHARS: [char; 2] = ['"', '\\'];
        for substr in self.0.split_inclusive(ESCAPE_CHARS) {
            // The last string returned by `split_inclusive` may or may not contain the
            // search character, unfortunately.
            if substr.ends_with(ESCAPE_CHARS) {
                // Safe to use a byte offset rather than a character offset because the
                // ESCAPE_CHARS are each 1 byte in utf8.
                let (s, escape) = substr.split_at(substr.len() - 1);
                f.write_str(s)?;
                f.write_char('\\')?;
                f.write_str(escape)?;
            } else {
                f.write_str(substr)?;
            }
        }
        f.write_char('"')
    }
}