1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum OnlinePreference {
48 On,
50 Off,
52 Ask,
54}
55
56impl OnlinePreference {
57 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 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
83pub 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
97pub fn set_online_preference(preference: OnlinePreference) -> Result<(), OnlinePreferenceError> {
100 write_preference(&preference_path()?, preference)
101}
102
103pub fn allow_interactive_online_consent() {
109 set_interactive_online_consent(true);
110}
111
112pub(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
121pub(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
136pub(crate) fn saved_online_preference() -> Result<Option<OnlinePreference>, OnlinePreferenceError> {
138 read_preference(&preference_path()?)
139}
140
141pub(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 #[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 #[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}