mirror of
https://github.com/lnbits/lnbits.git
synced 2026-08-13 12:42:47 +02:00
test: better checks
This commit is contained in:
parent
2223733c2d
commit
74ecf0bd45
13 changed files with 792 additions and 58 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue