diff --git a/libervia/backend/core/constants.py b/libervia/backend/core/constants.py index 55ff1b94..80f2bd7e 100644 --- a/libervia/backend/core/constants.py +++ b/libervia/backend/core/constants.py @@ -18,6 +18,7 @@ import os from pathlib import Path +import tempfile from typing import Final from libervia import backend @@ -52,17 +53,54 @@ class Const: APP_RELEASE_NAME = "La Ruche" APP_URL = "https://libervia.org" + ## Isolation ## + isolation_name = os.environ.get("LIBERVIA_ISOLATION") + + if isolation_name: + # Validate isolation name: only allow [A-Za-z0-9_-] to prevent path traversal and + # other issues. + if not isolation_name.replace("-", "").replace("_", "").isalnum(): + raise ValueError( + f"Invalid LIBERVIA_ISOLATION name {isolation_name!r}, " + f"must only contain [A-Za-z0-9_-]" + ) + isolation_mode = os.environ.get("LIBERVIA_ISOLATION_MODE", "normal").lower() + match isolation_mode: + case "tmp": + ISOLATED_LOCAL_DIR = ( + Path(tempfile.gettempdir()) / f"libervia_isolated_{isolation_name}" + ) + ISOLATED_RUNTIME_DIR = ( + Path(tempfile.gettempdir()) + / f"libervia_isolated_{isolation_name}_run" + ) + ISOLATED_CONFIG_DIR = ( + xdg_config_home / APP_NAME_FILE / "isolated" / isolation_name + ) + case "normal" | "full": + _dir_suffix = Path("isolated") / isolation_name + ISOLATED_LOCAL_DIR = xdg_data_home / APP_NAME_FILE / _dir_suffix + ISOLATED_RUNTIME_DIR = xdg_runtime_dir / APP_NAME_FILE / _dir_suffix + ISOLATED_CONFIG_DIR = xdg_config_home / APP_NAME_FILE / _dir_suffix + case _: + raise ValueError( + f"Invalid LIBERVIA_ISOLATION_MODE {isolation_mode!r}, " + f"must be one of: normal, full, tmp" + ) + else: + isolation_mode = None + ISOLATED_LOCAL_DIR = None + ISOLATED_RUNTIME_DIR = None + ISOLATED_CONFIG_DIR = None + ## Configuration ## + # Config paths are ordered by priority (lowest first). + # Later entries override earlier ones. CONFIG_PATHS = [ # /etc/_ is used for system-related settings (when a value is set by the # distribution, or in a Docker image) Path("/etc/_"), Path("/etc/"), - Path.home(), - xdg_config_home / APP_NAME_FILE, - # We use backend root as last config path, this is useful for per instance - # configurations. - Path(backend.__file__).parent.parent, ] for d in xdg_config_dirs: @@ -75,6 +113,9 @@ class Const: # to add /app CONFIG_PATHS.append(Path("/app/")) + CONFIG_PATHS.append(xdg_config_home / APP_NAME_FILE) + CONFIG_PATHS.append(Path.home()) + CONFIG_FILES: list[Path] = [] for p in CONFIG_PATHS: CONFIG_FILES.append(p / f"{APP_NAME_FILE}.toml") @@ -82,7 +123,16 @@ class Const: # In $HOME, the config file can be hidden. CONFIG_FILES.append(Path.home() / f".{APP_NAME_FILE}.toml") - RUNTIME_DIR = xdg_runtime_dir / APP_NAME_FILE + if ISOLATED_CONFIG_DIR is not None: + isolated_conf = ISOLATED_CONFIG_DIR / f"{APP_NAME_FILE}.toml" + if isolation_mode == "normal": + CONFIG_FILES.append(isolated_conf) + else: + CONFIG_FILES = [isolated_conf] + + RUNTIME_DIR = ( + ISOLATED_RUNTIME_DIR if ISOLATED_RUNTIME_DIR else xdg_runtime_dir / APP_NAME_FILE + ) ## Runtime ## PLUGIN_EXT = "py" diff --git a/libervia/backend/core/launcher.py b/libervia/backend/core/launcher.py index 6192c139..4559ced8 100644 --- a/libervia/backend/core/launcher.py +++ b/libervia/backend/core/launcher.py @@ -21,6 +21,8 @@ import sys import os import argparse +import shutil +import tempfile from argparse import _SubParsersAction from pathlib import Path @@ -72,6 +74,25 @@ class Launcher: except Exception as e: print(rf"/!\ Can't read main config! {e}") sys.exit(1) + if C.isolation_mode == "tmp": + self.clean_tmp_dir() + if C.isolation_mode is not None: + print( + f"Isolation mode '{C.isolation_mode}' active " + f"(instance: {C.isolation_name}, " + f"config: {C.ISOLATED_CONFIG_DIR})" + ) + + def clean_tmp_dir(self) -> None: + """Remove temporary isolation directory on startup.""" + tmp_path = C.ISOLATED_LOCAL_DIR + if tmp_path is None: + return + # We want to be sure that we delete a temporary directory, and not accidentally + # another local directory. + if str(tmp_path).startswith(tempfile.gettempdir()): + shutil.rmtree(tmp_path, ignore_errors=True) + print(f"Cleaned temp isolation directory: {tmp_path}") def cmd_no_subparser(self, args): """Command launched by default""" diff --git a/libervia/backend/tools/config.py b/libervia/backend/tools/config.py index 7f87b1b2..6883c1f2 100644 --- a/libervia/backend/tools/config.py +++ b/libervia/backend/tools/config.py @@ -222,6 +222,18 @@ class DBusBridgeSettings(ConfigOptions): default="org.libervia.Libervia", description="Interface prefix" ) + @model_validator(mode="after") + def force_isolated_int_prefix(self) -> Self: + """Append isolation name to D-Bus service prefix to avoid conflicts.""" + if C.isolation_name is not None: + # The isolation name is already restricted, so we only need to handle D-Bus + # specific rules. + sanitized = C.isolation_name.replace("-", "_") + if sanitized[0].isdigit(): + sanitized = f"_{sanitized}" + self.int_prefix = f"{self.int_prefix}.isolated.{sanitized}" + return self + class AutoBridgeSettings(ConfigOptions): """Automatic Bridge. @@ -403,6 +415,13 @@ class CommonSettings(ConfigOptions): description="JID of the entity used for group chat searches.", ) + @model_validator(mode="after") + def force_isolated_local_dir(self) -> Self: + # In isolated mode, we force local_dir to avoid issues with parent configs. + if C.ISOLATED_LOCAL_DIR is not None: + self.local_dir = C.ISOLATED_LOCAL_DIR + return self + class Settings(BaseSettings): model_config = SettingsConfigDict(