1use 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#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
20pub enum DiagnosticStatus {
21 Cancelled,
22 Success,
23 Pending,
24 Failure,
25}
26
27#[derive(Debug, serde::Deserialize, serde::Serialize, Clone, Copy)]
29pub enum DiagnosticAction {
30 Install,
31 Uninstall,
32}
33
34#[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 pub failure_chain: Option<Vec<String>>,
49}
50
51#[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 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 #[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}