Path : /opt/imunify360/venv/lib64/python3.11/site-packages/defence360agent/internals/
File Upload :
Current File : //opt/imunify360/venv/lib64/python3.11/site-packages/defence360agent/internals/feature_flags.py

"""
Shared reader for the local feature flags file.

The file is written by:
- Go resident-agent FeatureFlags plugin (IM360 mode)
- Python FeatureFlagsSync plugin (AV mode)

Other subsystems (e.g. message_status_publisher) use this module
to check individual flag values at runtime.

Supported JSON shapes on disk (readers / ``is_enabled``):
- Legacy object ``{"mqtt_tracking": true, ...}`` (preferred on disk after sync).
- JSON array of enabled names ``["mqtt_tracking"]`` (still accepted).
- Legacy wrapper ``{"flags": ["mqtt_tracking", ...]}``.

The sync API checksum is always over a **canonical JSON array** of enabled
names (same as correlation sync). ``sync_checksum_hex_from_flags_file`` derives
that MD5 from whatever shape is on disk.

The sync plugin also writes ``FLAGS_PLAIN_PATH`` (``/var/imunify360/feature_flags``):
plain text, one enabled flag name per line (sorted), for scripts.
"""

from __future__ import annotations

import hashlib
import json
import os
from typing import Any

FLAGS_PATH = "/var/imunify360/feature_flags.json"
# Plain list of enabled flag names (one per line), same order as sorted JSON array.
FLAGS_PLAIN_PATH = "/var/imunify360/feature_flags"

_cached_flags: dict[str, Any] = {}
_cached_mtime: float = 0.0


def _normalize_flags_from_file(raw: Any) -> dict[str, Any]:
    """Map file JSON to a flat name->value dict for :func:`is_enabled`."""
    if raw is None:
        return {}
    if isinstance(raw, list):
        out: dict[str, Any] = {}
        for item in raw:
            if isinstance(item, str):
                out[item] = True
        return out
    if isinstance(raw, dict):
        inner = raw.get("flags")
        if isinstance(inner, list):
            return _normalize_flags_from_file(inner)
        return raw
    return {}


def _read_flags() -> dict[str, Any]:
    global _cached_flags, _cached_mtime
    try:
        mtime = os.path.getmtime(FLAGS_PATH)
    except OSError:
        _cached_flags = {}
        _cached_mtime = 0.0
        return _cached_flags

    if mtime == _cached_mtime:
        return _cached_flags

    try:
        with open(FLAGS_PATH) as f:
            raw = json.load(f)
        _cached_flags = _normalize_flags_from_file(raw)
    except (OSError, json.JSONDecodeError):
        _cached_flags = {}
    _cached_mtime = mtime
    return _cached_flags


def enabled_flag_names_sorted(flags: Any) -> list[str]:
    """Return sorted enabled flag names for JSON and plain-text sidecar.

    Accepts the same shapes as :func:`_normalize_flags_from_file` (array,
    flat map, ``{"flags": [...]}``) so checksums and sidecars match Go
    ``enabledNamesSortedForChecksum`` / :func:`is_enabled`.
    """
    if not isinstance(flags, (list, dict)):
        raise TypeError(
            f"flags must be list or dict, not {type(flags).__name__}"
        )
    normalized = _normalize_flags_from_file(flags)
    return sorted(k for k, v in normalized.items() if v)


def canonical_sync_flag_list_bytes(names: list[str]) -> bytes:
    """JSON array bytes used for sync MD5 (matches correlation_api checksum_for_sync_flag_list)."""
    ordered = sorted(names)
    return json.dumps(ordered, sort_keys=True, indent=2).encode()


def sync_checksum_hex_from_flags_file(path: str) -> str:
    """MD5 hex of canonical enabled-name array for ``path``, or "" if missing/invalid."""
    try:
        with open(path, encoding="utf-8") as f:
            raw = json.load(f)
    except (OSError, UnicodeDecodeError, json.JSONDecodeError):
        return ""
    names = enabled_flag_names_sorted(raw)
    payload = canonical_sync_flag_list_bytes(names)
    return hashlib.md5(payload, usedforsecurity=False).hexdigest()


def legacy_feature_flags_map_bytes(names: list[str]) -> bytes:
    """On-disk legacy JSON: ``{flag: true, ...}`` with sorted keys."""
    d = {n: True for n in sorted({x for x in names if isinstance(x, str)})}
    return json.dumps(d, sort_keys=True, indent=2).encode()


def plain_text_payload_for_enabled_flags(flags: Any) -> bytes:
    """Body for ``FLAGS_PLAIN_PATH``: one name per line, trailing newline if non-empty."""
    names = enabled_flag_names_sorted(flags)
    if not names:
        return b""
    return ("\n".join(names) + "\n").encode()


def serialize_feature_flags_file_payload(flags: Any) -> bytes:
    """Serialize dict flags for writing ``FLAGS_PATH`` (legacy map only)."""
    if isinstance(flags, dict):
        return json.dumps(flags, sort_keys=True, indent=2).encode()
    raise TypeError(f"flags must be dict, not {type(flags).__name__}")


def is_enabled(flag_name: str, default: bool = False) -> bool:
    """Return whether *flag_name* is enabled.

    If the file is missing, unreadable, or the flag is absent,
    *default* is returned. Defaults to False so unknown flags are
    treated as disabled unless the caller explicitly opts in.
    """
    flags = _read_flags()
    value = flags.get(flag_name)
    if value is None:
        return default
    return bool(value)