1use std::collections::{BTreeMap, VecDeque};
13use std::path::PathBuf;
14use std::sync::{Arc, Mutex};
15
16pub 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 ThisTime,
30 Always,
32 No,
34 Never,
36}
37
38#[derive(Debug)]
39pub struct Store {
40 file: Option<PathBuf>,
41 remembered: BTreeMap<Subject, bool>,
42 session: BTreeMap<Subject, bool>,
43 questions: VecDeque<Subject>,
45 ask: bool,
47 mod_hosts: BTreeMap<String, Vec<String>>,
49}
50
51pub type Consent = Arc<Mutex<Store>>;
53
54impl Store {
55 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 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 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 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 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 pub fn asking(&self) -> bool {
142 !self.questions.is_empty()
143 }
144
145 pub fn mod_hosts(&self, file: &str) -> &[String] {
147 self.mod_hosts.get(file).map_or(&[], Vec::as_slice)
148 }
149
150 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 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
195pub 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
213pub 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")); 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}