tools (backup): add backup/restore:

Add an offline backup/restore engine in `tools.backup`:

- format based on XEP-0227 (pie), extended with Libervia sections
- handle several data types (params, private, roster, pep , gpg, files and db)
- per profile export/import and global handlers
- a manifest is used to have metadata and checksums
- full database snapshot/restore with the `db` type
- sqla: add `get_db_url`
- tools.common.utils: add `is_safe_relpath` and `is_process_running`

rel 480
This commit is contained in:
Goffi 2026-09-15 11:34:58 +02:00
commit 86a91b5850
9 changed files with 2246 additions and 2 deletions

View file

@ -16,23 +16,41 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from pathlib import Path
from urllib.parse import quote
from libervia.backend.core.constants import Const as C
from libervia.backend.tools import config
def get_db_url(database_path: Path, readonly: bool = False) -> str:
"""Get the SQLAlchemy URL of a sqlite database
@param database_path: path to the sqlite DB
@param readonly: open in read only mode (sqlite URI mode)
@return: SQLAlchemy URL
"""
if readonly:
# file: URI, `uri=true` is needed for the dialect to use it as such
return (
f"sqlite+aiosqlite:///file:{quote(str(database_path))}"
"?mode=ro&timeout=30&uri=true"
)
return f"sqlite+aiosqlite:///{quote(str(database_path))}?timeout=30"
def get_db_config() -> dict:
"""Get configuration for database
@return: dict with following keys:
- type: only "sqlite" for now
- path: path to the sqlite DB
- url: SQLAlchemy URL
"""
main_conf = config.parse_main_conf()
database_path = main_conf.common.local_dir / C.SAVEFILE_DATABASE
url = f"sqlite+aiosqlite:///{quote(str(database_path))}?timeout=30"
return {
"type": "sqlite",
"path": database_path,
"url": url,
"url": get_db_url(database_path),
}

View file

@ -0,0 +1,532 @@
#!/usr/bin/env python3
# Libervia: an XMPP client
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from collections.abc import Callable
from datetime import datetime, timezone
import hashlib
import importlib.metadata
import lzma
import os
from pathlib import Path
import stat
import tarfile
import time
from typing import Any
from alembic.runtime import migration as al_migration
from lxml import etree
from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from libervia.backend.core.log import getLogger
from libervia.backend.memory import sqla_config
from libervia.backend.memory.sqla_mapping import ParamInd, Profile
from libervia.backend.tools.common.utils import is_safe_relpath
from .core import (
BackupContext,
BackupError,
CHUNK_SIZE,
FORMAT,
FORMAT_VERSION,
GlobalHandler,
Handler,
MANIFEST_FILE,
PIE_FILE_TPL,
ProfileData,
ProfileHandler,
Report,
Staging,
get_db_driver,
sqlite_db_config,
)
from .handlers import DATA_TYPES, DB_BACKED_TYPES, REGISTRY
from .models import Manifest, ProfileMeta
from .xep_0227 import (
PieError,
build_pie,
parse_pie,
sections_by_ns,
)
# pie section namespaces handled by this version of Libervia, derived from the handlers
# registry
KNOWN_SECTION_NS = frozenset(ns for handler in REGISTRY for ns in handler.known_ns())
# pie entry name check, derived from the pie file template
_PIE_NAME_PREFIX, _PIE_NAME_SUFFIX = PIE_FILE_TPL.split("{}")
log = getLogger(__name__)
__all__ = [
"Backup",
"BackupContext",
"BackupError",
"DATA_TYPES",
"DB_BACKED_TYPES",
"GlobalHandler",
"Handler",
"Manifest",
"PIE_FILE_TPL",
"ProfileData",
"ProfileHandler",
"ProfileMeta",
"Report",
"Staging",
"get_db_driver",
"sqlite_db_config",
]
class _ProgressFile:
"""File-like wrapper that reports write progress (for archive compression)"""
def __init__(
self, stream, progress_cb: Callable[[int, int], None] | None, total: int
):
self._stream = stream
self._progress_cb = progress_cb
self._total = total
self._pos = 0
self._reported = 0
def write(self, data: bytes) -> int:
count = self._stream.write(data)
self._pos += count
if self._progress_cb is not None and (
self._pos - self._reported >= 256 * 1024 or self._pos >= self._total
):
self._reported = self._pos
self._progress_cb(min(self._pos, self._total), self._total)
return count
def flush(self) -> None:
self._stream.flush()
def tell(self) -> int:
return self._pos
def close(self) -> None:
self._stream.close()
class Backup:
"""Backup/restore engine"""
@staticmethod
async def open_context(
db_config: dict,
param_file: Path,
local_dir: Path,
readonly: bool = False,
) -> BackupContext:
"""Open a backup context on a database
@param db_config: database config (see "sqla_config.get_db_config")
@param param_file: path of the parameters file
@param local_dir: local data directory (GPG homes, ...)
@param readonly: open the database in read only mode (export)
@return: backup context
"""
path = Path(db_config["path"])
log.debug(f"opening database {path} for backup (readonly={readonly})")
url = sqla_config.get_db_url(path, readonly)
engine = create_async_engine(url)
return BackupContext(
engine=engine,
session_factory=async_sessionmaker(engine, expire_on_commit=False),
db_url=url,
param_file=param_file,
local_dir=local_dir,
db=get_db_driver(db_config),
)
@staticmethod
async def get_profiles(ctx: BackupContext) -> list[ProfileData]:
"""List profiles with their bare JID
@param ctx: backup context
@return: profiles data
"""
async with ctx.session() as session:
profs = (
(await session.execute(select(Profile).order_by(Profile.name)))
.scalars()
.all()
)
jid_rows = (
await session.execute(
select(ParamInd.profile_id, ParamInd.value).where(
ParamInd.category == "Connection",
ParamInd.name == "JabberID",
)
)
).all()
jids = {
profile_id: (value or "").strip().split("/")[0]
for profile_id, value in jid_rows
}
return [
ProfileData(name=p.name, id=p.id, jid=jids.get(p.id) or None) for p in profs
]
@staticmethod
def get_app_version() -> str:
"""Libervia version, as reported by the package metadata"""
try:
return importlib.metadata.version("libervia-backend")
except importlib.metadata.PackageNotFoundError:
return "unknown"
@staticmethod
async def get_alembic_rev(ctx: BackupContext) -> str | None:
"""Get the alembic (schema) revision of the database"""
def _get_heads(conn) -> tuple[str, ...]:
context = al_migration.MigrationContext.configure(conn)
return context.get_current_heads()
async with ctx.engine.connect() as conn:
try:
heads = await conn.run_sync(_get_heads)
except Exception:
log.error(
f"no alembic revision: {ctx.db_url} has no alembic_version table"
)
return None
return heads[0] if heads else None
@staticmethod
def get_handlers(types: list[str]) -> list[Handler]:
"""Get the handlers of data types, in export order
@param types: data types (see DATA_TYPES)
@return: the matching handlers
"""
wanted = set(types)
return [handler for handler in REGISTRY if handler.name in wanted]
@staticmethod
async def export_profile(
ctx: BackupContext,
staging: Staging,
profile: ProfileData,
handlers: list[ProfileHandler],
) -> Report:
"""Export a profile data to the staging
@param ctx: backup context
@param staging: staging holding the archive entries
@param profile: profile to export
@param handlers: profile handlers to run (in export order)
@return: export report
"""
report = Report()
sections: list[etree._Element] = []
for handler in handlers:
sections.extend(await handler.export(ctx, staging, profile, report))
if sections:
staging.add_bytes(
PIE_FILE_TPL.format(profile.name),
build_pie(profile.host_jid, profile.user_name, sections),
0o600,
)
return report
@staticmethod
async def import_profile(
ctx: BackupContext,
staging: Staging,
profile_name: str,
handlers: list[ProfileHandler],
report: Report,
force: bool = False,
) -> None:
"""Import a profile data into the database
Unknown pie sections are reported and ignored. The roster section is not imported
yet.
@param ctx: backup context
@param staging: staging holding the archive entries (pie document and parameters
file)
@param profile_name: profile to import for
@param handlers: profile handlers to run (in export order)
@param report: import report
@param force: the restore is dangerous, the user confirmed it
"""
pie_file = PIE_FILE_TPL.format(profile_name)
if pie_file not in staging:
report.skipped.append(f"{profile_name}: no data in archive")
return
try:
host_jid, user_name, sections = parse_pie(staging.path(pie_file).read_bytes())
except PieError as e:
raise BackupError(f"{profile_name}: {e}") from e
jid = f"{user_name}@{host_jid}" if host_jid else user_name
by_ns = sections_by_ns(sections)
for ns in sorted(set(by_ns) - KNOWN_SECTION_NS):
report.skipped.append(f"unknown section {ns!r} ignored")
async with ctx.session() as session:
for handler in handlers:
await handler.import_(
ctx, session, staging, profile_name, jid, by_ns, report, force=force
)
await session.commit()
@staticmethod
def build_manifest(
app_version: str,
alembic_rev: str | None,
profiles: dict[str, ProfileMeta],
staging: Staging,
) -> Manifest:
"""Build the archive manifest and stage it
@param app_version: Libervia version
@param alembic_rev: database schema revision
@param profiles: profile name: exported data (jid, types)
@param staging: staging holding the archive entries (the manifest is added to it)
@return: the manifest
"""
manifest = Manifest(
format=FORMAT,
format_version=FORMAT_VERSION,
app="libervia",
app_version=app_version,
alembic=alembic_rev,
date=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
profiles=profiles,
files={name: {"sha256": staging.get_hash(name)} for name in staging.names()},
)
staging.add_bytes(
MANIFEST_FILE, manifest.model_dump_json(indent=2).encode(), 0o644
)
return manifest
@staticmethod
def _load_manifest(data: bytes) -> Manifest:
"""Parse and validate the archive manifest
@raise BackupError: if the manifest is invalid or not from this format
"""
try:
manifest = Manifest.model_validate_json(data)
except ValidationError as e:
raise BackupError(f"invalid manifest: {e}") from e
if manifest.format != FORMAT:
raise BackupError(f"unsupported format: {manifest.format!r}")
if manifest.format_version != FORMAT_VERSION:
raise BackupError(f"unsupported format version: {manifest.format_version}")
return manifest
@staticmethod
def _manifest_of(tar: tarfile.TarFile) -> Manifest:
"""Read and validate the manifest of an opened archive
@param tar: opened backup archive
@return: archive manifest
@raise BackupError: if the manifest is missing or invalid
"""
manifest_file = tar.extractfile(MANIFEST_FILE)
if manifest_file is None:
raise BackupError(f"missing manifest: {MANIFEST_FILE!r}")
return Backup._load_manifest(manifest_file.read())
@staticmethod
def _data_members(tar: tarfile.TarFile) -> list[tarfile.TarInfo]:
"""Archive entries holding data (the manifest is not included)"""
return [m for m in tar.getmembers() if m.isfile() and m.name != MANIFEST_FILE]
@staticmethod
def make_archive(
staging: Staging,
manifest: Manifest,
dest_dir: Path,
progress_cb: Callable[[int, int], None] | None = None,
) -> Path:
"""Pack the staged backup entries in a tar.xz archive
@param staging: staging holding the archive entries (including manifest)
@param manifest: archive manifest (used for the archive name)
@param dest_dir: directory where the archive is written
@param progress_cb: optional progress callback ``callable(written, total)``
@return: path of the created archive
"""
dest_dir.mkdir(parents=True, exist_ok=True)
date = datetime.fromisoformat(manifest.date)
base_name = f"libervia-backup-{date.strftime('%Y-%m-%d_%H-%M-%S')}"
path = dest_dir / f"{base_name}.tar.xz"
counter = 1
while path.exists():
path = dest_dir / f"{base_name}-{counter}.tar.xz"
counter += 1
mtime = int(time.time())
# approximate size of the tar stream (for the progress bar): each entry is a
# 512-byte header plus its data padded to a 512-byte block, and the 20 KB
# margin covers the end-of-archive marker and the extra headers tar writes
# for long entry names
total = sum(
512 + ((staging.size(name) + 511) // 512) * 512 for name in staging.names()
)
total += 20 * 1024
with lzma.open(path, "wb") as comp:
wrapper = _ProgressFile(comp, progress_cb, total)
# the tarfile stubs type fileobj with read/seek, which are not used
# in write mode
fileobj: Any = wrapper
with tarfile.open(fileobj=fileobj, mode="w") as tar:
for name in staging.names():
src = staging.path(name)
src_stat = src.stat()
info = tarfile.TarInfo(name)
info.size = src_stat.st_size
info.mtime = mtime
# the entry mode is set by the one who staged it (private
# entries are owner-only)
info.mode = stat.S_IMODE(src_stat.st_mode)
with src.open("rb") as f:
tar.addfile(info, f)
wrapper.close()
if progress_cb is not None:
progress_cb(total, total)
os.chmod(path, 0o600)
return path
@staticmethod
def read_archive(
path: Path,
staging: Staging,
progress_cb: Callable[[int, int], None] | None = None,
) -> Manifest:
"""Read and check a backup archive, extracting its entries to the staging
@param path: path to the archive
@param staging: staging where the entries are extracted
@param progress_cb: optional progress callback ``callable(done, total)``
@return: archive manifest
@raise BackupError: if the archive is invalid or tampered
"""
try:
with tarfile.open(path, "r:xz") as tar:
manifest = Backup._manifest_of(tar)
members = Backup._data_members(tar)
total = sum(m.size for m in members)
done = 0
for member in members:
if not is_safe_relpath(member.name):
raise BackupError(f"invalid entry name {member.name!r}")
dst = staging.path(member.name)
dst.parent.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256()
src_f = tar.extractfile(member)
if src_f is None:
raise BackupError(f"can't extract {member.name!r}")
with dst.open("wb") as out:
while chunk := src_f.read(CHUNK_SIZE):
out.write(chunk)
digest.update(chunk)
done += len(chunk)
if progress_cb:
progress_cb(done, total)
# we don't want special bits in the permissions (to avoid setuid,
# for instance)
os.chmod(dst, member.mode & 0o777)
expected = manifest.files.get(member.name, {}).get("sha256")
if expected is None:
# every entry of a valid archive is listed in the
# manifest: an unlisted one means the archive was
# modified after the export
raise BackupError(
f"unlisted entry {member.name!r} (archive tampered?)"
)
if digest.hexdigest() != expected:
raise BackupError(
f"{member.name}: checksum mismatch (archive tampered?)"
)
except (tarfile.TarError, OSError) as e:
raise BackupError(f"can't read archive: {e}") from e
for name in manifest.files:
if name not in staging:
raise BackupError(f"{name}: missing from archive")
return manifest
@staticmethod
def verify_archive(
path: Path,
progress_cb: Callable[[str, int, int], None] | None = None,
entry_cb: Callable[[str, dict], None] | None = None,
) -> tuple[Manifest, dict[str, dict]]:
"""Stream-check a backup archive (checksums and pie validity)
The entries are not extracted, only hashed (and parsed, for pie documents).
@param path: path to the archive
@param progress_cb: optional (entry name, bytes done, total bytes) callback,
called on each read chunk
@param entry_cb: optional (entry name, entry info) callback, called on each
entry completion
@return: (manifest, per-entry info: size, sha256, checksum status, pie error if
any)
@raise BackupError: if the archive is unreadable or the manifest is missing
"""
try:
with tarfile.open(path, "r:xz") as tar:
manifest = Backup._manifest_of(tar)
members = Backup._data_members(tar)
total = sum(m.size for m in members)
results: dict[str, dict] = {}
done = 0
for member in members:
is_pie = member.name.startswith(
_PIE_NAME_PREFIX
) and member.name.endswith(_PIE_NAME_SUFFIX)
digest = hashlib.sha256()
data = b""
src_f = tar.extractfile(member)
if src_f is None:
raise BackupError(f"can't extract {member.name!r}")
while chunk := src_f.read(CHUNK_SIZE):
digest.update(chunk)
if is_pie:
data += chunk
done += len(chunk)
if progress_cb:
progress_cb(member.name, done, total)
pie_error = None
if is_pie:
try:
parse_pie(data)
except PieError as e:
pie_error = f"invalid pie: {e}"
expected = manifest.files.get(member.name, {}).get("sha256")
info = {
"size": member.size,
"sha256": digest.hexdigest(),
"checksum": (
"ok" if expected == digest.hexdigest() else "mismatch"
),
"pie": pie_error,
}
results[member.name] = info
if entry_cb:
entry_cb(member.name, info)
return manifest, results
except (tarfile.TarError, OSError) as e:
raise BackupError(f"can't read archive: {e}") from e

View file

@ -0,0 +1,412 @@
#!/usr/bin/env python3
# Libervia: an XMPP client
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from abc import ABC, abstractmethod
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from datetime import datetime
import hashlib
import os
from pathlib import Path
import shutil
import sqlite3
import tempfile
from typing import ClassVar
from urllib.parse import quote
from lxml import etree
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from twisted.words.protocols.jabber import jid
from libervia.backend.core.constants import Const as C
from .xep_0227 import NS_PIE, PieError
# Pie Libervia custom extensions:
NS_PARAMS = NS_PIE + "#libervia-params"
NS_PRIVATE = NS_PIE + "#libervia-private"
NS_PRIVATE_BIN = NS_PIE + "#libervia-private-bin"
NS_ROSTER_VER = "urn:xmpp:features:rosterver"
ROSTER_VER_KEY = "@version@"
FORMAT = "libervia-backup"
FORMAT_VERSION = 1
MANIFEST_FILE = "manifest.json"
PIE_FILE_TPL = "server-data-{}.xml"
GPG_FILE_TPL = "gpg/{profile}/{path}"
FILES_FILE_TPL = "files/{path}"
PARAM_FILE = "param.xml"
DB_FILE = "db/libervia.db"
GPG_DIR = "gnupg-home"
# chunk size for streaming reads (hashing, extraction)
CHUNK_SIZE = 1024 * 1024
class BackupError(PieError):
"""Error while exporting or importing a backup"""
@dataclass
class ProfileData:
"""A profile and the data needed to build its pie document"""
name: str
id: int
# None if the JabberID parameter is not set yet
jid: str | None
def __post_init__(self):
# the JID may not be set yet, or may not be well-formed: keep the parsed JID when
# usable, None otherwise
self._parsed_jid = None
if self.jid is not None:
try:
self._parsed_jid = jid.JID(self.jid)
except jid.InvalidFormat:
pass
@property
def host_jid(self) -> str | None:
if self._parsed_jid is None:
return None
return self._parsed_jid.host or None
@property
def user_name(self) -> str:
if self._parsed_jid is not None and self._parsed_jid.user:
return self._parsed_jid.user
return self.name
@dataclass
class Report:
"""Result of an export or import pass"""
done: list[str] = field(default_factory=list)
skipped: list[str] = field(default_factory=list)
class Staging:
"""Temporary directory where backup entries are written (as files)"""
def __init__(self, prefix: str = "libervia-backup-") -> None:
"""Create a new staging in a fresh temporary directory
@param prefix: prefix of the temporary directory
"""
self._dir = Path(tempfile.mkdtemp(prefix=prefix))
self._hashes: dict[str, str] = {}
def path(self, name: str) -> Path:
"""Get the path of an entry in the staging
@param name: name of the entry
@return: path of the entry
"""
return self._dir / name
def add_bytes(self, name: str, data: bytes, mode: int = 0o600) -> None:
"""Write an entry to the staging from bytes
@param name: name of the entry
@param data: data to write
@param mode: file mode of the entry
"""
path = self.path(name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
os.chmod(path, mode)
self._hashes[name] = hashlib.sha256(data).hexdigest()
def add_file(self, name: str, src: Path, mode: int = 0o600) -> None:
"""Write an entry to the staging from a file
@param name: name of the entry
@param src: path of the file to copy
@param mode: file mode of the entry
"""
path = self.path(name)
path.parent.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256()
with src.open("rb") as s, path.open("wb") as d:
for chunk in iter(lambda: s.read(CHUNK_SIZE), b""):
d.write(chunk)
digest.update(chunk)
os.chmod(path, mode)
self._hashes[name] = digest.hexdigest()
def names(self) -> list[str]:
"""Get the names of all the staged entries
@return: sorted list of entry names
"""
return sorted(
path.relative_to(self._dir).as_posix()
for path in self._dir.rglob("*")
if path.is_file()
)
def __contains__(self, name: str) -> bool:
"""Check if an entry exists in the staging
@param name: name of the entry
@return: True if the entry exists
"""
return self.path(name).is_file()
def size(self, name: str) -> int:
"""Get the size of an entry
@param name: name of the entry
@return: size of the entry, in bytes
"""
return self.path(name).stat().st_size
def get_hash(self, name: str) -> str:
"""Get the SHA-256 of a staged entry
@param name: name of the entry
@return: SHA-256 of the entry, in hexadecimal
"""
if name in self._hashes:
return self._hashes[name]
# entries written directly to the staging (e.g. the database snapshot) are hashed
# on the fly
digest = hashlib.sha256()
with self.path(name).open("rb") as f:
for chunk in iter(lambda: f.read(CHUNK_SIZE), b""):
digest.update(chunk)
self._hashes[name] = digest.hexdigest()
return self._hashes[name]
def close(self) -> None:
"""Remove the staging directory and all its entries"""
shutil.rmtree(self._dir, ignore_errors=True)
class DbDriver(ABC):
"""Engine-specific database snapshot/restore"""
@abstractmethod
def snapshot(self, staging: Staging) -> None:
"""Write a snapshot of the database to the staging
@param staging: staging where the snapshot is written
"""
raise NotImplementedError
@abstractmethod
def restore(self, staging: Staging, force: bool) -> Report:
"""Restore the database snapshot from the staging
The application must not be running: the database is replaced in place.
@param staging: staging holding the snapshot
@param force: the restore is destructive, it requires force
@return: import report
@raise BackupError: if force is not set
"""
raise NotImplementedError
class SqliteDbDriver(DbDriver):
"""Database driver for SQLite (backup via "sqlite3.backup")"""
def __init__(self, db_path: Path):
self.db_path = db_path
def _backup_to(self, src_path: Path, dst_path: Path) -> None:
"""Copy a database to another file with "sqlite3.backup"
The destination is created with mode 0600.
@param src_path: source database (opened in read only mode)
@param dst_path: destination file
"""
dst_path.parent.mkdir(parents=True, exist_ok=True)
src = sqlite3.connect(f"file:{quote(str(src_path))}?mode=ro", uri=True)
try:
dst = sqlite3.connect(dst_path)
try:
src.backup(dst)
finally:
dst.close()
finally:
src.close()
os.chmod(dst_path, 0o600)
def snapshot(self, staging: Staging) -> None:
self._backup_to(self.db_path, staging.path(DB_FILE))
def restore(self, staging: Staging, force: bool) -> Report:
report = Report()
if DB_FILE not in staging:
return report
if not force:
raise BackupError(
"restoring the database backup replaces the current database: "
"confirmation is required"
)
db_path = self.db_path
db_path.parent.mkdir(parents=True, exist_ok=True)
if db_path.exists():
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
backup_path = db_path.with_name(f"{db_path.name}.pre-restore-{timestamp}")
# we copy with "sqlite3.backup" to include the changes not written to the main
# file yet (WAL)
self._backup_to(db_path, backup_path)
report.done.append(f"db: current database kept at {backup_path}")
# stale journal files belong to the previous (replaced) database
for suffix in ("-wal", "-shm", "-journal"):
old = db_path.with_name(db_path.name + suffix)
if old.exists():
old.unlink()
shutil.copyfile(staging.path(DB_FILE), db_path)
os.chmod(db_path, 0o600)
report.done.append("db: database")
return report
def get_db_driver(db_config: dict) -> DbDriver:
"""Get the database driver matching a database config
@param db_config: database config
@return: database driver
@raise BackupError: if the engine has no database driver
"""
if db_config.get("type") == "sqlite":
return SqliteDbDriver(Path(db_config["path"]))
raise BackupError(
"database backup is not supported by the "
f"{db_config.get('type')!r} database backend"
)
@dataclass
class BackupContext:
"""Data access context used by the export/import functions"""
engine: AsyncEngine
session_factory: async_sessionmaker
db_url: str
param_file: Path
local_dir: Path
db: DbDriver
@asynccontextmanager
async def session(self):
async with self.session_factory() as session:
yield session
async def close(self) -> None:
await self.engine.dispose()
def sqlite_db_config(local_dir: Path) -> dict:
"""Database config for the local sqlite database of a local directory"""
return {"type": "sqlite", "path": local_dir / C.SAVEFILE_DATABASE}
class Handler(ABC):
"""Export/import handler of a backup data type"""
name: ClassVar[str]
"""Data type name"""
uses_db: ClassVar[bool] = False
"""True if the handler reads or writes the database"""
def known_ns(self) -> tuple[str, ...]:
"""Namespaces of the pie sections handled by the handler
Used to detect sections from unknown (e.g. newer) versions on import.
"""
return ()
class ProfileHandler(Handler):
"""Handler for per profile data, exported in the profile pie document"""
@abstractmethod
async def export(
self,
ctx: BackupContext,
staging: Staging,
profile: ProfileData,
report: Report,
) -> list[etree._Element]:
"""Export the data of a profile, as pie sections
@return: the pie sections to add to the profile pie document
"""
raise NotImplementedError
@abstractmethod
async def import_(
self,
ctx: BackupContext,
session: AsyncSession,
staging: Staging,
profile_name: str,
profile_jid: str,
sections: dict[str, list[etree._Element]],
report: Report,
force: bool = False,
) -> None:
"""Import the data of a profile from its pie sections
@param profile_jid: JID of the profile, as stored in the pie document
@param sections: the profile pie sections, grouped by namespace
@param force: the restore is dangerous, the user confirmed it
"""
raise NotImplementedError
class GlobalHandler(Handler):
"""Handler for data not tied to a profile (GPG homes, files, database)
Some of this data is still organized per profile (GPG homes): the optional
``profiles`` argument limits the handler to these profiles (None means all of them).
"""
@abstractmethod
def export(
self,
local_dir: Path,
staging: Staging,
report: Report,
profiles: list[str] | None = None,
) -> None:
raise NotImplementedError
@abstractmethod
def import_(
self,
local_dir: Path,
staging: Staging,
report: Report,
profiles: list[str] | None = None,
force: bool = False,
) -> None:
"""Import the data, ``force`` is required for a dangerous restore"""
raise NotImplementedError

View file

@ -0,0 +1,210 @@
#!/usr/bin/env python3
# Libervia: an XMPP client
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
from pathlib import Path
import shutil
from libervia.backend.tools.common.gpg_provider import get_provider
from libervia.backend.tools.common.gpg_provider.exceptions import (
GPGProviderError,
InvalidPacket,
UnknownKey,
)
from .core import BackupError, GPG_DIR, GPG_FILE_TPL, GlobalHandler, Report, Staging
# errors the GPG provider can raise (they have no common base class)
GPG_ERRORS = (GPGProviderError, InvalidPacket, UnknownKey)
def export_gpg(home: Path, staging: Staging) -> None:
"""Copy a profile GPG home to the staging
@param home: GPG home to copy (the name is the profile name)
@param staging: staging where the GPG files are copied
"""
if not home.is_dir():
return
for path in sorted(home.rglob("*")):
if path.is_file():
staging.add_file(
GPG_FILE_TPL.format(
profile=home.name, path=path.relative_to(home).as_posix()
),
path,
0o600,
)
def gpg_list_keys(home: Path) -> set[str]:
"""List the keys of a GPG home
@param home: GPG home path
@return: key fingerprints
@raise BackupError: if the keys can't be listed
"""
if not home.is_dir():
return set()
try:
provider = get_provider(str(home))
return {key.fingerprint for key in provider.list_public_keys("")}
except GPG_ERRORS as e:
raise BackupError(f"can't list GPG keys: {e}") from e
def _chmod_gpg_home(home: Path) -> None:
"""Set the restrictive permissions required by GPG on a home"""
for dirpath, dirnames, filenames in os.walk(home):
for dirname in dirnames:
os.chmod(Path(dirpath) / dirname, 0o700)
for filename in filenames:
os.chmod(Path(dirpath) / filename, 0o600)
os.chmod(home, 0o700)
def check_gpg_restore(
target_home: Path, backup_home: Path
) -> tuple[list[str], list[str]]:
"""Check a GPG home restore is safe (union merge, nothing is deleted)
The only risk is restoring an old backup onto a diverged keyring.
@param target_home: existing GPG home (restore target)
@param backup_home: GPG home from the backup
@return: (added, diverged) fingerprints
"""
backup_keys = gpg_list_keys(backup_home)
target_keys = gpg_list_keys(target_home)
return sorted(backup_keys - target_keys), sorted(target_keys - backup_keys)
def import_gpg(target_home: Path, backup_home: Path) -> int:
"""Merge the keys of a backup GPG home into a target home
Union merge: nothing is ever deleted or overwritten; if the target doesn't exist, the
backup home is simply copied.
@param target_home: GPG home to restore into
@param backup_home: GPG home from the backup
@return: number of secret key files added to the target
@raise BackupError: if the import fails
"""
if not target_home.exists():
target_home.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(backup_home, target_home)
_chmod_gpg_home(target_home)
return 0
# union copy of the secret keys (never overwrites an existing one)
src_keys = backup_home / "private-keys-v1.d"
dst_keys = target_home / "private-keys-v1.d"
dst_keys.mkdir(parents=True, exist_ok=True)
added = 0
if src_keys.is_dir():
for key in sorted(src_keys.glob("*.key")):
dst = dst_keys / key.name
if not dst.exists():
shutil.copy2(key, dst)
added += 1
# the keybox is a binary database which can't be merged: the backup keys are
# exported and re-imported (the secret keys are already in the copied .key files)
try:
src_provider = get_provider(str(backup_home))
dst_provider = get_provider(str(target_home))
packets = [
src_provider.export_public_key(key)
for key in src_provider.list_public_keys("")
]
for packet in packets:
try:
dst_provider.import_public_key(packet)
except InvalidPacket:
# key already in the target keybox: nothing to import
continue
except GPG_ERRORS as e:
raise BackupError(f"GPG import failed: {e}") from e
_chmod_gpg_home(target_home)
return added
class GpgHandler(GlobalHandler):
name = "gpg"
def _staged_profiles(self, staging: Staging) -> list[str]:
return sorted(
{name.split("/")[1] for name in staging.names() if name.startswith("gpg/")}
)
def export(
self,
local_dir: Path,
staging: Staging,
report: Report,
profiles: list[str] | None = None,
) -> None:
"""Copy the GPG homes to the staging
@param profiles: GPG homes to copy
None to copy all of them
@param report: export report
"""
gpg_dir = local_dir / GPG_DIR
if not gpg_dir.is_dir():
return
wanted = set(profiles) if profiles is not None else None
for home in sorted(gpg_dir.iterdir()):
if home.is_dir() and (wanted is None or home.name in wanted):
export_gpg(home, staging)
def import_(
self,
local_dir: Path,
staging: Staging,
report: Report,
profiles: list[str] | None = None,
force: bool = False,
) -> None:
"""Merge the backup GPG homes into the local ones (union merge)
A keyring which diverged from the backup is skipped unless ``force`` is set (the
backup may be older than the current keys).
@param profiles: GPG homes to import (None means all of them)
"""
wanted = set(profiles) if profiles is not None else None
for profile_name in self._staged_profiles(staging):
if wanted is not None and profile_name not in wanted:
continue
backup_home = staging.path(f"gpg/{profile_name}")
if not backup_home.is_dir() or not any(backup_home.iterdir()):
continue
target_home = local_dir / GPG_DIR / profile_name
added, diverged = check_gpg_restore(target_home, backup_home)
if diverged and not force:
report.skipped.append(
f"gpg: {profile_name}: the current keyring has keys not in "
f"the backup ({', '.join(diverged)})"
)
continue
count = import_gpg(target_home, backup_home)
report.done.append(
f"gpg: keyring merged into {target_home} ({count} new secret key file(s))"
)
for fp in added:
report.done.append(f"gpg: added key {fp}")

View file

@ -0,0 +1,166 @@
#!/usr/bin/env python3
# Libervia: an XMPP client
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from pathlib import Path
import shutil
from libervia.backend.core.constants import Const as C
from .core import (
DB_FILE,
DbDriver,
FILES_FILE_TPL,
GlobalHandler,
Handler,
ProfileHandler,
Report,
Staging,
get_db_driver,
sqlite_db_config,
)
from .gpg import GpgHandler
from .pie import ParamsHandler, PepHandler, PrivateHandler, RosterHandler
class FilesHandler(GlobalHandler):
name = "files"
def export(
self,
local_dir: Path,
staging: Staging,
report: Report,
profiles: list[str] | None = None,
) -> None:
"""Copy the file sharing blobs to the staging
@param local_dir: local data directory
@param staging: staging where the blobs are copied
@param report: export report
"""
files_dir = local_dir / C.FILES_DIR
if not files_dir.is_dir():
return
count = 0
for path in sorted(files_dir.rglob("*")):
if path.is_file():
staging.add_file(
FILES_FILE_TPL.format(path=path.relative_to(files_dir).as_posix()),
path,
0o600,
)
count += 1
if count:
report.done.append(f"files: {count} file(s)")
def import_(
self,
local_dir: Path,
staging: Staging,
report: Report,
profiles: list[str] | None = None,
force: bool = False,
) -> None:
"""Copy the staged file sharing blobs to the local data directory
The blobs are content-addressed (the name is the content hash), so overwriting an
existing file is safe and repairs a corrupted one.
@param local_dir: local data directory
@param staging: staging holding the blobs
@param report: import report
"""
files_dir = local_dir / C.FILES_DIR
# entry name prefix, derived from the staging name template
prefix = FILES_FILE_TPL.split("{}")[0]
count = 0
for name in staging.names():
if not name.startswith(prefix):
continue
dst = files_dir / name[len(prefix) :]
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(staging.path(name), dst)
count += 1
if count:
report.done.append(f"files: {count} file(s)")
class DbHandler(GlobalHandler):
name = "db"
def _driver(self, local_dir: Path) -> DbDriver:
return get_db_driver(sqlite_db_config(local_dir))
def export(
self,
local_dir: Path,
staging: Staging,
report: Report,
profiles: list[str] | None = None,
) -> None:
"""Write a snapshot of the database to the staging
@param report: export report
"""
self._driver(local_dir).snapshot(staging)
def import_(
self,
local_dir: Path,
staging: Staging,
report: Report,
profiles: list[str] | None = None,
force: bool = False,
) -> None:
"""Restore the database snapshot from the staging (destructive)
@param report: import report
@param force: the restore is destructive, it requires force
"""
if DB_FILE not in staging:
return
res = self._driver(local_dir).restore(staging, force)
report.done.extend(res.done)
report.skipped.extend(res.skipped)
# one handler per data type, in export order (the order is semantic: the database restore
# comes last, it replaces the database)
REGISTRY: list[Handler] = [
ParamsHandler(),
PrivateHandler(),
RosterHandler(),
PepHandler(),
GpgHandler(),
FilesHandler(),
DbHandler(),
]
# "db" is a non-portable copy of the database (it will be migrated automatically on start
# if the Libervia version is newer): it covers all the data, while the portable types
# don't cover everything yet.
DATA_TYPES: tuple[str, ...] = tuple(handler.name for handler in REGISTRY)
# data types stored in the database: a database restore brings them back, so
# there is no need to import them on top of it
DB_BACKED_TYPES: tuple[str, ...] = tuple(
handler.name
for handler in REGISTRY
if isinstance(handler, ProfileHandler) and handler.uses_db
)

View file

@ -0,0 +1,48 @@
#!/usr/bin/env python3
# Libervia: an XMPP client
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from pydantic import BaseModel, Field
class ProfileMeta(BaseModel):
"""Exported data of a profile."""
jid: str | None = Field(default=None, description="JabberID of the profile")
types: list[str] = Field(
default_factory=list, description="data types exported for this profile"
)
class Manifest(BaseModel):
"""Manifest of a backup archive (manifest.json)."""
format: str = Field(description="backup format name")
format_version: int = Field(description="backup format version")
app: str = Field(description="application that made the backup")
app_version: str = Field(description="version of the application")
alembic: str | None = Field(
default=None, description="database schema revision (None if unknown)"
)
date: str = Field(description="backup date (UTC, ISO 8601)")
profiles: dict[str, ProfileMeta] = Field(
default_factory=dict, description="exported profiles, by name"
)
files: dict[str, dict[str, str]] = Field(
default_factory=dict,
description="checksums of the archive entries, by entry name",
)

View file

@ -0,0 +1,656 @@
#!/usr/bin/env python3
# Libervia: an XMPP client
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from datetime import datetime, timezone
import json
import os
import shutil
import time
from lxml import etree
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import cast
from sqlalchemy.types import Text
from libervia.backend.memory.sqla_mapping import (
ParamGen,
ParamInd,
PrivateGen,
PrivateGenBin,
PrivateInd,
PrivateIndBin,
Profile,
PubsubItem,
PubsubNode,
)
from .core import (
BackupContext,
BackupError,
NS_PARAMS,
NS_PRIVATE,
NS_PRIVATE_BIN,
NS_ROSTER_VER,
PARAM_FILE,
ProfileData,
ProfileHandler,
ROSTER_VER_KEY,
Report,
Staging,
)
from .xep_0227 import (
NS_PUBSUB,
NS_ROSTER,
append_xml_payload,
build_pep_items,
build_roster,
item_payload,
)
async def _get_profile_id(session, profile_name: str) -> int | None:
"""Get the database id of a profile
@param session: database session
@param profile_name: profile to look for
@return: profile id, or None if the profile is unknown
"""
return (
await session.execute(select(Profile.id).where(Profile.name == profile_name))
).scalar_one_or_none()
class ParamsHandler(ProfileHandler):
name = "params"
uses_db = True
def known_ns(self) -> tuple[str, ...]:
return (NS_PARAMS,)
async def export(
self,
ctx: BackupContext,
staging: Staging,
profile: ProfileData,
report: Report,
) -> list[etree._Element]:
section = await self._section(ctx, profile)
if ctx.param_file.is_file() and PARAM_FILE not in staging:
staging.add_file(PARAM_FILE, ctx.param_file, 0o644)
return [section]
async def import_(
self,
ctx: BackupContext,
session: AsyncSession,
staging: Staging,
profile_name: str,
profile_jid: str,
sections: dict[str, list[etree._Element]],
report: Report,
force: bool = False,
) -> None:
for section in sections.get(NS_PARAMS, []):
await self._import(session, profile_name, section, report)
if PARAM_FILE in staging:
shutil.copyfile(staging.path(PARAM_FILE), ctx.param_file)
os.chmod(ctx.param_file, 0o600)
report.done.append("params: parameters file")
async def _section(self, ctx: BackupContext, profile: ProfileData) -> etree._Element:
"""Build the params section from the database
@param ctx: backup context
@param profile: profile to export
@return: the params section element
"""
async with ctx.session() as session:
gen = (
(
await session.execute(
select(ParamGen).order_by(ParamGen.category, ParamGen.name)
)
)
.scalars()
.all()
)
ind = (
(
await session.execute(
select(ParamInd)
.where(ParamInd.profile_id == profile.id)
.order_by(ParamInd.category, ParamInd.name)
)
)
.scalars()
.all()
)
section = etree.Element(f"{{{NS_PARAMS}}}params", nsmap={None: NS_PARAMS})
for scope, rows in (("general", gen), ("individual", ind)):
for row in rows:
# values are stored as-is in the database, XML only carries strings
attr = {
"category": str(row.category),
"name": str(row.name),
"scope": scope,
}
if row.value is not None:
attr["value"] = str(row.value)
etree.SubElement(section, f"{{{NS_PARAMS}}}param", **attr)
return section
async def _import(
self, session, profile_name: str, section: etree._Element, report: Report
) -> None:
"""Import a params section into the database
@param session: database session
@param profile_name: profile to import for
@param section: params section element
@param report: import report (individual rows of unknown profiles are skipped)
"""
profile_id = await _get_profile_id(session, profile_name)
gen_rows = {
(row.category, row.name): row
for row in (await session.execute(select(ParamGen))).scalars()
}
ind_rows = {}
if profile_id is not None:
ind_rows = {
(row.category, row.name): row
for row in (
await session.execute(
select(ParamInd).where(ParamInd.profile_id == profile_id)
)
).scalars()
}
count = 0
for elt in section.findall(f"{{{NS_PARAMS}}}param"):
category, name = elt.get("category", ""), elt.get("name", "")
value = elt.get("value")
if elt.get("scope", "general") == "general":
row = gen_rows.get((category, name))
if row is None:
session.add(ParamGen(category=category, name=name, value=value))
else:
row.value = value
count += 1
elif profile_id is None:
report.skipped.append(f"params: unknown profile {profile_name!r}")
else:
row = ind_rows.get((category, name))
if row is None:
session.add(
ParamInd(
category=category,
name=name,
profile_id=profile_id,
value=value,
)
)
else:
row.value = value
count += 1
if count:
report.done.append(f"params: {count} value(s)")
class PrivateHandler(ProfileHandler):
name = "private"
uses_db = True
def known_ns(self) -> tuple[str, ...]:
return (NS_PRIVATE, NS_PRIVATE_BIN)
async def export(
self,
ctx: BackupContext,
staging: Staging,
profile: ProfileData,
report: Report,
) -> list[etree._Element]:
sections: list[etree._Element] = []
for bin_ in (False, True):
if (section := await self._section(ctx, profile, bin_)) is not None:
sections.append(section)
return sections
async def import_(
self,
ctx: BackupContext,
session: AsyncSession,
staging: Staging,
profile_name: str,
profile_jid: str,
sections: dict[str, list[etree._Element]],
report: Report,
force: bool = False,
) -> None:
for ns, bin_ in ((NS_PRIVATE, False), (NS_PRIVATE_BIN, True)):
for section in sections.get(ns, []):
await self._import(
session, profile_name, section, bin_=bin_, report=report
)
async def _section(
self, ctx: BackupContext, profile: ProfileData, bin_: bool = False
) -> etree._Element | None:
"""Build a private data section (key-value storage, or its binary variant)
Keys are not necessarily valid XML element names, so each value is stored
in a <row> element with namespace, key and scope attributes (Libervia
extension).
@param ctx: backup context
@param profile: profile to export
@param bin_: True for the binary (JSON) values
@return: the private section element, or None if empty
"""
ns = NS_PRIVATE_BIN if bin_ else NS_PRIVATE
gen_model = PrivateGenBin if bin_ else PrivateGen
ind_model = PrivateIndBin if bin_ else PrivateInd
async with ctx.session() as session:
gen = (
(
await session.execute(
select(gen_model).order_by(gen_model.namespace, gen_model.key)
)
)
.scalars()
.all()
)
ind = (
(
await session.execute(
select(ind_model)
.where(ind_model.profile_id == profile.id)
.order_by(ind_model.namespace, ind_model.key)
)
)
.scalars()
.all()
)
rows = [("general", row) for row in gen] + [("individual", row) for row in ind]
if not rows:
return None
section = etree.Element(f"{{{ns}}}private", nsmap={None: ns})
for scope, row in rows:
attr = {
"namespace": str(row.namespace or ""),
"key": str(row.key),
"scope": scope,
}
if row.value is None:
# XML can't distinguish an empty value from a missing one
attr["nil"] = "true"
elt = etree.SubElement(section, f"{{{ns}}}row", **attr)
if row.value is not None:
if bin_:
# JSON values: serialize back with the same serializer
# used by the Json column type (byte-identical)
elt.text = json.dumps(row.value, ensure_ascii=False)
else:
elt.text = str(row.value)
return section
async def _import(
self,
session,
profile_name: str,
section: etree._Element,
bin_: bool,
report: Report,
) -> None:
"""Import a private data section into the database
@param session: database session
@param profile_name: profile to import for
@param section: private section element
@param bin_: True for the binary (JSON) values
@param report: import report (individual rows of unknown profiles are skipped)
"""
suffix = "_bin" if bin_ else ""
gen_model = PrivateGenBin if bin_ else PrivateGen
ind_model = PrivateIndBin if bin_ else PrivateInd
profile_id = await _get_profile_id(session, profile_name)
gen_rows = {
(row.namespace, row.key): row
for row in (await session.execute(select(gen_model))).scalars()
}
ind_rows = {}
if profile_id is not None:
ind_rows = {
(row.namespace, row.key): row
for row in (
await session.execute(
select(ind_model).where(ind_model.profile_id == profile_id)
)
).scalars()
}
count = 0
for elt in section.findall(f"{{{NS_PRIVATE_BIN if bin_ else NS_PRIVATE}}}row"):
namespace, key = elt.get("namespace", ""), elt.get("key", "")
nil = elt.get("nil") == "true"
if bin_ and not nil:
try:
# the Json column type will serialize it back
value = json.loads(elt.text or "")
except json.JSONDecodeError as e:
raise BackupError(
f"private{suffix}: invalid JSON for {namespace}/{key!r}: {e}"
) from e
else:
value = None if nil else (elt.text or "")
if elt.get("scope", "general") == "general":
row = gen_rows.get((namespace, key))
if row is None:
session.add(gen_model(namespace=namespace, key=key, value=value))
else:
row.value = value
count += 1
elif profile_id is None:
report.skipped.append(
f"private: unknown profile {profile_name!r}, row ignored"
)
else:
row = ind_rows.get((namespace, key))
if row is None:
session.add(
ind_model(
namespace=namespace,
key=key,
value=value,
profile_id=profile_id,
)
)
else:
row.value = value
count += 1
if count:
report.done.append(f"private{suffix}: {count} value(s)")
class RosterHandler(ProfileHandler):
name = "roster"
uses_db = True
def known_ns(self) -> tuple[str, ...]:
return (NS_ROSTER,)
async def export(
self,
ctx: BackupContext,
staging: Staging,
profile: ProfileData,
report: Report,
) -> list[etree._Element]:
# the roster is exported from its persistent cache in the database, kept
# up to date on every connection and roster push (roster versioning)
items: list[etree._Element] = []
version: str | None = None
async with ctx.session() as session:
rows = (
(
await session.execute(
select(PrivateInd)
.where(
PrivateInd.profile_id == profile.id,
PrivateInd.namespace == NS_ROSTER_VER,
)
.order_by(PrivateInd.key)
)
)
.scalars()
.all()
)
for row in rows:
if row.key == ROSTER_VER_KEY:
version = row.value
else:
items.append(etree.fromstring(row.value.encode()))
if not items:
return []
return [build_roster(items, version)]
async def import_(
self,
ctx: BackupContext,
session: AsyncSession,
staging: Staging,
profile_name: str,
profile_jid: str,
sections: dict[str, list[etree._Element]],
report: Report,
force: bool = False,
) -> None:
# importing the roster needs a server connection (the roster has to be pushed to
# the server), it is not implemented yet
if sections.get(NS_ROSTER):
report.skipped.append(
f"{profile_name}: roster not imported (not implemented yet, "
"needs a server connection)"
)
class PepHandler(ProfileHandler):
name = "pep"
uses_db = True
def known_ns(self) -> tuple[str, ...]:
return (NS_PUBSUB,)
async def export(
self,
ctx: BackupContext,
staging: Staging,
profile: ProfileData,
report: Report,
) -> list[etree._Element]:
section = await self._section(ctx, profile)
if section is None:
report.skipped.append("pep: no PEP node in cache")
return []
return [section]
async def import_(
self,
ctx: BackupContext,
session: AsyncSession,
staging: Staging,
profile_name: str,
profile_jid: str,
sections: dict[str, list[etree._Element]],
report: Report,
force: bool = False,
) -> None:
await self._import(
session, profile_name, profile_jid, sections.get(NS_PUBSUB, []), report
)
async def _section(
self, ctx: BackupContext, profile: ProfileData
) -> etree._Element | None:
"""Build the PEP items section from the pubsub cache
Node configuration is not cached locally, so only items are exported.
@param ctx: backup context
@param profile: profile to export
@return: the PEP section element, or None if the profile has no PEP nodes
"""
async with ctx.session() as session:
# the service and data columns carry app-specific types (JID, parsed
# XML), so they are cast to text to keep the backup byte-identical
node_rows = (
await session.execute(
select(
PubsubNode.id,
PubsubNode.name,
cast(PubsubNode.service, Text),
)
.where(PubsubNode.profile_id == profile.id, PubsubNode.pep.is_(True))
.order_by(PubsubNode.name)
)
).all()
if not node_rows:
return None
item_rows = (
await session.execute(
select(
PubsubItem.node_id,
PubsubItem.name,
cast(PubsubItem.data, Text),
)
.where(
PubsubItem.node_id.in_([node_id for node_id, _, _ in node_rows])
)
.order_by(PubsubItem.name)
)
).all()
by_id = {node_id: (name, service) for node_id, name, service in node_rows}
nodes: dict[tuple[str, str | None], list[tuple[str, str, str | None]]] = {}
for node_id, item_name, data in item_rows:
payload, publisher = self._item_parts(data)
name, service = by_id[node_id]
nodes.setdefault((name, service), []).append(
(item_name or "", payload or "", publisher)
)
return build_pep_items(nodes)
async def _import(
self,
session,
profile_name: str,
jid: str,
sections: list[etree._Element],
report: Report,
) -> None:
"""Import the PEP items sections into the pubsub cache
Rows are written with plain SQL (the columns carry app-specific types)
to keep the data byte-identical.
@param session: database session
@param profile_name: profile to import for
@param jid: profile JID, used as the node service
@param sections: PEP items section elements
@param report: import report (unknown profiles are skipped)
"""
profile_id = await _get_profile_id(session, profile_name)
if profile_id is None:
report.skipped.append(f"pep: unknown profile {profile_name!r}")
return
for section in sections:
for items in section.findall(f"{{{NS_PUBSUB}}}items"):
node_name = items.get("node")
if not node_name:
continue
# the node owner is not necessarily the pie user (e.g. service profiles),
# so the service is stored in the items element (Libervia extension);
# pie documents from other servers don't have it, and there the node
# owner is the pie user
service = items.get("service") or jid
await session.execute(
text(
"INSERT INTO pubsub_nodes (profile_id, service, name, pep, "
"subscribed, sync_state_updated, max_items) "
"VALUES (:profile_id, :service, :name, 1, 0, :now, 0) "
"ON CONFLICT (profile_id, service, name, pep) "
"DO UPDATE SET pep = 1"
),
{
"profile_id": profile_id,
"service": service,
"name": node_name,
"now": time.time(),
},
)
node_id = (
await session.execute(
text(
"SELECT id FROM pubsub_nodes "
"WHERE profile_id=:profile_id AND service=:service "
"AND name=:name"
),
{
"profile_id": profile_id,
"service": service,
"name": node_name,
},
)
).scalar_one()
now = datetime.now(timezone.utc)
for item in items.findall(f"{{{NS_PUBSUB}}}item"):
data = self._item_data(
item.get("id", ""), item.get("publisher"), item_payload(item)
)
await session.execute(
text(
"INSERT INTO pubsub_items (node_id, name, data, created, "
"updated) VALUES (:node_id, :name, :data, :now, :now) "
"ON CONFLICT (node_id, name) DO UPDATE SET "
"data = excluded.data, updated = excluded.updated"
),
{
"node_id": node_id,
"name": item.get("id", ""),
"data": data,
"now": now,
},
)
report.done.append(f"pep: node {node_name!r}")
def _item_parts(self, data: str | None) -> tuple[str | None, str | None]:
"""Extract the payload and publisher of a stored pubsub item
The data column stores the full <item> element, but the pie document
carries the payload and the publisher instead.
@param data: stored item data
@return: (payload, publisher), payload is an XML string or plain text
"""
if not data:
return data, None
try:
root = etree.fromstring(data.encode())
except etree.XMLSyntaxError:
return data, None
if etree.QName(root).localname != "item":
return data, None
return item_payload(root), root.get("publisher")
def _item_data(self, item_id: str, publisher: str | None, payload: str) -> str:
"""Build the data to store in the pubsub cache for a pie item
The payload is wrapped in a pubsub <item> element, as in the cache.
@param item_id: item id
@param publisher: item publisher (attribute of the <item> element)
@param payload: item payload (XML string or plain text)
@return: data to store
"""
# the item element is in the pubsub namespace (XEP-0060), as in the cache
wrapper = etree.Element(f"{{{NS_PUBSUB}}}item", nsmap={None: NS_PUBSUB})
if item_id:
wrapper.set("id", item_id)
if publisher:
wrapper.set("publisher", publisher)
append_xml_payload(wrapper, payload)
return etree.tostring(wrapper, encoding="unicode")

View file

@ -0,0 +1,169 @@
#!/usr/bin/env python3
# Libervia: an XMPP client
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from lxml import etree
NS_PIE = "urn:xmpp:pie:0"
NS_ROSTER = "jabber:iq:roster"
NS_PUBSUB = "http://jabber.org/protocol/pubsub"
class PieError(Exception):
"""Error while building or parsing a pie (XEP-0227) document"""
## pie document ##
def build_pie(
host_jid: str | None, user_name: str, sections: list[etree._Element]
) -> bytes:
"""Build a pie document for a user
@param host_jid: virtual host JID; the XEP makes the attribute required, so an empty
value is written when it is unknown (profile JID not set yet)
@param user_name: node part of the user JID (``<user name=...>``)
@param sections: section elements to include under the ``<user>`` element
@return: serialized pie document
"""
root = etree.Element(f"{{{NS_PIE}}}server-data", nsmap={None: NS_PIE})
host = etree.SubElement(root, f"{{{NS_PIE}}}host", jid=host_jid or "")
user = etree.SubElement(host, f"{{{NS_PIE}}}user", name=user_name)
for section in sections:
user.append(section)
return etree.tostring(root, xml_declaration=True, encoding="utf-8", pretty_print=True)
def parse_pie(data: bytes) -> tuple[str, str, list[etree._Element]]:
"""Parse a pie document
Unknown sections are kept as-is: the XEP requires importers to ignore data they don't
understand.
@param data: serialized pie document
@return: (host jid, user name, sections)
@raise PieError: if the document is not a valid pie document
"""
try:
root = etree.fromstring(data)
except etree.XMLSyntaxError as e:
raise PieError(f"invalid pie document: {e}") from e
if root.tag != f"{{{NS_PIE}}}server-data":
raise PieError(f"unexpected root element {root.tag!r}, expected pie")
host = root.find(f"{{{NS_PIE}}}host")
if host is None:
raise PieError("missing <host> element in pie document")
user = host.find(f"{{{NS_PIE}}}user")
if user is None:
raise PieError("missing <user> element in pie document")
return host.get("jid", ""), user.get("name", ""), list(user)
def sections_by_ns(sections: list[etree._Element]) -> dict[str, list[etree._Element]]:
"""Group pie sections by their namespace"""
by_ns: dict[str, list[etree._Element]] = {}
for section in sections:
ns = etree.QName(section).namespace or ""
by_ns.setdefault(ns, []).append(section)
return by_ns
## sections: roster ##
def build_roster(
items: list[etree._Element], version: str | None = None
) -> etree._Element:
"""Build the roster section
@param items: roster item elements, as in the roster query
@param version: roster version, if known
@return: the roster section element
"""
section = etree.Element(f"{{{NS_ROSTER}}}query", nsmap={None: NS_ROSTER})
if version:
section.set("ver", version)
for item in items:
section.append(item)
return section
## sections: pep items ##
def append_xml_payload(parent: etree._Element, data: str | None) -> None:
"""Append item data (stored as XML text) to an element, with text fallback
@param parent: element to append the payload to
@param data: payload data (XML string or plain text)
"""
if not data:
return
try:
parent.append(etree.fromstring(data.encode()))
except etree.XMLSyntaxError:
parent.text = data
def item_payload(item: etree._Element) -> str:
"""Serialize the payload of a pubsub item (without the <item> wrapper)
@param item: item element
@return: payload as string
"""
children = list(item)
if not children:
return item.text or ""
# keep the payload in a temporary container (several elements supported)
tmp = etree.Element("payload")
if (item.text or "").strip():
tmp.text = item.text
for child in children:
if child.tail and not child.tail.strip():
child.tail = None
tmp.append(child)
# exclusive: only the namespaces actually used by the payload are kept
data = etree.tostring(tmp, encoding="unicode", exclusive=True)
return data[len("<payload>") : -len("</payload>")]
def build_pep_items(
nodes: dict[tuple[str, str | None], list[tuple[str, str, str | None]]],
) -> etree._Element | None:
"""Build the PEP items section
@param nodes: (node name, service) mapped to the items as (item id, payload,
publisher) lists, payloads are XML strings or plain text. The service (node owner
JID) is stored in a ``service`` attribute
@return: the PEP items section element, or None if there is no node
"""
if not nodes:
return None
section = etree.Element(f"{{{NS_PUBSUB}}}pubsub", nsmap={None: NS_PUBSUB})
for (node_name, service), items in sorted(
nodes.items(), key=lambda kv: (kv[0][0], kv[0][1] or "")
):
items_elt = etree.SubElement(section, f"{{{NS_PUBSUB}}}items", node=node_name)
if service:
items_elt.set("service", service)
for item_id, payload, publisher in items:
item = etree.SubElement(items_elt, f"{{{NS_PUBSUB}}}item", id=item_id)
if publisher:
item.set("publisher", publisher)
append_xml_payload(item, payload)
return section

View file

@ -19,6 +19,8 @@
"""Misc utils for both backend and frontends"""
import collections.abc
import os
from pathlib import Path
size_units = {
"b": 1,
@ -178,3 +180,34 @@ def get_human_size(size: int | str, suffix: str = "o", sep: str = " ") -> str:
"""Return data size in a human readable format."""
size, symbol = get_size_multiplier(size, suffix)
return f"{size:.2f}{sep}{symbol}"
def is_safe_relpath(name: str) -> bool:
"""Check if a path is safe to join to a directory (as in a tar archive)
@param name: path to check
@return: True if the path is relative and has no parent reference
"""
return not name.startswith("/") and ".." not in name.split("/")
def is_process_running(pid_file: Path) -> bool:
"""Check if the process in a PID file is running
@param pid_file: path to the PID file
@return: True if a live process matches the PID
"""
if not pid_file.is_file():
return False
try:
pid = int(pid_file.read_text().strip())
except (OSError, ValueError):
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
# the process exists but belongs to another user
pass
return True