software package profiles implemeted, job deletion optimized
This commit is contained in:
Executable
+630
@@ -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")
|
||||
Reference in New Issue
Block a user