plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
9389e9f
— Peter John
2026-11-02T14:46:44+05:30
Move sprite generator out of the asset dir, reject symlinks escaping the asset root, discard-target test
- README.md +1 -1
- plum-checker/tests/checker_tests.rs +5 -0
- plum-examples/{assets → tools}/make_sprites.py +2 -2
- plum-game/src/api.rs +24 -2
README.md
CHANGED
|
@@ -1029,7 +1029,7 @@ Every module that imports `std/Str` also exports four helpers for hosts that can
|
|
|
1029
1029
|
|
|
1030
1030
|
`plum game file.plum` compiles a game and runs it in a native window (macroquad, with the module under wasmtime). `plum game --web file.plum [-o dist] [--assets DIR]` writes a static web build instead: `index.html`, `mq_js_bundle.js`, `plum_bridge.js`, `plum-game.wasm`, `plum.wasm`, `preload.txt` and an `assets/` copy of the whole asset directory (every file in it is listed in `preload.txt` and fetched before the first frame; `dist` defaults to `dist` beside the source file). Serve it from any static host; the browser needs wasm GC support. The web build needs `rustup target add wasm32-unknown-unknown`.
|
|
1031
1031
|
|
|
1032
|
-
`plum game plum-examples/game.plum` runs the example, a small arcade game: steer a ship with the arrow keys or WASD, collect orbs, dodge mines (Enter restarts). It draws shapes, text and an animated sprite sheet (`plum-examples/assets/sprites.png`, generated by `make_sprites.py`
|
|
1032
|
+
`plum game plum-examples/game.plum` runs the example, a small arcade game: steer a ship with the arrow keys or WASD, collect orbs, dodge mines (Enter restarts). It draws shapes, text and an animated sprite sheet (`plum-examples/assets/sprites.png`, generated by `plum-examples/tools/make_sprites.py`, kept outside the asset dir so it is not shipped). Its rules are plain functions with `test` blocks, so `plum test plum-examples` covers them without a window.
|
|
1033
1033
|
|
|
1034
1034
|

|
|
1035
1035
|
|
plum-checker/tests/checker_tests.rs
CHANGED
|
@@ -919,3 +919,8 @@ fn underscoreDiscardIsAccepted() {
|
|
|
919
919
|
);
|
|
920
920
|
assert!(r.is_ok(), "{:?}", r.err());
|
|
921
921
|
}
|
|
922
|
+
|
|
923
|
+
#[test]
|
|
924
|
+
fn discardTargetTwiceInOneScopeIsFine() {
|
|
925
|
+
assert!(checkDiscard("fun f() =\n _ := five()\n _ := five()\n _ = five()\n _ := five()\n").is_ok());
|
|
926
|
+
}
|
plum-examples/{assets → tools}/make_sprites.py
RENAMED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Writes sprites.png
|
|
2
|
+
"""Writes ../assets/sprites.png: a 64x48 sheet of 16x16 frames.
|
|
3
3
|
|
|
4
4
|
Row 0: the ship with four engine-flame frames (pointing up).
|
|
5
5
|
Row 1: an orb spinning (four frames, squeezed and widened).
|
|
@@ -81,5 +81,5 @@ png = b"\x89PNG\r\n\x1a\n"
|
|
|
81
81
|
png += chunk(b"IHDR", struct.pack(">IIBBBBB", W, H, 8, 6, 0, 0, 0))
|
|
82
82
|
png += chunk(b"IDAT", zlib.compress(raw, 9))
|
|
83
83
|
png += chunk(b"IEND", b"")
|
|
84
|
-
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "sprites.png"), "wb") as f:
|
|
84
|
+
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets", "sprites.png"), "wb") as f:
|
|
85
85
|
f.write(png)
|
plum-game/src/api.rs
CHANGED
|
@@ -61,6 +61,12 @@ pub fn path_in_root(path: &str) -> bool {
|
|
|
61
61
|
&& Path::new(path).components().all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/// `root/path` when it still resolves inside `root` after following symlinks.
|
|
65
|
+
fn contained(root: &Path, path: &str) -> Option<PathBuf> {
|
|
66
|
+
let full = std::fs::canonicalize(root.join(path)).ok()?;
|
|
67
|
+
full.starts_with(std::fs::canonicalize(root).ok()?).then_some(full)
|
|
68
|
+
}
|
|
69
|
+
|
|
64
70
|
/// Where an [`AssetStore`] reads its files from.
|
|
65
71
|
enum Source {
|
|
66
72
|
/// Files under a directory (native).
|
|
@@ -105,7 +111,7 @@ impl AssetStore {
|
|
|
105
111
|
return None;
|
|
106
112
|
}
|
|
107
113
|
match &self.source {
|
|
108
|
-
Source::Dir(root) => std::fs::read(
|
|
114
|
+
Source::Dir(root) => std::fs::read(contained(root, path)?).ok(),
|
|
109
115
|
Source::Files(files) => files.get(path.trim_start_matches("./")).cloned(),
|
|
110
116
|
}
|
|
111
117
|
}
|
|
@@ -115,7 +121,7 @@ impl AssetStore {
|
|
|
115
121
|
return false;
|
|
116
122
|
}
|
|
117
123
|
match &self.source {
|
|
118
|
-
Source::Dir(root) =>
|
|
124
|
+
Source::Dir(root) => contained(root, path).is_some_and(|p| p.is_file()),
|
|
119
125
|
Source::Files(files) => files.contains_key(path.trim_start_matches("./")),
|
|
120
126
|
}
|
|
121
127
|
}
|
|
@@ -184,6 +190,22 @@ mod tests {
|
|
|
184
190
|
assert_eq!(s.file_load("a.txt"), b"hi");
|
|
185
191
|
}
|
|
186
192
|
|
|
193
|
+
#[cfg(unix)]
|
|
194
|
+
#[test]
|
|
195
|
+
fn symlinks_out_of_the_root_are_rejected() {
|
|
196
|
+
let base = std::env::temp_dir().join(format!("plum_symlink_{}", std::process::id()));
|
|
197
|
+
let root = base.join("root");
|
|
198
|
+
std::fs::create_dir_all(&root).unwrap();
|
|
199
|
+
std::fs::write(base.join("secret.txt"), b"s").unwrap();
|
|
200
|
+
std::fs::write(root.join("ok.txt"), b"o").unwrap();
|
|
201
|
+
std::os::unix::fs::symlink(base.join("secret.txt"), root.join("link.txt")).unwrap();
|
|
202
|
+
let s = AssetStore::new(root);
|
|
203
|
+
assert!(s.file_exists("ok.txt"));
|
|
204
|
+
assert!(!s.file_exists("link.txt"));
|
|
205
|
+
assert!(s.file_load("link.txt").is_empty());
|
|
206
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
207
|
+
}
|
|
208
|
+
|
|
187
209
|
#[test]
|
|
188
210
|
fn paths_escaping_the_root_are_rejected() {
|
|
189
211
|
for bad in ["../x", "a/../../x", "/etc/passwd", "a\\b", ""] {
|