Skip to main content

gasm_host/
files.rs

1//! gasm:files: files a game hands the player. `save` queues a copy; the runner writes
2//! the queue after the frame (`Files::flush`) where the player finds it: images in
3//! Pictures/<game>/, everything else in Downloads/<game>/, or `--save-dir`. Names are
4//! reduced to a safe file name and never overwrite a file ("photo (2).png"). The game
5//! never learns the path. Headless runs without `--save-dir` write nothing (each save
6//! still completes at the end of its frame, so runs are reproducible).
7
8use std::path::{Path, PathBuf};
9
10/// The largest file a game can save
11pub const MAX_SAVE: usize = 256 << 20;
12/// Saves queued and not yet written, at most
13pub const MAX_PENDING: usize = 16;
14
15pub const PENDING: i32 = 0;
16pub const SAVED: i32 = 1;
17pub const FAILED: i32 = 2;
18
19/// Where saves go.
20#[derive(Clone, Debug)]
21pub enum SaveTarget {
22    /// `--no-save`: every save is refused
23    Off,
24    /// accepted and dropped (headless runs without `--save-dir`)
25    Discard,
26    /// Pictures/<game>/ for images, Downloads/<game>/ otherwise
27    Defaults { game: String },
28    /// `--save-dir`
29    Dir(PathBuf),
30}
31
32pub struct Files {
33    target: SaveTarget,
34    /// the window runner asks the player before the first save to the default folders
35    consent: Option<crate::consent::Consent>,
36    /// state by handle - 1
37    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    /// Queue a save: a handle > 0, or -1 (logged).
58    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    /// The state of a save, or None for a handle `save` never returned.
77    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    /// Write what the game saved during the frame (between frames). Saves to the
82    /// default folders wait (pending) until the player has answered.
83    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
124/// `type/subtype` with RFC 6838 name characters.
125pub 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
130/// The last path component, with characters no file system takes replaced by `_`,
131/// without leading dots or trailing dots and spaces, and not a Windows device name.
132pub 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
145/// Create `dir/name`, or `dir/stem (2).ext` and so on if it exists.
146fn 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
173/// An XDG user directory (`XDG_PICTURES_DIR="$HOME/Pictures"` in user-dirs.dirs).
174fn 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}