Skip to main content

encypher_c2pa/
online_consent.rs

1// Copyright 2026 Encypher Corporation
2// SPDX-License-Identifier: Apache-2.0
3
4//! Saved consent for online checks.
5//!
6//! This mirrors [`crate::telemetry_consent`] and deliberately keeps its own
7//! file. The telemetry preference lives in `c2pa.json`, which older SDKs read
8//! with `deny_unknown_fields`: adding a key there would make this release's
9//! configuration unreadable by an installed older binary. The online choice
10//! therefore lives beside it in `online.json`, under the same directory
11//! resolution.
12//!
13//! Only a person at a terminal writes this file, and only a terminal surface
14//! reads it. A library call may be verifying an untrusted file on a server,
15//! where a choice made at somebody's laptop must not switch fetching on, so
16//! library entry points never consult it. The CLI opts in for its own process
17//! with [`allow_interactive_online_consent`].
18
19use std::env;
20use std::fs;
21use std::io::{self, IsTerminal, Write};
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
24
25use serde::{Deserialize, Serialize};
26
27const ONLINE_ENV: &str = "ENCYPHER_C2PA_ONLINE";
28const CONFIG_FILE_NAME: &str = "online.json";
29static TEMP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
30static INTERACTIVE_CONSENT_ALLOWED: AtomicBool = AtomicBool::new(false);
31
32#[derive(Debug, thiserror::Error)]
33pub enum OnlinePreferenceError {
34    #[error("could not resolve a user configuration directory")]
35    ConfigDirectoryUnavailable,
36    #[error("invalid {ONLINE_ENV} value: {0} (expected on or off)")]
37    InvalidEnvironment(String),
38    #[error("could not read or write online preference: {0}")]
39    Io(#[from] io::Error),
40    #[error("invalid online preference file: {0}")]
41    InvalidConfig(#[from] serde_json::Error),
42}
43
44/// The saved answer to "may this machine fetch what a file references?".
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum OnlinePreference {
48    /// Fetch whatever a verification needs, without asking again.
49    On,
50    /// Never fetch.
51    Off,
52    /// Ask each time a verification would fetch something.
53    Ask,
54}
55
56impl OnlinePreference {
57    /// The saved token (`"on"`, `"off"`, `"ask"`).
58    pub const fn as_str(self) -> &'static str {
59        match self {
60            Self::On => "on",
61            Self::Off => "off",
62            Self::Ask => "ask",
63        }
64    }
65
66    /// Resolve a token, ASCII case-insensitively.
67    pub fn from_token(token: &str) -> Option<Self> {
68        match token.trim().to_ascii_lowercase().as_str() {
69            "on" | "1" | "true" | "yes" => Some(Self::On),
70            "off" | "0" | "false" | "no" => Some(Self::Off),
71            "ask" | "prompt" => Some(Self::Ask),
72            _ => None,
73        }
74    }
75}
76
77#[derive(Debug, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79struct SavedPreference {
80    online: OnlinePreference,
81}
82
83/// The effective saved choice. `ENCYPHER_C2PA_ONLINE` is an operator override
84/// and wins over the file; it accepts only `on` and `off`, because an operator
85/// setting an environment variable on a service has nobody to ask.
86pub fn online_preference() -> Result<Option<OnlinePreference>, OnlinePreferenceError> {
87    if let Some(enabled) = environment_online()? {
88        return Ok(Some(if enabled {
89            OnlinePreference::On
90        } else {
91            OnlinePreference::Off
92        }));
93    }
94    read_preference(&preference_path()?)
95}
96
97/// Persist the online choice for later runs of every terminal surface used by
98/// this operating-system account.
99pub fn set_online_preference(preference: OnlinePreference) -> Result<(), OnlinePreferenceError> {
100    write_preference(&preference_path()?, preference)
101}
102
103/// Let this process ask the operator before it fetches anything.
104///
105/// Call it from a command-line program, once, before verifying. Without it the
106/// saved file is ignored and no prompt is ever printed, which is what a server
107/// embedding the library wants.
108pub fn allow_interactive_online_consent() {
109    set_interactive_online_consent(true);
110}
111
112/// Set this process's interactive-consent flag.
113pub(crate) fn set_interactive_online_consent(allowed: bool) {
114    INTERACTIVE_CONSENT_ALLOWED.store(allowed, Ordering::Relaxed);
115}
116
117pub(crate) fn interactive_online_consent_allowed() -> bool {
118    INTERACTIVE_CONSENT_ALLOWED.load(Ordering::Relaxed)
119}
120
121/// Read the operator override. `Ok(None)` means it is not set.
122pub(crate) fn environment_online() -> Result<Option<bool>, OnlinePreferenceError> {
123    let Some(value) = env::var_os(ONLINE_ENV) else {
124        return Ok(None);
125    };
126    let value = value.to_string_lossy();
127    match value.trim().to_ascii_lowercase().as_str() {
128        "on" | "1" | "true" | "yes" => Ok(Some(true)),
129        "off" | "0" | "false" | "no" => Ok(Some(false)),
130        _ => Err(OnlinePreferenceError::InvalidEnvironment(
131            value.into_owned(),
132        )),
133    }
134}
135
136/// Read the saved file, ignoring the environment override.
137pub(crate) fn saved_online_preference() -> Result<Option<OnlinePreference>, OnlinePreferenceError> {
138    read_preference(&preference_path()?)
139}
140
141/// Ask once, listing every purpose and host this verification would contact.
142///
143/// `Ok(None)` means there was nobody to ask: a pipe, a cron job, a CI runner.
144/// Those runs stay offline rather than blocking on a prompt nobody reads.
145/// `a` and `v` persist the answer; `y` and `n` apply to this run only.
146pub(crate) fn prompt_for_online_consent(
147    lines: &[String],
148) -> Result<Option<bool>, OnlinePreferenceError> {
149    if !io::stdin().is_terminal() || !io::stderr().is_terminal() {
150        return Ok(None);
151    }
152
153    let mut stderr = io::stderr().lock();
154    writeln!(
155        stderr,
156        "This file references {} resource{} that only a network check can settle:",
157        lines.len(),
158        if lines.len() == 1 { "" } else { "s" }
159    )?;
160    for line in lines {
161        writeln!(stderr, "  {line}")?;
162    }
163    writeln!(
164        stderr,
165        "Contacting them tells those servers that this file is being checked."
166    )?;
167    write!(
168        stderr,
169        "Allow? [y] yes, this time  [N] no  [a] always  [v] never "
170    )?;
171    stderr.flush()?;
172
173    let mut answer = String::new();
174    io::stdin().read_line(&mut answer)?;
175    let enabled = match answer.trim().to_ascii_lowercase().as_str() {
176        "y" | "yes" => true,
177        "a" | "always" => {
178            set_online_preference(OnlinePreference::On)?;
179            true
180        }
181        "v" | "never" => {
182            set_online_preference(OnlinePreference::Off)?;
183            false
184        }
185        _ => false,
186    };
187    Ok(Some(enabled))
188}
189
190fn preference_path() -> Result<PathBuf, OnlinePreferenceError> {
191    crate::config_dir::config_directory()
192        .map(|directory| directory.join(CONFIG_FILE_NAME))
193        .ok_or(OnlinePreferenceError::ConfigDirectoryUnavailable)
194}
195
196fn read_preference(path: &Path) -> Result<Option<OnlinePreference>, OnlinePreferenceError> {
197    let contents = match fs::read(path) {
198        Ok(contents) => contents,
199        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
200        Err(error) => return Err(error.into()),
201    };
202    let preference: SavedPreference = serde_json::from_slice(&contents)?;
203    Ok(Some(preference.online))
204}
205
206fn write_preference(
207    path: &Path,
208    preference: OnlinePreference,
209) -> Result<(), OnlinePreferenceError> {
210    let parent = path
211        .parent()
212        .ok_or(OnlinePreferenceError::ConfigDirectoryUnavailable)?;
213    fs::create_dir_all(parent)?;
214    let payload = serde_json::to_vec_pretty(&SavedPreference { online: preference })?;
215    let sequence = TEMP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
216    let temporary = path.with_extension(format!("tmp.{}.{}", std::process::id(), sequence));
217    fs::write(&temporary, payload)?;
218    #[cfg(target_os = "windows")]
219    if path.exists() {
220        fs::remove_file(path)?;
221    }
222    if let Err(error) = fs::rename(&temporary, path) {
223        let _ = fs::remove_file(temporary);
224        return Err(error.into());
225    }
226    Ok(())
227}
228
229#[cfg(test)]
230mod tests {
231    use super::{
232        read_preference, write_preference, OnlinePreference, SavedPreference, CONFIG_FILE_NAME,
233    };
234    use std::path::PathBuf;
235
236    fn temporary_dir(name: &str) -> PathBuf {
237        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
238            .join("../../target/test-state")
239            .join(format!("online-consent-{}-{name}", std::process::id()));
240        std::fs::create_dir_all(&path).expect("test state directory");
241        path
242    }
243
244    #[test]
245    fn saved_choice_round_trips_on_off_and_ask() {
246        let directory = temporary_dir("round-trip");
247        let path = directory.join(CONFIG_FILE_NAME);
248        for preference in [
249            OnlinePreference::On,
250            OnlinePreference::Off,
251            OnlinePreference::Ask,
252        ] {
253            write_preference(&path, preference).unwrap();
254            assert_eq!(read_preference(&path).unwrap(), Some(preference));
255        }
256        let _ = std::fs::remove_dir_all(directory);
257    }
258
259    /// The file is the contract with older and newer installs alike, so the
260    /// on-disk spelling is asserted rather than assumed from the enum.
261    #[test]
262    fn the_saved_file_spells_the_choice_as_a_token() {
263        let directory = temporary_dir("spelling");
264        let path = directory.join(CONFIG_FILE_NAME);
265        write_preference(&path, OnlinePreference::Ask).unwrap();
266        let contents = std::fs::read_to_string(&path).unwrap();
267        assert!(contents.contains("\"online\""), "{contents}");
268        assert!(contents.contains("\"ask\""), "{contents}");
269        let _ = std::fs::remove_dir_all(directory);
270    }
271
272    #[test]
273    fn an_unset_choice_reads_as_none() {
274        let directory = temporary_dir("missing");
275        let path = directory.join(CONFIG_FILE_NAME);
276        let _ = std::fs::remove_file(&path);
277        assert_eq!(read_preference(&path).unwrap(), None);
278        let _ = std::fs::remove_dir_all(directory);
279    }
280
281    /// The telemetry key belongs to `c2pa.json`. Finding it here means the two
282    /// files were merged by mistake, and the merge would break older installs.
283    #[test]
284    fn an_unrelated_key_is_refused_rather_than_ignored() {
285        let directory = temporary_dir("foreign-key");
286        let path = directory.join(CONFIG_FILE_NAME);
287        std::fs::write(&path, br#"{"online":"on","telemetry_enabled":true}"#).unwrap();
288        assert!(read_preference(&path).is_err());
289        let _ = std::fs::remove_dir_all(directory);
290    }
291
292    #[test]
293    fn tokens_are_case_insensitive_and_strict() {
294        assert_eq!(
295            OnlinePreference::from_token("ON"),
296            Some(OnlinePreference::On)
297        );
298        assert_eq!(
299            OnlinePreference::from_token(" ask "),
300            Some(OnlinePreference::Ask)
301        );
302        assert_eq!(OnlinePreference::from_token("sometimes"), None);
303    }
304
305    #[test]
306    fn the_saved_shape_is_a_single_online_key() {
307        let payload = serde_json::to_string(&SavedPreference {
308            online: OnlinePreference::On,
309        })
310        .unwrap();
311        assert_eq!(payload, r#"{"online":"on"}"#);
312    }
313}