encypher_c2pa/
telemetry_consent.rs1use std::env;
5use std::fs;
6use std::io::{self, IsTerminal, Write};
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicU64, Ordering};
9
10use serde::{Deserialize, Serialize};
11
12const TELEMETRY_ENV: &str = "ENCYPHER_C2PA_TELEMETRY";
13const CONFIG_FILE_NAME: &str = "c2pa.json";
14static TEMP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
15
16#[derive(Debug, thiserror::Error)]
17pub enum TelemetryPreferenceError {
18 #[error("could not resolve a user configuration directory")]
19 ConfigDirectoryUnavailable,
20 #[error("invalid {TELEMETRY_ENV} value: {0}")]
21 InvalidEnvironment(String),
22 #[error("could not read or write telemetry preference: {0}")]
23 Io(#[from] io::Error),
24 #[error("invalid telemetry preference file: {0}")]
25 InvalidConfig(#[from] serde_json::Error),
26}
27
28#[derive(Debug, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30struct SavedPreference {
31 telemetry_enabled: bool,
32}
33
34pub fn telemetry_preference() -> Result<Option<bool>, TelemetryPreferenceError> {
37 if let Some(value) = env::var_os(TELEMETRY_ENV) {
38 let value = value.to_string_lossy();
39 return parse_environment_preference(&value)
40 .map(Some)
41 .ok_or_else(|| TelemetryPreferenceError::InvalidEnvironment(value.into_owned()));
42 }
43 read_preference(&preference_path()?)
44}
45
46pub fn set_telemetry_enabled(enabled: bool) -> Result<(), TelemetryPreferenceError> {
49 write_preference(&preference_path()?, enabled)
50}
51
52pub fn prompt_for_telemetry_consent() -> Result<Option<bool>, TelemetryPreferenceError> {
55 if !io::stdin().is_terminal() || !io::stderr().is_terminal() {
56 return Ok(None);
57 }
58
59 let mut stderr = io::stderr().lock();
60 writeln!(
61 stderr,
62 "Help improve Encypher's free C2PA detection? If enabled, failed verifications send only the media type and validation status codes. No files, manifests, paths, keys, or account identifiers are sent."
63 )?;
64 write!(stderr, "Enable failure telemetry? [y/N] ")?;
65 stderr.flush()?;
66
67 let mut answer = String::new();
68 io::stdin().read_line(&mut answer)?;
69 let enabled = matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes");
70 set_telemetry_enabled(enabled)?;
71 writeln!(
72 stderr,
73 "Failure telemetry {}. Change this later with `encypher-c2pa telemetry on|off`.",
74 if enabled { "enabled" } else { "disabled" }
75 )?;
76 Ok(Some(enabled))
77}
78
79pub(crate) fn resolve_telemetry_enabled(explicit: Option<bool>) -> bool {
80 if let Some(enabled) = explicit {
81 return enabled;
82 }
83 match telemetry_preference() {
84 Ok(Some(enabled)) => enabled,
85 Ok(None) => prompt_for_telemetry_consent()
86 .ok()
87 .flatten()
88 .unwrap_or(false),
89 Err(error) => {
90 if io::stderr().is_terminal() {
91 eprintln!("warning: {error}");
92 }
93 prompt_for_telemetry_consent()
94 .ok()
95 .flatten()
96 .unwrap_or(false)
97 }
98 }
99}
100
101fn parse_environment_preference(value: &str) -> Option<bool> {
102 match value.trim().to_ascii_lowercase().as_str() {
103 "1" | "true" | "yes" | "on" => Some(true),
104 "0" | "false" | "no" | "off" => Some(false),
105 _ => None,
106 }
107}
108
109fn preference_path() -> Result<PathBuf, TelemetryPreferenceError> {
110 crate::config_dir::config_directory()
111 .map(|directory| directory.join(CONFIG_FILE_NAME))
112 .ok_or(TelemetryPreferenceError::ConfigDirectoryUnavailable)
113}
114
115fn read_preference(path: &Path) -> Result<Option<bool>, TelemetryPreferenceError> {
116 let contents = match fs::read(path) {
117 Ok(contents) => contents,
118 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
119 Err(error) => return Err(error.into()),
120 };
121 let preference: SavedPreference = serde_json::from_slice(&contents)?;
122 Ok(Some(preference.telemetry_enabled))
123}
124
125fn write_preference(path: &Path, enabled: bool) -> Result<(), TelemetryPreferenceError> {
126 let parent = path
127 .parent()
128 .ok_or(TelemetryPreferenceError::ConfigDirectoryUnavailable)?;
129 fs::create_dir_all(parent)?;
130 let payload = serde_json::to_vec_pretty(&SavedPreference {
131 telemetry_enabled: enabled,
132 })?;
133 let sequence = TEMP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
134 let temporary = path.with_extension(format!("tmp.{}.{}", std::process::id(), sequence));
135 fs::write(&temporary, payload)?;
136 #[cfg(target_os = "windows")]
137 if path.exists() {
138 fs::remove_file(path)?;
139 }
140 if let Err(error) = fs::rename(&temporary, path) {
141 let _ = fs::remove_file(temporary);
142 return Err(error.into());
143 }
144 Ok(())
145}
146
147#[cfg(test)]
148mod tests {
149 use super::{parse_environment_preference, read_preference, write_preference};
150 use std::path::PathBuf;
151
152 fn temporary_path(name: &str) -> PathBuf {
153 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
154 .join("../../target/test-state")
155 .join(format!(
156 "encypher-c2pa-consent-{}-{name}.json",
157 std::process::id()
158 ))
159 }
160
161 #[test]
162 fn saved_preference_round_trips_on_and_off() {
163 let path = temporary_path("round-trip");
164 write_preference(&path, true).unwrap();
165 assert_eq!(read_preference(&path).unwrap(), Some(true));
166 write_preference(&path, false).unwrap();
167 assert_eq!(read_preference(&path).unwrap(), Some(false));
168 let _ = std::fs::remove_file(path);
169 }
170
171 #[test]
172 fn missing_preference_is_unset() {
173 let path = temporary_path("missing");
174 let _ = std::fs::remove_file(&path);
175 assert_eq!(read_preference(&path).unwrap(), None);
176 }
177
178 #[test]
179 fn environment_values_are_strict_and_case_insensitive() {
180 assert_eq!(parse_environment_preference("ON"), Some(true));
181 assert_eq!(parse_environment_preference(" false "), Some(false));
182 assert_eq!(parse_environment_preference("sometimes"), None);
183 }
184}