Skip to main content

encypher_c2pa/
telemetry_consent.rs

1use 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
32/// Return the effective saved preference. An environment override takes
33/// precedence over the per-user configuration file.
34pub 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
44/// Persist the telemetry preference for subsequent verifications by every
45/// native Encypher C2PA binding used by this operating-system account.
46pub fn set_telemetry_enabled(enabled: bool) -> Result<(), TelemetryPreferenceError> {
47    write_preference(&preference_path()?, enabled)
48}
49
50/// Ask for consent on the first interactive verification and persist the
51/// answer. Non-interactive processes return `None` and remain disabled.
52pub 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}