software package profiles implemeted, job deletion optimized

This commit is contained in:
2026-09-26 13:07:23 +02:00
parent 87a5041162
commit 7d314057e4
54 changed files with 3492 additions and 342 deletions
+630
View File
@@ -0,0 +1,630 @@
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")