Skip to main content

gasm_host/
consent.rs

1//! Player consent: the window runner asks before a game reaches outside itself, a
2//! network host (gasm:net, gasm:fetch) or saving files for the player (gasm:files).
3//! Four answers: this time (until the game ends), always (remembered), no (asked
4//! again next run) and never (remembered). Remembered answers live in
5//! `<data dir>/gasm/consent/<game>.txt`, one `allow <subject>` / `deny <subject>`
6//! line each; `gasm-run --forget-consent <game|all>` removes them.
7//!
8//! Requests wait while a question is open: a connection stays connecting, a fetch
9//! or a save pending, and each fails as a refused one would if the answer is no.
10//! Headless runs never ask (they deny what the command line didn't allow).
11
12use std::collections::{BTreeMap, VecDeque};
13use std::path::PathBuf;
14use std::sync::{Arc, Mutex};
15
16/// What a game asks for: `net:<host>` (`net:a.org,b.org` for a manifest's hosts,
17/// asked at once and answered for each), `save`, or `mod:<file>` (a mod whose
18/// manifest asks for hosts; allowing it allows them).
19pub type Subject = String;
20
21pub fn net_subject(host: &str) -> Subject {
22    format!("net:{host}")
23}
24pub const SAVE: &str = "save";
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum Answer {
28    /// allowed until the game ends
29    ThisTime,
30    /// allowed, remembered
31    Always,
32    /// denied this time, asked again next run
33    No,
34    /// denied, remembered
35    Never,
36}
37
38#[derive(Debug)]
39pub struct Store {
40    file: Option<PathBuf>,
41    remembered: BTreeMap<Subject, bool>,
42    session: BTreeMap<Subject, bool>,
43    /// asked for and not answered yet, in order
44    questions: VecDeque<Subject>,
45    /// headless and tests: unanswered means no
46    ask: bool,
47    /// mod file -> the hosts its manifest asks for
48    mod_hosts: BTreeMap<String, Vec<String>>,
49}
50
51/// Shared by the host's gasm:net, gasm:fetch and gasm:files and the window runner.
52pub type Consent = Arc<Mutex<Store>>;
53
54impl Store {
55    /// A store that asks (the window runner), with this game's remembered answers.
56    pub fn open(game: &str) -> Store {
57        let file = crate::storage::data_root().map(|r| r.join("consent").join(format!("{game}.txt")));
58        let mut remembered = BTreeMap::new();
59        if let Some(text) = file.as_ref().and_then(|f| std::fs::read_to_string(f).ok()) {
60            for line in text.lines() {
61                match line.trim().split_once(' ') {
62                    Some(("allow", s)) => _ = remembered.insert(s.to_owned(), true),
63                    Some(("deny", s)) => _ = remembered.insert(s.to_owned(), false),
64                    _ => {}
65                }
66            }
67        }
68        Store { file, remembered, session: BTreeMap::new(), questions: VecDeque::new(), ask: true, mod_hosts: BTreeMap::new() }
69    }
70
71    /// A store that never asks: unanswered is no (headless runs).
72    pub fn never_ask() -> Store {
73        Store { file: None, remembered: BTreeMap::new(), session: BTreeMap::new(), questions: VecDeque::new(), ask: false, mod_hosts: BTreeMap::new() }
74    }
75
76    pub fn shared(self) -> Consent {
77        Arc::new(Mutex::new(self))
78    }
79
80    /// The answer so far: Some(allowed), or None while the player hasn't answered
81    /// (the question is queued once).
82    pub fn check(&mut self, subject: &str) -> Option<bool> {
83        if let Some(&a) = self.session.get(subject).or_else(|| self.remembered.get(subject)) {
84            return Some(a);
85        }
86        if !self.ask {
87            return Some(false);
88        }
89        if !self.questions.iter().any(|q| q == subject) {
90            self.questions.push_back(subject.to_owned());
91        }
92        None
93    }
94
95    /// A manifest's hosts and saves, asked about before the game starts (one question
96    /// for the hosts not answered yet).
97    pub fn ask_up_front(&mut self, hosts: &[String], files: bool, media: bool) {
98        let open: Vec<&str> = hosts.iter().map(String::as_str).filter(|h| self.answered(&net_subject(h)).is_none()).collect();
99        if !open.is_empty() {
100            self.queue(net_subject(&open.join(",")));
101        }
102        if files {
103            _ = self.check(SAVE);
104        }
105        if media {
106            _ = self.check(crate::media::CONSENT_SUBJECT);
107        }
108    }
109
110    /// A mod whose manifest asks for hosts: Some(allowed) if answered, else asked
111    /// (before the game starts) and None.
112    pub fn check_mod(&mut self, file: &str, hosts: &[String]) -> Option<bool> {
113        self.mod_hosts.insert(file.to_owned(), hosts.to_vec());
114        let subject = format!("mod:{file}");
115        let a = self.answered(&subject);
116        if a == Some(true) {
117            for h in hosts {
118                self.session.insert(net_subject(h), true);
119            }
120        }
121        if a.is_none() {
122            if !self.ask {
123                return Some(false);
124            }
125            self.queue(subject);
126        }
127        a
128    }
129
130    fn answered(&self, subject: &str) -> Option<bool> {
131        self.session.get(subject).or_else(|| self.remembered.get(subject)).copied()
132    }
133
134    fn queue(&mut self, subject: Subject) {
135        if self.ask && !self.questions.contains(&subject) {
136            self.questions.push_back(subject);
137        }
138    }
139
140    /// Whether questions are waiting (the window runner doesn't start a game meanwhile).
141    pub fn asking(&self) -> bool {
142        !self.questions.is_empty()
143    }
144
145    /// The hosts a mod's manifest asks for (for the question).
146    pub fn mod_hosts(&self, file: &str) -> &[String] {
147        self.mod_hosts.get(file).map_or(&[], Vec::as_slice)
148    }
149
150    /// The next question to put to the player.
151    pub fn question(&self) -> Option<&Subject> {
152        self.questions.front()
153    }
154
155    pub fn answer(&mut self, subject: &str, answer: Answer) {
156        self.questions.retain(|q| q != subject);
157        let allowed = matches!(answer, Answer::ThisTime | Answer::Always);
158        // a manifest's hosts: an answer for each; an allowed mod allows its hosts
159        let mut subjects: Vec<String> = match subject.strip_prefix("net:") {
160            Some(hosts) => hosts.split(',').map(net_subject).collect(),
161            None => vec![subject.to_owned()],
162        };
163        if let (Some(file), true) = (subject.strip_prefix("mod:"), allowed) {
164            for h in self.mod_hosts(file).to_vec() {
165                self.session.insert(net_subject(&h), true);
166            }
167        }
168        for s in subjects.drain(..) {
169            self.session.insert(s.clone(), allowed);
170            if matches!(answer, Answer::Always | Answer::Never) {
171                self.remembered.insert(s, allowed);
172            }
173        }
174        if matches!(answer, Answer::Always | Answer::Never) {
175            self.save();
176        }
177        eprintln!("[gasm] consent: {subject}: {}", match answer {
178            Answer::ThisTime => "allowed this time",
179            Answer::Always => "always allowed",
180            Answer::No => "denied this time",
181            Answer::Never => "never allowed",
182        });
183    }
184
185    fn save(&self) {
186        let Some(f) = &self.file else { return };
187        let text: String = self.remembered.iter().map(|(s, &a)| format!("{} {s}\n", if a { "allow" } else { "deny" })).collect();
188        let r = f.parent().map_or(Ok(()), std::fs::create_dir_all).and_then(|_| std::fs::write(f, text));
189        if let Err(e) = r {
190            eprintln!("[gasm] consent: {}: {e}", f.display());
191        }
192    }
193}
194
195/// `--forget-consent <game|all>`: remove remembered answers; what was removed.
196pub fn forget(game: &str) -> Result<String, String> {
197    let dir = crate::storage::data_root().map(|r| r.join("consent")).ok_or("no data directory")?;
198    if game == "all" {
199        return match std::fs::remove_dir_all(&dir) {
200            Ok(()) => Ok(format!("removed every remembered answer ({})", dir.display())),
201            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok("no remembered answers".into()),
202            Err(e) => Err(format!("{}: {e}", dir.display())),
203        };
204    }
205    let f = dir.join(format!("{game}.txt"));
206    match std::fs::remove_file(&f) {
207        Ok(()) => Ok(format!("removed the remembered answers for {game} ({})", f.display())),
208        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(format!("no remembered answers for {game}")),
209        Err(e) => Err(format!("{}: {e}", f.display())),
210    }
211}
212
213/// The question for the player, in words: what the game wants, and to what.
214pub fn describe(subject: &str) -> (&'static str, String) {
215    if let Some(hosts) = subject.strip_prefix("net:") {
216        return ("wants to connect to", hosts.replace(',', ", "));
217    }
218    if let Some(file) = subject.strip_prefix("mod:") {
219        return ("wants to load a mod that connects out", file.to_owned());
220    }
221    if subject == crate::media::CONSENT_SUBJECT {
222        return ("wants to play internet radio", "from any site, as a browser plays audio".to_owned());
223    }
224    ("wants to save files for you", "in Pictures or Downloads".to_owned())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn answers() {
233        let mut s = Store { file: None, remembered: BTreeMap::new(), session: BTreeMap::new(), questions: VecDeque::new(), ask: true, mod_hosts: BTreeMap::new() };
234        assert_eq!(s.check("net:a.org"), None);
235        assert_eq!(s.check("net:a.org"), None);
236        assert_eq!(s.check("save"), None);
237        assert_eq!(s.question().map(String::as_str), Some("net:a.org")); // asked once each, in order
238        s.answer("net:a.org", Answer::ThisTime);
239        assert_eq!(s.check("net:a.org"), Some(true));
240        assert_eq!(s.question().map(String::as_str), Some("save"));
241        s.answer("save", Answer::Never);
242        assert_eq!((s.check("save"), s.remembered.get("save")), (Some(false), Some(&false)));
243        assert_eq!(s.question(), None);
244        assert_eq!(Store::never_ask().check("net:b.org"), Some(false));
245    }
246
247    #[test]
248    fn up_front_and_mods() {
249        let mut s = Store::never_ask();
250        s.ask = true;
251        s.ask_up_front(&["a.org".into(), "b.org".into()], false, false);
252        assert_eq!(s.question().map(String::as_str), Some("net:a.org,b.org"));
253        s.answer("net:a.org,b.org", Answer::ThisTime);
254        assert_eq!((s.check("net:a.org"), s.check("net:b.org")), (Some(true), Some(true)));
255        assert_eq!(s.check_mod("roads.pck", &["c.org".into()]), None);
256        assert_eq!(s.question().map(String::as_str), Some("mod:roads.pck"));
257        s.answer("mod:roads.pck", Answer::ThisTime);
258        assert_eq!((s.check_mod("roads.pck", &["c.org".into()]), s.check("net:c.org")), (Some(true), Some(true)));
259        assert_eq!(Store::never_ask().check_mod("x.pck", &["d.org".into()]), Some(false));
260    }
261
262    #[test]
263    fn media_up_front() {
264        let mut s = Store::never_ask();
265        s.ask = true;
266        s.ask_up_front(&[], false, true);
267        assert_eq!(s.question().map(String::as_str), Some("media"));
268        assert_eq!(describe("media").0, "wants to play internet radio");
269        s.answer("media", Answer::ThisTime);
270        assert_eq!(s.check("media"), Some(true));
271    }
272}