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