diff --git a/.github/workflows/installer-smoke.yml b/.github/workflows/installer-smoke.yml new file mode 100644 index 0000000..7a8cf71 --- /dev/null +++ b/.github/workflows/installer-smoke.yml @@ -0,0 +1,102 @@ +name: Installer Smoke + +on: + push: + branches: [main] + paths: + - "install-macos.sh" + - "install-windows.ps1" + - "pyproject.toml" + - "setup.py" + - "README.md" + - "src/**" + - ".github/workflows/installer-smoke.yml" + pull_request: + paths: + - "install-macos.sh" + - "install-windows.ps1" + - "pyproject.toml" + - "setup.py" + - "README.md" + - "src/**" + - ".github/workflows/installer-smoke.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + macos-installer: + name: macOS installer + runs-on: macos-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Run installer + env: + SLICER_URI_BRIDGE_PROJECT_SPEC: ${{ github.workspace }} + HOME: ${{ runner.temp }}/home + XDG_CONFIG_HOME: ${{ runner.temp }}/xdg-config + URI_BRIDGE_MACOS_APP_DIR: ${{ runner.temp }}/Applications + run: | + mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$URI_BRIDGE_MACOS_APP_DIR" + bash install-macos.sh + + - name: Verify installation + env: + HOME: ${{ runner.temp }}/home + XDG_CONFIG_HOME: ${{ runner.temp }}/xdg-config + URI_BRIDGE_MACOS_APP_DIR: ${{ runner.temp }}/Applications + run: | + export PATH="$HOME/.local/bin:$PATH" + command -v slicer-uri-bridge + slicer-uri-bridge --version + slicer-uri-bridge status + test -f "$URI_BRIDGE_MACOS_APP_DIR/SlicerURIBridge.app/Contents/Info.plist" + + windows-installer: + name: Windows installer + runs-on: windows-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Run installer + shell: powershell + env: + SLICER_URI_BRIDGE_PROJECT_SPEC: ${{ github.workspace }} + LOCALAPPDATA: ${{ runner.temp }}\LocalAppData + APPDATA: ${{ runner.temp }}\AppData + run: .\install-windows.ps1 + + - name: Verify installation + shell: powershell + env: + LOCALAPPDATA: ${{ runner.temp }}\LocalAppData + APPDATA: ${{ runner.temp }}\AppData + run: | + $bridge = Join-Path $env:LOCALAPPDATA 'slicer-uri-bridge\venv\Scripts\slicer-uri-bridge.exe' + if (-not (Test-Path -LiteralPath $bridge)) { + throw "Bridge command was not installed: $bridge" + } + + & $bridge --version + & $bridge status + + $command = (Get-Item -LiteralPath 'HKCU:\Software\Classes\bambustudioopen\shell\open\command').GetValue('') + if ($command -notmatch 'slicer_uri_bridge\.handler') { + throw "Unexpected bambustudioopen handler command: $command" + } diff --git a/.github/workflows/macos-applescript.yml b/.github/workflows/macos-applescript.yml new file mode 100644 index 0000000..1b1330e --- /dev/null +++ b/.github/workflows/macos-applescript.yml @@ -0,0 +1,40 @@ +name: macOS AppleScript + +on: + push: + branches: [main] + paths: + - "src/slicer_uri_bridge/manager.py" + - "src/slicer_uri_bridge/resources/macos-launcher.applescript" + - "tests/test_manager_macos.py" + - ".github/workflows/macos-applescript.yml" + pull_request: + paths: + - "src/slicer_uri_bridge/manager.py" + - "src/slicer_uri_bridge/resources/macos-launcher.applescript" + - "tests/test_manager_macos.py" + - ".github/workflows/macos-applescript.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + osacompile: + name: osacompile integration + runs-on: macos-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install package + run: python -m pip install -e . + + - name: Run macOS AppleScript tests + run: python -m unittest tests.test_manager_macos diff --git a/CHANGELOG.md b/CHANGELOG.md index ffa954b..d6630b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project are documented here. +## v0.1.3 - 2026-06-03 + +### Added + +- Add `allow_local_resolved_hosts` as a security setting for cases where you intentionally want to allow downloads from trusted hosts that resolve to local addresses. + ## v0.1.2 - 2026-05-20 ### Added diff --git a/README.md b/README.md index 07952b3..22bb624 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://opensource.org/licenses/MIT) [![Platform: Windows | macOS | Linux](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)]() +[![Tests](https://img.shields.io/github/actions/workflow/status/mbv06/slicer-uri-bridge/ci.yml?label=tests)](https://github.com/mbv06/slicer-uri-bridge/actions/workflows/ci.yml) [Installation](#installation) · [Security Model](#security-model) · [Changelog](CHANGELOG.md) @@ -58,7 +59,7 @@ First, install Python 3.11 or newer on the target system: Then install the package from GitHub: ```bash -python -m pip install https://github.com/mbv06/slicer-uri-bridge/archive/refs/heads/main.zip +python -m pip install --upgrade https://github.com/mbv06/slicer-uri-bridge/archive/refs/heads/main.zip ``` Installation only installs the CLI and Python package. It does not register URI handlers automatically. @@ -92,22 +93,30 @@ slicer-uri-bridge status ## Uninstall -Unregister all URI handlers managed by this package: +First, unregister all URI handlers managed by this package: ```bash slicer-uri-bridge unregister --auto ``` -You can also delete the config and log files manually. Find their location with: +Then remove the installed package or app files for your installation type. + +Manual install: ```bash -slicer-uri-bridge config-path +python -m pip uninstall slicer-uri-bridge +``` + +Automatic Windows install: + +```powershell +Remove-Item -LiteralPath (Join-Path $env:LOCALAPPDATA 'slicer-uri-bridge') -Recurse -Force ``` -Then remove the package: +Automatic macOS install: ```bash -pip uninstall slicer-uri-bridge +rm -rf "$HOME/.local/share/slicer-uri-bridge" "$HOME/Applications/SlicerURIBridge.app"; rm -f "$HOME/.local/bin/slicer-uri-bridge" ``` ## How It Works @@ -133,7 +142,7 @@ The bridge validates downloads before opening them: * only HTTPS URLs are allowed unless `allow_plain_http = true` * URLs with embedded credentials are rejected -* resolved hosts must not point to local/private/reserved addresses +* resolved hosts must not point to local/private/reserved addresses unless `allow_local_resolved_hosts = true` * redirect targets are revalidated * downloaded files must use an allowed model extension * empty files and obvious executable formats are refused diff --git a/install-macos.sh b/install-macos.sh index b61de18..2044bde 100644 --- a/install-macos.sh +++ b/install-macos.sh @@ -15,7 +15,7 @@ set -euo pipefail # # To update later, run this installer again. -PROJECT_SPEC="https://github.com/mbv06/slicer-uri-bridge/archive/refs/heads/main.zip" +PROJECT_SPEC="${SLICER_URI_BRIDGE_PROJECT_SPEC:-https://github.com/mbv06/slicer-uri-bridge/archive/refs/heads/main.zip}" APP_HOME="${HOME}/.local/share/slicer-uri-bridge" VENV="${APP_HOME}/venv" diff --git a/install-windows.ps1 b/install-windows.ps1 index d444eaa..0788de8 100644 --- a/install-windows.ps1 +++ b/install-windows.ps1 @@ -21,7 +21,8 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$ProjectSpec = 'https://github.com/mbv06/slicer-uri-bridge/archive/refs/heads/main.zip' +$DefaultProjectSpec = 'https://github.com/mbv06/slicer-uri-bridge/archive/refs/heads/main.zip' +$ProjectSpec = if ($env:SLICER_URI_BRIDGE_PROJECT_SPEC) { $env:SLICER_URI_BRIDGE_PROJECT_SPEC } else { $DefaultProjectSpec } $AppHome = Join-Path $env:LOCALAPPDATA 'slicer-uri-bridge' $VenvDir = Join-Path $AppHome 'venv' $ScriptsDir = Join-Path $VenvDir 'Scripts' diff --git a/pyproject.toml b/pyproject.toml index 9c53b93..181bc53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "slicer-uri-bridge" -version = "0.1.2" -description = "Register slicer URI handlers and bridge slicer links to Bambu Studio." +version = "0.1.3" +description = "Register slicer URI handlers and bridge slicer links to your favorite slicer app." readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } @@ -15,9 +15,6 @@ authors = [ keywords = ["uri-handler", "slicer", "bambu-studio", "cura", "prusaslicer", "orcaslicer"] classifiers = [ "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", "Operating System :: Microsoft :: Windows", "Operating System :: MacOS", "Operating System :: POSIX :: Linux", @@ -31,3 +28,38 @@ where = ["src"] [tool.setuptools.package-data] slicer_uri_bridge = ["resources/default_config.toml", "resources/macos-launcher.applescript"] + +[tool.ruff] +line-length = 120 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "SIM", # flake8-simplify +] +ignore = [ + "SIM102", # nested if statements + "SIM108", # use ternary operator + "SIM117", # nested with statements +] + + +[project.optional-dependencies] +dev = [ + "mypy>=2.1.0", + "ruff>=0.15.15", +] + +[tool.mypy] +python_version = "3.11" +check_untyped_defs = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_return_any = true +files = ["src", "tests"] diff --git a/setup.py b/setup.py index b024da8..6068493 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,3 @@ from setuptools import setup - setup() diff --git a/src/slicer_uri_bridge/cli.py b/src/slicer_uri_bridge/cli.py index ef627ff..773437e 100644 --- a/src/slicer_uri_bridge/cli.py +++ b/src/slicer_uri_bridge/cli.py @@ -6,13 +6,13 @@ import subprocess import sys import tomllib -from importlib.metadata import PackageNotFoundError, version as package_version +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as package_version from pathlib import Path from .config import config_matches_default, init_user_config, user_config_path from .manager import main as manager_main - PACKAGE_NAME = "slicer-uri-bridge" YES_VALUES = {"y", "yes"} NO_VALUES = {"n", "no"} @@ -124,7 +124,9 @@ def warn_if_bambu_target_missing(config_path: Path) -> None: eprint(f"Warning: Bambu Studio path from config was not found: {configured}") eprint(f"Edit {config_path} and update [bambu_studio].{key}.") if key != "windows": - eprint("Fallback: if this path stays invalid, the bridge will try to open models with your default application.") + eprint( + "Fallback: if this path stays invalid, the bridge will try to open models with your default application." + ) def interactive_onboarding() -> int: diff --git a/src/slicer_uri_bridge/config.py b/src/slicer_uri_bridge/config.py index e741b16..a004af5 100644 --- a/src/slicer_uri_bridge/config.py +++ b/src/slicer_uri_bridge/config.py @@ -2,9 +2,9 @@ import os import sys +from collections.abc import Mapping from importlib import resources from pathlib import Path -from typing import Mapping CONFIG_DIR_NAME = "slicer-uri-bridge" CONFIG_FILE_NAME = "config.toml" @@ -51,11 +51,7 @@ def user_log_path( def default_config_text() -> str: - return ( - resources.files("slicer_uri_bridge") - .joinpath("resources", "default_config.toml") - .read_text(encoding="utf-8") - ) + return resources.files("slicer_uri_bridge").joinpath("resources", "default_config.toml").read_text(encoding="utf-8") def init_user_config(*, force: bool = False) -> tuple[Path, bool]: diff --git a/src/slicer_uri_bridge/handler.py b/src/slicer_uri_bridge/handler.py index 9250426..7cb3cf0 100644 --- a/src/slicer_uri_bridge/handler.py +++ b/src/slicer_uri_bridge/handler.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse +import contextlib import ipaddress import json import logging @@ -30,7 +31,6 @@ USER_AGENT = "OrcaSlicer/2.4.0-dev" IS_WINDOWS = sys.platform == "win32" IS_MACOS = sys.platform == "darwin" -IS_LINUX = not IS_WINDOWS and not IS_MACOS MAX_REDIRECTS = 5 MAX_DOWNLOAD_BYTES = 200 * 1024 * 1024 BUFFER_SIZE = 81920 @@ -59,9 +59,7 @@ def return_response(self, req, fp, code, msg, headers): def parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Open supported slicer-style URIs in local Bambu Studio." - ) + parser = argparse.ArgumentParser(description="Open supported slicer-style URIs in local Bambu Studio.") parser.add_argument("uri", nargs="?") parser.add_argument("--uri-file", "-UriFile", dest="uri_file") return parser.parse_args(argv) @@ -122,6 +120,10 @@ def load_config() -> dict: logger.warning("Invalid security.allow_any_original_host; using false") security["allow_any_original_host"] = False + if not isinstance(security.get("allow_local_resolved_hosts", False), bool): + logger.warning("Invalid security.allow_local_resolved_hosts; using false") + security["allow_local_resolved_hosts"] = False + security["post_process_action"] = normalize_post_process_action(security.get("post_process_action")) allowed_hosts = security.get("allowed_hosts", []) @@ -146,19 +148,18 @@ def load_config() -> dict: if not isinstance(extension, str) or not extension.strip(): logger.warning(f"Ignoring invalid extension in security.allowed_extensions: {extension!r}") continue - + extension = extension.strip().lower() if not extension.startswith("."): extension = f".{extension}" valid_extensions.append(extension) security["allowed_extensions"] = valid_extensions - if not security["allowed_extensions"]: + if not security["allowed_extensions"]: message = "Config value must be a list: security.allowed_extensions" logger.error(message) raise BridgeError(message) - if not isinstance(config.get("bambu_studio"), dict): message = "Missing [bambu_studio] in config" logger.error(message) @@ -176,18 +177,16 @@ def read_protocol_uri(uri_file: str) -> str: return data.decode("utf-16").strip() return data.decode("utf-8-sig").strip() finally: - try: + with contextlib.suppress(OSError): path.unlink() - except OSError: - pass def resolve_protocol_uri(args: argparse.Namespace) -> str: if args.uri: - return args.uri.strip() + return str(args.uri).strip() if args.uri_file: - return read_protocol_uri(args.uri_file).strip() + return read_protocol_uri(str(args.uri_file)).strip() raise BridgeError("Missing URI argument.") @@ -300,8 +299,7 @@ def assert_public_host(host: str) -> None: for address in addresses: if not ipaddress.ip_address(address).is_global: raise BridgeError( - "Host resolves to a local/private/reserved address and is not allowed: " - f"{host} -> {address}" + f"Host resolves to a local/private/reserved address and is not allowed: {host} -> {address}" ) @@ -312,6 +310,7 @@ def validate_remote_url( allow_any_original_host: bool, allow_plain_http: bool, check_allowlist: bool, + allow_local_resolved_hosts: bool, ) -> None: parsed = urllib.parse.urlsplit(url) if not parsed.scheme or not parsed.netloc: @@ -331,7 +330,8 @@ def validate_remote_url( if normalize_host(host) not in allowed_hosts: raise BridgeError(f"Download host is not allow-listed: {host}") - assert_public_host(host) + if not allow_local_resolved_hosts or not check_allowlist: + assert_public_host(host) def download_folder_from_config(config: dict) -> Path | None: @@ -410,6 +410,7 @@ def download_model( allowed_hosts: set[str], allow_any_original_host: bool, allow_plain_http: bool, + allow_local_resolved_hosts: bool, ) -> Path: opener = urllib.request.build_opener(NoRedirectHandler()) current_url = initial_url @@ -422,6 +423,7 @@ def download_model( allow_any_original_host=allow_any_original_host, allow_plain_http=allow_plain_http, check_allowlist=redirect_index == 0, + allow_local_resolved_hosts=allow_local_resolved_hosts, ) request = urllib.request.Request( @@ -476,21 +478,15 @@ def download_model( while chunk := response.read(BUFFER_SIZE): total += len(chunk) if total > MAX_DOWNLOAD_BYTES: - raise BridgeError( - f"Download exceeded the size limit: {MAX_DOWNLOAD_BYTES} bytes" - ) + raise BridgeError(f"Download exceeded the size limit: {MAX_DOWNLOAD_BYTES} bytes") output.write(chunk) except Exception: if output_created: - try: + with contextlib.suppress(OSError): destination.unlink() - except OSError: - pass if download_folder is None: - try: + with contextlib.suppress(OSError): destination.parent.rmdir() - except OSError: - pass raise logger.info(f"Downloaded {total} bytes to {destination}") @@ -550,16 +546,9 @@ def post_process_message(path: Path, commands: list[str]) -> str: if len(commands) == 1: post_process = commands[0] else: - post_process = "\n\n".join( - f"[{index}]\n{command}" for index, command in enumerate(commands, start=1) - ) + post_process = "\n\n".join(f"[{index}]\n{command}" for index, command in enumerate(commands, start=1)) - return ( - "Downloaded 3MF file contains a post-processing script.\n\n" - f"File: {path}\n\n" - "post_process:\n" - f"{post_process}" - ) + return f"Downloaded 3MF file contains a post-processing script.\n\nFile: {path}\n\npost_process:\n{post_process}" def check_3mf_post_process(path: Path, action: str) -> None: @@ -621,9 +610,7 @@ def resolve_bambu_command(config: dict) -> list[str]: if not resolved: if IS_WINDOWS: raise BridgeError(f"Bambu Studio executable not found on PATH: {configured_path}") - return warn_and_resolve_default_open_command( - f"Bambu Studio executable not found on PATH: {configured_path}" - ) + return warn_and_resolve_default_open_command(f"Bambu Studio executable not found on PATH: {configured_path}") return [resolved] @@ -670,7 +657,7 @@ def launch_bambu(command: list[str], model_path: Path) -> None: def show_message(message: str, kind: str) -> None: print(message, file=sys.stderr) - try: + with contextlib.suppress(Exception): import tkinter from tkinter import messagebox @@ -678,8 +665,6 @@ def show_message(message: str, kind: str) -> None: root.withdraw() getattr(messagebox, kind)("Slicer URI Bridge", message) root.destroy() - except Exception: - pass def show_error(message: str) -> None: @@ -701,6 +686,7 @@ def main(argv: list[str] | None = None) -> int: config = load_config() security = config["security"] allow_plain_http = security.get("allow_plain_http", False) + allow_local_resolved_hosts = security.get("allow_local_resolved_hosts", False) allowed_extensions = security["allowed_extensions"] download_folder = download_folder_from_config(config) @@ -722,6 +708,7 @@ def main(argv: list[str] | None = None) -> int: allow_any_original_host=allow_any_original_host, allow_plain_http=allow_plain_http, check_allowlist=True, + allow_local_resolved_hosts=allow_local_resolved_hosts, ) command = resolve_bambu_command(config) @@ -734,6 +721,7 @@ def main(argv: list[str] | None = None) -> int: allowed_hosts=allowed_hosts, allow_any_original_host=allow_any_original_host, allow_plain_http=allow_plain_http, + allow_local_resolved_hosts=allow_local_resolved_hosts, ) validate_downloaded_file(local_path) check_3mf_post_process(local_path, security["post_process_action"]) @@ -744,15 +732,11 @@ def main(argv: list[str] | None = None) -> int: except Exception as exc: logger.error(f"Failed: {exc}") if local_path: - try: + with contextlib.suppress(OSError): local_path.unlink() - except OSError: - pass if download_folder is None: - try: + with contextlib.suppress(OSError): local_path.parent.rmdir() - except OSError: - pass show_error(str(exc)) return 1 diff --git a/src/slicer_uri_bridge/manager.py b/src/slicer_uri_bridge/manager.py index 6482a11..3f70f1e 100644 --- a/src/slicer_uri_bridge/manager.py +++ b/src/slicer_uri_bridge/manager.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse +import contextlib import importlib.resources import importlib.util import os @@ -12,9 +13,9 @@ import sys import tempfile from abc import ABC, abstractmethod +from collections.abc import Iterable from dataclasses import dataclass from pathlib import Path -from typing import Iterable from .config import missing_config_message, user_config_path, user_log_path @@ -58,7 +59,12 @@ class ActionResult: PROTOCOLS: tuple[ProtocolDef, ...] = ( ProtocolDef("bambu", "Bambu", "bambustudioopen", ("bambu", "bambuopen", "bambustudio", "bambustudioopen")), ProtocolDef("cura", "Cura", "cura", ("cura", "ultimaker-cura", "ultimakercura")), - ProtocolDef("creality", "Creality", "crealityprintlink", ("creality", "crealityprint", "creality-print", "crealityprintlink", "creality-print-link")), + ProtocolDef( + "creality", + "Creality", + "crealityprintlink", + ("creality", "crealityprint", "creality-print", "crealityprintlink", "creality-print-link"), + ), ProtocolDef("prusa", "Prusa", "prusaslicer", ("prusa", "prusa-slicer", "prusaslicer")), ProtocolDef("orca", "Orca", "orcaslicer", ("orca", "orca-slicer", "orcaslicer")), ) @@ -101,7 +107,6 @@ def __init__(self, script_dir: Path, python_command: str | None, dry_run: bool) self.python_command = python_command self.dry_run = dry_run - @property @abstractmethod def our_target(self) -> str: @@ -338,7 +343,7 @@ def get_desktop_field(self, desktop_id: str, field_name: str) -> str | None: prefix = f"{field_name}=" for raw_line in path.read_text(encoding="utf-8", errors="replace").splitlines(): if raw_line.startswith(prefix): - return raw_line[len(prefix):] + return raw_line[len(prefix) :] return None def get_desktop_exec(self, desktop_id: str) -> str | None: @@ -354,9 +359,7 @@ def command_current(self, definitions: Iterable[ProtocolDef]) -> bool: expected_mime_types = {self.mime_for(item.protocol) for item in definitions} if self.get_desktop_exec(DESKTOP_ID) != self.expected_desktop_exec(): return False - if self.get_desktop_mime_types(DESKTOP_ID) != expected_mime_types: - return False - return True + return self.get_desktop_mime_types(DESKTOP_ID) == expected_mime_types def get_state(self, definition: ProtocolDef) -> HandlerState: effective = self.get_effective_default_handler(definition.protocol) @@ -378,19 +381,22 @@ def refresh_desktop_database(self) -> None: command = shutil.which("update-desktop-database") if not command: return - try: - subprocess.run([command, str(self.applications_dir)], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except OSError: - pass + with contextlib.suppress(OSError): + subprocess.run( + [command, str(self.applications_dir)], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) def apply_xdg_default(self, mime: str) -> None: command = shutil.which("xdg-mime") if not command: return - try: - subprocess.run([command, "default", DESKTOP_ID, mime], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except OSError: - pass + with contextlib.suppress(OSError): + subprocess.run( + [command, "default", DESKTOP_ID, mime], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) def write_bridge_files(self, definitions: Iterable[ProtocolDef]) -> None: self.check_expected_runtime() @@ -408,19 +414,21 @@ def write_bridge_files(self, definitions: Iterable[ProtocolDef]) -> None: if definitions: mime_types = "".join(f"{self.mime_for(item.protocol)};" for item in definitions) self.desktop_file.write_text( - "\n".join(( - "[Desktop Entry]", - "Type=Application", - f"Name={APP_NAME}", - "Comment=Route slicer URI schemes to a Python bridge", - "NoDisplay=true", - "Terminal=false", - f"Exec={self.expected_desktop_exec()}", - f"MimeType={mime_types}", - "Categories=Utility;", - "StartupNotify=false", - "", - )), + "\n".join( + ( + "[Desktop Entry]", + "Type=Application", + f"Name={APP_NAME}", + "Comment=Route slicer URI schemes to a Python bridge", + "NoDisplay=true", + "Terminal=false", + f"Exec={self.expected_desktop_exec()}", + f"MimeType={mime_types}", + "Categories=Utility;", + "StartupNotify=false", + "", + ) + ), encoding="utf-8", ) else: @@ -553,6 +561,7 @@ def __init__(self, script_dir: Path, python_command: str | None, dry_run: bool) ) self._resolved_python_command: str | None = None import winreg + self.winreg = winreg @staticmethod @@ -569,7 +578,7 @@ def win_quote(value: str) -> str: return '"' + value.replace('"', '\\"') + '"' def expected_command(self) -> str: - return f"{self.win_quote(self.resolved_python_command())} -m {HANDLER_MODULE} \"%1\"" + return f'{self.win_quote(self.resolved_python_command())} -m {HANDLER_MODULE} "%1"' def resolved_python_command(self) -> str: if self._resolved_python_command: @@ -686,10 +695,8 @@ def delete_handler_registration(self, definition: ProtocolDef) -> None: def delete_expected_key(self, root, path: str, root_path: str) -> None: # noqa: ANN001 - winreg root type is platform-only if not self.safe_delete_path(root, path, root_path): return - try: + with contextlib.suppress(OSError): self.winreg.DeleteKey(root, path) - except OSError: - pass def safe_delete_path(self, root, path: str, root_path: str) -> bool: # noqa: ANN001 - winreg root type is platform-only if root != self.winreg.HKEY_CURRENT_USER: @@ -773,7 +780,7 @@ def macos_launcher_template() -> str: fallback = Path(__file__).with_name(RESOURCE_DIR_NAME) / MACOS_LAUNCHER_TEMPLATE_NAME if fallback.is_file(): return fallback.read_text(encoding="utf-8") - raise FileNotFoundError(f"macOS launcher template not found: {MACOS_LAUNCHER_TEMPLATE_NAME}") + raise FileNotFoundError(f"macOS launcher template not found: {MACOS_LAUNCHER_TEMPLATE_NAME}") from None def expected_applescript_source(self) -> str: replacements = { @@ -867,24 +874,26 @@ def write_info_plist(self, definitions: Iterable[ProtocolDef]) -> None: info = plistlib.load(handle) schemes = [item.protocol for item in definitions] - info.update({ - "CFBundleName": APP_NAME, - "CFBundleDisplayName": APP_NAME, - "CFBundleIdentifier": MACOS_BUNDLE_ID, - "CFBundleURLTypes": [ - { - "CFBundleURLName": APP_NAME, - "CFBundleTypeRole": "Viewer", - "CFBundleURLSchemes": schemes, - } - ], - "LSUIElement": True, - "NSHighResolutionCapable": True, - "SlicerURIBridgePython": self.expected_python(), - "SlicerURIBridgeModule": HANDLER_MODULE, - "SlicerURIBridgeConfig": str(user_config_path()), - "SlicerURIBridgeManagedSchemes": schemes, - }) + info.update( + { + "CFBundleName": APP_NAME, + "CFBundleDisplayName": APP_NAME, + "CFBundleIdentifier": MACOS_BUNDLE_ID, + "CFBundleURLTypes": [ + { + "CFBundleURLName": APP_NAME, + "CFBundleTypeRole": "Viewer", + "CFBundleURLSchemes": schemes, + } + ], + "LSUIElement": True, + "NSHighResolutionCapable": True, + "SlicerURIBridgePython": self.expected_python(), + "SlicerURIBridgeModule": HANDLER_MODULE, + "SlicerURIBridgeConfig": str(user_config_path()), + "SlicerURIBridgeManagedSchemes": schemes, + } + ) with self.info_plist.open("wb") as handle: plistlib.dump(info, handle, sort_keys=False) @@ -949,19 +958,19 @@ def register_app_bundle(self) -> None: command = self.lsregister_command() if not command or not self.app_bundle.exists(): return - try: - subprocess.run([command, "-f", str(self.app_bundle)], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except OSError: - pass + with contextlib.suppress(OSError): + subprocess.run( + [command, "-f", str(self.app_bundle)], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) def unregister_app_bundle(self) -> None: command = self.lsregister_command() if not command or not self.app_bundle.exists(): return - try: - subprocess.run([command, "-u", str(self.app_bundle)], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except OSError: - pass + with contextlib.suppress(OSError): + subprocess.run( + [command, "-u", str(self.app_bundle)], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) class _LaunchServices: ENCODING_UTF8 = 0x08000100 @@ -1094,7 +1103,9 @@ def best_alternative_handler(self, protocol: str, previous_default: str | None) return handler return None - def repair_unmanaged_defaults(self, managed_definitions: Iterable[ProtocolDef], previous_defaults: dict[str, str | None]) -> None: + def repair_unmanaged_defaults( + self, managed_definitions: Iterable[ProtocolDef], previous_defaults: dict[str, str | None] + ) -> None: managed_schemes = {item.protocol for item in managed_definitions} for item in PROTOCOLS: protocol = item.protocol @@ -1130,7 +1141,9 @@ def get_state(self, definition: ProtocolDef) -> HandlerState: else: display = effective or "" - current = bool(app_claims_scheme and effective_managed and self.command_current(self.current_bridge_definitions())) + current = bool( + app_claims_scheme and effective_managed and self.command_current(self.current_bridge_definitions()) + ) return HandlerState(definition, effective, display, managed, effective_managed, current) def status_text(self, state: HandlerState) -> str: @@ -1157,8 +1170,7 @@ def remove_handler(self, definition: ProtocolDef) -> None: definitions_before = self.current_bridge_definitions() previous_defaults = self.default_handlers_snapshot() defaults_that_were_ours = { - item.protocol: self.get_default_bundle_id(item.protocol) == MACOS_BUNDLE_ID - for item in definitions_before + item.protocol: self.get_default_bundle_id(item.protocol) == MACOS_BUNDLE_ID for item in definitions_before } remaining = [item for item in definitions_before if item.protocol != definition.protocol] @@ -1176,6 +1188,7 @@ def remove_handler(self, definition: ProtocolDef) -> None: if defaults_that_were_ours.get(item.protocol): self.set_default_handler(item.protocol) + def make_manager(script_dir: Path, python_command: str | None, dry_run: bool) -> UriHandlerManager: if sys.platform == "win32": return WindowsRegistryManager(script_dir, python_command, dry_run) @@ -1190,8 +1203,7 @@ def select_auto(manager: UriHandlerManager, action: str) -> list[ProtocolDef]: return [ state.definition for state in states - if not state.effective_target - or state.definition.protocol == "bambustudioopen" + if not state.effective_target or state.definition.protocol == "bambustudioopen" ] return [state.definition for state in states if state.managed_by_us] @@ -1200,7 +1212,9 @@ def print_statuses(manager: UriHandlerManager, numbered: bool = False) -> None: print("Supported URI handlers:") for index, state in enumerate((manager.get_state(item) for item in PROTOCOLS), start=1): if numbered: - print(f" {index}) {state.definition.protocol:<18} ({state.definition.name:<9}) {manager.status_text(state)}") + print( + f" {index}) {state.definition.protocol:<18} ({state.definition.name:<9}) {manager.status_text(state)}" + ) else: print(f" {state.definition.protocol:<18} ({state.definition.name:<9}) {manager.status_text(state)}") @@ -1289,10 +1303,24 @@ def normalize_argv(argv: list[str]) -> list[str]: def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Register or unregister slicer URI handlers on Windows/Linux/macOS.") parser.add_argument("items", nargs="*", help="Optional action followed by protocol names or aliases.") - parser.add_argument("--register", dest="flag_action", action="store_const", const="register", help="Register selected protocols.") - parser.add_argument("--unregister", dest="flag_action", action="store_const", const="unregister", help="Unregister selected protocols.") - parser.add_argument("--auto", action="store_true", help="register: protocols without a handler; unregister: protocols handled by us.") - parser.add_argument("--python", dest="python_command", help="Python executable used by the registered bridge command.") + parser.add_argument( + "--register", dest="flag_action", action="store_const", const="register", help="Register selected protocols." + ) + parser.add_argument( + "--unregister", + dest="flag_action", + action="store_const", + const="unregister", + help="Unregister selected protocols.", + ) + parser.add_argument( + "--auto", + action="store_true", + help="register: protocols without a handler; unregister: protocols handled by us.", + ) + parser.add_argument( + "--python", dest="python_command", help="Python executable used by the registered bridge command." + ) parser.add_argument("--dry-run", action="store_true", help="Print changes without writing them.") args = parser.parse_args(normalize_argv(argv)) @@ -1362,7 +1390,7 @@ def main(argv: list[str] | None = None) -> int: try: raise SystemExit(main()) except KeyboardInterrupt: - raise SystemExit(0) + raise SystemExit(0) from None except Exception as exc: eprint(f"Error: {exc}") - raise SystemExit(1) + raise SystemExit(1) from exc diff --git a/src/slicer_uri_bridge/resources/default_config.toml b/src/slicer_uri_bridge/resources/default_config.toml index 3dbf884..425885a 100644 --- a/src/slicer_uri_bridge/resources/default_config.toml +++ b/src/slicer_uri_bridge/resources/default_config.toml @@ -3,6 +3,7 @@ [security] allow_plain_http = false allow_any_original_host = true +allow_local_resolved_hosts = false allowed_extensions = [".3mf", ".stl", ".step", ".stp", ".obj"] # 3MF files can contain Bambu/Orca post-processing commands. # Values: "ignore", "warn" (show a warning and open), or "block" (refuse to open). diff --git a/tests/test_cli.py b/tests/test_cli.py index 0bf2703..4f1afd5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,15 +5,14 @@ import sys import unittest import uuid -from io import StringIO from collections.abc import Iterator from contextlib import contextmanager +from io import StringIO from pathlib import Path from unittest.mock import patch from slicer_uri_bridge import cli - TEMP_ROOT = Path(__file__).resolve().parent / ".tmp" @@ -120,7 +119,7 @@ def test_init_config_warns_about_missing_linux_bambu_target_with_fallback_note(s with temporary_directory() as temp_dir: config_path = temp_dir / "config.toml" config_path.write_text( - "[bambu_studio]\nlinux = \"MissingBambuStudio.AppImage\"\n", + '[bambu_studio]\nlinux = "MissingBambuStudio.AppImage"\n', encoding="utf-8", ) @@ -165,7 +164,7 @@ def test_init_config_checks_existing_config_too(self) -> None: with temporary_directory() as temp_dir: config_path = temp_dir / "config.toml" config_path.write_text( - "[bambu_studio]\nlinux = \"MissingBambuStudio.AppImage\"\n", + '[bambu_studio]\nlinux = "MissingBambuStudio.AppImage"\n', encoding="utf-8", ) @@ -200,7 +199,10 @@ def test_linux_uri_opener_prefers_xdg_open(self) -> None: with ( patch("slicer_uri_bridge.cli.IS_WINDOWS", False), patch("slicer_uri_bridge.cli.IS_MACOS", False), - patch("slicer_uri_bridge.cli.shutil.which", side_effect=lambda name: f"/usr/bin/{name}" if name == "xdg-open" else None), + patch( + "slicer_uri_bridge.cli.shutil.which", + side_effect=lambda name: f"/usr/bin/{name}" if name == "xdg-open" else None, + ), patch("slicer_uri_bridge.cli.subprocess.Popen") as popen, ): cli.open_system_uri(cli.TEST_URI) diff --git a/tests/test_core.py b/tests/test_core.py index 4eefc19..f8ec288 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -6,7 +6,15 @@ from unittest.mock import Mock, patch from slicer_uri_bridge.config import user_config_dir, user_config_path -from slicer_uri_bridge.manager import BRIDGE_DISPLAY_TARGET, HandlerState, PROTOCOLS, WindowsRegistryManager, main as manager_main, resolve_protocols, select_auto +from slicer_uri_bridge.manager import ( + BRIDGE_DISPLAY_TARGET, + PROTOCOLS, + HandlerState, + WindowsRegistryManager, + resolve_protocols, + select_auto, +) +from slicer_uri_bridge.manager import main as manager_main class FakeManager: @@ -37,23 +45,27 @@ def test_unknown_alias_raises(self) -> None: class AutoSelectionTests(unittest.TestCase): def test_register_auto_selects_empty_handlers_and_always_bambu(self) -> None: - manager = FakeManager({ - "bambustudioopen": ("vendor.bambu", False), - "cura": (None, False), - "prusaslicer": ("vendor.prusa", False), - "orcaslicer": ("vendor.orca", False), - "crealityprintlink": ("vendor.creality", False), - }) - selected = select_auto(manager, "register") + manager = FakeManager( + { + "bambustudioopen": ("vendor.bambu", False), + "cura": (None, False), + "prusaslicer": ("vendor.prusa", False), + "orcaslicer": ("vendor.orca", False), + "crealityprintlink": ("vendor.creality", False), + } + ) + selected = select_auto(manager, "register") # type: ignore[arg-type] self.assertEqual([item.protocol for item in selected], ["bambustudioopen", "cura"]) def test_unregister_auto_selects_only_our_handlers(self) -> None: - manager = FakeManager({ - "bambustudioopen": ("slicer-uri-bridge", True), - "cura": ("vendor.cura", False), - "orcaslicer": ("slicer-uri-bridge", True), - }) - selected = select_auto(manager, "unregister") + manager = FakeManager( + { + "bambustudioopen": ("slicer-uri-bridge", True), + "cura": ("vendor.cura", False), + "orcaslicer": ("slicer-uri-bridge", True), + } + ) + selected = select_auto(manager, "unregister") # type: ignore[arg-type] self.assertEqual([item.protocol for item in selected], ["bambustudioopen", "orcaslicer"]) @@ -88,7 +100,9 @@ def test_windows_appdata_config_dir(self) -> None: class StatusDisplayTests(unittest.TestCase): def test_windows_managed_package_handler_hides_python_path(self) -> None: - command = r'"C:\Users\Alice\AppData\Local\Programs\Python\Python313\pythonw.exe" -m slicer_uri_bridge.handler "%1"' + command = ( + r'"C:\Users\Alice\AppData\Local\Programs\Python\Python313\pythonw.exe" -m slicer_uri_bridge.handler "%1"' + ) class FakeWindowsManager(WindowsRegistryManager): def current_user_command(self, protocol: str) -> str | None: @@ -130,7 +144,7 @@ def test_init_imports_winreg_immediately(self) -> None: def make_manager(self) -> WindowsRegistryManager: manager = object.__new__(WindowsRegistryManager) - manager.winreg = self.FakeWinReg() + manager.winreg = self.FakeWinReg() # type: ignore[assignment] manager.dry_run = False return manager @@ -152,8 +166,8 @@ def test_delete_handler_registration_deletes_only_known_scheme_paths(self) -> No def test_remove_handler_skips_registry_delete_for_unmanaged_command(self) -> None: manager = self.make_manager() - manager.current_user_command = Mock(return_value=r"C:\Vendor\App.exe") - manager.delete_handler_registration = Mock() + manager.current_user_command = Mock(return_value=r"C:\Vendor\App.exe") # type: ignore[method-assign] + manager.delete_handler_registration = Mock() # type: ignore[method-assign] manager.remove_handler(PROTOCOLS[0]) diff --git a/tests/test_handler.py b/tests/test_handler.py index 7388c6d..c1f0b61 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -18,20 +18,19 @@ choose_filename, extract_download, filename_from_url, - load_config, + has_executable_bits, is_empty_bambustudioopen_uri, + launch_bambu, + load_config, + main, normalize_host, normalize_post_process_action, - main, read_protocol_uri, - has_executable_bits, - launch_bambu, scan_3mf_post_process, validate_downloaded_file, validate_remote_url, ) - TEMP_ROOT = Path(__file__).resolve().parent / ".tmp" @@ -87,9 +86,7 @@ def test_detects_empty_bambustudioopen_uri(self) -> None: self.assertTrue(is_empty_bambustudioopen_uri("bambustudioopen:///")) self.assertTrue(is_empty_bambustudioopen_uri("bambustudioopen://%20%20")) self.assertFalse( - is_empty_bambustudioopen_uri( - "bambustudioopen://https%3A%2F%2Ffiles.example%2Fmodels%2Fbenchy.3mf" - ) + is_empty_bambustudioopen_uri("bambustudioopen://https%3A%2F%2Ffiles.example%2Fmodels%2Fbenchy.3mf") ) self.assertFalse(is_empty_bambustudioopen_uri("prusaslicer://open?file=")) @@ -137,12 +134,8 @@ def test_logs_input_uri_when_download_extraction_fails(self) -> None: exit_code = main(["prusaslicer://open?file=%20%20"]) self.assertEqual(exit_code, 1) - self.assertTrue( - any("Input URI: 'prusaslicer://open?file=%20%20'" in line for line in captured.output) - ) - self.assertTrue( - any("Failed: Invalid prusaslicer URI." in line for line in captured.output) - ) + self.assertTrue(any("Input URI: 'prusaslicer://open?file=%20%20'" in line for line in captured.output)) + self.assertTrue(any("Failed: Invalid prusaslicer URI." in line for line in captured.output)) class FilenameTests(unittest.TestCase): @@ -213,6 +206,7 @@ def test_validate_remote_url_checks_allowlist_and_public_host(self) -> None: allow_any_original_host=False, allow_plain_http=False, check_allowlist=True, + allow_local_resolved_hosts=False, ) assert_public_host.assert_called_once_with("files.example") @@ -225,6 +219,7 @@ def test_validate_remote_url_rejects_plain_http_by_default(self) -> None: allow_any_original_host=False, allow_plain_http=False, check_allowlist=True, + allow_local_resolved_hosts=False, ) def test_validate_remote_url_rejects_embedded_credentials(self) -> None: @@ -235,6 +230,7 @@ def test_validate_remote_url_rejects_embedded_credentials(self) -> None: allow_any_original_host=False, allow_plain_http=False, check_allowlist=True, + allow_local_resolved_hosts=False, ) def test_validate_remote_url_skips_allowlist_for_redirect_targets(self) -> None: @@ -245,10 +241,37 @@ def test_validate_remote_url_skips_allowlist_for_redirect_targets(self) -> None: allow_any_original_host=False, allow_plain_http=False, check_allowlist=False, + allow_local_resolved_hosts=False, ) assert_public_host.assert_called_once_with("cdn.example") + def test_validate_remote_url_can_allow_local_resolved_hosts(self) -> None: + with patch("slicer_uri_bridge.handler.assert_public_host") as assert_public_host: + validate_remote_url( + "https://localhost/model.3mf", + allowed_hosts={"localhost"}, + allow_any_original_host=False, + allow_plain_http=False, + check_allowlist=True, + allow_local_resolved_hosts=True, + ) + + assert_public_host.assert_not_called() + + def test_validate_remote_url_still_checks_redirect_targets_when_local_hosts_are_allowed(self) -> None: + with patch("slicer_uri_bridge.handler.assert_public_host") as assert_public_host: + validate_remote_url( + "https://127.0.0.1/model.3mf", + allowed_hosts={"files.example"}, + allow_any_original_host=False, + allow_plain_http=False, + check_allowlist=False, + allow_local_resolved_hosts=True, + ) + + assert_public_host.assert_called_once_with("127.0.0.1") + class FileValidationTests(unittest.TestCase): def test_validate_downloaded_file_accepts_non_empty_model_payload(self) -> None: @@ -388,7 +411,8 @@ def test_post_process_action_defaults_to_warn_for_invalid_values(self) -> None: def test_load_config_defaults_post_process_action_to_warn(self) -> None: with temporary_directory() as temp_dir: config_path = Path(temp_dir) / "config.toml" - config_path.write_text("""\ + config_path.write_text( + """\ [security] allow_any_original_host = true allowed_extensions = [".3mf"] @@ -402,6 +426,7 @@ def test_load_config_defaults_post_process_action_to_warn(self) -> None: config = load_config() self.assertEqual(config["security"]["post_process_action"], "warn") + self.assertFalse(config["security"].get("allow_local_resolved_hosts", False)) class ProtocolFileTests(unittest.TestCase): diff --git a/tests/test_manager_linux.py b/tests/test_manager_linux.py index 17cadbc..254fcc6 100644 --- a/tests/test_manager_linux.py +++ b/tests/test_manager_linux.py @@ -9,7 +9,6 @@ from slicer_uri_bridge.manager import DESKTOP_ID, LinuxXdgManager - TEMP_ROOT = Path(__file__).resolve().parent / ".tmp" diff --git a/tests/test_manager_macos.py b/tests/test_manager_macos.py index 597f60d..fd8675a 100644 --- a/tests/test_manager_macos.py +++ b/tests/test_manager_macos.py @@ -18,7 +18,6 @@ resolve_protocols, ) - TEMP_ROOT = Path(__file__).resolve().parent / ".tmp" @@ -117,6 +116,7 @@ def test_command_current_detects_current_and_stale_applescript(self) -> None: def test_status_displays_app_bundle_not_inner_python_details(self) -> None: with temporary_directory() as temp_dir: + class FakeMacOSManager(MacOSLaunchServicesManager): def get_default_bundle_id(self, protocol: str) -> str | None: return MACOS_BUNDLE_ID if protocol == "bambustudioopen" else None @@ -192,13 +192,13 @@ def test_repair_unmanaged_defaults_restores_vendor_handlers(self) -> None: ) class FakeMacOSManager(MacOSLaunchServicesManager): - def launch_services(self) -> FakeLaunchServices: + def launch_services(self) -> FakeLaunchServices: # type: ignore[override] return services with patch.dict(os.environ, {"URI_BRIDGE_MACOS_APP_DIR": str(temp_dir / "Applications")}): manager = FakeMacOSManager(temp_dir / "project", sys.executable, False) - previous_defaults = { + previous_defaults: dict[str, str | None] = { "bambustudioopen": MACOS_BUNDLE_ID, "cura": "nl.ultimaker.cura_UltiMaker_Cura", } @@ -213,6 +213,7 @@ def launch_services(self) -> FakeLaunchServices: class MacOSOsacompileIntegrationTests(unittest.TestCase): def test_write_bridge_app_builds_real_applescript_bundle(self) -> None: with temporary_directory() as temp_dir: + class NoLaunchServicesRegistration(MacOSLaunchServicesManager): def require_user_config(self) -> None: return None