Skip to content

Commit 2407fcc

Browse files
committed
Keep the telemetry opt-out when config.json has a bad or unknown key
1 parent caee02f commit 2407fcc

6 files changed

Lines changed: 97 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,10 @@ that may never merge. They are not releases and are not listed here.
264264
shell, the way `MAPBOX_CLI_NO_TELEMETRY=1` does for one: the run's event,
265265
the `User-Agent` markers and the update notice. The run that turns it off
266266
records nothing either. `mapbox config list` now also reports `telemetry`,
267-
and `mapbox doctor` reports it as `telemetry_persisted`.
267+
and `mapbox doctor` reports it as `telemetry_persisted`; its
268+
`telemetry_allowed` still means the environment variable alone. Version
269+
0.3.0 doesn't know this key, and its `mapbox config set` drops it from the
270+
file, so with two installs, set it again after using the older one.
268271

269272
- Each run sends one `cli.command` telemetry event to Mapbox, from a
270273
background process the command doesn't wait for, with your own token

‎docs/commands.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4591,6 +4591,10 @@ Telemetry: on
45914591
</td></tr>
45924592
</table>
45934593

4594+
In `switches`, `telemetry_allowed` is `MAPBOX_CLI_NO_TELEMETRY` alone and
4595+
`telemetry_persisted` is `mapbox config set telemetry`; telemetry is on only
4596+
when both are `true`.
4597+
45944598
With `--verify`, a `connectivity` object joins the JSON and a `Reachable:`
45954599
line joins the text — `{ "reachable": true, "status": 200 }`, or `{
45964600
"reachable": false }` (plus an `error` field under `--debug`) when the

‎src/config.rs‎

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ use std::path::PathBuf;
1919
use anyhow::{Context, Result};
2020
use clap::builder::PossibleValuesParser;
2121
use clap::{Arg, ArgMatches, Command};
22-
use serde::{Deserialize, Serialize};
23-
use serde_json::{json, Value};
22+
use serde::Serialize;
23+
use serde_json::{json, Map, Value};
2424

2525
use crate::auth;
2626
use crate::output::{self, CliError, Mode};
@@ -43,7 +43,7 @@ const OFF: &str = "off";
4343
/// default is `on` — distinct from `Some(true)`, which is someone turning it
4444
/// back on after having turned it off, but read identically by
4545
/// [`update_check_setting`].
46-
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
46+
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
4747
struct Config {
4848
#[serde(default, skip_serializing_if = "Option::is_none")]
4949
update_check: Option<bool>,
@@ -53,6 +53,33 @@ struct Config {
5353
log: Option<bool>,
5454
#[serde(default, skip_serializing_if = "Option::is_none")]
5555
telemetry: Option<bool>,
56+
/// Keys this version doesn't know, kept so that writing the file doesn't
57+
/// erase a setting a newer version saved.
58+
#[serde(flatten)]
59+
rest: Map<String, Value>,
60+
}
61+
62+
impl Config {
63+
/// Reads each key on its own, so one malformed value resets only that
64+
/// key. Parsed as a whole, a single bad value would reset every key,
65+
/// `telemetry` included, and an opt-out would quietly turn back on.
66+
fn parse(text: &str) -> Config {
67+
let Ok(Value::Object(mut rest)) = serde_json::from_str(text) else {
68+
return Config::default();
69+
};
70+
let mut flag = |key: &str| rest.remove(key).and_then(|value| value.as_bool());
71+
let update_check = flag("update_check");
72+
let history = flag("history");
73+
let log = flag("log");
74+
let telemetry = flag("telemetry");
75+
Config {
76+
update_check,
77+
history,
78+
log,
79+
telemetry,
80+
rest,
81+
}
82+
}
5683
}
5784

5885
fn config_path() -> Option<PathBuf> {
@@ -67,7 +94,7 @@ fn config_path() -> Option<PathBuf> {
6794
fn read_config() -> Config {
6895
config_path()
6996
.and_then(|path| std::fs::read_to_string(path).ok())
70-
.and_then(|text| serde_json::from_str(&text).ok())
97+
.map(|text| Config::parse(&text))
7198
.unwrap_or_default()
7299
}
73100

@@ -105,8 +132,8 @@ pub fn log_enabled() -> bool {
105132
read_config().log.unwrap_or(false)
106133
}
107134

108-
/// Whether the run's telemetry event may be recorded, per the persisted
109-
/// setting. [`crate::telemetry_event`] checks it alongside `MAPBOX_CLI_NO_TELEMETRY`.
135+
/// Whether telemetry is on, per the persisted setting. Read through
136+
/// [`crate::telemetry::telemetry_allowed`], alongside `MAPBOX_CLI_NO_TELEMETRY`.
110137
pub fn telemetry_enabled() -> bool {
111138
read_config().telemetry.unwrap_or(true)
112139
}
@@ -285,15 +312,32 @@ mod tests {
285312
};
286313
let text = serde_json::to_string(&off).expect("serialize");
287314
assert_eq!(text, r#"{"update_check":false}"#);
288-
let read: Config = serde_json::from_str(&text).expect("deserialize");
289-
assert_eq!(read, off);
315+
assert_eq!(Config::parse(&text), off);
290316

291317
// A file from before this key existed, or one with nothing set yet.
292-
let empty: Config = serde_json::from_str("{}").expect("an empty object");
318+
let empty = Config::parse("{}");
293319
assert_eq!(empty.update_check, None);
294320
assert!(update_check_setting(&empty));
295321
}
296322

323+
#[test]
324+
fn a_malformed_value_resets_only_its_own_key() {
325+
let config = Config::parse(r#"{"telemetry":false,"history":"off"}"#);
326+
assert_eq!(config.telemetry, Some(false));
327+
assert_eq!(config.history, None);
328+
assert_eq!(Config::parse("not json"), Config::default());
329+
}
330+
331+
#[test]
332+
fn keys_from_a_newer_version_survive_a_write() {
333+
let mut config = Config::parse(r#"{"history":true,"future_key":[1]}"#);
334+
config.history = Some(false);
335+
assert_eq!(
336+
serde_json::to_string(&config).expect("serialize"),
337+
r#"{"history":false,"future_key":[1]}"#
338+
);
339+
}
340+
297341
#[test]
298342
fn on_and_off_round_trip_through_on_off() {
299343
assert_eq!(on_off(true), ON);

‎src/telemetry.rs‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,13 @@ fn terminal_marker() -> String {
110110

111111
/// The full `User-Agent`: [`PRODUCT_TOKEN`], then markers if allowed.
112112
pub fn user_agent(command_group: Option<&str>) -> String {
113-
if telemetry_allowed() {
113+
user_agent_if(telemetry_allowed(), command_group)
114+
}
115+
116+
/// [`user_agent`] with the decision passed in, so a test isn't at the mercy
117+
/// of the persisted setting on the machine running it.
118+
fn user_agent_if(allowed: bool, command_group: Option<&str>) -> String {
119+
if allowed {
114120
assemble(&telemetry_markers(command_group))
115121
} else {
116122
assemble(&[])
@@ -148,11 +154,11 @@ mod tests {
148154
let previous = std::env::var_os(MAPBOX_CLI_NO_TELEMETRY_ENV);
149155
let read = |value: &&str| {
150156
std::env::set_var(MAPBOX_CLI_NO_TELEMETRY_ENV, value);
151-
user_agent(None)
157+
user_agent_if(env_allows_telemetry(), None)
152158
};
153159

154160
std::env::remove_var(MAPBOX_CLI_NO_TELEMETRY_ENV);
155-
let unset = user_agent(None);
161+
let unset = user_agent_if(env_allows_telemetry(), None);
156162
let opted_out: Vec<String> = opt_outs.iter().map(read).collect();
157163
let allowed: Vec<String> = left_alone.iter().map(read).collect();
158164

@@ -162,6 +168,7 @@ mod tests {
162168
}
163169

164170
assert!(unset.starts_with(PRODUCT_TOKEN), "{unset}");
171+
assert_ne!(unset, PRODUCT_TOKEN, "no markers with the switch unset");
165172
for (value, agent) in opt_outs.iter().zip(&opted_out) {
166173
assert_eq!(agent, PRODUCT_TOKEN, "MAPBOX_CLI_NO_TELEMETRY={value:?}");
167174
}

‎tests/source_guards.rs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,3 +546,24 @@ fn every_test_that_runs_the_binary_decides_about_telemetry() {
546546
token sends real telemetry: {undecided:?}. Set it on the command they build."
547547
);
548548
}
549+
550+
/// `env_allows_telemetry` is `MAPBOX_CLI_NO_TELEMETRY` alone, so a caller
551+
/// that gates telemetry on it ignores `mapbox config set telemetry off`.
552+
/// Only `telemetry.rs`, which combines the two, and `doctor.rs`, which
553+
/// reports them apart, may call it.
554+
#[test]
555+
fn only_telemetry_and_doctor_read_the_env_opt_out_alone() {
556+
let unexpected: Vec<String> = sources()
557+
.into_iter()
558+
.filter(|(name, source)| {
559+
!["telemetry.rs", "doctor.rs"].contains(&name.as_str())
560+
&& source.contains("env_allows_telemetry")
561+
})
562+
.map(|(name, _)| format!("src/{name}"))
563+
.collect();
564+
assert!(
565+
unexpected.is_empty(),
566+
"these modules call `telemetry::env_allows_telemetry`, which ignores the \
567+
persisted setting: {unexpected:?}. Call `telemetry::telemetry_allowed` instead."
568+
);
569+
}

‎tests/telemetry_events.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,11 @@ fn either_opt_out_records_nothing() {
240240
received.recv_timeout(Duration::from_secs(3)).is_err(),
241241
"`telemetry off` still sent an event"
242242
);
243+
// Not left to the send child's own check: the run itself records nothing.
244+
assert!(
245+
!config_dir(&home).join(".telemetry").exists(),
246+
"`telemetry off` still wrote telemetry"
247+
);
243248
}
244249

245250
#[test]

0 commit comments

Comments
 (0)