Skip to content

Commit 99f5bcf

Browse files
committed
Add 3MF post-processing script checks
1 parent d687427 commit 99f5bcf

5 files changed

Lines changed: 238 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Changelog
2+
3+
All notable changes to this project are documented here.
4+
5+
## v0.1.2 - 2026-05-20
6+
7+
### Added
8+
9+
- Add optional 3MF post-processing script checks. By default, the bridge notifies the user, writes the script text to the log, and then continues opening the file.
10+
11+
### Fixed
12+
13+
- Ignore empty `bambustudioopen` URI launches without showing an error or running the download flow.
14+
- Improve diagnostics for malformed slicer links by logging the original input URI when URL extraction fails.
15+
16+
## v0.1.1 - 2026-04-28
17+
18+
### Added
19+
20+
- Add an automatic Windows installer that sets up the bridge and registers URI handlers.
21+
- Add `python -m slicer_uri_bridge` support as a fallback way to run the CLI when the script entry point is not on `PATH`.
22+
23+
## v0.1.0 - 2026-04-28
24+
25+
### Initial Release
26+
27+
- Add the first Slicer URI Bridge CLI for routing slicer links to Bambu Studio.
28+
- Support Bambu Studio, PrusaSlicer, OrcaSlicer, Cura, and Creality Print style URI links.
29+
- Add safe model download validation before opening files in Bambu Studio.
30+
- Add interactive URI handler registration and status management.
31+
- Add an automatic macOS installer.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,12 @@ The bridge validates downloads before opening them:
135135
* redirect targets are revalidated
136136
* downloaded files must use an allowed model extension
137137
* empty files and obvious executable formats are refused
138+
* 3MF files are checked for embedded post-processing scripts ([scripts that can run after slicing](https://manual.slic3r.org/advanced/post-processing))
138139

139140
By default, downloads are accepted from any host. To restrict downloads to specific hosts, set `allow_any_original_host = false` in the config and use the `allowed_hosts` list (the default config includes CDNs for Printables, Thingiverse, and Creality).
140141

142+
All available options are described in the bundled [`default_config.toml`](src/slicer_uri_bridge/resources/default_config.toml) template and copied into the generated `config.toml` file.
143+
141144
## Troubleshooting
142145

143146
The bridge writes log files next to the config file. To find their location:

src/slicer_uri_bridge/handler.py

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import argparse
55
import ipaddress
6+
import json
67
import logging
78
import posixpath
89
import re
@@ -17,6 +18,7 @@
1718
import urllib.parse
1819
import urllib.request
1920
import urllib.response
21+
import zipfile
2022
from pathlib import Path, PurePosixPath, PureWindowsPath
2123
from urllib.parse import urlsplit
2224

@@ -34,6 +36,9 @@
3436
BUFFER_SIZE = 81920
3537
REDIRECT_CODES = {301, 302, 303, 307, 308}
3638
EXECUTABLE_MODE_BITS = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
39+
PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
40+
POST_PROCESS_ACTION_DEFAULT = "warn"
41+
POST_PROCESS_ACTIONS = {"ignore", "warn", "block"}
3742

3843

3944
class BridgeError(RuntimeError):
@@ -80,7 +85,18 @@ def is_host(value: str) -> bool:
8085
return bool(p.hostname) and not any((p.scheme, p.path, p.query, p.fragment, p.username, p.password))
8186
except ValueError:
8287
return False
83-
88+
89+
90+
def normalize_post_process_action(value: object) -> str:
91+
if isinstance(value, str):
92+
action = value.strip().lower()
93+
if action in POST_PROCESS_ACTIONS:
94+
return action
95+
96+
logger.warning("Invalid security.post_process_action; using warn")
97+
return POST_PROCESS_ACTION_DEFAULT
98+
99+
84100
def load_config() -> dict:
85101
if not CONFIG_FILE.is_file():
86102
raise BridgeError(missing_config_message(CONFIG_FILE))
@@ -106,6 +122,8 @@ def load_config() -> dict:
106122
logger.warning("Invalid security.allow_any_original_host; using false")
107123
security["allow_any_original_host"] = False
108124

125+
security["post_process_action"] = normalize_post_process_action(security.get("post_process_action"))
126+
109127
allowed_hosts = security.get("allowed_hosts", [])
110128
valid_hosts = []
111129
if isinstance(allowed_hosts, list):
@@ -513,6 +531,55 @@ def validate_downloaded_file(path: Path) -> None:
513531
raise BridgeError("Downloaded file is a Mach-O executable, refusing to open it.")
514532

515533

534+
def scan_3mf_post_process(path: Path) -> list[str] | None:
535+
if path.suffix.lower() != ".3mf":
536+
return None
537+
538+
try:
539+
with zipfile.ZipFile(path) as archive:
540+
settings = json.loads(archive.read(PROJECT_SETTINGS_PATH).decode("utf-8-sig"))
541+
if isinstance(raw := settings.get("post_process"), list):
542+
return raw or None
543+
except Exception as exc:
544+
logger.warning("Could not inspect 3MF project settings in %s: %s", path, exc)
545+
546+
return None
547+
548+
549+
def post_process_message(path: Path, commands: list[str]) -> str:
550+
if len(commands) == 1:
551+
post_process = commands[0]
552+
else:
553+
post_process = "\n\n".join(
554+
f"[{index}]\n{command}" for index, command in enumerate(commands, start=1)
555+
)
556+
557+
return (
558+
"Downloaded 3MF file contains a post-processing script.\n\n"
559+
f"File: {path}\n\n"
560+
"post_process:\n"
561+
f"{post_process}"
562+
)
563+
564+
565+
def check_3mf_post_process(path: Path, action: str) -> None:
566+
if action == "ignore":
567+
return
568+
569+
commands = scan_3mf_post_process(path)
570+
# No real commands if every value becomes empty after trimming whitespace.
571+
if commands is None or not any(command.strip() for command in commands):
572+
return
573+
574+
message = post_process_message(path, commands)
575+
logger.warning("%s", message)
576+
577+
if action == "block":
578+
raise BridgeError(message)
579+
580+
show_warning(message)
581+
582+
516583
def has_executable_bits(mode: int) -> bool:
517584
if IS_WINDOWS:
518585
return False
@@ -601,20 +668,28 @@ def launch_bambu(command: list[str], model_path: Path) -> None:
601668
raise BridgeError(f"Failed to start Bambu Studio: {exc}") from exc
602669

603670

604-
def show_error(message: str) -> None:
671+
def show_message(message: str, kind: str) -> None:
605672
print(message, file=sys.stderr)
606673
try:
607674
import tkinter
608675
from tkinter import messagebox
609676

610677
root = tkinter.Tk()
611678
root.withdraw()
612-
messagebox.showerror("Bambu Studio URI Bridge", message)
679+
getattr(messagebox, kind)("Slicer URI Bridge", message)
613680
root.destroy()
614681
except Exception:
615682
pass
616683

617684

685+
def show_error(message: str) -> None:
686+
show_message(message, "showerror")
687+
688+
689+
def show_warning(message: str) -> None:
690+
show_message(message, "showwarning")
691+
692+
618693
def main(argv: list[str] | None = None) -> int:
619694
setup_logging()
620695
args = parse_args(sys.argv[1:] if argv is None else argv)
@@ -661,6 +736,7 @@ def main(argv: list[str] | None = None) -> int:
661736
allow_plain_http=allow_plain_http,
662737
)
663738
validate_downloaded_file(local_path)
739+
check_3mf_post_process(local_path, security["post_process_action"])
664740

665741
launch_bambu(command, local_path)
666742

src/slicer_uri_bridge/resources/default_config.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
allow_plain_http = false
55
allow_any_original_host = true
66
allowed_extensions = [".3mf", ".stl", ".step", ".stp", ".obj"]
7+
# 3MF files can contain Bambu/Orca post-processing commands.
8+
# Values: "ignore", "warn" (show a warning and open), or "block" (refuse to open).
9+
post_process_action = "warn"
710
# allowed_hosts is only used when allow_any_original_host = false
811
allowed_hosts = [
912
"files.printables.com",

tests/test_handler.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from __future__ import annotations
22

3+
import json
34
import shutil
45
import subprocess
56
import unittest
67
import uuid
8+
import zipfile
79
from collections.abc import Iterator
810
from contextlib import contextmanager
911
from pathlib import Path
@@ -12,15 +14,19 @@
1214
from slicer_uri_bridge.handler import (
1315
BridgeError,
1416
build_destination,
17+
check_3mf_post_process,
1518
choose_filename,
1619
extract_download,
1720
filename_from_url,
21+
load_config,
1822
is_empty_bambustudioopen_uri,
1923
normalize_host,
24+
normalize_post_process_action,
2025
main,
2126
read_protocol_uri,
2227
has_executable_bits,
2328
launch_bambu,
29+
scan_3mf_post_process,
2430
validate_downloaded_file,
2531
validate_remote_url,
2632
)
@@ -40,6 +46,11 @@ def temporary_directory() -> Iterator[str]:
4046
shutil.rmtree(path, ignore_errors=True)
4147

4248

49+
def write_3mf(path: Path, project_settings: object, *, member: str = "Metadata/project_settings.config") -> None:
50+
with zipfile.ZipFile(path, "w") as archive:
51+
archive.writestr(member, json.dumps(project_settings))
52+
53+
4354
class DownloadUriTests(unittest.TestCase):
4455
def test_bambu_uri_decodes_payload_and_strips_model_slash(self) -> None:
4556
url, suggested_name = extract_download(
@@ -282,6 +293,117 @@ def test_has_executable_bits_is_disabled_on_windows(self) -> None:
282293
self.assertFalse(has_executable_bits(0o100755))
283294

284295

296+
class ThreeMfPostProcessTests(unittest.TestCase):
297+
def test_scan_3mf_post_process_detects_project_setting(self) -> None:
298+
script = r"C:\Users\maker\inspect_model.ps1"
299+
with temporary_directory() as temp_dir:
300+
path = Path(temp_dir) / "model.3mf"
301+
write_3mf(path, {"post_process": [script]})
302+
303+
result = scan_3mf_post_process(path)
304+
305+
self.assertEqual(result, [script])
306+
307+
def test_check_3mf_post_process_ignores_blank_values(self) -> None:
308+
with temporary_directory() as temp_dir:
309+
path = Path(temp_dir) / "model.3mf"
310+
write_3mf(path, {"post_process": ["", " "]})
311+
312+
with patch("slicer_uri_bridge.handler.show_warning") as show_warning:
313+
check_3mf_post_process(path, "warn")
314+
315+
show_warning.assert_not_called()
316+
317+
def test_check_3mf_post_process_warns_and_allows(self) -> None:
318+
script = r"C:\Users\maker\inspect_model.ps1"
319+
with temporary_directory() as temp_dir:
320+
path = Path(temp_dir) / "model.3mf"
321+
write_3mf(path, {"post_process": [script]})
322+
323+
with (
324+
patch("slicer_uri_bridge.handler.show_warning") as show_warning,
325+
self.assertLogs("slicer_uri_bridge", level="WARNING") as captured,
326+
):
327+
check_3mf_post_process(path, "warn")
328+
329+
show_warning.assert_called_once()
330+
self.assertIn(script, show_warning.call_args.args[0])
331+
self.assertTrue(any(script in line for line in captured.output))
332+
333+
def test_check_3mf_post_process_blocks_when_configured(self) -> None:
334+
with temporary_directory() as temp_dir:
335+
path = Path(temp_dir) / "model.3mf"
336+
write_3mf(path, {"post_process": [r"C:\Users\maker\inspect_model.ps1"]})
337+
338+
with self.assertRaisesRegex(BridgeError, "post-processing script"):
339+
check_3mf_post_process(path, "block")
340+
341+
def test_main_does_not_launch_bambu_when_post_process_is_blocked(self) -> None:
342+
script = r"C:\Users\maker\inspect_model.ps1"
343+
config = {
344+
"security": {
345+
"allowed_extensions": [".3mf"],
346+
"allow_plain_http": False,
347+
"allowed_hosts": [],
348+
"allow_any_original_host": True,
349+
"post_process_action": "block",
350+
},
351+
"bambu_studio": {},
352+
}
353+
354+
with temporary_directory() as temp_dir:
355+
path = Path(temp_dir) / "model.3mf"
356+
write_3mf(path, {"post_process": [script]})
357+
358+
with (
359+
patch("slicer_uri_bridge.handler.load_config", return_value=config),
360+
patch("slicer_uri_bridge.handler.validate_remote_url"),
361+
patch("slicer_uri_bridge.handler.resolve_bambu_command", return_value=["bambu-studio"]),
362+
patch("slicer_uri_bridge.handler.download_model", return_value=path),
363+
patch("slicer_uri_bridge.handler.launch_bambu") as launch,
364+
patch("slicer_uri_bridge.handler.show_error") as show_error,
365+
):
366+
exit_code = main(["bambustudioopen://https%3A%2F%2Ffiles.example%2Fmodel.3mf"])
367+
368+
self.assertEqual(exit_code, 1)
369+
launch.assert_not_called()
370+
show_error.assert_called_once()
371+
self.assertIn(script, show_error.call_args.args[0])
372+
373+
def test_check_3mf_post_process_ignore_skips_archive_inspection(self) -> None:
374+
with temporary_directory() as temp_dir:
375+
path = Path(temp_dir) / "model.3mf"
376+
path.write_bytes(b"not a zip")
377+
378+
with patch("slicer_uri_bridge.handler.zipfile.ZipFile") as zip_file:
379+
check_3mf_post_process(path, "ignore")
380+
381+
zip_file.assert_not_called()
382+
383+
def test_post_process_action_defaults_to_warn_for_invalid_values(self) -> None:
384+
self.assertEqual(normalize_post_process_action(None), "warn")
385+
self.assertEqual(normalize_post_process_action("block"), "block")
386+
self.assertEqual(normalize_post_process_action("off"), "warn")
387+
388+
def test_load_config_defaults_post_process_action_to_warn(self) -> None:
389+
with temporary_directory() as temp_dir:
390+
config_path = Path(temp_dir) / "config.toml"
391+
config_path.write_text("""\
392+
[security]
393+
allow_any_original_host = true
394+
allowed_extensions = [".3mf"]
395+
396+
[bambu_studio]
397+
""",
398+
encoding="utf-8",
399+
)
400+
401+
with patch("slicer_uri_bridge.handler.CONFIG_FILE", config_path):
402+
config = load_config()
403+
404+
self.assertEqual(config["security"]["post_process_action"], "warn")
405+
406+
285407
class ProtocolFileTests(unittest.TestCase):
286408
def test_read_protocol_uri_decodes_bom_and_removes_temp_file(self) -> None:
287409
with temporary_directory() as temp_dir:

0 commit comments

Comments
 (0)