1use time::OffsetDateTime;
10
11use crate::handlers::Settings;
12use crate::payload::{self, Batch};
13
14pub const ROTATION_DAYS: u32 = 30;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Consent {
22 On,
24 Off(OffBecause),
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum OffBecause {
31 OptedOut,
33 DoNotTrack,
36 ContinuousIntegration,
38}
39
40impl OffBecause {
41 #[must_use]
43 pub const fn reason(self) -> &'static str {
44 match self {
45 Self::OptedOut => "reporting was switched off on this machine",
46 Self::DoNotTrack => "the DO_NOT_TRACK environment variable is set",
47 Self::ContinuousIntegration => {
48 "the CI environment variable is set: an automated environment has not \
49 consented to anything"
50 }
51 }
52 }
53}
54
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
59pub struct Environment {
60 pub do_not_track: Option<String>,
62 pub ci: Option<String>,
64 pub opted_out: bool,
66}
67
68impl Environment {
69 #[must_use]
72 pub fn from_vars(get: impl Fn(&str) -> Option<String>, opted_out: bool) -> Self {
73 Self {
74 do_not_track: get("DO_NOT_TRACK"),
75 ci: get("CI"),
76 opted_out,
77 }
78 }
79}
80
81fn is_set(raw: Option<&str>) -> bool {
87 raw.map(str::trim).is_some_and(|value| {
88 !value.is_empty()
89 && !value.eq_ignore_ascii_case("0")
90 && !value.eq_ignore_ascii_case("false")
91 })
92}
93
94#[must_use]
99pub fn decide(env: &Environment) -> Consent {
100 if env.opted_out {
101 Consent::Off(OffBecause::OptedOut)
102 } else if is_set(env.do_not_track.as_deref()) {
103 Consent::Off(OffBecause::DoNotTrack)
104 } else if is_set(env.ci.as_deref()) {
105 Consent::Off(OffBecause::ContinuousIntegration)
106 } else {
107 Consent::On
108 }
109}
110
111#[must_use]
118pub fn install_id_from_bytes(bytes: [u8; 16]) -> String {
119 use std::fmt::Write as _;
120 let mut id = String::with_capacity(32);
121 for byte in bytes {
122 let _ = write!(id, "{byte:02x}");
123 }
124 id
125}
126
127#[must_use]
130pub fn install_id_is_valid(id: &str) -> bool {
131 id.len() == 32
132 && id
133 .bytes()
134 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
135}
136
137#[must_use]
141pub fn rotation_due(minted_at: OffsetDateTime, now: OffsetDateTime) -> bool {
142 now - minted_at >= time::Duration::days(i64::from(ROTATION_DAYS))
143}
144
145#[must_use]
151pub fn notice(settings: &Settings) -> String {
152 use std::fmt::Write as _;
153 let mut text = String::from(
154 "This client counts which of this venture's named commands ran — and nothing \
155 else. Every batch it sends carries only:\n",
156 );
157 for field in payload::fields(&settings.vocabulary) {
158 if field.values.is_empty() {
159 let _ = write!(text, "\n {} — {}", field.path, field.rule);
160 } else {
161 let _ = write!(
162 text,
163 "\n {} — one of: {}",
164 field.path,
165 field.values.join(", ")
166 );
167 if !field.rule.is_empty() {
168 let _ = write!(text, " ({})", field.rule);
169 }
170 }
171 }
172 let _ = write!(
173 text,
174 "\n\nNo identifier, path, repository or branch name, prompt, model output, \
175 commit or issue text, or free text of any kind is sent: every field above is \
176 one value from a closed list, and anything else is rejected, never trimmed or \
177 stored. The install id is pseudonymous, with no stored link to a person, and \
178 is replaced every {ROTATION_DAYS} days."
179 );
180 let _ = write!(
181 text,
182 "\n\nReporting is on by default. It honours the DO_NOT_TRACK and CI environment \
183 variables without further configuration, and switching it off is local: no \
184 network call is made, and nothing is sent afterwards.\n\nTurn it off: {}\nSee \
185 exactly what would be sent: {}",
186 settings.opt_out_command, settings.status_command
187 );
188 text
189}
190
191#[must_use]
196pub fn status(consent: &Consent, pending: &Batch) -> String {
197 let state = match consent {
198 Consent::On => "on".to_owned(),
199 Consent::Off(because) => format!("off ({})", because.reason()),
200 };
201 format!("telemetry: {state}\n{}", payload::to_wire(pending))
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use crate::payload::{
208 Arch, Client, ClientKind, ErrorKind, EventRecord, Outcome, Platform, Version,
209 };
210
211 fn vars<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
212 move |name: &str| {
213 pairs
214 .iter()
215 .find(|(key, _)| *key == name)
216 .map(|(_, value)| (*value).to_owned())
217 }
218 }
219
220 fn batch() -> Batch {
221 Batch {
222 schema: payload::SCHEMA,
223 install: install_id_from_bytes([7; 16]),
224 client: Client {
225 kind: ClientKind::Cli,
226 version: Version {
227 major: 0,
228 minor: 4,
229 patch: 1,
230 },
231 platform: Platform::Linux,
232 arch: Arch::Aarch64,
233 },
234 modules: vec!["telemetry".to_owned()],
235 events: vec![EventRecord {
236 name: "run".to_owned(),
237 outcome: Outcome::Ok,
238 error: ErrorKind::None,
239 duration: crate::payload::DurationBucket::Unknown,
240 count: 1,
241 }],
242 }
243 }
244
245 #[test]
246 fn the_local_opt_out_beats_everything() {
247 assert_eq!(
250 decide(&Environment {
251 opted_out: true,
252 ..Environment::default()
253 }),
254 Consent::Off(OffBecause::OptedOut)
255 );
256 assert_eq!(
257 decide(&Environment::from_vars(
258 vars(&[("DO_NOT_TRACK", "1"), ("CI", "1")]),
259 true,
260 )),
261 Consent::Off(OffBecause::OptedOut)
262 );
263 }
264
265 #[test]
266 fn do_not_track_beats_ci() {
267 assert_eq!(
268 decide(&Environment::from_vars(
269 vars(&[("DO_NOT_TRACK", "1"), ("CI", "1")]),
270 false
271 )),
272 Consent::Off(OffBecause::DoNotTrack)
273 );
274 assert_eq!(
275 decide(&Environment::from_vars(vars(&[("CI", "1")]), false)),
276 Consent::Off(OffBecause::ContinuousIntegration)
277 );
278 assert_eq!(
279 decide(&Environment::from_vars(vars(&[]), false)),
280 Consent::On
281 );
282 }
283
284 #[test]
285 fn a_value_that_names_no_is_unset() {
286 for raw in ["", " ", "0", "false", "FALSE", "False"] {
288 assert_eq!(
289 decide(&Environment::from_vars(
290 vars(&[("DO_NOT_TRACK", raw)]),
291 false
292 )),
293 Consent::On,
294 "DO_NOT_TRACK={raw:?} must read as unset"
295 );
296 }
297 }
298
299 #[test]
300 fn install_ids_are_random_bytes_never_names() {
301 let id =
302 install_id_from_bytes([0x7b, 0x0a, 0x1f, 0x2c, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
303 assert!(install_id_is_valid(&id), "{id}");
304 assert_eq!(id.len(), 32);
305 assert_eq!(
306 id, "7b0a1f2c000102030405060708090a0b",
307 "lowercase hex, padded"
308 );
309 for bad in [
310 "",
311 "7B0A",
312 &"g".repeat(32),
313 &"7b".repeat(15),
314 &"7b".repeat(17),
315 ] {
316 assert!(!install_id_is_valid(bad), "{bad:?} is not an install id");
317 }
318 }
319
320 #[test]
321 fn rotation_is_due_on_the_thirtieth_day() {
322 let minted = OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("timestamp");
323 assert!(!rotation_due(
324 minted,
325 minted + time::Duration::days(i64::from(ROTATION_DAYS) - 1)
326 ));
327 assert!(rotation_due(
328 minted,
329 minted + time::Duration::days(i64::from(ROTATION_DAYS))
330 ));
331 assert!(rotation_due(minted, minted + time::Duration::days(365)));
332 }
333
334 #[test]
335 fn the_notice_names_what_is_never_sent_and_how_to_stop() {
336 let settings = Settings {
337 vocabulary: crate::payload::Vocabulary {
338 events: vec!["run".to_owned()],
339 modules: vec!["telemetry".to_owned()],
340 max_events: crate::payload::MAX_EVENTS_PER_BATCH,
341 },
342 retention_days: 180,
343 opt_out_command: "fz telemetry off".to_owned(),
344 status_command: "fz telemetry status".to_owned(),
345 };
346 let text = notice(&settings);
347 for promised in [
349 "prompt",
350 "model output",
351 "commit or issue text",
352 "path",
353 "free text",
354 ] {
355 assert!(
356 text.contains(promised),
357 "the notice must name {promised:?}:\n{text}"
358 );
359 }
360 assert!(text.contains("/events[]/name — one of: run"), "{text}");
363 assert!(text.contains("/modules — one of: telemetry"), "{text}");
364 assert!(text.contains("every 30 days"), "{text}");
366 assert!(text.contains("Turn it off: fz telemetry off"), "{text}");
367 assert!(
368 text.contains("See exactly what would be sent: fz telemetry status"),
369 "{text}"
370 );
371 }
372
373 #[test]
374 fn status_reports_the_state_and_the_exact_bytes() {
375 let pending = batch();
376 let out = status(&Consent::On, &pending);
377 let (state, json) = out
378 .split_once('\n')
379 .expect("a status line then the payload");
380 assert_eq!(state, "telemetry: on");
381 assert_eq!(
382 json,
383 payload::to_wire(&pending),
384 "the status bytes are the wire bytes"
385 );
386
387 let out = status(&Consent::Off(OffBecause::DoNotTrack), &pending);
388 let (state, json) = out
389 .split_once('\n')
390 .expect("a status line then the payload");
391 assert!(state.contains("DO_NOT_TRACK"), "{state}");
392 assert_eq!(
393 json,
394 payload::to_wire(&pending),
395 "off does not change the payload"
396 );
397 }
398}