use time::OffsetDateTime;
use crate::handlers::Settings;
use crate::payload::{self, Batch};
pub const ROTATION_DAYS: u32 = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Consent {
On,
Off(OffBecause),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OffBecause {
OptedOut,
DoNotTrack,
ContinuousIntegration,
}
impl OffBecause {
#[must_use]
pub const fn reason(self) -> &'static str {
match self {
Self::OptedOut => "reporting was switched off on this machine",
Self::DoNotTrack => "the DO_NOT_TRACK environment variable is set",
Self::ContinuousIntegration => {
"the CI environment variable is set: an automated environment has not \
consented to anything"
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Environment {
pub do_not_track: Option<String>,
pub ci: Option<String>,
pub opted_out: bool,
}
impl Environment {
#[must_use]
pub fn from_vars(get: impl Fn(&str) -> Option<String>, opted_out: bool) -> Self {
Self {
do_not_track: get("DO_NOT_TRACK"),
ci: get("CI"),
opted_out,
}
}
}
fn is_set(raw: Option<&str>) -> bool {
raw.map(str::trim).is_some_and(|value| {
!value.is_empty()
&& !value.eq_ignore_ascii_case("0")
&& !value.eq_ignore_ascii_case("false")
})
}
#[must_use]
pub fn decide(env: &Environment) -> Consent {
if env.opted_out {
Consent::Off(OffBecause::OptedOut)
} else if is_set(env.do_not_track.as_deref()) {
Consent::Off(OffBecause::DoNotTrack)
} else if is_set(env.ci.as_deref()) {
Consent::Off(OffBecause::ContinuousIntegration)
} else {
Consent::On
}
}
#[must_use]
pub fn install_id_from_bytes(bytes: [u8; 16]) -> String {
use std::fmt::Write as _;
let mut id = String::with_capacity(32);
for byte in bytes {
let _ = write!(id, "{byte:02x}");
}
id
}
#[must_use]
pub fn install_id_is_valid(id: &str) -> bool {
id.len() == 32
&& id
.bytes()
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
}
#[must_use]
pub fn rotation_due(minted_at: OffsetDateTime, now: OffsetDateTime) -> bool {
now - minted_at >= time::Duration::days(i64::from(ROTATION_DAYS))
}
#[must_use]
pub fn notice(settings: &Settings) -> String {
use std::fmt::Write as _;
let mut text = String::from(
"This client counts which of this venture's named commands ran — and nothing \
else. Every batch it sends carries only:\n",
);
for field in payload::fields(&settings.vocabulary) {
if field.values.is_empty() {
let _ = write!(text, "\n {} — {}", field.path, field.rule);
} else {
let _ = write!(
text,
"\n {} — one of: {}",
field.path,
field.values.join(", ")
);
if !field.rule.is_empty() {
let _ = write!(text, " ({})", field.rule);
}
}
}
let _ = write!(
text,
"\n\nNo identifier, path, repository or branch name, prompt, model output, \
commit or issue text, or free text of any kind is sent: every field above is \
one value from a closed list, and anything else is rejected, never trimmed or \
stored. The install id is pseudonymous, with no stored link to a person, and \
is replaced every {ROTATION_DAYS} days."
);
let _ = write!(
text,
"\n\nReporting is on by default. It honours the DO_NOT_TRACK and CI environment \
variables without further configuration, and switching it off is local: no \
network call is made, and nothing is sent afterwards.\n\nTurn it off: {}\nSee \
exactly what would be sent: {}",
settings.opt_out_command, settings.status_command
);
text
}
#[must_use]
pub fn status(consent: &Consent, pending: &Batch) -> String {
let state = match consent {
Consent::On => "on".to_owned(),
Consent::Off(because) => format!("off ({})", because.reason()),
};
format!("telemetry: {state}\n{}", payload::to_wire(pending))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::payload::{
Arch, Client, ClientKind, ErrorKind, EventRecord, Outcome, Platform, Version,
};
fn vars<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |name: &str| {
pairs
.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| (*value).to_owned())
}
}
fn batch() -> Batch {
Batch {
schema: payload::SCHEMA,
install: install_id_from_bytes([7; 16]),
client: Client {
kind: ClientKind::Cli,
version: Version {
major: 0,
minor: 4,
patch: 1,
},
platform: Platform::Linux,
arch: Arch::Aarch64,
},
modules: vec!["telemetry".to_owned()],
events: vec![EventRecord {
name: "run".to_owned(),
outcome: Outcome::Ok,
error: ErrorKind::None,
duration: crate::payload::DurationBucket::Unknown,
count: 1,
}],
}
}
#[test]
fn the_local_opt_out_beats_everything() {
assert_eq!(
decide(&Environment {
opted_out: true,
..Environment::default()
}),
Consent::Off(OffBecause::OptedOut)
);
assert_eq!(
decide(&Environment::from_vars(
vars(&[("DO_NOT_TRACK", "1"), ("CI", "1")]),
true,
)),
Consent::Off(OffBecause::OptedOut)
);
}
#[test]
fn do_not_track_beats_ci() {
assert_eq!(
decide(&Environment::from_vars(
vars(&[("DO_NOT_TRACK", "1"), ("CI", "1")]),
false
)),
Consent::Off(OffBecause::DoNotTrack)
);
assert_eq!(
decide(&Environment::from_vars(vars(&[("CI", "1")]), false)),
Consent::Off(OffBecause::ContinuousIntegration)
);
assert_eq!(
decide(&Environment::from_vars(vars(&[]), false)),
Consent::On
);
}
#[test]
fn a_value_that_names_no_is_unset() {
for raw in ["", " ", "0", "false", "FALSE", "False"] {
assert_eq!(
decide(&Environment::from_vars(
vars(&[("DO_NOT_TRACK", raw)]),
false
)),
Consent::On,
"DO_NOT_TRACK={raw:?} must read as unset"
);
}
}
#[test]
fn install_ids_are_random_bytes_never_names() {
let id =
install_id_from_bytes([0x7b, 0x0a, 0x1f, 0x2c, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
assert!(install_id_is_valid(&id), "{id}");
assert_eq!(id.len(), 32);
assert_eq!(
id, "7b0a1f2c000102030405060708090a0b",
"lowercase hex, padded"
);
for bad in [
"",
"7B0A",
&"g".repeat(32),
&"7b".repeat(15),
&"7b".repeat(17),
] {
assert!(!install_id_is_valid(bad), "{bad:?} is not an install id");
}
}
#[test]
fn rotation_is_due_on_the_thirtieth_day() {
let minted = OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("timestamp");
assert!(!rotation_due(
minted,
minted + time::Duration::days(i64::from(ROTATION_DAYS) - 1)
));
assert!(rotation_due(
minted,
minted + time::Duration::days(i64::from(ROTATION_DAYS))
));
assert!(rotation_due(minted, minted + time::Duration::days(365)));
}
#[test]
fn the_notice_names_what_is_never_sent_and_how_to_stop() {
let settings = Settings {
vocabulary: crate::payload::Vocabulary {
events: vec!["run".to_owned()],
modules: vec!["telemetry".to_owned()],
max_events: crate::payload::MAX_EVENTS_PER_BATCH,
},
retention_days: 180,
opt_out_command: "fz telemetry off".to_owned(),
status_command: "fz telemetry status".to_owned(),
};
let text = notice(&settings);
for promised in [
"prompt",
"model output",
"commit or issue text",
"path",
"free text",
] {
assert!(
text.contains(promised),
"the notice must name {promised:?}:\n{text}"
);
}
assert!(text.contains("/events[]/name — one of: run"), "{text}");
assert!(text.contains("/modules — one of: telemetry"), "{text}");
assert!(text.contains("every 30 days"), "{text}");
assert!(text.contains("Turn it off: fz telemetry off"), "{text}");
assert!(
text.contains("See exactly what would be sent: fz telemetry status"),
"{text}"
);
}
#[test]
fn status_reports_the_state_and_the_exact_bytes() {
let pending = batch();
let out = status(&Consent::On, &pending);
let (state, json) = out
.split_once('\n')
.expect("a status line then the payload");
assert_eq!(state, "telemetry: on");
assert_eq!(
json,
payload::to_wire(&pending),
"the status bytes are the wire bytes"
);
let out = status(&Consent::Off(OffBecause::DoNotTrack), &pending);
let (state, json) = out
.split_once('\n')
.expect("a status line then the payload");
assert!(state.contains("DO_NOT_TRACK"), "{state}");
assert_eq!(
json,
payload::to_wire(&pending),
"off does not change the payload"
);
}
}