Files
MoviePilot/tests/test_architecture_egress.py
T

926 lines
33 KiB
Python

"""宿主 direct egress 事实与人工政策契约。"""
import json
from pathlib import Path
from scripts.architecture.baseline import collect_dependency_baseline
from scripts.architecture.egress import collect_direct_egress
PROJECT_ROOT = Path(__file__).parents[1]
DEPENDENCY_POLICY_PATH = (
PROJECT_ROOT / "tests" / "fixtures" / "architecture" / "dependency-policy.json"
)
FROZEN_EGRESS_EDGES_BY_REASON = {
"direct_http": {
("app.agent.llm.provider", "httpx"),
("app.modules.filemanager.storages.alipan", "requests"),
("app.modules.filemanager.storages.u115", "httpx"),
("app.modules.ugreen.api", "requests"),
},
"requestutils_session_bridge": {
("app.modules.bangumi.bangumi", "requests"),
("app.modules.douban.apiv2", "requests"),
("app.modules.imdb.api", "requests"),
("app.modules.indexer.parser", "requests"),
("app.modules.musicbrainz", "requests"),
("app.modules.plex.plex", "requests"),
("app.modules.trimemedia.api", "requests"),
},
"direct_dns": {
("app.application.security.url", "socket.getaddrinfo"),
},
"canonical_transport": {
("app.adapters.cache.redis", "redis"),
("app.adapters.network.browser", "cloakbrowser"),
("app.adapters.network.doh", "socket.getaddrinfo"),
("app.adapters.network.doh", "urllib.request"),
("app.adapters.network.http", "httpx2"),
("app.adapters.network.http", "requests"),
("app.adapters.network.ip", "socket.gethostbyname"),
},
"transport_configuration": {
("app.adapters.network.http", "urllib3"),
},
"sdk_transport": {
("app.agent.llm.capability", "openai"),
("app.agent.llm.helper", "google.genai"),
("app.agent.llm.helper", "httpx"),
("app.agent.llm.helper", "langchain_anthropic"),
("app.agent.llm.helper", "langchain_aws"),
("app.agent.llm.helper", "langchain_deepseek"),
("app.agent.llm.helper", "langchain_google_genai"),
("app.agent.llm.helper", "langchain_openai"),
("app.agent.llm.helper", "openai"),
("app.agent.llm.provider", "boto3"),
("app.agent.llm.provider", "botocore"),
("app.agent.llm.provider", "google.genai"),
("app.agent.llm.provider", "openai"),
("app.agent.tools.impl.search_web", "ddgs"),
("app.api.endpoints.message", "pywebpush"),
("app.modules.discord.discord", "discord"),
("app.modules.feishu.feishu", "lark_oapi"),
("app.modules.filemanager.storages.smb", "smbclient"),
("app.modules.filemanager.storages.u115", "oss2"),
("app.modules.plex.plex", "plexapi"),
("app.modules.qbittorrent.qbittorrent", "qbittorrentapi"),
("app.modules.slack.slack", "slack_bolt"),
("app.modules.slack.slack", "slack_sdk"),
("app.modules.telegram.telegram", "telebot"),
("app.modules.transmission.transmission", "transmission_rpc"),
("app.modules.webpush", "pywebpush"),
},
"streaming_protocol": {
("app.modules.qqbot.gateway", "websocket"),
("app.modules.rtorrent.rtorrent", "socket.create_connection"),
("app.modules.rtorrent.rtorrent", "xmlrpc.client.ServerProxy"),
("app.modules.wechat.wechatbot", "websocket"),
},
"contained_vendor": {
("app.modules.themoviedb.tmdbv3api.tmdb", "requests"),
},
"local_control_plane": {
("app.cli", "urllib.request"),
},
"diagnostic_probe": {
("app.doctor.checks", "socket.create_connection"),
("app.doctor.checks", "urllib.request"),
},
"type_or_compat_only": {
("app.adapters.external.market", "httpx2"),
("app.adapters.external.market", "requests"),
("app.modules.douban.apiv2", "httpx2"),
("app.modules.emby.emby", "requests"),
("app.modules.filemanager.storages.smb", "smbprotocol"),
("app.modules.jellyfin.jellyfin", "requests"),
("app.modules.rtorrent.rtorrent", "http.client"),
("app.modules.telegram.compat", "urllib3"),
("app.modules.zspace.zspace", "requests"),
("app.startup.lifecycle", "urllib3"),
},
"daemon_control_plane": {
("app.runtime.state", "docker"),
},
"test_network_guard": {
("app.testing.network_guard", "socket.getaddrinfo"),
},
}
FROZEN_EGRESS_REASON_BY_EDGE = {
edge: reason
for reason, edges in FROZEN_EGRESS_EDGES_BY_REASON.items()
for edge in edges
}
FROZEN_EGRESS_FINGERPRINT_BY_EDGE = {
("app.adapters.cache.redis", "redis"): "9d455a5298d4373ff18d74a9d498a3dd797bcb5f5543af9776c0c741c30362c7",
("app.adapters.external.market", "httpx2"): "d4a648e8188818c0465013fc63cc3e49899da4df38541344303c251896564b1f",
("app.adapters.external.market", "requests"): "ecc5368adfced20741e5ed8696008ea7555a4a81ceda6d7149e09f5f3d0ff7e3",
("app.adapters.network.browser", "cloakbrowser"): "15c1777b14eb9147d6cab9783f67577011714f9220600dda5db5269b59726173",
("app.adapters.network.doh", "socket.getaddrinfo"): "4ff03419dfacc6bf582b7d4421dd5a0666a63f8ca79be2b1e625f4c8f4c96b71",
("app.adapters.network.doh", "urllib.request"): "6f5f5fd3da02a9e780ea5e7cc1e47bd962314a1a358f14b4ee698485f96ab52b",
("app.adapters.network.http", "httpx2"): "a70799e9930ff79cd28ebed92836c8108cd2d18bc668e0081f68533d84f69d0d",
("app.adapters.network.http", "requests"): "95fb4576cec363355f40aa1758d90fbb09242a2b1d029b7ab5ab65e2aaca12f8",
("app.adapters.network.http", "urllib3"): "66cbd8ec4e7552bd458db0baada30f1953e6d0493793822d23a2199567bca98a",
("app.adapters.network.ip", "socket.gethostbyname"): "a43e8969e2f26546fcf925b258728b309f1b9b966ff3c7b48a64273b8c82d048",
("app.agent.llm.capability", "openai"): "60160ab01b60be2c9794b9724a50b90f8875c6a10748ff9392a1e4bc7d48c334",
("app.agent.llm.helper", "google.genai"): "d41688ecda4c5de296fabb0d0d25c79a7b0f29f67ccdd44569dc1110e1db9b86",
("app.agent.llm.helper", "httpx"): "caeccd432f48d9a23948dd5b9220ddbebc57d2b2936bfa499fc7d4876e935ea8",
("app.agent.llm.helper", "langchain_anthropic"): "c8d2c3166c4d42d61e7ef38a25080cac0f93422f47393df6dc19b329a7f72265",
("app.agent.llm.helper", "langchain_aws"): "51dd02f5e2305cce4e1f6f29b4ed47edf54dde9f6557781178a6edf96bfeeb6b",
("app.agent.llm.helper", "langchain_deepseek"): "bcf8b1ffae0995cfc33cfe26609cef8014cd2ce036b845c2a88c0cd878b1ebc4",
("app.agent.llm.helper", "langchain_google_genai"): "79ae88c63908da9ea36dcfc23c4ad5345bc5d1977b80cc2e0b89292d063ed8ee",
("app.agent.llm.helper", "langchain_openai"): "15775bf1694780d5cf516e6cc3d3b5174ebb25e3038433132d3071e6abceba52",
("app.agent.llm.helper", "openai"): "dbebd5e9fa38b2a525a5a2e5a92d855ba9d05762410590bfb544e665fe38f620",
("app.agent.llm.provider", "boto3"): "04ca4b5ecbda1531266d5f53817dde061dcfdd3293cabf9301fc8d8e4c281a88",
("app.agent.llm.provider", "botocore"): "fbeaa702703665ca60233a153aade0da7e49dfb6fe31f72e5593c883fb7d3f11",
("app.agent.llm.provider", "google.genai"): "b545cffb169b87f87606c17762b5d9da3f35372bd411c1f4ebba010b3d7c3ebb",
("app.agent.llm.provider", "httpx"): "a68ad216d86b4b33aa8c35572c26f7993245c0c12dd512e962bcacb46d7f15fc",
("app.agent.llm.provider", "openai"): "ffac3b355eb640f6421299cf838cb4e24866a39afabd9dcfd8d0f340925dc1a8",
("app.agent.tools.impl.search_web", "ddgs"): "377e73bb3be804b825d60ad6e792344ca633de107f8c4dbb45d50a34a4dfa04b",
("app.api.endpoints.message", "pywebpush"): "7d83ca0dc89dfb6af1cdd7bc90d922105de94222e4e3a44bba4f53bbd97bc31b",
("app.application.security.url", "socket.getaddrinfo"): "89c563fdb640259584563fc718f7104a7b4dc6717e97b9c9e449f70f74a9532b",
("app.cli", "urllib.request"): "71b3e5be2a7d85fdc7a207d15c0e690d7ab96b335b882a3eaec03a1cb4ec1d1b",
("app.doctor.checks", "socket.create_connection"): "dfb34d4710353029dcc06e0e0ce3b299bae1a7e71a5743de1d253ac26380f9b6",
("app.doctor.checks", "urllib.request"): "d8dd2279263ef58c3b4c59c70451533a4fc27d2ccd76960ca3aff29cd73f3354",
("app.modules.bangumi.bangumi", "requests"): "e5baf8a89b8661025169e9eec8d3c13d30660f63b35d94ec4896cd97e7fde3bf",
("app.modules.discord.discord", "discord"): "e1486525fedbfe574aa47d60b483426c8c48e4e27fa35dbe34f6cd2f47d37c86",
("app.modules.douban.apiv2", "httpx2"): "edc337b12976d978cad3f83d0fa6faf71b2af2d814926dff890d5acc94f0a0a6",
("app.modules.douban.apiv2", "requests"): "6e871a27b88263676797edd65093d0b38db48f77fb5db2bb581b7b82e9b2977d",
("app.modules.emby.emby", "requests"): "6c4c17fe170226ea1272f5859bc119c2775442cabe8ce2a9edcabaf9c37bec31",
("app.modules.feishu.feishu", "lark_oapi"): "99d16968a59932b5980f6d42ea2bbc8fa35ccdb1a27933beec29b4bdc093d78b",
("app.modules.filemanager.storages.alipan", "requests"): "82d84425f58ed9664a6e2a4ece53240198c95a0f9fee5d0afd0a11e3c459ecf6",
("app.modules.filemanager.storages.smb", "smbclient"): "62e0585282ef206ac81b2bf9e93a58423fb058685edc0ef4ee592453bbf30376",
("app.modules.filemanager.storages.smb", "smbprotocol"): "f02686afd99c59820dffa7b4c2627a0be1ad62980c98a9cd33b7564697842b22",
("app.modules.filemanager.storages.u115", "httpx"): "f6678109963f9973d5d15e38e5f5e8dca01ec5e0117598ad1ac877b841ff7961",
("app.modules.filemanager.storages.u115", "oss2"): "f7b89c8ae6dad2603f0a9e0caaa159769aef5b3581d7e728f62445b979366eae",
("app.modules.imdb.api", "requests"): "3d05532e62d2cddf3bb05495196176b76aa02216c2b0d868cf9281d2257d9627",
("app.modules.indexer.parser", "requests"): "facdb091442bacba0fc0f631637643e95e18545bd7ab63f1d49d67a9a928d016",
("app.modules.jellyfin.jellyfin", "requests"): "5c46d09ca9a4bcc0bae21ca5d554ef09baa3f901c562b27c5f5ee1439c7746b5",
("app.modules.musicbrainz", "requests"): "c54a10dd672fb0ebb32a536ae42c221ad434313466cc108d43e433dd84cac9a5",
("app.modules.plex.plex", "plexapi"): "76c1334863dc6c6623ce6ad3415bec5ff92656f51c31e4d982285898f193e57e",
("app.modules.plex.plex", "requests"): "93f20b9c01571553e9601f4b9f4ee9471073e65f938cb635320f72fa41ec3843",
("app.modules.qbittorrent.qbittorrent", "qbittorrentapi"): "b2f5a27f0c54cf95ed42fe99848fd6c9c0641caca8bc4ff8006a411cb427066d",
("app.modules.qqbot.gateway", "websocket"): "6f6b7d61f3a95e620e67c450d544f0aa077f087a188d98f32e4501d94c6b37ae",
("app.modules.rtorrent.rtorrent", "http.client"): "6adc93b3bc479dfe81197554c7977930a60abc51714f14ef29298eb40730aaf7",
("app.modules.rtorrent.rtorrent", "socket.create_connection"): "878114d6b1bda091bd3b3d8aa819831d84381682feba78267592d2593295af90",
("app.modules.rtorrent.rtorrent", "xmlrpc.client.ServerProxy"): "2ac64b5670c930bc28bb2135cc4b73891c1cae01a74a9586f74e302878c45c85",
("app.modules.slack.slack", "slack_bolt"): "be5dcb032ece8d8627abeb243f98143aaf60f26751ab6e5e098813d4048419e6",
("app.modules.slack.slack", "slack_sdk"): "7559f31e4172ad3bbbaf161e1164ea48b997c56d05c77b49b82626298c39aa14",
("app.modules.telegram.compat", "urllib3"): "18862bbdb252b59573f57ea776b5d64bfb775e7739603fecd23cc8f2c38a7e0d",
("app.modules.telegram.telegram", "telebot"): "78f5ab18bfd67ba4fa0f3c0fc4a1a561a7b1d9e81e335fb80de86edb480b84b8",
("app.modules.themoviedb.tmdbv3api.tmdb", "requests"): "d605eb176a203b3f4d205c5d183469b3002426682eb9cb4e7408bb6013652484",
("app.modules.transmission.transmission", "transmission_rpc"): "1652e661cb17dbadb039fdc4ab73d6d06e47eb118292ba3693313e45834959cf",
("app.modules.trimemedia.api", "requests"): "aecbe00404b2866c918c939bee5f1002045fc72aed4440c85e0811a4379913e4",
("app.modules.ugreen.api", "requests"): "a266dd2b74f216b289682e5d83c0b5a7d0d08b5447d79c165659b6d7302656d6",
("app.modules.webpush", "pywebpush"): "389c73b06150e3d5bcaf31f35a25178873d2ed38ef9a28354cb9bab691eeab76",
("app.modules.wechat.wechatbot", "websocket"): "1bae78270eadce0571e2caaa111a5c0a9065ba2da97ebb26b8a5b76d3ed5eef6",
("app.modules.zspace.zspace", "requests"): "9df3fd27b9696d45a72e7c8f67b5a9ad79a7371d1fe690bbaa17485bd1960d51",
("app.runtime.state", "docker"): "20a91ec521f7dfe6a0153dfd8ea49c4bac7f0a16b55f1c0655dfb33f54a01215",
("app.startup.lifecycle", "urllib3"): "cb6f0a314aeb1e2d3e76c240aa20460ac0c36d9f5c18c1a6ea3170f64dd3366b",
("app.testing.network_guard", "socket.getaddrinfo"): "2518de211c9ba32ccbc004cf58fe9c98837fe8ac95fe8ffb6ea82897b28d753f",
}
def _module(tmp_path: Path, name: str, content: str) -> dict[str, Path]:
"""创建供 direct egress collector 使用的独立模块。"""
path = tmp_path / f"{name.replace('.', '_')}.py"
path.write_text(content, encoding="utf-8")
return {name: path}
def _entries_by_target(value: dict[str, object]) -> dict[str, dict[str, object]]:
"""把单 source 合成事实按 target 建索引。"""
return {entry["target"]: entry for entry in value["entries"]}
def _policy_facts(groups: list[dict[str, object]]) -> list[dict[str, str]]:
"""展开按语义分组保存的人工 egress fact 引用。"""
return [fact for group in groups for fact in group["facts"]]
def _policy_differences(
facts: list[dict[str, object]],
reviewed: list[dict[str, str]],
) -> tuple[list[str], list[str]]:
"""按完整事实指纹返回未审查事实和陈旧人工 policy。"""
actual = {str(fact["fingerprint"]) for fact in facts}
expected = {fact["fingerprint"] for fact in reviewed}
return sorted(actual - expected), sorted(expected - actual)
def _classification_differences(
facts: list[dict[str, object]],
groups: list[dict[str, object]],
) -> tuple[list[tuple[str, str]], list[tuple[str, str]], list[tuple[str, str]]]:
"""返回新增边、陈旧分类和与初始语义不符的分类。"""
actual = {(str(fact["source"]), str(fact["target"])) for fact in facts}
reviewed = {
(str(fact["source"]), str(fact["target"])): str(group["reason_code"])
for group in groups
for fact in group["facts"]
}
unexpected = sorted(actual - FROZEN_EGRESS_REASON_BY_EDGE.keys())
stale = sorted(reviewed.keys() - actual)
misclassified = sorted(
edge
for edge in actual & reviewed.keys() & FROZEN_EGRESS_REASON_BY_EDGE.keys()
if reviewed[edge] != FROZEN_EGRESS_REASON_BY_EDGE[edge]
)
return unexpected, stale, misclassified
def _fingerprint_differences(
facts: list[dict[str, object]],
) -> list[tuple[str, str]]:
"""返回调用面已超过初始上界的 direct egress 边。"""
actual = {
(str(fact["source"]), str(fact["target"])): str(fact["fingerprint"])
for fact in facts
}
return sorted(
edge
for edge, fingerprint in actual.items()
if FROZEN_EGRESS_FINGERPRINT_BY_EDGE.get(edge) != fingerprint
)
def test_egress_collector_distinguishes_bindings_and_runtime_uses(
tmp_path: Path,
) -> None:
"""类型导入、真实调用、lazy import、re-export 与 SDK 必须可区分。"""
modules = _module(
tmp_path,
"app.sample",
'''
from typing import TYPE_CHECKING
import requests as req
from requests import Response
import httpx
from urllib import request as urlrequest
from app.adapters.network.http import requests as bridged
from google import genai
if TYPE_CHECKING:
import cloudscraper
EXAMPLE = "requests.get('https://docstring.invalid')"
def run():
import aiohttp
session = req.Session()
session.get("https://example.com")
client = httpx.AsyncClient()
client.stream("GET", "https://example.com")
urlrequest.urlopen("https://example.com")
bridged.Session()
genai.Client()
aiohttp.ClientSession()
unknown.get("https://ignored.example")
''',
)
value = collect_direct_egress(modules)
entries = _entries_by_target(value)
assert set(entries) == {
"aiohttp",
"google.genai",
"httpx",
"requests",
"urllib.request",
}
assert entries["requests"]["bindings"] == [
"from:requests.Response as Response",
"import:requests as req",
"reexport:app.adapters.network.http.requests as bridged",
]
assert entries["requests"]["uses"] == [
"run|call:Session",
"run|call:Session",
"run|call:get",
]
assert entries["httpx"]["uses"] == [
"run|call:AsyncClient",
"run|call:stream",
]
assert entries["urllib.request"]["uses"] == ["run|call:urlopen"]
assert entries["google.genai"]["kind"] == "network_sdk"
assert entries["aiohttp"]["uses"] == ["run|call:ClientSession"]
assert all("line" not in entry for entry in value["entries"])
def test_egress_collector_preserves_type_only_runtime_import(tmp_path: Path) -> None:
"""用于响应类型的运行期 import 必须保留,但不能伪报为外呼操作。"""
value = collect_direct_egress(
_module(tmp_path, "app.sample", "from requests import Response\n")
)
assert value["entries"] == [
{
"source": "app.sample",
"target": "requests",
"kind": "raw_transport",
"bindings": ["from:requests.Response as Response"],
"uses": [],
"fingerprint": value["entries"][0]["fingerprint"],
}
]
def test_egress_collector_tracks_aliases_contexts_and_protocol_operations(
tmp_path: Path,
) -> None:
"""Client 别名、上下文实例、socket 与 XML-RPC 获取都进入稳定 uses。"""
value = collect_direct_egress(
_module(
tmp_path,
"app.sample",
'''
import httpx
import requests
import socket
import xmlrpc.client
from urllib.parse import urlparse
def sync_call(flag):
client_cls = requests.Session if flag else requests.Session
client = client_cls()
client.post("https://example.com")
socket.create_connection(("localhost", 1))
xmlrpc.client.ServerProxy("http://localhost")
urlparse("https://not-egress.example")
async def async_call():
async with httpx.AsyncClient() as client:
await client.get("https://example.com")
''',
)
)
entries = _entries_by_target(value)
assert entries["requests"]["uses"] == [
"sync_call|call:__call__",
"sync_call|call:post",
]
assert entries["httpx"]["uses"] == [
"async_call|call:AsyncClient",
"async_call|call:get",
]
assert entries["socket.create_connection"]["kind"] == "protocol_operation"
assert entries["xmlrpc.client.ServerProxy"]["kind"] == "protocol_operation"
assert "urllib.request" not in entries
def test_egress_collector_keeps_lexical_scopes_and_merges_branches(
tmp_path: Path,
) -> None:
"""函数 lazy import 不得泄漏,分支与 self client 必须保留可证明来源。"""
value = collect_direct_egress(
_module(
tmp_path,
"app.sample",
'''
import requests
def first():
import httpx as client
client.get("https://example.com")
def second(client):
client.get("https://ignored.example")
def branch(flag):
if flag:
requester = requests.request
else:
requester = unknown
requester("GET", "https://example.com")
class Service:
def __init__(self):
self.client = requests.Session()
def run(self):
self.client.get("https://example.com")
''',
)
)
entries = _entries_by_target(value)
assert entries["httpx"]["uses"] == ["first|call:get"]
assert entries["requests"]["uses"] == [
"Service.__init__|call:Session",
"Service.run|call:get",
"branch|call:request",
]
def test_egress_collector_preserves_wildcard_chains_and_duplicate_calls(
tmp_path: Path,
) -> None:
"""高风险 wildcard、链式 client 和重复操作都必须改变事实 identity。"""
value = collect_direct_egress(
_module(
tmp_path,
"app.sample",
'''
from requests import *
import requests
def run():
requests.Session().get("https://example.com/one")
requests.get("https://example.com/two")
requests.get("https://example.com/three")
''',
)
)
entry = _entries_by_target(value)["requests"]
assert entry["bindings"] == [
"from:requests.* as *",
"import:requests as requests",
]
assert entry["uses"] == [
"run|call:Session",
"run|call:get",
"run|call:get",
"run|call:get",
]
def test_egress_collector_tracks_annotations_class_order_and_union_branches(
tmp_path: Path,
) -> None:
"""注入参数、后置初始化与双 transport 分支都必须保留 operation。"""
value = collect_direct_egress(
_module(
tmp_path,
"app.sample",
'''
import httpx
import requests
async def injected(client: httpx.AsyncClient):
await client.get("https://example.com")
class Service:
def run(self):
self.client.post("https://example.com")
def __init__(self):
try:
self.client = requests.Session()
except Exception:
self.client = None
def branch(flag):
if flag:
client = requests.Session()
else:
client = httpx.Client()
client.get("https://example.com")
''',
)
)
entries = _entries_by_target(value)
assert entries["requests"]["uses"] == [
"Service.__init__|call:Session",
"Service.run|call:post",
"branch|call:Session",
"branch|call:get",
]
assert entries["httpx"]["uses"] == [
"branch|call:Client",
"branch|call:get",
"injected|call:get",
]
def test_egress_collector_tracks_dynamic_and_late_bound_modules(
tmp_path: Path,
) -> None:
"""注册目标的字面量动态导入与后置模块别名不能绕过门禁。"""
value = collect_direct_egress(
_module(
tmp_path,
"app.sample",
'''
import importlib
import requests
def late_bound():
transport.post("https://example.com")
def dynamic():
importlib.import_module("httpx").get("https://example.com")
def builtin_dynamic():
__import__("httpx").post("https://example.com")
transport = requests
''',
)
)
entries = _entries_by_target(value)
assert entries["requests"]["uses"] == ["late_bound|call:post"]
assert entries["httpx"]["bindings"] == [
"dynamic:__import__(httpx)",
"dynamic:importlib.import_module(httpx)"
]
assert entries["httpx"]["uses"] == [
"builtin_dynamic|call:dynamic-import",
"builtin_dynamic|call:post",
"dynamic|call:dynamic-import",
"dynamic|call:get",
]
def test_egress_collector_respects_local_shadow_scopes(tmp_path: Path) -> None:
"""lambda、for、comprehension 与 except-as 局部名不得污染 import provenance。"""
value = collect_direct_egress(
_module(
tmp_path,
"app.sample",
'''
import requests
def run():
for requests in []:
requests.get("https://ignored.example")
[requests.get("https://ignored.example") for requests in []]
(lambda requests: requests.get("https://ignored.example"))(object())
try:
raise RuntimeError
except RuntimeError as requests:
requests.get("https://ignored.example")
''',
)
)
assert _entries_by_target(value)["requests"]["uses"] == []
def test_egress_collector_stops_at_response_operations(tmp_path: Path) -> None:
"""直接请求进入事实,但响应 json/text 解析不是新的 egress operation。"""
value = collect_direct_egress(
_module(
tmp_path,
"app.sample",
'''
import requests
def run():
requests.get("https://example.com").json()
''',
)
)
assert _entries_by_target(value)["requests"]["uses"] == ["run|call:get"]
def test_egress_collector_tracks_local_wrapper_injections(tmp_path: Path) -> None:
"""本地包装函数接收的可证明 client 必须传播,未知调用不得伪造来源。"""
value = collect_direct_egress(
_module(
tmp_path,
"app.sample",
'''
import httpx
import requests
def helper(client):
client.get("https://example.com")
def run():
requests_client = requests.Session()
httpx_client = httpx.Client()
helper(requests_client)
helper(httpx_client)
helper(object())
''',
)
)
entries = _entries_by_target(value)
assert entries["requests"]["uses"] == [
"helper|call:get",
"run|call:Session",
]
assert entries["httpx"]["uses"] == [
"helper|call:get",
"run|call:Client",
]
def test_egress_collector_handles_runtime_else_and_final_module_binding(
tmp_path: Path,
) -> None:
"""TYPE_CHECKING else 是运行期路径,函数使用模块最终绑定。"""
value = collect_direct_egress(
_module(
tmp_path,
"app.sample",
'''
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import httpx as transport
else:
import requests as transport
captured = transport
transport = object()
def run():
captured.get("https://example.com")
transport.get("https://ignored.example")
''',
)
)
assert _entries_by_target(value)["requests"]["uses"] == ["run|call:get"]
def test_egress_collector_merges_class_and_zero_iteration_paths(
tmp_path: Path,
) -> None:
"""类 client 清空与零次循环都不得抹掉已证明的可达能力。"""
value = collect_direct_egress(
_module(
tmp_path,
"app.sample",
'''
import requests
class Service:
def connect(self):
client = requests.Session()
self.client = client
def close(self):
self.client = None
def run(self):
self.client.get("https://example.com/class")
def loop():
client = requests.Session()
for client in []:
pass
client.post("https://example.com/loop")
''',
)
)
assert _entries_by_target(value)["requests"]["uses"] == [
"Service.connect|call:Session",
"Service.run|call:get",
"loop|call:Session",
"loop|call:post",
]
def test_egress_collector_preserves_annotated_optional_client(tmp_path: Path) -> None:
"""None 默认值不得抹掉注解 client 经实例属性传递的真实调用。"""
value = collect_direct_egress(
_module(
tmp_path,
"app.sample",
'''
import httpx
class Service:
def __init__(self, client: httpx.AsyncClient = None):
self.client = client
async def run(self):
await self.client.get("https://example.com")
''',
)
)
assert _entries_by_target(value)["httpx"]["uses"] == [
"Service.run|call:get"
]
def test_current_egress_facts_are_complete_and_self_consistent() -> None:
"""当前宿主 egress identity 必须完整、可收缩、排序且统计自洽。"""
value = collect_dependency_baseline()["direct_egress"]
entries = value["entries"]
application_entries = [
entry
for entry in entries
if str(entry["source"]).startswith("app.application.")
]
chain_entries = [
entry
for entry in entries
if str(entry["source"]).startswith("app.chain.")
]
assert value["count"] == len(entries)
assert sum(value["counts_by_kind"].values()) == value["count"]
assert set(value["counts_by_kind"]) == {
"raw_transport",
"network_sdk",
"protocol_operation",
}
assert value["application_chain_counts"] == {
"app.application": len(application_entries),
"app.chain": len(chain_entries),
}
assert {
(entry["source"], entry["target"])
for entry in application_entries
} <= FROZEN_EGRESS_EDGES_BY_REASON["direct_dns"]
assert chain_entries == []
assert entries == sorted(
entries,
key=lambda entry: (entry["source"], entry["target"], entry["kind"]),
)
assert all(entry["target"] != "aiohttp" for entry in entries)
assert all(len(entry["fingerprint"]) == 64 for entry in entries)
assert all(set(entry) == {
"source",
"target",
"kind",
"bindings",
"uses",
"fingerprint",
} for entry in entries)
assert all(
"*" not in binding
for entry in entries
for binding in entry["bindings"]
)
assert '"line"' not in json.dumps(value)
def test_current_egress_facts_match_exact_policy() -> None:
"""现存事实必须逐条分类,且 registry/scope 不得与 collector 漂移。"""
facts = collect_dependency_baseline()["direct_egress"]
policy = json.loads(DEPENDENCY_POLICY_PATH.read_text(encoding="utf-8"))
egress_policy = policy["direct_egress"]
groups = egress_policy["groups"]
reviewed = _policy_facts(groups)
facts_by_fingerprint = {
fact["fingerprint"]: fact
for fact in facts["entries"]
}
assert policy["schema_version"] == 3
assert egress_policy["scope"] == facts["scope"]
assert egress_policy["registry"] == facts["registry"]
assert len(reviewed) == len({fact["fingerprint"] for fact in reviewed}) == len(
facts["entries"]
)
assert all(set(fact) == {"source", "target", "kind", "fingerprint"} for fact in reviewed)
assert all("*" not in fact["source"] + fact["target"] for fact in reviewed)
assert all(len(fact["fingerprint"]) == 64 for fact in reviewed)
assert all(
{
key: facts_by_fingerprint[fact["fingerprint"]][key]
for key in ("source", "target", "kind", "fingerprint")
}
== fact
for fact in reviewed
)
assert _policy_differences(facts["entries"], reviewed) == ([], [])
assert set(FROZEN_EGRESS_FINGERPRINT_BY_EDGE) == set(
FROZEN_EGRESS_REASON_BY_EDGE
)
assert _fingerprint_differences(facts["entries"]) == []
assert _classification_differences(facts["entries"], groups) == ([], [], [])
def test_egress_policy_enforces_debt_and_exception_schemas() -> None:
"""债务必须绑定清零叶,精确例外必须有 owner 与业务理由。"""
policy = json.loads(DEPENDENCY_POLICY_PATH.read_text(encoding="utf-8"))
groups = policy["direct_egress"]["groups"]
debt_tracking = {
"direct_http": "S2-L7",
"requestutils_session_bridge": "S2-L7",
"direct_dns": "S2-L6",
}
exception_reasons = {
"canonical_transport",
"transport_configuration",
"sdk_transport",
"streaming_protocol",
"contained_vendor",
"local_control_plane",
"diagnostic_probe",
"type_or_compat_only",
"daemon_control_plane",
"test_network_guard",
}
for group in groups:
if group["classification"] == "temporary_debt":
assert set(group) == {
"classification",
"reason_code",
"tracking",
"target_state",
"reason",
"facts",
}
assert group["reason_code"] in debt_tracking
assert group["tracking"] == debt_tracking[group["reason_code"]]
assert group["target_state"] == "empty"
assert str(group["reason"]).strip()
else:
assert group["classification"] == "approved_exception"
assert set(group) == {
"classification",
"reason_code",
"owner",
"reason",
"facts",
}
assert group["reason_code"] in exception_reasons
assert group["owner"] == "$source"
assert str(group["reason"]).strip()
def test_egress_policy_rejects_classification_swaps() -> None:
"""事实即使仍被覆盖,也不能在债务与例外 reason 间互换。"""
facts = [
{"source": "app.agent.llm.provider", "target": "httpx"},
{"source": "app.agent.llm.capability", "target": "openai"},
]
swapped = [
{
"reason_code": "sdk_transport",
"facts": [{"source": "app.agent.llm.provider", "target": "httpx"}],
},
{
"reason_code": "direct_http",
"facts": [{"source": "app.agent.llm.capability", "target": "openai"}],
},
]
assert _classification_differences(facts, swapped) == (
[],
[],
[
("app.agent.llm.capability", "openai"),
("app.agent.llm.provider", "httpx"),
],
)
def test_egress_policy_rejects_same_edge_surface_growth() -> None:
"""同一 source/target 的调用面变化不能靠刷新 policy 绕过。"""
facts = [
{
"source": "app.agent.llm.provider",
"target": "httpx",
"fingerprint": "f" * 64,
}
]
assert _fingerprint_differences(facts) == [
("app.agent.llm.provider", "httpx")
]
def test_egress_policy_rejects_add_remove_replacement_and_stale_entries() -> None:
"""新增、事实变化和删除后未清 policy 都必须产生精确差异。"""
original = [
{
"source": "app.sample",
"target": "requests",
"kind": "raw_transport",
"fingerprint": "a" * 64,
}
]
reviewed = [
{
"source": "app.sample",
"target": "requests",
"kind": "raw_transport",
"fingerprint": "a" * 64,
}
]
added = [
*original,
{
"source": "app.other",
"target": "aiohttp",
"kind": "raw_transport",
"fingerprint": "b" * 64,
},
]
replacement = [{**original[0], "fingerprint": "c" * 64}]
assert _policy_differences(original, reviewed) == ([], [])
assert _policy_differences(added, reviewed) == (["b" * 64], [])
assert _policy_differences([], reviewed) == ([], ["a" * 64])
assert _policy_differences(replacement, reviewed) == (
["c" * 64],
["a" * 64],
)
assert _policy_differences([], []) == ([], [])