Skip to main content

encypher_c2pa/
telemetry_consent.rs

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