1use std::collections::HashMap;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use crate::configuration::Configuration;
16use crate::event_builder::format_unix_timestamp;
17use crate::pii_scrubber::{json_string, Value};
18
19const KINDS: [&str; 6] = [
22 "feature_flag",
23 "config",
24 "migration",
25 "dependency",
26 "infrastructure",
27 "other",
28];
29
30const MAX_TITLE_CHARS: usize = 200;
31
32#[derive(Clone, Debug, PartialEq)]
46pub struct Change {
47 pub kind: String,
50 pub title: String,
52 pub details: HashMap<String, Value>,
53 pub environment: Option<String>,
55 pub service: Option<String>,
56 pub actor: Option<String>,
57 pub url: Option<String>,
59 pub id: Option<String>,
61 pub occurred_at: Option<SystemTime>,
63}
64
65impl Change {
66 pub fn new(kind: &str, title: &str) -> Self {
67 Change {
68 kind: kind.to_string(),
69 title: title.to_string(),
70 details: HashMap::new(),
71 environment: None,
72 service: None,
73 actor: None,
74 url: None,
75 id: None,
76 occurred_at: None,
77 }
78 }
79}
80
81pub fn change_body(configuration: &Configuration, change: &Change) -> Option<String> {
83 let title = change.title.trim();
84 if title.is_empty() {
85 return None;
86 }
87 let title: String = title.chars().take(MAX_TITLE_CHARS).collect();
88 let kind = if KINDS.contains(&change.kind.as_str()) {
89 change.kind.as_str()
90 } else {
91 "other"
92 };
93 let environment = change
94 .environment
95 .as_deref()
96 .unwrap_or(&configuration.environment);
97 let occurred_at = change
98 .occurred_at
99 .unwrap_or_else(SystemTime::now)
100 .duration_since(UNIX_EPOCH)
101 .map(|d| d.as_secs())
102 .unwrap_or(0);
103
104 let mut body = format!(
105 "{{\"kind\":{},\"title\":{},\"environment\":{},\"occurred_at\":{}",
106 json_string(kind),
107 json_string(&title),
108 json_string(environment),
109 json_string(&format_unix_timestamp(occurred_at)),
110 );
111 if !change.details.is_empty() {
112 body.push_str(",\"details\":");
113 body.push_str(&Value::Object(change.details.clone()).to_json());
114 }
115 for (key, value) in [
116 ("service", &change.service),
117 ("actor", &change.actor),
118 ("url", &change.url),
119 ("id", &change.id),
120 ] {
121 if let Some(value) = value.as_deref().filter(|v| !v.is_empty()) {
122 body.push_str(&format!(",\"{key}\":{}", json_string(value)));
123 }
124 }
125 body.push('}');
126 Some(body)
127}
128
129pub fn runtime() -> String {
131 match option_env!("FORGE_OPS_RUSTC_VERSION") {
132 Some(version) => format!("rust {version}"),
133 None => "rust".to_string(),
134 }
135}
136
137pub fn is_noise_env_var_name(name: &str) -> bool {
142 const EXACT: [&str; 19] = [
143 "HOSTNAME",
144 "HOST",
145 "HOME",
146 "PATH",
147 "PWD",
148 "OLDPWD",
149 "SHLVL",
150 "_",
151 "TERM",
152 "USER",
153 "LOGNAME",
154 "SHELL",
155 "LANG",
156 "TMPDIR",
157 "TZ",
158 "PORT",
159 "DYNO",
160 "INVOCATION_ID",
161 "JOURNAL_STREAM",
162 ];
163 const PREFIXES: [&str; 5] = [
164 "LC_",
165 "SYSTEMD_",
166 "MEMORY_PRESSURE_",
167 "KUBERNETES_",
168 "FORGE_OPS_",
169 ];
170
171 let name = name.to_ascii_uppercase();
173 EXACT.contains(&name.as_str())
174 || PREFIXES.iter().any(|prefix| name.starts_with(prefix))
175 || name.ends_with("_SERVICE_HOST")
176 || name.contains("_SERVICE_PORT")
177 || name
178 .find("_PORT_")
179 .is_some_and(|i| name[i + "_PORT_".len()..].contains("_TCP"))
180}
181
182pub fn env_var_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
185 let mut kept: Vec<String> = names
186 .into_iter()
187 .filter(|name| !name.is_empty() && !is_noise_env_var_name(name))
188 .collect();
189 kept.sort();
190 kept.dedup();
191 kept
192}
193
194pub fn snapshot_body(
197 configuration: &Configuration,
198 env_names: impl FnOnce() -> Vec<String>,
199) -> String {
200 let mut state = format!("\"runtime\":{}", json_string(&runtime()));
201 if configuration.track_env_var_names {
202 let names: Vec<String> = env_var_names(env_names())
203 .iter()
204 .map(|name| json_string(name))
205 .collect();
206 state.push_str(&format!(",\"env_var_names\":[{}]", names.join(",")));
207 }
208 format!(
209 "{{\"environment\":{},\"state\":{{{state}}}}}",
210 json_string(&configuration.environment)
211 )
212}
213
214pub fn process_env_var_names() -> Vec<String> {
218 std::env::vars_os()
219 .filter_map(|(name, _)| name.into_string().ok())
220 .collect()
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use std::time::Duration;
227
228 fn config() -> Configuration {
229 let mut config = Configuration::new();
230 config.environment = "production".to_string();
231 config
232 }
233
234 #[test]
235 fn change_body_has_the_required_fields_and_defaults_environment() {
236 let body = change_body(
237 &config(),
238 &Change {
239 occurred_at: Some(UNIX_EPOCH + Duration::from_secs(1_700_000_000)),
240 ..Change::new("migration", "Add index to orders")
241 },
242 )
243 .unwrap();
244 assert_eq!(
245 body,
246 "{\"kind\":\"migration\",\"title\":\"Add index to orders\",\"environment\":\"production\",\"occurred_at\":\"2023-11-14T22:13:20Z\"}"
247 );
248 }
249
250 #[test]
251 fn change_body_includes_every_optional_field_given() {
252 let body = change_body(
253 &config(),
254 &Change {
255 details: crate::context! {"flag" => "new_checkout"},
256 environment: Some("staging".to_string()),
257 service: Some("billing".to_string()),
258 actor: Some("alice".to_string()),
259 url: Some("https://example.com/pr/1".to_string()),
260 id: Some("flag-42".to_string()),
261 ..Change::new("feature_flag", "Enabled \"new checkout\"")
262 },
263 )
264 .unwrap();
265 assert!(body.starts_with(
266 "{\"kind\":\"feature_flag\",\"title\":\"Enabled \\\"new checkout\\\"\",\"environment\":\"staging\""
267 ));
268 assert!(body.contains(",\"details\":{\"flag\":\"new_checkout\"}"));
269 assert!(body.ends_with(
270 ",\"service\":\"billing\",\"actor\":\"alice\",\"url\":\"https://example.com/pr/1\",\"id\":\"flag-42\"}"
271 ));
272 }
273
274 #[test]
275 fn an_unknown_kind_is_sent_as_other() {
276 for kind in ["deploy", "", "Feature_Flag"] {
277 let body = change_body(&config(), &Change::new(kind, "x")).unwrap();
278 assert!(body.starts_with("{\"kind\":\"other\","), "kind = {kind:?}");
279 }
280 for kind in KINDS {
281 let body = change_body(&config(), &Change::new(kind, "x")).unwrap();
282 assert!(body.starts_with(&format!("{{\"kind\":\"{kind}\",")));
283 }
284 }
285
286 #[test]
287 fn a_blank_title_is_dropped_and_a_long_one_truncated() {
288 assert_eq!(change_body(&config(), &Change::new("config", " ")), None);
289 let body = change_body(&config(), &Change::new("config", &"é".repeat(300))).unwrap();
290 assert!(body.contains(&format!("\"title\":\"{}\"", "é".repeat(200))));
291 }
292
293 #[test]
294 fn runtime_names_rust_and_the_compiler_version_when_known() {
295 let runtime = runtime();
296 assert!(
297 runtime == "rust" || runtime.starts_with("rust 1."),
298 "{runtime}"
299 );
300 }
301
302 #[test]
303 fn the_denylist_drops_host_noise_and_keeps_app_names() {
304 for name in [
305 "HOSTNAME",
306 "PATH",
307 "Path",
308 "_",
309 "LC_ALL",
310 "SYSTEMD_EXEC_PID",
311 "MEMORY_PRESSURE_WATCH",
312 "KUBERNETES_SERVICE_HOST",
313 "REDIS_SERVICE_HOST",
314 "REDIS_SERVICE_PORT",
315 "REDIS_SERVICE_PORT_HTTP",
316 "REDIS_PORT_6379_TCP",
317 "REDIS_PORT_6379_TCP_ADDR",
318 "FORGE_OPS_DSN",
319 "DYNO",
320 ] {
321 assert!(is_noise_env_var_name(name), "{name} should be dropped");
322 }
323 for name in [
324 "DATABASE_URL",
325 "STRIPE_KEY",
326 "REDIS_PORT",
327 "PORTAL_URL",
328 "HOSTS",
329 ] {
330 assert!(!is_noise_env_var_name(name), "{name} should be kept");
331 }
332 }
333
334 #[test]
335 fn snapshot_sends_runtime_only_by_default() {
336 let body = snapshot_body(&config(), || panic!("env names read without opting in"));
337 assert_eq!(
338 body,
339 format!(
340 "{{\"environment\":\"production\",\"state\":{{\"runtime\":{}}}}}",
341 json_string(&runtime())
342 )
343 );
344 assert!(!body.contains("dependencies") && !body.contains("schema_version"));
345 }
346
347 #[test]
348 fn snapshot_sends_sorted_filtered_env_var_names_when_opted_in() {
349 let mut config = config();
350 config.track_env_var_names = true;
351 let body = snapshot_body(&config, || {
352 [
353 "STRIPE_KEY",
354 "HOME",
355 "DATABASE_URL",
356 "FORGE_OPS_DSN",
357 "STRIPE_KEY",
358 ]
359 .map(str::to_string)
360 .to_vec()
361 });
362 assert!(
363 body.ends_with(",\"env_var_names\":[\"DATABASE_URL\",\"STRIPE_KEY\"]}}"),
364 "{body}"
365 );
366 }
367
368 #[test]
369 fn process_env_var_names_are_names_only() {
370 let names = process_env_var_names();
371 assert!(names.iter().all(|name| !name.contains('=')));
372 }
373}