1use std::path::{Path, PathBuf};
9
10pub const MAX_SAVE: usize = 256 << 20;
12pub const MAX_PENDING: usize = 16;
14
15pub const PENDING: i32 = 0;
16pub const SAVED: i32 = 1;
17pub const FAILED: i32 = 2;
18
19#[derive(Clone, Debug)]
21pub enum SaveTarget {
22 Off,
24 Discard,
26 Defaults { game: String },
28 Dir(PathBuf),
30}
31
32pub struct Files {
33 target: SaveTarget,
34 consent: Option<crate::consent::Consent>,
36 states: Vec<i32>,
38 queue: Vec<(usize, String, String, Vec<u8>)>,
39}
40
41impl Default for Files {
42 fn default() -> Files {
43 Files::new(SaveTarget::Discard)
44 }
45}
46
47impl Files {
48 pub fn new(target: SaveTarget) -> Files {
49 Files { target, consent: None, states: Vec::new(), queue: Vec::new() }
50 }
51
52 pub fn with_consent(mut self, consent: Option<crate::consent::Consent>) -> Files {
53 self.consent = consent;
54 self
55 }
56
57 pub fn save(&mut self, name: &str, mime: &str, data: &[u8]) -> i32 {
59 let why = match () {
60 _ if matches!(self.target, SaveTarget::Off) => Some("saving is turned off (--no-save)"),
61 _ if name.is_empty() || name.len() > 255 => Some("the name must be 1 to 255 bytes"),
62 _ if !valid_mime(mime) => Some("the type must be type/subtype (image/png)"),
63 _ if data.len() > MAX_SAVE => Some("over 256 MiB"),
64 _ if self.queue.len() >= MAX_PENDING => Some("16 saves are still pending"),
65 _ => None,
66 };
67 if let Some(why) = why {
68 eprintln!("[gasm] files: save {name:?} refused: {why}");
69 return -1;
70 }
71 self.states.push(PENDING);
72 self.queue.push((self.states.len() - 1, name.to_owned(), mime.to_owned(), data.to_vec()));
73 self.states.len() as i32
74 }
75
76 pub fn state(&self, handle: i32) -> Option<i32> {
78 usize::try_from(handle).ok().and_then(|h| h.checked_sub(1)).and_then(|i| self.states.get(i).copied())
79 }
80
81 pub fn flush(&mut self) {
84 if let (SaveTarget::Defaults { .. }, Some(c), false) = (&self.target, &self.consent, self.queue.is_empty()) {
85 match c.lock().unwrap().check(crate::consent::SAVE) {
86 None => return,
87 Some(true) => {}
88 Some(false) => {
89 for (i, name, ..) in std::mem::take(&mut self.queue) {
90 eprintln!("[gasm] files: save {name:?} refused: the player said no");
91 self.states[i] = FAILED;
92 }
93 return;
94 }
95 }
96 }
97 for (i, name, mime, data) in std::mem::take(&mut self.queue) {
98 let dir = match &self.target {
99 SaveTarget::Off => None,
100 SaveTarget::Discard => {
101 self.states[i] = SAVED;
102 continue;
103 }
104 SaveTarget::Dir(d) => Some(d.clone()),
105 SaveTarget::Defaults { game } => {
106 let base = if mime.starts_with("image/") { pictures_dir() } else { downloads_dir() };
107 base.map(|b| b.join(safe_name(game)))
108 }
109 };
110 self.states[i] = match dir.ok_or_else(|| "no folder to save in".to_string()).and_then(|d| write_unique(&d, &safe_name(&name), &data)) {
111 Ok(path) => {
112 eprintln!("[gasm] files: saved {}", path.display());
113 SAVED
114 }
115 Err(e) => {
116 eprintln!("[gasm] files: save {name:?} failed: {e}");
117 FAILED
118 }
119 };
120 }
121 }
122}
123
124pub fn valid_mime(m: &str) -> bool {
126 let part = |s: &str| !s.is_empty() && s.len() <= 127 && s.bytes().all(|c| c.is_ascii_alphanumeric() || b"!#$&^_.+-".contains(&c));
127 m.split_once('/').is_some_and(|(t, s)| part(t) && part(s))
128}
129
130pub fn safe_name(name: &str) -> String {
133 let last = name.rsplit(['/', '\\']).next().unwrap_or("");
134 let mut s: String = last.chars().map(|c| if c.is_control() || "<>:\"|?*".contains(c) { '_' } else { c }).collect();
135 s = s.trim_start_matches(['.', ' ']).trim_end_matches(['.', ' ']).to_owned();
136 if s.is_empty() {
137 return "file".into();
138 }
139 let stem = s.split('.').next().unwrap_or("").to_ascii_uppercase();
140 let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
141 || (stem.len() == 4 && (stem.starts_with("COM") || stem.starts_with("LPT")) && stem.as_bytes()[3].is_ascii_digit());
142 if device { format!("_{s}") } else { s }
143}
144
145fn write_unique(dir: &Path, name: &str, data: &[u8]) -> Result<PathBuf, String> {
147 use std::io::Write;
148 std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
149 let (stem, ext) = match name.rfind('.') {
150 Some(i) if i > 0 => (&name[..i], &name[i..]),
151 _ => (name, ""),
152 };
153 for n in 1..1000 {
154 let path = dir.join(if n == 1 { name.to_owned() } else { format!("{stem} ({n}){ext}") });
155 match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
156 Ok(mut f) => {
157 return f.write_all(data).and_then(|_| f.sync_all()).map(|_| path.clone()).map_err(|e| {
158 let _ = std::fs::remove_file(&path);
159 format!("{}: {e}", path.display())
160 });
161 }
162 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
163 Err(e) => return Err(format!("{}: {e}", path.display())),
164 }
165 }
166 Err(format!("{}: too many files named {name}", dir.display()))
167}
168
169fn home() -> Option<PathBuf> {
170 std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from)
171}
172
173fn xdg_user_dir(key: &str) -> Option<PathBuf> {
175 let config = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home().map(|h| h.join(".config")))?;
176 let text = std::fs::read_to_string(config.join("user-dirs.dirs")).ok()?;
177 let value = text.lines().find_map(|l| l.trim().strip_prefix(key)?.strip_prefix('='))?.trim().trim_matches('"');
178 match value.strip_prefix("$HOME") {
179 Some(rest) => home().map(|h| h.join(rest.trim_start_matches('/'))),
180 None => Some(PathBuf::from(value)),
181 }
182}
183
184pub fn pictures_dir() -> Option<PathBuf> {
185 (!cfg!(any(target_os = "macos", windows))).then(|| xdg_user_dir("XDG_PICTURES_DIR")).flatten().or_else(|| home().map(|h| h.join("Pictures")))
186}
187
188pub fn downloads_dir() -> Option<PathBuf> {
189 (!cfg!(any(target_os = "macos", windows))).then(|| xdg_user_dir("XDG_DOWNLOAD_DIR")).flatten().or_else(|| home().map(|h| h.join("Downloads")))
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn names_are_made_safe() {
198 assert_eq!(safe_name("photo.png"), "photo.png");
199 assert_eq!(safe_name("../../etc/passwd"), "passwd");
200 assert_eq!(safe_name("C:\\Users\\x\\shot.png"), "shot.png");
201 assert_eq!(safe_name(".hidden"), "hidden");
202 assert_eq!(safe_name("a<b>:c?.txt"), "a_b__c_.txt");
203 assert_eq!(safe_name("con.txt"), "_con.txt");
204 assert_eq!(safe_name("..."), "file");
205 assert_eq!(safe_name("map. "), "map");
206 }
207
208 #[test]
209 fn mime_types() {
210 assert!(valid_mime("image/png") && valid_mime("application/vnd.gasm+json"));
211 assert!(!valid_mime("png") && !valid_mime("image/") && !valid_mime("text/plain; charset=utf-8"));
212 }
213
214 #[test]
215 fn saves_are_written_once_each_without_overwriting() {
216 let dir = std::env::temp_dir().join(format!("gasm-files-test-{}", std::process::id()));
217 let _ = std::fs::remove_dir_all(&dir);
218 let mut f = Files::new(SaveTarget::Dir(dir.clone()));
219 let a = f.save("shot.png", "image/png", b"one");
220 let b = f.save("shot.png", "image/png", b"two");
221 assert_eq!((f.state(a), f.state(b), f.state(3), f.state(0)), (Some(PENDING), Some(PENDING), None, None));
222 f.flush();
223 assert_eq!((f.state(a), f.state(b)), (Some(SAVED), Some(SAVED)));
224 assert_eq!(std::fs::read(dir.join("shot.png")).unwrap(), b"one");
225 assert_eq!(std::fs::read(dir.join("shot (2).png")).unwrap(), b"two");
226 assert_eq!(Files::new(SaveTarget::Off).save("x.txt", "text/plain", b"x"), -1);
227 let _ = std::fs::remove_dir_all(&dir);
228 }
229}