# Copyright (c) Alibaba, Inc. and its affiliates.
"""Regression test for Issue #1672.

Pins the `trust_remote_code` gate that prevents `Config.from_file` and
`check_trust_remote_code_for_config` from executing a `.py` config from an
untrusted model repository.

Coverage matrix
---------------
The helper / choke point exercised here is the only mechanism guarding every
sink listed in the issue:

  * via `Config.from_file('.py')`:
    - `SalientDetection`            (models/cv/salient_detection)
    - `AbnormalDetectionModel`      (models/cv/abnormal_object_detection)
    - `DetectionModel`              (models/cv/object_detection)
    - `ObjectDetection3DPipeline`   (pipelines/cv/object_detection_3d_pipeline)

  * via `check_trust_remote_code_for_config(...)` invoked before
    `mmcv.Config.fromfile` / `mmseg.apis.init_segmentor`:
    - `ClassificationModel`                     (image_classification/mmcls_model)
    - `SwinLPanopticSegmentation`               (image_panoptic_segmentation)
    - `SemanticSegmentation`                    (image_semantic_segmentation)
    - `ScrfdDetect` / `TinyMogDetect` / `DamoFdDetect`  (face_detection/scrfd)
    - `ImageClassificationMmcvPreprocessor`     (preprocessors/cv)
    - `SegformerDetector`                       (controllable_image_generation)
"""
import os
import shutil
import sys
import tempfile
import unittest
from unittest import mock

from modelscope.utils.config import Config, check_trust_remote_code_for_config


def _make_repo(root, owner, name):
    """`check_model_from_owner_group` reads the owner from the parent dir."""
    repo = os.path.join(root, owner, name)
    os.makedirs(repo)
    return repo


def _write(path, content):
    with open(path, 'w') as f:
        f.write(content)


# Canary: any caller that fails the gate must NEVER execute this body.
_CANARY = ('raise SystemExit("this script must not run")\n'
           'CANARY = "should not be reachable"\n')


class ConfigChokePointSecurityTest(unittest.TestCase):
    """End-to-end gating at `Config.from_file` for `.py` configs.

    Covers SalientDetection / AbnormalDetectionModel / DetectionModel /
    ObjectDetection3DPipeline, all of which call `Config.from_file(*.py,
    trust_remote_code=self.trust_remote_code)`.
    """

    def setUp(self):
        self.tmp_root = tempfile.mkdtemp(prefix='ms_cfg_security_')
        self.untrusted_dir = _make_repo(self.tmp_root, 'attacker', 'badrepo')
        self.trusted_dir = _make_repo(self.tmp_root, 'damo', 'goodrepo')

        self.untrusted_py = os.path.join(self.untrusted_dir, 'mmcv_config.py')
        self.trusted_py = os.path.join(self.trusted_dir, 'mmcv_config.py')
        for p in (self.untrusted_py, self.trusted_py):
            _write(p, _CANARY)

        self.untrusted_json = os.path.join(self.untrusted_dir,
                                           'configuration.json')
        _write(self.untrusted_json, '{"a": 1}')

    def tearDown(self):
        shutil.rmtree(self.tmp_root, ignore_errors=True)

    def test_untrusted_py_without_optin_is_blocked(self):
        with self.assertRaises(RuntimeError):
            Config.from_file(self.untrusted_py)

    def test_untrusted_py_with_optin_is_allowed(self):
        # opt-in must let the load proceed, which means the canary will run
        # and raise SystemExit -- proving the gate did not block.
        with self.assertRaises(SystemExit):
            Config.from_file(self.untrusted_py, trust_remote_code=True)

    def test_trusted_owner_py_is_allowed_without_optin(self):
        with self.assertRaises(SystemExit):
            Config.from_file(self.trusted_py)

    def test_json_passive_load_unaffected(self):
        cfg = Config.from_file(self.untrusted_json)
        self.assertEqual(cfg.a, 1)

    def test_from_string_in_process_is_not_gated(self):
        # `Config.from_string` materializes a caller-supplied in-process
        # string and must keep working without `trust_remote_code`.
        cfg = Config.from_string('a = 42\n', '.py')
        self.assertEqual(cfg.a, 42)

    def test_explicit_model_dir_overrides_default(self):
        # Some callers pass `model_dir` explicitly; verify trust derives
        # from that path, not from the file's parent directory.
        nested = os.path.join(self.untrusted_dir, 'subdir')
        os.makedirs(nested)
        nested_py = os.path.join(nested, 'mmcv_config.py')
        _write(nested_py, _CANARY)
        with self.assertRaises(RuntimeError):
            Config.from_file(nested_py, model_dir=self.untrusted_dir)
        # Same file, but with a trusted model_dir override -> allowed.
        with self.assertRaises(SystemExit):
            Config.from_file(nested_py, model_dir=self.trusted_dir)


class CheckTrustRemoteCodeForConfigTest(unittest.TestCase):
    """Unit tests for the helper invoked at every mmcv / init_segmentor sink.

    Covers ClassificationModel, SwinLPanopticSegmentation, SemanticSegmentation,
    ScrfdDetect (and subclasses TinyMogDetect / DamoFdDetect),
    ImageClassificationMmcvPreprocessor, and SegformerDetector.
    """

    def setUp(self):
        self.tmp_root = tempfile.mkdtemp(prefix='ms_cfg_security_helper_')
        self.untrusted_dir = _make_repo(self.tmp_root, 'attacker', 'badrepo')
        self.trusted_dir = _make_repo(self.tmp_root, 'damo', 'goodrepo')
        # Helper only inspects file extensions; no need to create real files.
        self.untrusted_py = os.path.join(self.untrusted_dir, 'config.py')
        self.trusted_py = os.path.join(self.trusted_dir, 'config.py')
        self.untrusted_json = os.path.join(self.untrusted_dir,
                                           'configuration.json')

    def tearDown(self):
        shutil.rmtree(self.tmp_root, ignore_errors=True)

    def test_non_py_path_always_passes(self):
        # JSON / YAML configs are passive data; helper must not block them.
        check_trust_remote_code_for_config(
            self.untrusted_json,
            trust_remote_code=False,
            model_dir=self.untrusted_dir)

    def test_untrusted_py_without_optin_raises(self):
        with self.assertRaises(RuntimeError):
            check_trust_remote_code_for_config(
                self.untrusted_py,
                trust_remote_code=False,
                model_dir=self.untrusted_dir)

    def test_untrusted_py_with_optin_passes(self):
        check_trust_remote_code_for_config(
            self.untrusted_py,
            trust_remote_code=True,
            model_dir=self.untrusted_dir)

    def test_trusted_owner_py_passes(self):
        check_trust_remote_code_for_config(
            self.trusted_py,
            trust_remote_code=False,
            model_dir=self.trusted_dir)

    def test_default_model_dir_inferred_from_filename(self):
        # When `model_dir` is omitted, helper must infer it from the file's
        # parent directory and still gate untrusted owners correctly.
        with self.assertRaises(RuntimeError):
            check_trust_remote_code_for_config(
                self.untrusted_py, trust_remote_code=False)
        # Trusted parent should pass.
        check_trust_remote_code_for_config(
            self.trusted_py, trust_remote_code=False)

    def test_helper_uses_raise_not_assert(self):
        # Security gates must survive `python -O`. The helper raises
        # RuntimeError, not AssertionError; verify the failure type so the
        # contract is locked in.
        try:
            check_trust_remote_code_for_config(
                self.untrusted_py,
                trust_remote_code=False,
                model_dir=self.untrusted_dir)
        except RuntimeError:
            return
        except AssertionError:
            self.fail(
                'Gate used `assert`; would be a no-op under `python -O`.')
        self.fail('Gate did not raise on untrusted `.py` config.')


class SinkWiringTest(unittest.TestCase):
    """Spot-check that a representative mmcv sink calls the helper before
    reaching `mmcv.Config.fromfile`.

    We patch the helper to raise a sentinel; if the sink is wired correctly,
    the sentinel propagates and the unsafe `mmcv.Config.fromfile` is never
    invoked.
    """

    def setUp(self):
        self.tmp_root = tempfile.mkdtemp(prefix='ms_cfg_sink_wiring_')
        self.untrusted_dir = _make_repo(self.tmp_root, 'attacker', 'badrepo')

    def tearDown(self):
        shutil.rmtree(self.tmp_root, ignore_errors=True)

    def _import_module_text(self, dotted):
        # Read the source of a module without importing it (avoids dragging
        # in mmcv / mmdet / torch on test machines that lack them).
        import importlib.util
        spec = importlib.util.find_spec(dotted)
        self.assertIsNotNone(spec, f'{dotted} not found')
        with open(spec.origin, 'r') as f:
            return f.read()

    def _assert_helper_precedes_sink(self, dotted, sink_call):
        src = self._import_module_text(dotted)
        helper_pos = src.find('check_trust_remote_code_for_config(')
        sink_pos = src.find(sink_call)
        self.assertGreater(
            helper_pos, -1,
            f'{dotted} does not call check_trust_remote_code_for_config')
        self.assertGreater(sink_pos, -1,
                           f'{dotted} no longer contains `{sink_call}`')
        self.assertLess(helper_pos, sink_pos,
                        f'{dotted}: helper call must precede `{sink_call}`')

    def test_classification_model_wires_helper(self):
        self._assert_helper_precedes_sink(
            'modelscope.models.cv.image_classification.mmcls_model',
            'mmcv.Config.fromfile(')

    def test_panoptic_segmentation_wires_helper(self):
        self._assert_helper_precedes_sink(
            'modelscope.models.cv.image_panoptic_segmentation.panseg_model',
            'mmcv.Config.fromfile(')

    def test_semantic_segmentation_wires_helper(self):
        self._assert_helper_precedes_sink(
            'modelscope.models.cv.image_semantic_segmentation.semantic_seg_model',
            'mmcv.Config.fromfile(')

    def test_scrfd_detect_wires_helper(self):
        self._assert_helper_precedes_sink(
            'modelscope.models.cv.face_detection.scrfd.scrfd_detect',
            'Config.fromfile(')

    def test_mmcls_preprocessor_wires_helper(self):
        self._assert_helper_precedes_sink(
            'modelscope.preprocessors.cv.mmcls_preprocessor',
            'mmcv.Config.fromfile(')

    def test_segformer_detector_wires_helper(self):
        self._assert_helper_precedes_sink(
            'modelscope.models.cv.controllable_image_generation.annotator.annotator',
            'init_segmentor(')


if __name__ == '__main__':
    unittest.main()
