from __future__ import annotations import configparser import hashlib import io import json import os import re import shutil import tempfile import urllib.parse import urllib.request import zipfile from pathlib import Path from typing import Any PROFILE_SCHEMA = "assetmanager-analyzer-profile-v1" PROFILE_BUNDLE_SCHEMA = "assetmanager-analyzer-profile-bundle-v1" PROFILE_REPOSITORY_SCHEMA = "assetmanager-analyzer-profile-repository-v1" PROFILE_API = 1 SYSTEM_PROFILE_DIR = Path(__file__).resolve().parent / "analyzer_profiles" / "system" DATA_ROOT = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data")) PROFILE_DATA_ROOT = Path(os.getenv("ANALYZER_PROFILE_DIR", str(DATA_ROOT / "analyzer-profiles"))) COMMUNITY_PROFILE_DIR = PROFILE_DATA_ROOT / "community" LOCAL_PROFILE_DIR = PROFILE_DATA_ROOT / "local" PROFILE_STATE_FILE = PROFILE_DATA_ROOT / "state.json" REPOSITORY_URL = os.getenv("ANALYZER_PROFILE_REPOSITORY_URL", "").strip() MAX_PROFILE_BYTES = max(64 * 1024, int(os.getenv("ANALYZER_PROFILE_MAX_BYTES", str(2 * 1024 * 1024)))) MAX_REPOSITORY_INDEX_BYTES = max(64 * 1024, int(os.getenv("ANALYZER_PROFILE_REPOSITORY_MAX_BYTES", str(4 * 1024 * 1024)))) for _directory in (COMMUNITY_PROFILE_DIR, LOCAL_PROFILE_DIR): _directory.mkdir(parents=True, exist_ok=True) def _safe_profile_id(value: str) -> str: value = str(value or "").strip().lower() if not re.fullmatch(r"[a-z0-9][a-z0-9._-]{1,95}", value): raise ValueError("invalid profile id") return value def _clean_text(value: Any, maximum: int = 500) -> str: return re.sub(r"[\x00-\x1f]+", " ", str(value or "")).strip()[:maximum] def _state() -> dict[str, Any]: try: data = json.loads(PROFILE_STATE_FILE.read_text(encoding="utf-8")) return data if isinstance(data, dict) else {} except Exception: return {} def _write_state(data: dict[str, Any]) -> None: PROFILE_DATA_ROOT.mkdir(parents=True, exist_ok=True) temporary = PROFILE_STATE_FILE.with_suffix(".tmp") temporary.write_text(json.dumps(data, ensure_ascii=True, indent=2) + "\n", encoding="utf-8") temporary.replace(PROFILE_STATE_FILE) def profile_enabled(profile_id: str) -> bool: return not bool((_state().get("disabled") or {}).get(profile_id)) def set_profile_enabled(profile_id: str, enabled: bool) -> None: profile_id = _safe_profile_id(profile_id) data = _state() disabled = data.setdefault("disabled", {}) if enabled: disabled.pop(profile_id, None) else: disabled[profile_id] = True _write_state(data) def validate_profile(raw: Any) -> dict[str, Any]: if not isinstance(raw, dict): raise ValueError("profile is not an object") profile = json.loads(json.dumps(raw)) if str(profile.get("schema") or "") != PROFILE_SCHEMA: raise ValueError("unsupported analyzer profile schema") if int(profile.get("profile_api") or 0) != PROFILE_API: raise ValueError("unsupported analyzer profile API") profile["id"] = _safe_profile_id(profile.get("id")) profile["name"] = _clean_text(profile.get("name"), 180) profile["version"] = _clean_text(profile.get("version") or "1.0.0", 40) if not profile["name"]: raise ValueError("profile name is required") stage = str(profile.get("stage") or "analysis").strip().lower() if stage not in {"marker", "analysis", "sfx_extracted"}: raise ValueError("invalid analyzer profile stage") profile["stage"] = stage kind = str(profile.get("kind") or "vendor").strip().lower() if kind not in {"technology", "vendor", "generic"}: raise ValueError("invalid analyzer profile kind") profile["kind"] = kind try: profile["priority"] = max(-10000, min(int(profile.get("priority") or 0), 10000)) except (TypeError, ValueError): profile["priority"] = 0 for key in ("match", "result", "command", "metadata"): if key in profile and not isinstance(profile[key], dict): raise ValueError(f"profile {key} must be an object") if stage == "marker": markers = (profile.get("match") or {}).get("markers") or [] if not isinstance(markers, list) or not markers: raise ValueError("marker profile requires match.markers") for marker in markers: if not isinstance(marker, dict) or not _clean_text(marker.get("text"), 1024): raise ValueError("invalid marker definition") return profile def _load_profile_file(path: Path, source: str) -> dict[str, Any] | None: try: if path.stat().st_size > MAX_PROFILE_BYTES: return None profile = validate_profile(json.loads(path.read_text(encoding="utf-8"))) profile["source"] = source profile["path"] = str(path) profile["enabled"] = profile_enabled(profile["id"]) return profile except Exception: return None def load_profiles(include_disabled: bool = False) -> list[dict[str, Any]]: profiles: dict[str, dict[str, Any]] = {} # System is the fallback. Community can override a system profile and local # profiles have highest precedence without modifying application files. for directory, source in ( (SYSTEM_PROFILE_DIR, "system"), (COMMUNITY_PROFILE_DIR, "community"), (LOCAL_PROFILE_DIR, "local"), ): if not directory.is_dir(): continue for path in sorted(directory.glob("*.json")): profile = _load_profile_file(path, source) if profile: profiles[profile["id"]] = profile result = list(profiles.values()) if not include_disabled: result = [profile for profile in result if profile.get("enabled", True)] result.sort(key=lambda item: (int(item.get("priority") or 0), item.get("name", "").casefold()), reverse=True) return result def marker_needles() -> dict[bytes, str]: result: dict[bytes, str] = {} for profile in load_profiles(): if profile.get("stage") != "marker": continue for marker in (profile.get("match") or {}).get("markers") or []: text = _clean_text(marker.get("text"), 1024).casefold() if not text: continue try: raw = text.encode("utf-8") except UnicodeEncodeError: continue result[raw] = text try: result[text.encode("utf-16le")] = text except UnicodeEncodeError: pass return result def detect_marker_profiles(found_markers: set[str]) -> list[dict[str, Any]]: candidates: list[dict[str, Any]] = [] for profile in load_profiles(): if profile.get("stage") != "marker": continue score = 0 signals: list[str] = [] matched: list[str] = [] for marker in (profile.get("match") or {}).get("markers") or []: text = _clean_text(marker.get("text"), 1024).casefold() if text and text in found_markers: try: score += int(marker.get("points") or 0) except (TypeError, ValueError): pass signal = _clean_text(marker.get("signal_key"), 180) if signal and signal not in signals: signals.append(signal) matched.append(text) if not score: continue result = profile.get("result") or {} floor = int(result.get("confidence_floor") or 55) candidates.append({ "key": str(result.get("installer_type") or profile["id"]), "label": str(result.get("installer_label") or profile["name"]), "confidence": max(floor, min(score, 99)), "signals": signals, "profile_id": profile["id"], "profile_name": profile["name"], "profile_version": profile["version"], "profile_source": profile["source"], "matched_rules": matched, }) candidates.sort(key=lambda item: int(item.get("confidence") or 0), reverse=True) return candidates def _profile_for_installer_type(installer_type: str) -> dict[str, Any] | None: installer_type = str(installer_type or "").strip().casefold() matches = [] for profile in load_profiles(): result = profile.get("result") or {} if str(result.get("installer_type") or "").strip().casefold() == installer_type: matches.append(profile) matches.sort(key=lambda item: int(item.get("priority") or 0), reverse=True) return matches[0] if matches else None def installer_type_flag(installer_type: str, key: str, default: bool = False) -> bool: profile = _profile_for_installer_type(installer_type) if not profile: return default result = profile.get("result") or {} if key in result: return bool(result.get(key)) return default def command_profile(installer_type: str) -> dict[str, Any]: profile = _profile_for_installer_type(installer_type) if not profile: return {} command = json.loads(json.dumps(profile.get("command") or {})) if command: command["profile_id"] = profile["id"] command["profile_name"] = profile["name"] command["profile_version"] = profile["version"] command["profile_source"] = profile["source"] return command def _identity(analysis: dict[str, Any], filename: str) -> str: return " ".join([ filename, str(analysis.get("product_name") or ""), str(analysis.get("manufacturer") or ""), str(analysis.get("installer_label") or ""), ]).casefold() def _matches_analysis(profile: dict[str, Any], analysis: dict[str, Any], filename: str, found_markers: set[str]) -> tuple[bool, list[str]]: match = profile.get("match") or {} reasons: list[str] = [] installer_types = [str(item).casefold() for item in match.get("installer_types") or []] if installer_types and str(analysis.get("installer_type") or "").casefold() not in installer_types: return False, [] if installer_types: reasons.append("installer_type") patterns = match.get("filename_regex") or [] if patterns: if not any(re.search(str(pattern), filename, flags=re.IGNORECASE) for pattern in patterns): return False, [] reasons.append("filename") identity = _identity(analysis, filename) all_values = [str(item).casefold() for item in match.get("identity_contains_all") or []] if any(value not in identity for value in all_values): return False, [] if all_values: reasons.append("identity_all") any_values = [str(item).casefold() for item in match.get("identity_contains_any") or []] if any_values and not any(value in identity for value in any_values): return False, [] if any_values: reasons.append("identity_any") marker_all = [str(item).casefold() for item in match.get("markers_all") or []] if any(value not in found_markers for value in marker_all): return False, [] marker_any = [str(item).casefold() for item in match.get("markers_any") or []] if marker_any and not any(value in found_markers for value in marker_any): return False, [] if marker_all or marker_any: reasons.append("markers") return True, reasons def _apply_result_overlay(analysis: dict[str, Any], profile: dict[str, Any]) -> dict[str, Any]: result = profile.get("result") or {} updated = dict(analysis) for key, value in result.get("set", {}).items(): updated[key] = value for key, value in result.get("set_if_empty", {}).items(): if not updated.get(key): updated[key] = value for key, values in result.get("append", {}).items(): current = updated.get(key) if not isinstance(current, list): current = [] for value in values if isinstance(values, list) else [values]: if value not in current: current.append(value) updated[key] = current updated["profile_id"] = profile["id"] updated["profile_name"] = profile["name"] updated["profile_version"] = profile["version"] updated["profile_source"] = profile["source"] return updated def apply_analysis_profiles(analysis: dict[str, Any], filename: str, found_markers: set[str]) -> dict[str, Any]: matches: list[tuple[int, dict[str, Any], list[str]]] = [] for profile in load_profiles(): if profile.get("stage") != "analysis": continue matched, reasons = _matches_analysis(profile, analysis, filename, found_markers) if matched: matches.append((int(profile.get("priority") or 0), profile, reasons)) matches.sort(key=lambda item: item[0], reverse=True) updated = dict(analysis) applied: list[dict[str, Any]] = [] for _priority, profile, reasons in matches: updated = _apply_result_overlay(updated, profile) command = profile.get("command") or {} if command: updated = apply_command_overlay(updated, command, filename) metadata = profile.get("metadata") or {} if metadata.get("filename_version_regex") and not updated.get("product_version"): match = re.search(str(metadata["filename_version_regex"]), filename, flags=re.IGNORECASE) if match: updated["product_version"] = match.groupdict().get("version") or (match.group(1) if match.groups() else "") applied.append({ "id": profile["id"], "name": profile["name"], "version": profile["version"], "source": profile["source"], "reasons": reasons, }) if applied: updated["applied_profiles"] = applied return updated def apply_command_overlay(analysis: dict[str, Any], command: dict[str, Any], filename: str) -> dict[str, Any]: updated = dict(analysis) arguments = str(command.get("install_arguments") or "").strip() if arguments: updated["install_arguments"] = arguments updated["install_command"] = f'"{filename}" {arguments}'.strip() alternative = str(command.get("alternative_install_arguments") or "").strip() if alternative: updated["alternative_install_arguments"] = alternative updated["alternative_install_command"] = f'"{filename}" {alternative}'.strip() for key in ("success_codes", "reboot_codes", "detect_method", "command_confidence", "uninstall_command"): if key in command: updated[key] = command[key] for warning in command.get("warning_keys") or []: warnings = updated.setdefault("warning_keys", []) if warning not in warnings: warnings.append(warning) return updated def _read_relaxed_ini(path: Path) -> dict[str, dict[str, str]]: data = path.read_bytes() text = "" for encoding in ("utf-8-sig", "cp1252", "latin-1"): try: text = data.decode(encoding) break except UnicodeDecodeError: continue sections: dict[str, dict[str, str]] = {} current = "" for raw in text.splitlines(): line = raw.strip() if not line or line.startswith((";", "#", "//")): continue if line.startswith("[") and line.endswith("]"): current = line[1:-1].strip().casefold() sections.setdefault(current, {}) continue if current and "=" in line: key, value = line.split("=", 1) sections[current][key.strip().casefold()] = value.strip() return sections def _ini_value(sections: dict[str, dict[str, str]], spec: dict[str, Any]) -> str: section = str(spec.get("section") or "").casefold() key = str(spec.get("key") or "").casefold() return str((sections.get(section) or {}).get(key) or "").strip() def _find_glob(root: Path, pattern: str) -> list[Path]: pattern = str(pattern or "").strip() if not pattern: return [] return sorted([item for item in root.rglob(pattern) if item.is_file()], key=lambda p: (len(p.relative_to(root).parts), p.as_posix().casefold())) def _matches_sfx_profile(profile: dict[str, Any], root: Path) -> tuple[bool, dict[str, Any], list[str]]: match = profile.get("match") or {} reasons: list[str] = [] for pattern in match.get("extracted_files_all") or []: if not _find_glob(root, str(pattern)): return False, {}, [] reasons.append(f"file:{pattern}") any_patterns = match.get("extracted_files_any") or [] if any_patterns and not any(_find_glob(root, str(pattern)) for pattern in any_patterns): return False, {}, [] if any_patterns: reasons.append("extracted_file_any") ini_spec = match.get("ini") or {} context: dict[str, Any] = {} if ini_spec: files = _find_glob(root, str(ini_spec.get("glob") or "")) if not files: return False, {}, [] ini_path = files[0] sections = _read_relaxed_ini(ini_path) for condition in ini_spec.get("conditions_all") or []: value = _ini_value(sections, condition).casefold() contains = [str(item).casefold() for item in condition.get("contains_any") or []] regex = str(condition.get("regex") or "") if contains and not any(item in value for item in contains): return False, {}, [] if regex and not re.search(regex, value, flags=re.IGNORECASE): return False, {}, [] context = {"ini_path": ini_path, "ini": sections} reasons.append(f"ini:{ini_path.relative_to(root).as_posix()}") return True, context, reasons def _metadata_from_sfx(profile: dict[str, Any], context: dict[str, Any], outer_path: Path) -> dict[str, Any]: metadata = profile.get("metadata") or {} sections = context.get("ini") or {} result: dict[str, Any] = {} for target, source in (metadata.get("ini_fields") or {}).items(): specs = source if isinstance(source, list) else [source] for spec in specs: value = _ini_value(sections, spec) if value: result[target] = value break for target, value in (metadata.get("fixed") or {}).items(): if not result.get(target): result[target] = value arch = metadata.get("architecture_from") or {} if arch: value = _ini_value(sections, arch) for item in arch.get("patterns") or []: if re.search(str(item.get("regex") or ""), value, flags=re.IGNORECASE): result["architecture"] = str(item.get("value") or "") result["architecture_source"] = str(metadata.get("architecture_source") or "profile_metadata") break process = metadata.get("process_name_from") or {} if process: value = _ini_value(sections, process) if value: result["process_names_default"] = value version_regex = str(metadata.get("filename_version_regex") or "") if version_regex and not result.get("product_version"): match = re.search(version_regex, outer_path.name, flags=re.IGNORECASE) if match: result["product_version"] = match.groupdict().get("version") or (match.group(1) if match.groups() else "") return result def match_sfx_profiles(root: Path, outer_path: Path, base: dict[str, Any]) -> dict[str, Any] | None: matches: list[tuple[int, dict[str, Any], dict[str, Any], list[str]]] = [] for profile in load_profiles(): if profile.get("stage") != "sfx_extracted": continue matched, context, reasons = _matches_sfx_profile(profile, root) if matched: matches.append((int(profile.get("priority") or 0), profile, context, reasons)) if not matches: return None matches.sort(key=lambda item: item[0], reverse=True) _priority, profile, context, reasons = matches[0] result = dict(base) profile_result = profile.get("result") or {} result.update(profile_result.get("set") or {}) result.update(_metadata_from_sfx(profile, context, outer_path)) command = profile.get("command") or {} result = apply_command_overlay(result, command, outer_path.name) result["profile_id"] = profile["id"] result["profile_name"] = profile["name"] result["profile_version"] = profile["version"] result["profile_source"] = profile["source"] result["applied_profiles"] = [{ "id": profile["id"], "name": profile["name"], "version": profile["version"], "source": profile["source"], "reasons": reasons, }] ini_path = context.get("ini_path") if ini_path: result["metadata_file"] = ini_path.relative_to(root).as_posix() return result def apply_profile(profile_id: str, analysis: dict[str, Any], filename: str) -> dict[str, Any]: profile = get_profile(profile_id) if not profile or not profile.get("enabled", True): return dict(analysis) updated = _apply_result_overlay(analysis, profile) command = profile.get("command") or {} if command: updated = apply_command_overlay(updated, command, filename) updated["applied_profiles"] = [{ "id": profile["id"], "name": profile["name"], "version": profile["version"], "source": profile["source"], "reasons": ["marker_profile"], }] return updated def _profile_path(profile_id: str, source: str) -> Path: profile_id = _safe_profile_id(profile_id) if source == "system": return SYSTEM_PROFILE_DIR / f"{profile_id}.json" if source == "community": return COMMUNITY_PROFILE_DIR / f"{profile_id}.json" if source == "local": return LOCAL_PROFILE_DIR / f"{profile_id}.json" raise ValueError("invalid profile source") def get_profile(profile_id: str) -> dict[str, Any] | None: for profile in load_profiles(include_disabled=True): if profile.get("id") == profile_id: return profile return None def export_profile_bundle(profile_id: str) -> bytes: profile = get_profile(_safe_profile_id(profile_id)) if not profile: raise FileNotFoundError("profile not found") public = {key: value for key, value in profile.items() if key not in {"source", "path", "enabled"}} profile_bytes = (json.dumps(public, ensure_ascii=True, indent=2) + "\n").encode("utf-8") manifest = { "schema": PROFILE_BUNDLE_SCHEMA, "bundle_version": 1, "profile_id": profile["id"], "profile_version": profile["version"], "profile_api": PROFILE_API, "sha256": hashlib.sha256(profile_bytes).hexdigest(), } stream = io.BytesIO() with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED) as archive: archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=True, indent=2) + "\n") archive.writestr("profile.json", profile_bytes) stream.seek(0) return stream.read() def import_profile_bundle(data: bytes, source: str = "community") -> dict[str, Any]: if source not in {"community", "local"}: raise ValueError("invalid import source") if not data or len(data) > MAX_PROFILE_BYTES: raise ValueError("profile bundle is empty or too large") with zipfile.ZipFile(io.BytesIO(data), "r") as archive: names = archive.namelist() if any(name.startswith(("/", "\\")) or ".." in Path(name).parts for name in names): raise ValueError("unsafe profile bundle path") if "manifest.json" not in names or "profile.json" not in names: raise ValueError("profile bundle is incomplete") manifest = json.loads(archive.read("manifest.json")) if str(manifest.get("schema") or "") != PROFILE_BUNDLE_SCHEMA: raise ValueError("unsupported profile bundle") profile_bytes = archive.read("profile.json") if hashlib.sha256(profile_bytes).hexdigest() != str(manifest.get("sha256") or ""): raise ValueError("profile checksum mismatch") profile = validate_profile(json.loads(profile_bytes)) if profile["id"] != str(manifest.get("profile_id") or ""): raise ValueError("profile id mismatch") path = _profile_path(profile["id"], source) temporary = path.with_suffix(".tmp") temporary.write_text(json.dumps(profile, ensure_ascii=True, indent=2) + "\n", encoding="utf-8") temporary.replace(path) set_profile_enabled(profile["id"], True) return get_profile(profile["id"]) or profile def delete_imported_profile(profile_id: str) -> bool: profile = get_profile(_safe_profile_id(profile_id)) if not profile or profile.get("source") == "system": return False path = Path(str(profile.get("path") or "")) if path.is_file(): path.unlink() return True def repository_index(url: str | None = None) -> dict[str, Any]: url = str(url or REPOSITORY_URL).strip() if not url: return {"configured": False, "url": "", "profiles": []} parsed = urllib.parse.urlparse(url) if parsed.scheme != "https": raise ValueError("profile repository URL must use HTTPS") request = urllib.request.Request(url, headers={"User-Agent": "AssetManager-AnalyzerProfiles/1"}) with urllib.request.urlopen(request, timeout=12) as response: data = response.read(MAX_REPOSITORY_INDEX_BYTES + 1) if len(data) > MAX_REPOSITORY_INDEX_BYTES: raise ValueError("profile repository index is too large") index = json.loads(data.decode("utf-8")) if not isinstance(index, dict) or str(index.get("schema") or "") != PROFILE_REPOSITORY_SCHEMA: raise ValueError("unsupported profile repository index") profiles = index.get("profiles") or [] if not isinstance(profiles, list): raise ValueError("invalid profile repository index") return {"configured": True, "url": url, "profiles": profiles, "name": _clean_text(index.get("name"), 180)} def install_repository_profile(profile_id: str, url: str | None = None) -> dict[str, Any]: profile_id = _safe_profile_id(profile_id) index = repository_index(url) entry = next((item for item in index.get("profiles") or [] if str(item.get("id") or "") == profile_id), None) if not entry: raise FileNotFoundError("profile is not present in repository") bundle_url = str(entry.get("url") or "").strip() parsed = urllib.parse.urlparse(bundle_url) if parsed.scheme != "https": raise ValueError("repository profile URL must use HTTPS") request = urllib.request.Request(bundle_url, headers={"User-Agent": "AssetManager-AnalyzerProfiles/1"}) with urllib.request.urlopen(request, timeout=20) as response: data = response.read(MAX_PROFILE_BYTES + 1) if len(data) > MAX_PROFILE_BYTES: raise ValueError("repository profile is too large") expected = str(entry.get("sha256") or "").strip().lower() if expected and hashlib.sha256(data).hexdigest() != expected: raise ValueError("repository profile checksum mismatch") return import_profile_bundle(data, source="community")