Skip to main content

cratefield_module_telemetry/
consent.rs

1//! Consent for aggregate usage reporting (issue #413): the decision, the
2//! notice and the status rendering, all pure. Nothing here reads
3//! `std::env`, opens a connection or touches the network — the decision
4//! runs over an [`Environment`] its caller built, so the same code decides
5//! on a laptop, in continuous integration and on wasm, and a rule that
6//! never makes a request cannot leak one on the way to deciding not to
7//! report.
8
9use time::OffsetDateTime;
10
11use crate::handlers::Settings;
12use crate::payload::{self, Batch};
13
14/// How many days one install id lives (issue #413). Rotation bounds how
15/// long any one id can accumulate; nothing anywhere stores the link
16/// between one id and the next, which is the point of rotating it.
17pub const ROTATION_DAYS: u32 = 30;
18
19/// Whether this client reports.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Consent {
22    /// Reporting is on: the person has not said no anywhere.
23    On,
24    /// Reporting is off, for the stated reason.
25    Off(OffBecause),
26}
27
28/// Why reporting is off, in the order the decision checks them.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum OffBecause {
31    /// The person switched it off on this machine.
32    OptedOut,
33    /// `DO_NOT_TRACK` is set: a standing industry-wide opt-out, honoured
34    /// verbatim.
35    DoNotTrack,
36    /// `CI` is set: an automated environment has not consented to anything.
37    ContinuousIntegration,
38}
39
40impl OffBecause {
41    /// The fixed sentence printed with the off state.
42    #[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/// The inputs [`decide`] reads: the two environment variables that matter
56/// and the local opt-out. Built through [`Environment::from_vars`] so the
57/// decision itself never touches `std::env`, and a test can hand it a map.
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
59pub struct Environment {
60    /// `DO_NOT_TRACK`, if the variable is present at all.
61    pub do_not_track: Option<String>,
62    /// `CI`, if the variable is present at all.
63    pub ci: Option<String>,
64    /// Whether the person opted out on this machine.
65    pub opted_out: bool,
66}
67
68impl Environment {
69    /// Builds the environment through a lookup the caller supplies —
70    /// `std::env::var` in production, a map in a test.
71    #[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
81/// Whether a variable counts as set: present, non-empty after trimming,
82/// and neither `0` nor `false` (ASCII case-insensitive). A vendor that
83/// exports `CI=false` switches nothing, and neither does an empty
84/// `DO_NOT_TRACK=` left in a dotfile — a value that names "no" must not
85/// act as a yes.
86fn 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/// The consent decision, in a fixed precedence (issue #413): the person's
95/// own opt-out first — it beats everything and needs no network, because a
96/// switch that only worked offline some of the time would not be one —
97/// then `DO_NOT_TRACK`, then `CI`.
98#[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/// The install id the caller minted, as the 32 lowercase hex characters
112/// the payload grammar accepts. The bytes are the caller's: this crate
113/// generates no randomness — it must stay wasm-safe and dependency-free —
114/// and the id is deliberately never derived from the hostname, a MAC
115/// address, an account or anything else about the machine, because an id
116/// derived from hardware is a tracking id wearing a random costume.
117#[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/// Whether a string is a well-formed install id: exactly 32 lowercase hex
128/// characters, the grammar [`crate::payload::Batch::parse`] enforces.
129#[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/// Whether the id minted at `minted_at` is due for rotation at `now`.
138/// Due on the boundary, not after it, so a client that checks daily never
139/// carries an id past its thirtieth day.
140#[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/// The first-run notice: every field the payload can carry, with its
146/// permitted vocabulary — generated from the schema so the text cannot
147/// drift from the parser — what is never sent, the rotation interval, and
148/// the one-line opt-out. Printable with no network: consent that needs a
149/// round trip to be checked is not consent.
150#[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/// The `telemetry status` rendering: the state, the reason if off, then
192/// the pending payload as pretty JSON — the same bytes the sender would
193/// POST, from the same serializer. Nobody has to trust a description of
194/// the data when the data itself is one command away.
195#[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        // The person's own switch wins even when the environment says
248        // nothing and when it says both of the other things.
249        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        // Empty, zero and false — in any case — must not act as a yes.
287        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        // The promise is about the categories a payload has no field for.
348        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        // The vocabularies are listed, so the notice cannot be vaguer than
361        // the parser.
362        assert!(text.contains("/events[]/name — one of: run"), "{text}");
363        assert!(text.contains("/modules — one of: telemetry"), "{text}");
364        // Rotation and the two commands, as printed lines.
365        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}