Skip to content

Commit 1c5c23c

Browse files
authored
Add safeguards for 3MF post-processing scripts (#1)
* Add 3MF post-processing script checks * Handle empty Bambu Studio URIs Ignore empty bambustudioopen URI triggers, log problematic input URIs during extraction failures * Bump the package version to 0.1.2.
1 parent 6f78a2d commit 1c5c23c

9 files changed

Lines changed: 364 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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+
- Add `post_process_action` config support with `warn`, `block`, and `ignore` modes.
11+
12+
### Fixed
13+
14+
- Ignore empty `bambustudioopen` URI launches without showing an error or running the download flow.
15+
- Improve diagnostics for malformed slicer links by logging the original input URI when URL extraction fails.
16+
17+
## v0.1.1 - 2026-04-28
18+
19+
### Added
20+
21+
- Add an automatic Windows installer that sets up the bridge and registers URI handlers.
22+
- Add `python -m slicer_uri_bridge` support as a fallback way to run the CLI when the script entry point is not on `PATH`.
23+
24+
## v0.1.0 - 2026-04-28
25+
26+
### Initial Release
27+
28+
- Add the first Slicer URI Bridge CLI for routing slicer links to Bambu Studio.
29+
- Support Bambu Studio, PrusaSlicer, OrcaSlicer, Cura, and Creality Print style URI links.
30+
- Add safe model download validation before opening files in Bambu Studio.
31+
- Add interactive URI handler registration and status management.
32+
- Add an automatic macOS installer.

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://opensource.org/licenses/MIT)
66
[![Platform: Windows | macOS | Linux](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)]()
77

8+
[Installation](#installation) · [Security Model](#security-model) · [Changelog](CHANGELOG.md)
9+
810
Slicer URI Bridge helps open 3D model links from websites in Bambu Studio, including sites that do not provide a native Bambu Studio button or where that integration is not available.
911

1012
https://github.com/user-attachments/assets/32b1fd48-4498-42de-81d6-629b452712b9
@@ -135,9 +137,12 @@ The bridge validates downloads before opening them:
135137
* redirect targets are revalidated
136138
* downloaded files must use an allowed model extension
137139
* empty files and obvious executable formats are refused
140+
* 3MF files are checked for embedded post-processing scripts ([scripts that can run after slicing](https://manual.slic3r.org/advanced/post-processing))
138141

139142
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).
140143

144+
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.
145+
141146
## Troubleshooting
142147

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

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "slicer-uri-bridge"
7-
version = "0.1.1"
7+
version = "0.1.2"
88
description = "Register slicer URI handlers and bridge slicer links to Bambu Studio."
99
readme = "README.md"
1010
requires-python = ">=3.11"

src/slicer_uri_bridge/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
11
"""Slicer URI Bridge package."""
22

33
from __future__ import annotations
4-
5-
__version__ = "0.1.1"

src/slicer_uri_bridge/cli.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66
import subprocess
77
import sys
88
import tomllib
9+
from importlib.metadata import PackageNotFoundError, version as package_version
910
from pathlib import Path
1011

11-
from . import __version__
1212
from .config import config_matches_default, init_user_config, user_config_path
1313
from .manager import main as manager_main
1414

1515

16+
PACKAGE_NAME = "slicer-uri-bridge"
1617
YES_VALUES = {"y", "yes"}
1718
NO_VALUES = {"n", "no"}
1819
IS_WINDOWS = sys.platform == "win32"
@@ -27,12 +28,23 @@ def eprint(message: str) -> None:
2728
print(message, file=sys.stderr)
2829

2930

31+
def metadata_version() -> str:
32+
try:
33+
return package_version(PACKAGE_NAME)
34+
except PackageNotFoundError:
35+
return "unknown"
36+
37+
3038
def build_parser() -> argparse.ArgumentParser:
3139
parser = argparse.ArgumentParser(
3240
prog="slicer-uri-bridge",
3341
description="Register slicer URI handlers and bridge slicer links to Bambu Studio.",
3442
)
35-
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
43+
parser.add_argument(
44+
"--version",
45+
action="version",
46+
version=f"%(prog)s {metadata_version()}",
47+
)
3648

3749
subparsers = parser.add_subparsers(dest="command", required=True)
3850

src/slicer_uri_bridge/handler.py

Lines changed: 98 additions & 4 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):
@@ -224,6 +242,15 @@ def extract_download(protocol_uri: str, allowed_extensions: set[str]) -> tuple[s
224242
return strip_trailing_model_slash(download_url, allowed_extensions), suggested_name
225243

226244

245+
def is_empty_bambustudioopen_uri(protocol_uri: str) -> bool:
246+
parsed = urllib.parse.urlsplit(protocol_uri)
247+
if parsed.scheme.lower() != "bambustudioopen":
248+
return False
249+
250+
payload = protocol_uri.split(":", 1)[1].lstrip("/")
251+
return not urllib.parse.unquote(payload).strip()
252+
253+
227254
def filename_from_url(url: str) -> str | None:
228255
parsed = urllib.parse.urlsplit(url)
229256
query_name = urllib.parse.parse_qs(parsed.query).get("name", [""])[0].strip()
@@ -504,6 +531,55 @@ def validate_downloaded_file(path: Path) -> None:
504531
raise BridgeError("Downloaded file is a Mach-O executable, refusing to open it.")
505532

506533

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+
507583
def has_executable_bits(mode: int) -> bool:
508584
if IS_WINDOWS:
509585
return False
@@ -592,23 +668,32 @@ def launch_bambu(command: list[str], model_path: Path) -> None:
592668
raise BridgeError(f"Failed to start Bambu Studio: {exc}") from exc
593669

594670

595-
def show_error(message: str) -> None:
671+
def show_message(message: str, kind: str) -> None:
596672
print(message, file=sys.stderr)
597673
try:
598674
import tkinter
599675
from tkinter import messagebox
600676

601677
root = tkinter.Tk()
602678
root.withdraw()
603-
messagebox.showerror("Bambu Studio URI Bridge", message)
679+
getattr(messagebox, kind)("Slicer URI Bridge", message)
604680
root.destroy()
605681
except Exception:
606682
pass
607683

608684

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+
609693
def main(argv: list[str] | None = None) -> int:
610694
setup_logging()
611695
args = parse_args(sys.argv[1:] if argv is None else argv)
696+
uri: str | None = None
612697
local_path: Path | None = None
613698
download_folder: Path | None = None
614699

@@ -620,7 +705,15 @@ def main(argv: list[str] | None = None) -> int:
620705
download_folder = download_folder_from_config(config)
621706

622707
uri = resolve_protocol_uri(args)
623-
download_url, suggested_name = extract_download(uri, allowed_extensions)
708+
if is_empty_bambustudioopen_uri(uri):
709+
logger.info("Ignoring empty bambustudioopen URI: %r", uri)
710+
return 0
711+
712+
try:
713+
download_url, suggested_name = extract_download(uri, allowed_extensions)
714+
except BridgeError:
715+
logger.error("Input URI: %r", uri)
716+
raise
624717
logger.info(f"Resolved input URI with download URL: {download_url}")
625718
allowed_hosts, allow_any_original_host = load_allowed_hosts(config)
626719
validate_remote_url(
@@ -643,6 +736,7 @@ def main(argv: list[str] | None = None) -> int:
643736
allow_plain_http=allow_plain_http,
644737
)
645738
validate_downloaded_file(local_path)
739+
check_3mf_post_process(local_path, security["post_process_action"])
646740

647741
launch_bambu(command, local_path)
648742

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_cli.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,30 @@ def isatty(self) -> bool:
3333
return True
3434

3535

36+
class CliVersionTests(unittest.TestCase):
37+
def test_version_action_uses_fallback_when_package_metadata_is_absent(self) -> None:
38+
with (
39+
patch("slicer_uri_bridge.cli.package_version", side_effect=cli.PackageNotFoundError),
40+
patch("sys.stdout", new_callable=StringIO) as stdout,
41+
):
42+
parser = cli.build_parser()
43+
with self.assertRaises(SystemExit) as exit_context:
44+
parser.parse_args(["--version"])
45+
46+
self.assertEqual(exit_context.exception.code, 0)
47+
self.assertIn("slicer-uri-bridge unknown", stdout.getvalue())
48+
49+
def test_subcommands_do_not_require_package_metadata(self) -> None:
50+
with (
51+
patch("slicer_uri_bridge.cli.package_version", side_effect=cli.PackageNotFoundError),
52+
patch("slicer_uri_bridge.cli.user_config_path", return_value=Path("config.toml")),
53+
patch("sys.stdout", new_callable=StringIO) as stdout,
54+
):
55+
self.assertEqual(cli.main(["config-path"]), 0)
56+
57+
self.assertIn("config.toml", stdout.getvalue())
58+
59+
3660
class InteractiveOnboardingTests(unittest.TestCase):
3761
def test_package_can_run_as_python_module(self) -> None:
3862
completed = subprocess.run(

0 commit comments

Comments
 (0)