test: better checks

This commit is contained in:
Vlad Stan 2026-07-21 17:23:58 +03:00
parent 2223733c2d
commit 74ecf0bd45
13 changed files with 792 additions and 58 deletions

View file

@ -53,9 +53,6 @@ from lnbits.core.wasm_ext.routes.register import (
unregister_wasm_extension,
)
from lnbits.core.wasm_ext.wasm.events import dispatch_wasm_invoice_paid
from lnbits.core.wasm_ext.wasm.loader import (
is_wasm_extension_id,
)
from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse
from lnbits.llms_txt import create_llms_txt_route
@ -374,7 +371,15 @@ async def check_installed_extension_files(ext: InstallableExtension) -> bool:
if f"./{ext.zip_path!s}" not in zip_files:
await ext.download_archive()
ext.extract_archive()
extension_config = ext.load_archive_config()
if ext.expects_wasm or extension_config.get("extension_type") == "wasm":
if ext.expects_wasm and extension_config.get("extension_type") != "wasm":
raise ValueError(
f"Extension '{ext.id}' release is WASM but archive is not WASM."
)
ext.extract_wasm_archive(ext.load_wasm_archive_config())
else:
ext.extract_archive()
return False
@ -494,7 +499,7 @@ async def check_and_register_extensions(app: FastAPI) -> None:
await check_installed_extensions(app)
for ext in await get_valid_extensions(False):
try:
if is_wasm_extension_id(ext.code):
if ext.is_wasm:
register_wasm_extension(app, ext.code)
continue
register_ext_routes(app, ext)

View file

@ -16,7 +16,6 @@ from lnbits.core.db import db as core_db
from lnbits.core.models import DbVersion
from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.wasm_ext.storage.crud import migrate_wasm_extension_database
from lnbits.core.wasm_ext.wasm.loader import is_wasm_extension_id
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
from lnbits.settings import settings
@ -24,7 +23,7 @@ from lnbits.settings import settings
async def migrate_extension_database(
ext: InstallableExtension, current_version: DbVersion | None = None
):
if is_wasm_extension_id(ext.id):
if ext.is_wasm:
await migrate_wasm_extension_database(ext, current_version)
return
else:

View file

@ -4,9 +4,12 @@ import asyncio
import hashlib
import json
import os
import re
import shutil
import stat
import zipfile
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path, PurePosixPath
@ -27,6 +30,21 @@ from lnbits.settings import settings
from lnbits.task_manager import task_manager
from lnbits.utils.cache import cache
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
_PATH_TOKEN_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
_WASM_NATIVE_ARTIFACT_SUFFIXES = {
".dll",
".dylib",
".exe",
".py",
".pyc",
".pyd",
".pyo",
".so",
}
_WASM_NATIVE_ARTIFACT_FILENAMES = {"__init__.py"}
_WASM_NATIVE_ARTIFACT_DIRS = {"__pycache__"}
class ExplicitRelease(BaseModel):
id: str
@ -34,6 +52,7 @@ class ExplicitRelease(BaseModel):
version: str
archive: str
hash: str
extension_type: str | None = None
dependencies: list[str] = []
repo: str | None
icon: str | None
@ -339,6 +358,7 @@ class ExtensionRelease(BaseModel):
version: str
archive: str
source_repo: str
extension_type: str | None = None
is_github_release: bool = False
hash: str | None = None
min_lnbits_version: str | None = None
@ -411,6 +431,7 @@ class ExtensionRelease(BaseModel):
name=e.name,
version=e.version,
archive=e.archive,
extension_type=e.extension_type,
hash=e.hash,
source_repo=source_repo,
description=e.short_description,
@ -449,6 +470,7 @@ class ExtensionRelease(BaseModel):
release.min_lnbits_version = config.min_lnbits_version
release.max_lnbits_version = config.max_lnbits_version
release.extension_type = config.extension_type
release.is_version_compatible = config.is_version_compatible()
release.icon = icon_to_github_url(f"{org}/{repo}", config.tile)
@ -523,17 +545,30 @@ class InstallableExtension(BaseModel):
@property
def zip_path(self) -> Path:
ext_id = _extension_id_for_path(self.id)
extensions_data_dir = Path(settings.lnbits_data_folder, "zips")
Path(extensions_data_dir).mkdir(parents=True, exist_ok=True)
return Path(extensions_data_dir, f"{self.id}.zip")
return Path(extensions_data_dir, f"{ext_id}.zip")
@property
def ext_dir(self) -> Path:
return Path(settings.lnbits_extensions_path, "extensions", self.id)
return Path(
settings.lnbits_extensions_path,
"extensions",
_extension_id_for_path(self.id),
)
@property
def wasm_ext_dir(self) -> Path:
return Path(
settings.lnbits_wasm_extensions_path, _extension_id_for_path(self.id)
)
@property
def ext_upgrade_dir(self) -> Path:
return Path(settings.lnbits_extensions_upgrade_path, f"{self.id}-{self.hash}")
ext_id = _extension_id_for_path(self.id)
ext_hash = _path_token(self.hash, "extension archive hash")
return Path(settings.lnbits_extensions_upgrade_path, f"{ext_id}-{ext_hash}")
@property
def module_name(self) -> str:
@ -546,6 +581,10 @@ class InstallableExtension(BaseModel):
@property
def has_installed_version(self) -> bool:
if self.expects_wasm:
return Path(self.wasm_ext_dir, "config.json").is_file()
if Path(self.wasm_ext_dir, "config.json").is_file():
return True
if not self.ext_dir.is_dir():
return False
return Path(self.ext_dir, "config.json").is_file()
@ -564,6 +603,16 @@ class InstallableExtension(BaseModel):
@property
def is_wasm(self) -> bool:
if self.expects_wasm:
return True
config_path = Path(self.wasm_ext_dir, "config.json")
if config_path.is_file():
try:
with open(config_path, encoding="utf-8") as json_file:
config_json = json.load(json_file)
except Exception:
return False
return config_json.get("extension_type") == "wasm"
config_path = Path(self.ext_dir, "config.json")
if not config_path.is_file():
return False
@ -574,6 +623,14 @@ class InstallableExtension(BaseModel):
return False
return config_json.get("extension_type") == "wasm"
@property
def expects_wasm(self) -> bool:
return bool(
self.meta
and self.meta.installed_release
and self.meta.installed_release.extension_type == "wasm"
)
async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
ext_zip_file = self.zip_path
@ -622,6 +679,27 @@ class InstallableExtension(BaseModel):
return config if isinstance(config, dict) else {}
def load_wasm_archive_config(self) -> dict[str, Any]:
if not self.zip_path.is_file():
raise ValueError(f"Missing WASM extension archive for '{self.id}'.")
try:
with zipfile.ZipFile(self.zip_path, "r") as archive:
layout = _wasm_archive_layout(archive.infolist())
with archive.open(layout.config_name) as config_file:
config = json.load(config_file)
except Exception as exc:
if isinstance(exc, ValueError):
raise
raise ValueError(
f"Cannot read WASM extension config for '{self.id}'."
) from exc
if not isinstance(config, dict):
raise ValueError(f"WASM extension '{self.id}' config file is invalid.")
_validate_wasm_archive_config(self.id, config)
return config
def extract_archive(self):
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
Path(settings.lnbits_extensions_upgrade_path).mkdir(parents=True, exist_ok=True)
@ -660,6 +738,52 @@ class InstallableExtension(BaseModel):
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
logger.info(f"Extension {self.name} ({self.installed_version}) extracted.")
def extract_wasm_archive(self, extension_config: dict[str, Any]) -> None:
logger.info(
f"Extracting WASM extension {self.name} ({self.installed_version})."
)
_validate_wasm_archive_config(self.id, extension_config)
tmp_dir = Path(
settings.lnbits_data_folder,
"wasm-unzip-temp",
_path_token(self.hash, "extension archive hash"),
)
package_dir = tmp_dir / "package"
shutil.rmtree(tmp_dir, True)
package_dir.mkdir(parents=True, exist_ok=True)
try:
with zipfile.ZipFile(self.zip_path, "r") as archive:
layout = _wasm_archive_layout(archive.infolist())
with archive.open(layout.config_name) as config_file:
staged_config = json.load(config_file)
if staged_config != extension_config:
raise ValueError(
f"WASM extension '{self.id}' archive config changed."
)
_extract_wasm_archive_to_stage(archive, layout, package_dir)
_validate_wasm_staged_package(self.id, extension_config, package_dir)
_publish_wasm_package(package_dir, self.wasm_ext_dir)
shutil.rmtree(self.ext_dir, True)
finally:
shutil.rmtree(tmp_dir, True)
self.name = extension_config.get("name")
self.short_description = extension_config.get("short_description")
if (
self.meta
and self.meta.installed_release
and self.meta.installed_release.is_github_release
and extension_config.get("tile")
):
self.icon = icon_to_github_url(
self.meta.installed_release.source_repo, extension_config.get("tile")
)
logger.info(f"WASM extension {self.name} ({self.installed_version}) extracted.")
def clean_extension_files(self):
# remove downloaded archive
if self.zip_path.is_file():
@ -667,6 +791,7 @@ class InstallableExtension(BaseModel):
# remove module from extensions
shutil.rmtree(self.ext_dir, True)
shutil.rmtree(self.wasm_ext_dir, True)
shutil.rmtree(self.ext_upgrade_dir, True)
@ -749,6 +874,10 @@ class InstallableExtension(BaseModel):
github_release.organisation, github_release.repository
)
source_repo = f"{github_release.organisation}/{github_release.repository}"
latest_extension_release = ExtensionRelease.from_github_release(
source_repo, latest_release
)
latest_extension_release.extension_type = config.extension_type
return InstallableExtension(
id=github_release.id,
name=config.name,
@ -760,9 +889,7 @@ class InstallableExtension(BaseModel):
config.tile,
),
meta=ExtensionMeta(
latest_release=ExtensionRelease.from_github_release(
source_repo, latest_release
),
latest_release=latest_extension_release,
),
)
except Exception as e:
@ -791,6 +918,8 @@ class InstallableExtension(BaseModel):
return None
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
if config_json.get("extension_type") == "wasm":
return None
version = config_json.get("version", "0.0")
return InstallableExtension(
@ -1059,6 +1188,202 @@ def _extension_tile(ext_info: InstallableExtension) -> str | None:
return ext_info.icon
@dataclass(frozen=True)
class _WasmArchiveLayout:
root: str
config_name: str
def _extension_id_for_path(ext_id: str) -> str:
if not _EXTENSION_ID_RE.fullmatch(ext_id):
raise ValueError(f"Invalid extension id '{ext_id}'.")
return ext_id
def _path_token(value: str, label: str) -> str:
if not _PATH_TOKEN_RE.fullmatch(value):
raise ValueError(f"Invalid {label}.")
return value
def _wasm_archive_layout(infos: list[zipfile.ZipInfo]) -> _WasmArchiveLayout:
roots: set[str] = set()
config_names: list[str] = []
seen_names: set[str] = set()
for info in infos:
if info.is_dir():
directory_path = PurePosixPath(info.filename)
if len(directory_path.parts) == 1 and directory_path.parts[0] not in {
"",
".",
"..",
}:
roots.add(directory_path.parts[0])
continue
path = _safe_wasm_archive_path(info.filename)
normalized_name = path.as_posix()
if normalized_name in seen_names:
raise ValueError(f"WASM extension archive contains duplicate path: {path}")
seen_names.add(normalized_name)
_reject_wasm_archive_member(info, path)
roots.add(path.parts[0])
if len(path.parts) == 2 and path.name == "config.json":
config_names.append(info.filename)
elif path.name == "config.json":
raise ValueError(
"WASM extension archive must contain exactly one top-level config.json."
)
if len(roots) != 1:
raise ValueError(
"WASM extension archive must contain exactly one top-level directory."
)
if len(config_names) != 1:
raise ValueError(
"WASM extension archive must contain exactly one top-level config.json."
)
return _WasmArchiveLayout(root=next(iter(roots)), config_name=config_names[0])
def _safe_wasm_archive_path(name: str) -> PurePosixPath:
path = PurePosixPath(name)
if (
not name
or path.is_absolute()
or len(path.parts) < 2
or any(part in {"", ".", ".."} for part in path.parts)
):
raise ValueError(f"WASM extension archive contains unsafe path: {name}")
return path
def _reject_wasm_archive_member(info: zipfile.ZipInfo, path: PurePosixPath) -> None:
mode = info.external_attr >> 16
if stat.S_ISLNK(mode):
raise ValueError(f"WASM extension archive contains symlink: {path}")
lowered_parts = {part.lower() for part in path.parts}
if lowered_parts & _WASM_NATIVE_ARTIFACT_DIRS:
raise ValueError(f"WASM extension archive contains Python cache path: {path}")
name = path.name.lower()
if name in _WASM_NATIVE_ARTIFACT_FILENAMES:
raise ValueError(f"WASM extension archive contains native Python file: {path}")
if path.suffix.lower() in _WASM_NATIVE_ARTIFACT_SUFFIXES:
raise ValueError(f"WASM extension archive contains native artifact: {path}")
if _is_wasm_storage_migration_path(path) and path.suffix.lower() != ".json":
raise ValueError(
f"WASM extension storage migration must be a JSON file: {path}"
)
def _is_wasm_storage_migration_path(path: PurePosixPath) -> bool:
return (
len(path.parts) >= 4
and path.parts[1] == "storage"
and path.parts[2] == "migrations"
and path.name != ""
)
def _validate_wasm_archive_config(ext_id: str, config: dict[str, Any]) -> None:
_extension_id_for_path(ext_id)
if config.get("extension_type") != "wasm":
raise ValueError(f"Extension '{ext_id}' archive is not a WASM extension.")
config_id = config.get("id")
if not isinstance(config_id, str) or not config_id:
raise ValueError(f"WASM extension '{ext_id}' config must define id.")
if config_id != ext_id:
raise ValueError(
f"WASM extension id mismatch: installed as '{ext_id}' "
f"but config declares '{config_id}'."
)
def _extract_wasm_archive_to_stage(
archive: zipfile.ZipFile,
layout: _WasmArchiveLayout,
package_dir: Path,
) -> None:
for info in archive.infolist():
if info.is_dir():
continue
path = _safe_wasm_archive_path(info.filename)
if path.parts[0] != layout.root:
raise ValueError("WASM extension archive changed while extracting package.")
target = package_dir.joinpath(*path.parts[1:])
target.parent.mkdir(parents=True, exist_ok=True)
with archive.open(info) as source_file:
with target.open("wb") as target_file:
shutil.copyfileobj(source_file, target_file)
def _validate_wasm_staged_package(
ext_id: str,
config: dict[str, Any],
package_dir: Path,
) -> None:
root = package_dir.resolve()
for path in package_dir.rglob("*"):
if path.is_symlink():
raise ValueError(f"WASM extension '{ext_id}' contains symlink: {path}")
installed_config_path = package_dir / "config.json"
if not installed_config_path.is_file():
raise ValueError(f"WASM extension '{ext_id}' staged config is missing.")
with installed_config_path.open(encoding="utf-8") as config_file:
installed_config = json.load(config_file)
if installed_config != config:
raise ValueError(f"WASM extension '{ext_id}' staged config does not match.")
wasm_config = config.get("wasm")
module = wasm_config.get("module") if isinstance(wasm_config, dict) else None
if not isinstance(module, str) or not module:
raise ValueError(f"WASM extension '{ext_id}' config has no module path.")
module_path = (package_dir / module).resolve()
if root != module_path and root not in module_path.parents:
raise ValueError(f"WASM extension '{ext_id}' module escapes package root.")
if not module_path.is_file():
raise FileNotFoundError(f"WASM extension module not found: {module_path}")
with module_path.open("rb") as wasm_file:
if wasm_file.read(4) != b"\0asm":
raise ValueError(f"Invalid WASM module file: {module_path}")
def _publish_wasm_package(package_dir: Path, target_dir: Path) -> None:
target_dir.parent.mkdir(parents=True, exist_ok=True)
token = uuid4().hex
replacement_dir = target_dir.parent / f".{target_dir.name}-{token}.new"
backup_dir = target_dir.parent / f".{target_dir.name}-{token}.old"
_remove_path(replacement_dir)
_remove_path(backup_dir)
shutil.copytree(package_dir, replacement_dir, symlinks=False)
try:
if target_dir.exists() or target_dir.is_symlink():
target_dir.replace(backup_dir)
replacement_dir.replace(target_dir)
except Exception:
_remove_path(target_dir)
if backup_dir.exists() or backup_dir.is_symlink():
backup_dir.replace(target_dir)
raise
finally:
_remove_path(replacement_dir)
_remove_path(backup_dir)
def _remove_path(path: Path) -> None:
if path.is_symlink() or path.is_file():
path.unlink(missing_ok=True)
elif path.is_dir():
shutil.rmtree(path, True)
def _archive_config_name(names: list[str]) -> str | None:
for name in names:
path = PurePosixPath(name)

View file

@ -209,16 +209,12 @@ async def install_extension(
if not skip_download:
await ext_info.download_archive()
extension_config = ext_info.load_archive_config()
ext_info.permissions = validate_wasm_extension_permissions(
_extract_installable_extension_archive(
ext_info,
granted_permissions,
extension_config,
allow_admin_policy_overrides=allow_admin_policy_overrides,
)
ext_info.extract_archive()
db_version = await get_db_version(ext_info.id)
await migrate_extension_database(ext_info, db_version)
@ -240,6 +236,60 @@ async def install_extension(
return extension
def _extract_installable_extension_archive(
ext_info: InstallableExtension,
granted_permissions: list[ExtensionPermission] | None,
*,
allow_admin_policy_overrides: bool,
) -> None:
extension_config = ext_info.load_archive_config()
archive_is_wasm = extension_config.get("extension_type") == "wasm"
if ext_info.expects_wasm and not archive_is_wasm:
raise ValueError(
f"Extension '{ext_info.id}' release is WASM but archive is not WASM."
)
if archive_is_wasm:
_extract_wasm_extension_archive(
ext_info,
granted_permissions,
allow_admin_policy_overrides=allow_admin_policy_overrides,
)
return
_extract_native_extension_archive(ext_info, granted_permissions)
def _extract_wasm_extension_archive(
ext_info: InstallableExtension,
granted_permissions: list[ExtensionPermission] | None,
*,
allow_admin_policy_overrides: bool,
) -> None:
extension_config = ext_info.load_wasm_archive_config()
ext_info.permissions = validate_wasm_extension_permissions(
ext_info,
granted_permissions,
extension_config,
allow_admin_policy_overrides=allow_admin_policy_overrides,
require_wasm=True,
)
ext_info.extract_wasm_archive(extension_config)
def _extract_native_extension_archive(
ext_info: InstallableExtension,
granted_permissions: list[ExtensionPermission] | None,
) -> None:
if granted_permissions:
raise ValueError(
f"Extension '{ext_info.id}' is not a WASM extension and cannot "
"receive WASM permissions."
)
ext_info.permissions = []
ext_info.extract_archive()
async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
if settings.lnbits_max_extensions == 0 or installed_ext:
return
@ -661,7 +711,8 @@ async def activate_extension(ext: Extension):
async def deactivate_extension(ext_id: str):
if is_wasm_extension_id(ext_id):
installed_ext = await get_installed_extension(ext_id)
if (installed_ext and installed_ext.is_wasm) or is_wasm_extension_id(ext_id):
await stop_wasm_extension_invocations(ext_id, reason="Extension deactivated.")
settings.deactivate_extension_paths(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False)
@ -673,7 +724,8 @@ async def stop_extension_background_work(ext_id: str) -> bool:
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extension must expose a `myextension_stop()` function if it is starting tasks.
"""
if is_wasm_extension_id(ext_id):
installed_ext = await get_installed_extension(ext_id)
if (installed_ext and installed_ext.is_wasm) or is_wasm_extension_id(ext_id):
return True
upgrade_hash = settings.extension_upgrade_hash(ext_id)
@ -708,7 +760,8 @@ async def start_extension_background_work(ext_id: str) -> bool:
Extension CAN expose a `myextension_start()` function if it is starting tasks.
Extension MUST expose a `myextension_stop()` in that case.
"""
if is_wasm_extension_id(ext_id):
installed_ext = await get_installed_extension(ext_id)
if (installed_ext and installed_ext.is_wasm) or is_wasm_extension_id(ext_id):
return False
upgrade_hash = settings.extension_upgrade_hash(ext_id)

View file

@ -1049,7 +1049,11 @@ async def create_extension_review(
def _load_installed_extension_config(extension: InstallableExtension) -> dict:
config_path = extension.ext_dir / "config.json"
config_path = (
extension.wasm_ext_dir / "config.json"
if extension.is_wasm
else extension.ext_dir / "config.json"
)
if not config_path.is_file():
raise ValueError(f"Extension '{extension.id}' config file is missing.")
try:

View file

@ -57,10 +57,15 @@ def validate_wasm_extension_permissions(
extension_config: dict[str, Any] | WasmExtensionConfig,
*,
allow_admin_policy_overrides: bool = False,
require_wasm: bool = False,
) -> list[ExtensionPermission]:
if isinstance(extension_config, WasmExtensionConfig):
config = extension_config
elif extension_config.get("extension_type") != "wasm":
if require_wasm:
raise ValueError(
f"Extension '{ext_info.id}' archive is not a WASM extension."
)
return []
else:
config = parse_wasm_extension_config(ext_info.id, extension_config)

View file

@ -254,7 +254,7 @@ async def migrate_wasm_extension_database(
ext: InstallableExtension,
current_version: DbVersion | None = None,
) -> None:
migrations_dir = ext.ext_dir / "storage" / "migrations"
migrations_dir = ext.wasm_ext_dir / "storage" / "migrations"
migration_files = _migration_files(migrations_dir)
if not migration_files:
logger.debug(f"No storage migrations for WASM extension '{ext.id}'.")
@ -434,11 +434,7 @@ def _load_table_schema(ext_id: str, table: str) -> dict[str, Any]:
def _load_storage_schema(ext_id: str) -> dict[str, Any]:
schema_path = (
Path(settings.lnbits_extensions_path)
/ "extensions"
/ ext_id
/ "storage"
/ "schema.json"
Path(settings.lnbits_wasm_extensions_path) / ext_id / "storage" / "schema.json"
)
if not schema_path.is_file():
raise ValueError(f"WASM extension '{ext_id}' has no storage schema.")

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@ -12,6 +13,8 @@ from lnbits.core.wasm_ext.wasm.config import (
)
from lnbits.settings import settings
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
@dataclass(frozen=True)
class WasmExtension:
@ -27,7 +30,9 @@ class WasmExtension:
def is_wasm_extension_id(ext_id: str) -> bool:
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
if not _EXTENSION_ID_RE.fullmatch(ext_id):
return False
ext_dir = wasm_extension_root_path(ext_id)
config = _load_json(ext_dir / "config.json")
return bool(config and config.get("extension_type") == "wasm")
@ -38,7 +43,7 @@ def is_wasm_extension_dir(ext_dir: Path) -> bool:
def load_wasm_extension_config(ext_id: str) -> WasmExtensionConfig | None:
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
ext_dir = wasm_extension_root_path(ext_id)
config = _load_json(ext_dir / "config.json")
if not config or config.get("extension_type") != "wasm":
return None
@ -46,7 +51,7 @@ def load_wasm_extension_config(ext_id: str) -> WasmExtensionConfig | None:
def load_wasm_extension(ext_id: str) -> WasmExtension:
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
ext_dir = wasm_extension_root_path(ext_id)
raw_config = _load_json(ext_dir / "config.json")
if not raw_config:
raise FileNotFoundError(f"Missing WASM extension config for '{ext_id}'.")
@ -73,6 +78,12 @@ def load_wasm_extension(ext_id: str) -> WasmExtension:
)
def wasm_extension_root_path(ext_id: str) -> Path:
if not _EXTENSION_ID_RE.fullmatch(ext_id):
raise ValueError(f"Invalid WASM extension id '{ext_id}'.")
return Path(settings.lnbits_wasm_extensions_path, ext_id)
def _load_json(path: Path) -> dict[str, Any] | None:
if not path.is_file():
return None

View file

@ -1232,6 +1232,10 @@ class ReadOnlySettings(
def lnbits_extensions_upgrade_path(self) -> str:
return str(Path(self.lnbits_data_folder, "upgrades"))
@property
def lnbits_wasm_extensions_path(self) -> str:
return str(Path(self.lnbits_data_folder, "wasm_extensions"))
@validator(
"lnbits_allowed_funding_sources",
pre=True,

View file

@ -8,6 +8,8 @@ import pytest
from fastapi import HTTPException
from starlette.requests import Request
from lnbits.core.crud import db_versions as db_versions_crud
from lnbits.core.crud import extensions as extension_crud
from lnbits.core.crud.db_versions import get_db_version, update_migration_version
from lnbits.core.crud.extensions import (
create_installed_extension,
@ -17,6 +19,12 @@ from lnbits.core.crud.extensions import (
)
from lnbits.core.crud.users import get_account
from lnbits.core.crud.wallets import create_wallet
from lnbits.core.migrations import (
m010_create_installed_extensions_table,
m046_add_permissions_to_installed_extensions,
m047_create_wasm_invocations_table,
m048_add_wasm_runtime_limits_to_installed_extensions,
)
from lnbits.core.models import Account, CreateInvoice
from lnbits.core.models.extensions import (
CreateExtension,
@ -36,6 +44,7 @@ from lnbits.core.models.extensions import (
from lnbits.core.models.users import AccountId
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views import extension_api as extension_api_module
from lnbits.core.views.extension_api import (
api_activate_extension,
api_deactivate_extension,
@ -59,6 +68,7 @@ from lnbits.core.views.extension_api import (
get_pay_to_enable_invoice,
get_pay_to_install_invoice,
)
from lnbits.db import Database
from tests.helpers import make_extension_release, make_installable_extension
@ -181,6 +191,7 @@ async def test_extension_api_installs_wasm_with_granted_permissions(
):
ext_id = f"wasm_{uuid4().hex[:8]}"
release = make_extension_release(ext_id)
release.extension_type = "wasm"
granted_permissions = [
ExtensionPermission(
id="http.request",
@ -213,7 +224,12 @@ async def test_extension_api_installs_wasm_with_granted_permissions(
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
await _patch_extension_api_core_db(settings, mocker)
_write_wasm_extension_archive(ext_id, release.version, settings)
wasm_config_path = (
Path(settings.lnbits_wasm_extensions_path) / ext_id / "config.json"
)
native_ext_dir = Path(settings.lnbits_extensions_path) / "extensions" / ext_id
installed = await api_install_extension(create_data)
stored = await get_installed_extension(ext_id)
@ -232,9 +248,114 @@ async def test_extension_api_installs_wasm_with_granted_permissions(
policies=[{"host": "https://api.example.com"}],
)
]
assert wasm_config_path.is_file()
assert not native_ext_dir.exists()
register_wasm_routes_mock.assert_called_once_with(ext_id)
@pytest.mark.anyio
async def test_extension_api_rejects_wasm_release_with_native_archive(
tmp_path,
settings,
mocker,
):
ext_id = f"wasm_{uuid4().hex[:8]}"
release = make_extension_release(ext_id)
release.extension_type = "wasm"
create_data = CreateExtension(
ext_id=ext_id,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
)
original_data_folder = settings.lnbits_data_folder
original_extensions_path = settings.lnbits_extensions_path
mocker.patch.object(
InstallableExtension,
"get_extension_release",
mocker.AsyncMock(return_value=release),
)
mocker.patch.object(
InstallableExtension,
"download_archive",
mocker.AsyncMock(),
)
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
await _patch_extension_api_core_db(settings, mocker)
_write_native_extension_archive(ext_id, release.version, settings)
wasm_ext_dir = Path(settings.lnbits_wasm_extensions_path) / ext_id
native_ext_dir = Path(settings.lnbits_extensions_path) / "extensions" / ext_id
with pytest.raises(HTTPException) as exc_info:
await api_install_extension(create_data)
finally:
await delete_installed_extension(ext_id=ext_id)
settings.lnbits_data_folder = original_data_folder
settings.lnbits_extensions_path = original_extensions_path
assert exc_info.value.status_code == 400
assert "release is WASM but archive is not WASM" in exc_info.value.detail
assert not wasm_ext_dir.exists()
assert not native_ext_dir.exists()
@pytest.mark.anyio
async def test_extension_api_rejects_wasm_archive_with_python_files(
tmp_path,
settings,
mocker,
):
ext_id = f"wasm_{uuid4().hex[:8]}"
release = make_extension_release(ext_id)
release.extension_type = "wasm"
create_data = CreateExtension(
ext_id=ext_id,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
)
original_data_folder = settings.lnbits_data_folder
original_extensions_path = settings.lnbits_extensions_path
mocker.patch.object(
InstallableExtension,
"get_extension_release",
mocker.AsyncMock(return_value=release),
)
mocker.patch.object(
InstallableExtension,
"download_archive",
mocker.AsyncMock(),
)
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
await _patch_extension_api_core_db(settings, mocker)
_write_wasm_extension_archive(
ext_id,
release.version,
settings,
extra_files={"__init__.py": "raise RuntimeError('imported')"},
)
wasm_ext_dir = Path(settings.lnbits_wasm_extensions_path) / ext_id
native_ext_dir = Path(settings.lnbits_extensions_path) / "extensions" / ext_id
with pytest.raises(HTTPException) as exc_info:
await api_install_extension(create_data)
finally:
await delete_installed_extension(ext_id=ext_id)
settings.lnbits_data_folder = original_data_folder
settings.lnbits_extensions_path = original_extensions_path
assert exc_info.value.status_code == 400
assert "native Python file" in exc_info.value.detail
assert not wasm_ext_dir.exists()
assert not native_ext_dir.exists()
@pytest.mark.anyio
async def test_extension_api_wasm_runtime_limits_and_catalog_use_installed_metadata(
tmp_path,
@ -251,10 +372,13 @@ async def test_extension_api_wasm_runtime_limits_and_catalog_use_installed_metad
)
]
original_extensions_path = settings.lnbits_extensions_path
original_data_folder = settings.lnbits_data_folder
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path)
_write_installed_wasm_config(ext_id, tmp_path)
await _patch_extension_api_core_db(settings, mocker)
_write_installed_wasm_config(ext_id, settings)
await create_installed_extension(
InstallableExtension(
id=ext_id,
@ -295,6 +419,7 @@ async def test_extension_api_wasm_runtime_limits_and_catalog_use_installed_metad
finally:
await delete_installed_extension(ext_id=ext_id)
await delete_installed_extension(ext_id=py_ext_id)
settings.lnbits_data_folder = original_data_folder
settings.lnbits_extensions_path = original_extensions_path
assert wasm_info.wasm_runtime_limits == {"wasm_runtime_max_execution_ms": 1234}
@ -318,9 +443,11 @@ async def test_extension_api_wasm_runtime_limits_and_catalog_use_installed_metad
async def test_extension_api_admin_updates_wasm_extension_permission_limits(
tmp_path,
settings,
mocker,
):
ext_id = f"wasm_{uuid4().hex[:8]}"
original_extensions_path = settings.lnbits_extensions_path
original_data_folder = settings.lnbits_data_folder
manifest_permissions = [
{
"id": "ext.storage.append_public",
@ -355,10 +482,12 @@ async def test_extension_api_admin_updates_wasm_extension_permission_limits(
]
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path)
await _patch_extension_api_core_db(settings, mocker)
_write_installed_wasm_config(
ext_id,
tmp_path,
settings,
permissions=manifest_permissions,
)
await create_installed_extension(
@ -378,6 +507,7 @@ async def test_extension_api_admin_updates_wasm_extension_permission_limits(
stored = await get_installed_extension(ext_id)
finally:
await delete_installed_extension(ext_id=ext_id)
settings.lnbits_data_folder = original_data_folder
settings.lnbits_extensions_path = original_extensions_path
assert response.extension_permissions == [
@ -672,6 +802,7 @@ def _write_wasm_extension_archive(
version: str,
settings,
permissions: list[dict] | None = None,
extra_files: dict[str, str | bytes] | None = None,
) -> None:
zip_path = Path(settings.lnbits_data_folder, "zips", f"{ext_id}.zip")
zip_path.parent.mkdir(parents=True, exist_ok=True)
@ -680,14 +811,58 @@ def _write_wasm_extension_archive(
with zipfile.ZipFile(zip_path, "w") as archive:
archive.writestr(f"{root}/config.json", json.dumps(config))
archive.writestr(f"{root}/{config['wasm']['module']}", b"\0asm")
for path, content in (extra_files or {}).items():
archive.writestr(f"{root}/{path}", content)
async def _patch_extension_api_core_db(
settings,
mocker,
) -> None:
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
db = Database(f"core_extapi_{uuid4().hex[:8]}")
async with db.connect() as conn:
await conn.execute("""
CREATE TABLE dbversions (
db TEXT PRIMARY KEY,
version INT NOT NULL
)
""")
await update_migration_version(conn, "core", 48)
await m010_create_installed_extensions_table(conn)
await m046_add_permissions_to_installed_extensions(conn)
await m047_create_wasm_invocations_table(conn)
await m048_add_wasm_runtime_limits_to_installed_extensions(conn)
mocker.patch.object(extension_crud, "db", db)
mocker.patch.object(db_versions_crud, "db", db)
mocker.patch.object(extension_api_module, "db", db)
def _write_native_extension_archive(
ext_id: str,
version: str,
settings,
) -> None:
zip_path = Path(settings.lnbits_data_folder, "zips", f"{ext_id}.zip")
zip_path.parent.mkdir(parents=True, exist_ok=True)
root = f"{ext_id}-{version}"
config = {
"name": "Native Demo",
"short_description": "Native extension",
"version": version,
}
with zipfile.ZipFile(zip_path, "w") as archive:
archive.writestr(f"{root}/config.json", json.dumps(config))
archive.writestr(f"{root}/__init__.py", "raise RuntimeError('imported')")
def _write_installed_wasm_config(
ext_id: str,
extensions_path,
settings,
permissions: list[dict] | None = None,
) -> None:
config_dir = extensions_path / "extensions" / ext_id
config_dir = Path(settings.lnbits_wasm_extensions_path) / ext_id
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(
json.dumps(_wasm_config(ext_id, permissions=permissions)),

View file

@ -1,6 +1,7 @@
import json
import zipfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
@ -278,7 +279,7 @@ async def test_uninstall_wasm_extension_unregisters_live_routes(
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
ext_dir = tmp_path / "code" / "extensions" / ext_id
ext_dir = Path(settings.lnbits_wasm_extensions_path) / ext_id
ext_dir.mkdir(parents=True, exist_ok=True)
(ext_dir / "config.json").write_text(
json.dumps(_wasm_install_config(ext_id)),
@ -579,9 +580,11 @@ async def test_update_wasm_extension_runtime_limits_saves_sparse_overrides(
):
ext_id = "wasm_demo"
original_extensions_path = settings.lnbits_extensions_path
original_data_folder = settings.lnbits_data_folder
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path)
config_dir = tmp_path / "extensions" / ext_id
config_dir = Path(settings.lnbits_wasm_extensions_path) / ext_id
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(
'{"extension_type": "wasm"}',
@ -611,6 +614,7 @@ async def test_update_wasm_extension_runtime_limits_saves_sparse_overrides(
},
)
finally:
settings.lnbits_data_folder = original_data_folder
settings.lnbits_extensions_path = original_extensions_path
assert saved_limits == {
@ -783,6 +787,50 @@ async def test_start_extension_background_work_handles_missing_and_sync_starts(
assert called["start"] is True
@pytest.mark.anyio
async def test_wasm_installed_extension_never_imports_native_background_module(
tmp_path,
settings: Settings,
mocker: MockerFixture,
):
ext_id = f"wasm_{uuid4().hex[:8]}"
ext_info = make_installable_extension(ext_id)
assert ext_info.meta and ext_info.meta.installed_release
ext_info.meta.installed_release.extension_type = "wasm"
original_data_folder = settings.lnbits_data_folder
original_extensions_path = settings.lnbits_extensions_path
import_module_mock = mocker.patch(
"lnbits.core.services.extensions.importlib.import_module"
)
mocker.patch.object(
extension_services,
"get_installed_extension",
mocker.AsyncMock(return_value=ext_info),
)
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
native_ext_dir = Path(settings.lnbits_extensions_path) / "extensions" / ext_id
native_ext_dir.mkdir(parents=True)
(native_ext_dir / "config.json").write_text(
json.dumps({"name": "Native Demo", "version": "1.0.0"}),
encoding="utf-8",
)
(native_ext_dir / "__init__.py").write_text(
"raise RuntimeError('imported')",
encoding="utf-8",
)
assert await start_extension_background_work(ext_id) is False
assert await stop_extension_background_work(ext_id) is True
finally:
settings.lnbits_data_folder = original_data_folder
settings.lnbits_extensions_path = original_extensions_path
import_module_mock.assert_not_called()
@pytest.mark.anyio
async def test_get_valid_extensions_and_single_extension_respect_settings(
tmp_path, settings: Settings

View file

@ -1,4 +1,6 @@
import json
import stat
import zipfile
from pathlib import Path
from typing import Any
@ -165,6 +167,76 @@ def test_install_time_permission_validation_rejects_config_id_mismatch():
)
def test_wasm_archive_config_rejects_multiple_top_level_directories(
tmp_path: Path,
settings: Settings,
):
settings.lnbits_data_folder = str(tmp_path / "data")
ext_info = make_installable_extension("demoext")
with zipfile.ZipFile(ext_info.zip_path, "w") as archive:
archive.writestr("safe/config.json", json.dumps(_wasm_config("demoext")))
archive.writestr("safe/extension.wasm", b"\0asm")
archive.writestr("other/readme.txt", "extra top-level directory")
with pytest.raises(ValueError, match="one top-level directory"):
ext_info.load_wasm_archive_config()
def test_wasm_archive_rejects_native_python_files(
tmp_path: Path,
settings: Settings,
):
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "native")
ext_info = make_installable_extension("demoext")
_write_wasm_archive(
ext_info,
_wasm_config("demoext"),
extra_files={"demoext-1.0.0/__init__.py": "raise RuntimeError('imported')"},
)
with pytest.raises(ValueError, match="native Python file"):
ext_info.load_wasm_archive_config()
assert not ext_info.ext_dir.exists()
assert not ext_info.wasm_ext_dir.exists()
def test_wasm_archive_rejects_symlinks(
tmp_path: Path,
settings: Settings,
):
settings.lnbits_data_folder = str(tmp_path / "data")
ext_info = make_installable_extension("demoext")
root = "demoext-1.0.0"
symlink_info = zipfile.ZipInfo(f"{root}/linked")
symlink_info.external_attr = (stat.S_IFLNK | 0o777) << 16
with zipfile.ZipFile(ext_info.zip_path, "w") as archive:
archive.writestr(f"{root}/config.json", json.dumps(_wasm_config("demoext")))
archive.writestr(f"{root}/extension.wasm", b"\0asm")
archive.writestr(symlink_info, "extension.wasm")
with pytest.raises(ValueError, match="symlink"):
ext_info.load_wasm_archive_config()
def test_extract_wasm_archive_publishes_to_wasm_root_only(
tmp_path: Path,
settings: Settings,
):
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "native")
ext_info = make_installable_extension("demoext")
config = _wasm_config("demoext")
_write_wasm_archive(ext_info, config)
ext_info.extract_wasm_archive(ext_info.load_wasm_archive_config())
assert (ext_info.wasm_ext_dir / "config.json").is_file()
assert (ext_info.wasm_ext_dir / "extension.wasm").is_file()
assert not ext_info.ext_dir.exists()
def test_wasm_extension_registry_rejects_same_id_from_different_root(tmp_path: Path):
registry = WasmExtensionRegistry()
first = _wasm_extension("demoext", tmp_path / "one")
@ -185,8 +257,8 @@ def _write_wasm_extension(
*,
config_id: str | None,
) -> None:
settings.lnbits_extensions_path = str(tmp_path)
ext_dir = tmp_path / "extensions" / ext_id
settings.lnbits_data_folder = str(tmp_path / "data")
ext_dir = Path(settings.lnbits_wasm_extensions_path) / ext_id
ext_dir.mkdir(parents=True)
(ext_dir / "extension.wasm").write_bytes(b"\0asm")
config = {
@ -225,3 +297,17 @@ def _wasm_config(ext_id: str) -> dict[str, Any]:
"extension_type": "wasm",
"wasm": {"module": "extension.wasm"},
}
def _write_wasm_archive(
ext_info,
config: dict[str, Any],
*,
extra_files: dict[str, str | bytes] | None = None,
) -> None:
root = f"{ext_info.id}-1.0.0"
with zipfile.ZipFile(ext_info.zip_path, "w") as archive:
archive.writestr(f"{root}/config.json", json.dumps(config))
archive.writestr(f"{root}/{config['wasm']['module']}", b"\0asm")
for path, content in (extra_files or {}).items():
archive.writestr(path, content)

View file

@ -42,22 +42,28 @@ async def test_core_wasm_migrations_create_persistent_columns(
if DB_TYPE != SQLITE:
pytest.skip("temporary core databases are SQLite-only")
db = _temporary_database(tmp_path, settings, "wasm_core_migrations")
original_data_folder = settings.lnbits_data_folder
try:
db = _temporary_database(tmp_path, settings, "wasm_core_migrations")
async with db.connect() as conn:
await m010_create_installed_extensions_table(conn)
await m046_add_permissions_to_installed_extensions(conn)
await m047_create_wasm_invocations_table(conn)
await m048_add_wasm_runtime_limits_to_installed_extensions(conn)
async with db.connect() as conn:
await m010_create_installed_extensions_table(conn)
await m046_add_permissions_to_installed_extensions(conn)
await m047_create_wasm_invocations_table(conn)
await m048_add_wasm_runtime_limits_to_installed_extensions(conn)
installed_columns = {
row["name"]
for row in await conn.fetchall("PRAGMA table_info(installed_extensions)")
}
invocation_columns = {
row["name"]
for row in await conn.fetchall("PRAGMA table_info(wasm_invocations)")
}
installed_columns = {
row["name"]
for row in await conn.fetchall(
"PRAGMA table_info(installed_extensions)"
)
}
invocation_columns = {
row["name"]
for row in await conn.fetchall("PRAGMA table_info(wasm_invocations)")
}
finally:
settings.lnbits_data_folder = original_data_folder
assert {"permissions", "wasm_runtime_limits"}.issubset(installed_columns)
assert {
@ -75,7 +81,6 @@ async def test_core_wasm_migrations_create_persistent_columns(
@pytest.mark.anyio
async def test_installed_extension_permissions_and_wasm_limits_round_trip(
app,
tmp_path: Path,
settings: Settings,
mocker: MockerFixture,
@ -105,7 +110,6 @@ async def test_installed_extension_permissions_and_wasm_limits_round_trip(
@pytest.mark.anyio
async def test_wasm_invocation_crud_stats_and_cleanup_are_isolated(
app,
tmp_path: Path,
settings: Settings,
mocker: MockerFixture,
@ -222,6 +226,7 @@ async def test_wasm_datetime_queries_use_postgres_placeholders(
async def test_wasm_storage_migration_and_owner_scoped_crud(
tmp_path: Path,
settings: Settings,
mocker: MockerFixture,
):
ext_id = f"wasmstore_{uuid4().hex[:8]}"
original_extensions_path = settings.lnbits_extensions_path
@ -230,6 +235,7 @@ async def test_wasm_storage_migration_and_owner_scoped_crud(
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
Path(settings.lnbits_data_folder).mkdir(parents=True)
await _patch_wasm_storage_core_db(tmp_path, settings, mocker)
ext_dir = _write_storage_extension(settings, ext_id)
await migrate_wasm_extension_database(make_installable_extension(ext_id))
@ -301,6 +307,7 @@ async def test_wasm_storage_migration_and_owner_scoped_crud(
async def test_wasm_storage_public_append_generates_id_and_counts_by_owner(
tmp_path: Path,
settings: Settings,
mocker: MockerFixture,
):
ext_id = f"wasmstore_{uuid4().hex[:8]}"
original_extensions_path = settings.lnbits_extensions_path
@ -309,6 +316,7 @@ async def test_wasm_storage_public_append_generates_id_and_counts_by_owner(
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
Path(settings.lnbits_data_folder).mkdir(parents=True)
await _patch_wasm_storage_core_db(tmp_path, settings, mocker)
_write_storage_extension(settings, ext_id)
await migrate_wasm_extension_database(make_installable_extension(ext_id))
@ -376,8 +384,7 @@ async def test_wasm_storage_rejects_reserved_fields_and_invalid_identifiers(
)
schema_path = (
Path(settings.lnbits_extensions_path)
/ "extensions"
Path(settings.lnbits_wasm_extensions_path)
/ ext_id
/ "storage"
/ "schema.json"
@ -428,8 +435,24 @@ async def _temporary_core_crud_database(
return db
async def _patch_wasm_storage_core_db(
tmp_path: Path,
settings: Settings,
mocker: MockerFixture,
) -> None:
db = _temporary_database(tmp_path, settings, f"core_versions_{uuid4().hex[:8]}")
async with db.connect() as conn:
await conn.execute("""
CREATE TABLE dbversions (
db TEXT PRIMARY KEY,
version INT NOT NULL
)
""")
mocker.patch.object(storage_crud, "core_db", db)
def _write_storage_extension(settings: Settings, ext_id: str) -> Path:
ext_dir = Path(settings.lnbits_extensions_path) / "extensions" / ext_id
ext_dir = Path(settings.lnbits_wasm_extensions_path) / ext_id
storage_dir = ext_dir / "storage"
migrations_dir = storage_dir / "migrations"
migrations_dir.mkdir(parents=True)