plum

#treesitter#compiler#wasm

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 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` beside it). Its rules are plain functions with `test` blocks, so `plum test plum-examples` covers them without a window.
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
  ![the example game](docs/game.png)
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 beside this script: a 64x48 sheet of 16x16 frames.
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(root.join(path)).ok(),
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) => root.join(path).is_file(),
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", ""] {