#!/usr/bin/env python3 """Joomla 3.x manifest -> deployed-path mapper. Generikus baseline-generátor komponens: egy kicsomagolt Joomla-bővítmény- csomagot (manifest-XML + a hozzá tartozó forrásfák) a Joomla-installer mapping-szabályai szerint másolja a megfelelő deployed docroot-útvonalakra. Támogatott ```` gyökér-típusok: component, module, plugin, library, template, language, file, package (pkg_ csomag, rekurzívan feldolgozott al-bővítményekkel). A ``folder`` attribútum (````, ````, ````) a manifest-fájl könyvtárához relatív forrás- alkönyvtárat jelöli; ha nincs megadva, a manifest melletti könyvtár a forrás. Csak a Python standard library-t használja (nincs külső dependency). """ from __future__ import annotations import argparse import shutil import sys import xml.etree.ElementTree as ET from collections.abc import Callable from dataclasses import dataclass from pathlib import Path class JoomlaManifestError(Exception): """A manifest-feldolgozás vagy a mapping-szabályok megsértése esetén dobott hiba. Minden hibás/hiányzó manifest-tartalom, ismeretlen extension-típus vagy hiányzó forrás-fájl ezt dobja — a hívó CLI ezt explicit elkapja és érthető hibaüzenettel, nemnulla exit-kóddal fejezi be a futást. """ @dataclass(frozen=True) class CopyOp: """Egyetlen másolási művelet: forrás -> cél. A src lehet fájl vagy könyvtár.""" src: Path dest: Path # --------------------------------------------------------------------------- # XML-segédfüggvények # --------------------------------------------------------------------------- def _text(elem: ET.Element | None) -> str: """Egy elem trimmelt szöveg-tartalma, vagy üres string, ha nincs elem/szöveg.""" if elem is None or elem.text is None: return "" return elem.text.strip() def _slug(text: str) -> str: """Emberi-olvasható nevet gépi slug-gá normalizál (kisbetű, szóköz -> '_').""" normalized = text.strip().lower() return "_".join(normalized.split()) def _extension_name(root: ET.Element) -> str: """A bővítmény gépi-nevét adja vissza a gyökér ``element`` attribútumból, vagy ennek hiányában a ```` elem szövegéből (slugolva). """ element_attr = root.get("element") if element_attr: return element_attr.strip() name_text = _text(root.find("name")) if not name_text: raise JoomlaManifestError( "nem határozható meg a bővítmény neve: hiányzik a elem és az 'element' attribútum" ) return _slug(name_text) def _plugin_element(root: ET.Element) -> str: """A plugin element-slugját adja vissza. Elsődleges forrás: ```` attribútum (ez a Joomla-konvenció szerinti autoritatív érték). Ha ez hiányzik, a ``PLG_GROUP_ELEMENT`` konvenció utolsó '_'-tagját használja fallback-ként. """ files_elem = root.find("files") if files_elem is not None: for filename_elem in files_elem.findall("filename"): plugin_attr = filename_elem.get("plugin") if plugin_attr: return plugin_attr.strip() name_text = _text(root.find("name")) if name_text: return _slug(name_text.rsplit("_", 1)[-1]) raise JoomlaManifestError( 'nem határozható meg a plugin element-je: nincs és nincs ' ) # --------------------------------------------------------------------------- # / / blokk -> CopyOp lista # --------------------------------------------------------------------------- def _apply_files(files_elem: ET.Element | None, manifest_dir: Path, dest_dir: Path) -> list[CopyOp]: """Egy ````-szerű blokk (files/media) gyerekeit (filename/folder) CopyOp-listává alakítja. A ``folder`` attribútum a forrás-alkönyvtárat jelöli a manifest könyvtárához képest; a ``target`` attribútum (ha van) felülírja a cél-relatív-útvonalat. """ if files_elem is None: return [] folder_attr = files_elem.get("folder", "") src_base = manifest_dir / folder_attr if folder_attr else manifest_dir ops: list[CopyOp] = [] for child in files_elem: if child.tag not in ("filename", "folder"): continue rel_text = _text(child) if not rel_text: raise JoomlaManifestError( f"<{child.tag}> elem üres útvonal-szöveggel a(z) {manifest_dir} manifest-ben" ) rel_path = Path(rel_text) target_attr = child.get("target") dest_rel = Path(target_attr) if target_attr else rel_path ops.append(CopyOp(src=src_base / rel_path, dest=dest_dir / dest_rel)) return ops def _apply_media(media_elem: ET.Element | None, manifest_dir: Path, media_root: Path) -> list[CopyOp]: """```` blokk -> ``media//`` alá.""" if media_elem is None: return [] destination = media_elem.get("destination") if not destination: raise JoomlaManifestError( f" elem hiányzó 'destination' attribútummal a(z) {manifest_dir} manifest-ben" ) return _apply_files(media_elem, manifest_dir, media_root / destination) def _apply_languages(languages_elem: ET.Element | None, manifest_dir: Path, lang_root: Path) -> list[CopyOp]: """```` blokk -> ``//``. A gyerek ``relatív/út.ini`` elemek szövege a forrás-relatív útvonal; a cél a tag-alkönyvtárban a fájl alap-neve (a Joomla-konvenció szerint a forrás már tag-alkönyvtárban van). """ if languages_elem is None: return [] folder_attr = languages_elem.get("folder", "") src_base = manifest_dir / folder_attr if folder_attr else manifest_dir ops: list[CopyOp] = [] for language_elem in languages_elem.findall("language"): tag = language_elem.get("tag") if not tag: raise JoomlaManifestError( f" elem hiányzó 'tag' attribútummal a(z) {manifest_dir} manifest-ben" ) rel_text = _text(language_elem) if not rel_text: raise JoomlaManifestError( f' elem üres fájl-útvonallal a(z) {manifest_dir} manifest-ben' ) rel_path = Path(rel_text) ops.append(CopyOp(src=src_base / rel_path, dest=lang_root / tag / rel_path.name)) return ops # --------------------------------------------------------------------------- # Extension-típus -> mapping-szabály # --------------------------------------------------------------------------- def map_component(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]: """com_X: files->components/com_X, administration/files->administrator/components/com_X, media->media/com_X, languages(site+admin)->language/ ill. administrator/language/. """ name = _extension_name(root) ops = _apply_files(root.find("files"), manifest_dir, out_root / "components" / name) admin_elem = root.find("administration") if admin_elem is not None: ops += _apply_files( admin_elem.find("files"), manifest_dir, out_root / "administrator" / "components" / name ) ops += _apply_languages( admin_elem.find("languages"), manifest_dir, out_root / "administrator" / "language" ) ops += _apply_media(root.find("media"), manifest_dir, out_root / "media") ops += _apply_languages(root.find("languages"), manifest_dir, out_root / "language") return ops def map_module(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]: """mod_X: files->modules/mod_X (vagy administrator/modules/mod_X ha client=administrator).""" name = _extension_name(root) is_admin = root.get("client") == "administrator" base = out_root / ("administrator/modules" if is_admin else "modules") / name ops = _apply_files(root.find("files"), manifest_dir, base) ops += _apply_media(root.find("media"), manifest_dir, out_root / "media") lang_root = out_root / ("administrator/language" if is_admin else "language") ops += _apply_languages(root.find("languages"), manifest_dir, lang_root) return ops def map_plugin(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]: """plugin: files->plugins//.""" group = root.get("group") if not group: raise JoomlaManifestError(f"plugin manifest hiányzó 'group' attribútummal: {manifest_dir}") element = _plugin_element(root) base = out_root / "plugins" / group / element ops = _apply_files(root.find("files"), manifest_dir, base) ops += _apply_media(root.find("media"), manifest_dir, out_root / "media") ops += _apply_languages(root.find("languages"), manifest_dir, out_root / "language") return ops def map_library(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]: """library: files->libraries/.""" name = _extension_name(root) ops = _apply_files(root.find("files"), manifest_dir, out_root / "libraries" / name) ops += _apply_media(root.find("media"), manifest_dir, out_root / "media") return ops def map_template(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]: """template: files->templates/ (vagy administrator/templates/).""" name = _extension_name(root) is_admin = root.get("client") == "administrator" base = out_root / ("administrator/templates" if is_admin else "templates") / name ops = _apply_files(root.find("files"), manifest_dir, base) ops += _apply_media(root.find("media"), manifest_dir, out_root / "media") return ops def map_language(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]: """language (nyelvi csomag): files -> language/ (vagy administrator/language/).""" tag = _text(root.find("tag")) or _slug(_text(root.find("name"))) if not tag: raise JoomlaManifestError(f"language manifest hiányzó és elemmel: {manifest_dir}") is_admin = root.get("client") == "administrator" lang_root = out_root / ("administrator/language" if is_admin else "language") / tag return _apply_files(root.find("files"), manifest_dir, lang_root) def map_file(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]: """type=file: a gyerekek target-attribútuma (vagy a relatív útvonaluk) közvetlenül a docroot gyökeréhez képest értelmezendő. """ return _apply_files(root.find("files"), manifest_dir, out_root) def map_package(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]: """pkg_ csomag: a bejegyzések al-csomag alkönyvtárakra mutatnak; mindegyiket a saját manifestjével rekurzívan dolgozza fel. """ files_elem = root.find("files") if files_elem is None: raise JoomlaManifestError(f"package manifest hiányzó elemmel: {manifest_dir}") folder_attr = files_elem.get("folder", "") src_base = manifest_dir / folder_attr if folder_attr else manifest_dir ops: list[CopyOp] = [] for file_elem in sorted(files_elem.findall("file"), key=lambda e: (_text(e), e.get("id", ""))): rel_text = _text(file_elem) if not rel_text: raise JoomlaManifestError(f" al-csomag bejegyzés üres útvonal-szöveggel: {manifest_dir}") sub_dir = src_base / rel_text if not sub_dir.is_dir(): raise JoomlaManifestError( f"az al-csomag könyvtára nem található: {sub_dir} (package manifest: {manifest_dir})" ) sub_manifest = find_manifest(sub_dir) ops += build_operations(sub_manifest, out_root) return ops TypeHandler = Callable[[ET.Element, Path, Path], list[CopyOp]] TYPE_HANDLERS: dict[str, TypeHandler] = { "component": map_component, "module": map_module, "plugin": map_plugin, "library": map_library, "template": map_template, "language": map_language, "file": map_file, "package": map_package, } # --------------------------------------------------------------------------- # Manifest-keresés + dispatch + végrehajtás # --------------------------------------------------------------------------- def _is_extension_manifest(path: Path) -> bool: """True, ha a path egy jólformált XML, aminek gyökér-eleme .""" try: # bandit B314: stdlib ElementTree XXE-re elméletileg sebezhető; a bemenet # egy lokálisan kicsomagolt, adminisztrátor-szolgáltatta bővítmény-csomag # (nem távoli/attacker-controlled forrás), és a feladat-kontraktus # kifejezetten csak stdlib-et enged (defusedxml külső dependency lenne # -> PO-jóváhagyás szükséges, jelen scope-ban nincs) — lásd záró-riport. root = ET.parse(path).getroot() # nosec B314 except ET.ParseError: return False except OSError: return False return root.tag == "extension" def find_manifest(directory: Path) -> Path: """A directory közvetlen szintjén (nem rekurzívan) megkeresi az egyetlen Joomla extension-manifestet ( gyökér-elemű XML-fájl). Raises: JoomlaManifestError: ha egy sincs, vagy ha egynél több található (ez utóbbi esetben a mapping nem egyértelmű, a hívónak szét kell választania a csomagot). """ candidates = sorted( (p for p in directory.glob("*.xml") if _is_extension_manifest(p)), key=lambda p: p.name, ) if not candidates: raise JoomlaManifestError(f"nem található Joomla extension-manifest itt: {directory}") if len(candidates) > 1: names = ", ".join(str(c) for c in candidates) raise JoomlaManifestError( f"több lehetséges manifest is található itt: {directory} ({names}); " "egy csomag-könyvtárban pontosan egy gyökér-manifest várt" ) return candidates[0] def build_operations(manifest_path: Path, out_root: Path) -> list[CopyOp]: """A manifest-path-hoz tartozó extension teljes CopyOp-listáját építi fel (a saját mapping-szabálya szerint; package-típus esetén rekurzívan az al-bővítményekével együtt). """ try: tree = ET.parse(manifest_path) # nosec B314 -- lásd _is_extension_manifest WHY-kommentje except ET.ParseError as exc: raise JoomlaManifestError(f"hibás XML a manifest-ben: {manifest_path} ({exc})") from exc root = tree.getroot() if root.tag != "extension": raise JoomlaManifestError(f"{manifest_path}: a gyökér-elem <{root.tag}>, elvárt: ") ext_type = root.get("type") if not ext_type: raise JoomlaManifestError(f"{manifest_path}: hiányzó 'type' attribútummal") handler = TYPE_HANDLERS.get(ext_type) if handler is None: supported = ", ".join(sorted(TYPE_HANDLERS)) raise JoomlaManifestError( f"{manifest_path}: nem támogatott extension type '{ext_type}' (támogatott: {supported})" ) return handler(root, manifest_path.parent, out_root) def _copytree_merge(src: Path, dest: Path) -> None: """src könyvtárfát dest alá másolja rendezett bejárással, meglévő dest-et megtartva/bővítve.""" dest.mkdir(parents=True, exist_ok=True) for entry in sorted(src.iterdir(), key=lambda p: p.name): target = dest / entry.name if entry.is_dir(): _copytree_merge(entry, target) else: shutil.copy2(entry, target) def execute(ops: list[CopyOp]) -> None: """A CopyOp-listát ténylegesen végrehajtja: fájlokat/könyvtárfákat másol. Raises: JoomlaManifestError: ha egy forrás-útvonal nem létezik. """ for op in ops: if not op.src.exists(): raise JoomlaManifestError(f"a forrás-útvonal nem létezik: {op.src}") if op.src.is_dir(): _copytree_merge(op.src, op.dest) else: op.dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(op.src, op.dest) # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main(argv: list[str] | None = None) -> int: """CLI belépőpont: ``python3 jmap.py --package --out ``.""" parser = argparse.ArgumentParser( description=( "Joomla 3.x manifest-alapú bővítmény-deployer: kicsomagolt bővítmény-csomagot " "másol a megfelelő deployed docroot-útvonalakra a Joomla-installer mapping-szabályai szerint." ) ) parser.add_argument( "--package", required=True, type=Path, help="kicsomagolt bővítmény-csomag gyökér-könyvtára" ) parser.add_argument("--out", required=True, type=Path, help="cél deployed docroot könyvtár") args = parser.parse_args(argv) package_dir: Path = args.package out_dir: Path = args.out if not package_dir.is_dir(): parser.error(f"--package nem létező könyvtár: {package_dir}") try: manifest_path = find_manifest(package_dir) ops = build_operations(manifest_path, out_dir) execute(ops) except JoomlaManifestError as exc: print(f"hiba: {exc}", file=sys.stderr) return 1 print(f"kész: {len(ops)} másolási művelet végrehajtva -> {out_dir}") return 0 if __name__ == "__main__": raise SystemExit(main())