nix_installer/
diagnostics.rs

1/*! Diagnostic reporting functionality
2
3When enabled with the `diagnostics` feature (default) this module provides automated install success/failure reporting to an endpoint.
4
5That endpoint can be a URL such as `https://our.project.org/nix-installer/diagnostics` or `file:///home/$USER/diagnostic.json` which receives a [`DiagnosticReport`] in JSON format.
6*/
7
8use std::{path::PathBuf, time::Duration};
9
10use os_release::OsRelease;
11use reqwest::Url;
12
13use crate::{
14    action::ActionError, parse_ssl_cert, planner::PlannerError, settings::InstallSettingsError,
15    CertificateError, NixInstallerError,
16};
17
18/// The static of an action attempt
19#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
20pub enum DiagnosticStatus {
21    Cancelled,
22    Success,
23    Pending,
24    Failure,
25}
26
27/// The action attempted
28#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, Copy)]
29pub enum DiagnosticAction {
30    Install,
31    Uninstall,
32}
33
34/// A report sent to an endpoint
35#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
36pub struct DiagnosticReport {
37    pub attribution: Option<String>,
38    pub version: String,
39    pub planner: String,
40    pub configured_settings: Vec<String>,
41    pub os_name: String,
42    pub os_version: String,
43    pub triple: String,
44    pub is_ci: bool,
45    pub action: DiagnosticAction,
46    pub status: DiagnosticStatus,
47    /// Generally this includes the [`strum::IntoStaticStr`] representation of the error, we take special care not to include parameters of the error (which may include secrets)
48    pub failure_chain: Option<Vec<String>>,
49}
50
51/// A preparation of data to be sent to the `endpoint`.
52#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, Default)]
53pub struct DiagnosticData {
54    attribution: Option<String>,
55    version: String,
56    planner: String,
57    configured_settings: Vec<String>,
58    os_name: String,
59    os_version: String,
60    triple: String,
61    is_ci: bool,
62    endpoint: Option<Url>,
63    ssl_cert_file: Option<PathBuf>,
64    /// Generally this includes the [`strum::IntoStaticStr`] representation of the error, we take special care not to include parameters of the error (which may include secrets)
65    failure_chain: Option<Vec<String>>,
66}
67
68impl DiagnosticData {
69    pub fn new(
70        attribution: Option<String>,
71        endpoint: Option<String>,
72        planner: String,
73        configured_settings: Vec<String>,
74        ssl_cert_file: Option<PathBuf>,
75    ) -> Result<Self, DiagnosticError> {
76        let endpoint = match endpoint {
77            Some(endpoint) => diagnostic_endpoint_parser(&endpoint)?,
78            None => None,
79        };
80        let (os_name, os_version) = match OsRelease::new() {
81            Ok(os_release) => (os_release.name, os_release.version),
82            Err(_) => ("unknown".into(), "unknown".into()),
83        };
84        let is_ci = is_ci::cached()
85            || std::env::var("NIX_INSTALLER_CI").unwrap_or_else(|_| "0".into()) == "1";
86        Ok(Self {
87            attribution,
88            endpoint,
89            version: env!("CARGO_PKG_VERSION").into(),
90            planner,
91            configured_settings,
92            os_name,
93            os_version,
94            triple: target_lexicon::HOST.to_string(),
95            is_ci,
96            ssl_cert_file: ssl_cert_file.and_then(|v| v.canonicalize().ok()),
97            failure_chain: None,
98        })
99    }
100
101    pub fn failure(mut self, err: &NixInstallerError) -> Self {
102        let mut failure_chain = vec![];
103        let diagnostic = err.diagnostic();
104        failure_chain.push(diagnostic);
105
106        let mut walker: &dyn std::error::Error = &err;
107        while let Some(source) = walker.source() {
108            if let Some(downcasted) = source.downcast_ref::<ActionError>() {
109                let downcasted_diagnostic = downcasted.kind().diagnostic();
110                failure_chain.push(downcasted_diagnostic);
111            }
112            if let Some(downcasted) = source.downcast_ref::<Box<ActionError>>() {
113                let downcasted_diagnostic = downcasted.kind().diagnostic();
114                failure_chain.push(downcasted_diagnostic);
115            }
116            if let Some(downcasted) = source.downcast_ref::<PlannerError>() {
117                let downcasted_diagnostic = downcasted.diagnostic();
118                failure_chain.push(downcasted_diagnostic);
119            }
120            if let Some(downcasted) = source.downcast_ref::<InstallSettingsError>() {
121                let downcasted_diagnostic = downcasted.diagnostic();
122                failure_chain.push(downcasted_diagnostic);
123            }
124            if let Some(downcasted) = source.downcast_ref::<DiagnosticError>() {
125                let downcasted_diagnostic = downcasted.diagnostic();
126                failure_chain.push(downcasted_diagnostic);
127            }
128
129            walker = source;
130        }
131
132        self.failure_chain = Some(failure_chain);
133        self
134    }
135
136    pub fn report(&self, action: DiagnosticAction, status: DiagnosticStatus) -> DiagnosticReport {
137        let Self {
138            attribution,
139            version,
140            planner,
141            configured_settings,
142            os_name,
143            os_version,
144            triple,
145            is_ci,
146            endpoint: _,
147            ssl_cert_file: _,
148            failure_chain,
149        } = self;
150        DiagnosticReport {
151            attribution: attribution.clone(),
152            version: version.clone(),
153            planner: planner.clone(),
154            configured_settings: configured_settings.clone(),
155            os_name: os_name.clone(),
156            os_version: os_version.clone(),
157            triple: triple.clone(),
158            is_ci: *is_ci,
159            action,
160            status,
161            failure_chain: failure_chain.clone(),
162        }
163    }
164
165    #[tracing::instrument(level = "debug", skip_all)]
166    pub async fn send(
167        self,
168        action: DiagnosticAction,
169        status: DiagnosticStatus,
170    ) -> Result<(), DiagnosticError> {
171        let serialized = serde_json::to_string_pretty(&self.report(action, status))?;
172
173        let endpoint = match self.endpoint {
174            Some(endpoint) => endpoint,
175            None => return Ok(()),
176        };
177
178        match endpoint.scheme() {
179            "https" | "http" => {
180                tracing::debug!("Sending diagnostic to `{endpoint}`");
181                let mut buildable_client = reqwest::Client::builder();
182                if let Some(ssl_cert_file) = &self.ssl_cert_file {
183                    let ssl_cert = parse_ssl_cert(ssl_cert_file).await.ok();
184                    if let Some(ssl_cert) = ssl_cert {
185                        buildable_client = buildable_client.add_root_certificate(ssl_cert);
186                    }
187                }
188                let client = buildable_client.build().map_err(DiagnosticError::Reqwest)?;
189
190                let res = client
191                    .post(endpoint.clone())
192                    .body(serialized)
193                    .header("Content-Type", "application/json")
194                    .timeout(Duration::from_millis(3000))
195                    .send()
196                    .await;
197
198                if let Err(_err) = res {
199                    tracing::info!("Failed to send diagnostic to `{endpoint}`, continuing")
200                }
201            },
202            "file" => {
203                let path = endpoint.path();
204                tracing::debug!("Writing diagnostic to `{path}`");
205                let res = tokio::fs::write(path, serialized).await;
206
207                if let Err(_err) = res {
208                    tracing::info!("Failed to send diagnostic to `{path}`, continuing")
209                }
210            },
211            _ => return Err(DiagnosticError::UnknownUrlScheme),
212        };
213        Ok(())
214    }
215}
216
217#[non_exhaustive]
218#[derive(thiserror::Error, Debug, strum::IntoStaticStr)]
219pub enum DiagnosticError {
220    #[error("Unknown url scheme")]
221    UnknownUrlScheme,
222    #[error("Request error")]
223    Reqwest(
224        #[from]
225        #[source]
226        reqwest::Error,
227    ),
228    /// Parsing URL
229    #[error("Parsing URL")]
230    Parse(
231        #[source]
232        #[from]
233        url::ParseError,
234    ),
235    #[error("Write path `{0}`")]
236    Write(std::path::PathBuf, #[source] std::io::Error),
237    #[error("Serializing receipt")]
238    Serializing(
239        #[from]
240        #[source]
241        serde_json::Error,
242    ),
243    #[error(transparent)]
244    Certificate(#[from] CertificateError),
245}
246
247pub trait ErrorDiagnostic {
248    fn diagnostic(&self) -> String;
249}
250
251impl ErrorDiagnostic for DiagnosticError {
252    fn diagnostic(&self) -> String {
253        let static_str: &'static str = (self).into();
254        static_str.to_string()
255    }
256}
257
258pub fn diagnostic_endpoint_parser(input: &str) -> Result<Option<Url>, DiagnosticError> {
259    match Url::parse(input) {
260        Ok(v) => match v.scheme() {
261            "https" | "http" | "file" => Ok(Some(v)),
262            _ => Err(DiagnosticError::UnknownUrlScheme),
263        },
264        Err(url::ParseError::RelativeUrlWithoutBase) => {
265            match Url::parse(&format!("file://{input}")) {
266                Ok(v) => Ok(Some(v)),
267                Err(file_error) => Err(file_error)?,
268            }
269        },
270        Err(url_error) => Err(url_error)?,
271    }
272}
273
274pub fn diagnostic_endpoint_validator(input: &str) -> Result<String, DiagnosticError> {
275    let _ = diagnostic_endpoint_parser(input)?;
276    Ok(input.to_string())
277}