zerochat 9.4.0__tar.gz → 9.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of zerochat might be problematic. Click here for more details.
- {zerochat-9.4.0/zerochat.egg-info → zerochat-9.5.0}/PKG-INFO +1 -1
- {zerochat-9.4.0 → zerochat-9.5.0}/pyproject.toml +1 -1
- {zerochat-9.4.0 → zerochat-9.5.0/zerochat.egg-info}/PKG-INFO +1 -1
- {zerochat-9.4.0 → zerochat-9.5.0}/zerochat.py +126 -133
- {zerochat-9.4.0 → zerochat-9.5.0}/LICENSE +0 -0
- {zerochat-9.4.0 → zerochat-9.5.0}/README.md +0 -0
- {zerochat-9.4.0 → zerochat-9.5.0}/setup.cfg +0 -0
- {zerochat-9.4.0 → zerochat-9.5.0}/zerochat.egg-info/SOURCES.txt +0 -0
- {zerochat-9.4.0 → zerochat-9.5.0}/zerochat.egg-info/dependency_links.txt +0 -0
- {zerochat-9.4.0 → zerochat-9.5.0}/zerochat.egg-info/entry_points.txt +0 -0
- {zerochat-9.4.0 → zerochat-9.5.0}/zerochat.egg-info/top_level.txt +0 -0
|
@@ -17,7 +17,6 @@ import base64
|
|
|
17
17
|
import datetime
|
|
18
18
|
import fnmatch
|
|
19
19
|
import functools
|
|
20
|
-
import gzip
|
|
21
20
|
import hashlib
|
|
22
21
|
import hmac
|
|
23
22
|
import importlib.metadata
|
|
@@ -30,6 +29,7 @@ import secrets
|
|
|
30
29
|
import shutil
|
|
31
30
|
import shlex
|
|
32
31
|
import signal
|
|
32
|
+
import socket
|
|
33
33
|
import subprocess
|
|
34
34
|
import sys
|
|
35
35
|
import tempfile
|
|
@@ -46,7 +46,7 @@ import zlib
|
|
|
46
46
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
47
47
|
from pathlib import Path
|
|
48
48
|
|
|
49
|
-
SOURCE_BACKEND_VERSION = "9.
|
|
49
|
+
SOURCE_BACKEND_VERSION = "9.5.0"
|
|
50
50
|
|
|
51
51
|
def _read_source_version(filename: str) -> str | None:
|
|
52
52
|
"""Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
|
|
@@ -1344,15 +1344,32 @@ def set_project_directory(path) -> dict:
|
|
|
1344
1344
|
return {"cwd": str(target), "previous_cwd": previous}
|
|
1345
1345
|
|
|
1346
1346
|
|
|
1347
|
-
def
|
|
1347
|
+
def _configured_gui_diff_tool(git_bin: str, repo_root: Path) -> str:
|
|
1348
|
+
"""Herramienta gráfica que usaría `git difftool --gui`, o '' si no hay ninguna configurada.
|
|
1349
|
+
|
|
1350
|
+
Sin diff.guitool ni merge.guitool, git recurre a diff.tool o adivina una, que puede ser de
|
|
1351
|
+
terminal (vimdiff): lanzada sin terminal quedaría invisible y colgada.
|
|
1352
|
+
"""
|
|
1353
|
+
for key in ("diff.guitool", "merge.guitool"):
|
|
1354
|
+
res = subprocess.run(
|
|
1355
|
+
[git_bin, "config", "--get", key],
|
|
1356
|
+
cwd=str(repo_root),
|
|
1357
|
+
capture_output=True,
|
|
1358
|
+
text=True,
|
|
1359
|
+
timeout=5,
|
|
1360
|
+
check=False
|
|
1361
|
+
)
|
|
1362
|
+
if res.returncode == 0 and res.stdout.strip():
|
|
1363
|
+
return res.stdout.strip()
|
|
1364
|
+
return ""
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def open_file_diff(path: str, commit_fallback: bool = False) -> dict:
|
|
1348
1368
|
"""Abre un visor externo de diffs (git difftool) para comparar el fichero actual frente a Git HEAD, su último commit o fichero vacío."""
|
|
1349
1369
|
if not isinstance(path, str) or not path.strip():
|
|
1350
1370
|
raise ValueError("La ruta del archivo está vacía.")
|
|
1351
1371
|
if len(path) > MAX_PATH_LENGTH:
|
|
1352
1372
|
raise ValueError("La ruta del archivo excede el tamaño máximo permitido.")
|
|
1353
|
-
if tool is not None:
|
|
1354
|
-
if not isinstance(tool, str) or not re.match(r"^[a-zA-Z0-9_-]{1,32}$", tool):
|
|
1355
|
-
raise ValueError("Nombre de herramienta de diff inválido.")
|
|
1356
1373
|
|
|
1357
1374
|
target = Path(path.strip()).expanduser().resolve()
|
|
1358
1375
|
if not target.exists():
|
|
@@ -1380,6 +1397,11 @@ def open_file_diff(path: str, tool: str | None = None, commit_fallback: bool = F
|
|
|
1380
1397
|
raise ValueError(f"El archivo '{target.name}' no pertenece a ningún repositorio Git.")
|
|
1381
1398
|
|
|
1382
1399
|
repo_root = Path(res.stdout.strip()).resolve()
|
|
1400
|
+
if not _configured_gui_diff_tool(git_bin, repo_root):
|
|
1401
|
+
raise ValueError(
|
|
1402
|
+
"No hay un visor gráfico de diffs configurado en Git. Configura uno, por ejemplo: "
|
|
1403
|
+
"git config --global diff.guitool meld"
|
|
1404
|
+
)
|
|
1383
1405
|
|
|
1384
1406
|
has_head = subprocess.run(
|
|
1385
1407
|
[git_bin, "rev-parse", "--verify", "HEAD"],
|
|
@@ -1414,11 +1436,7 @@ def open_file_diff(path: str, tool: str | None = None, commit_fallback: bool = F
|
|
|
1414
1436
|
except ValueError:
|
|
1415
1437
|
in_head = False
|
|
1416
1438
|
|
|
1417
|
-
cmd = [git_bin, "difftool", "--no-prompt", "
|
|
1418
|
-
if tool:
|
|
1419
|
-
cmd.append(f"--tool={tool}")
|
|
1420
|
-
else:
|
|
1421
|
-
cmd.append("--gui")
|
|
1439
|
+
cmd = [git_bin, "difftool", "--no-prompt", "--gui"]
|
|
1422
1440
|
|
|
1423
1441
|
mode = "working_tree"
|
|
1424
1442
|
last_commit = ""
|
|
@@ -1465,6 +1483,7 @@ def open_file_diff(path: str, tool: str | None = None, commit_fallback: bool = F
|
|
|
1465
1483
|
|
|
1466
1484
|
popen_kwargs = {
|
|
1467
1485
|
"cwd": str(repo_root),
|
|
1486
|
+
"stdin": subprocess.DEVNULL,
|
|
1468
1487
|
"stdout": subprocess.DEVNULL,
|
|
1469
1488
|
"stderr": subprocess.DEVNULL,
|
|
1470
1489
|
}
|
|
@@ -2860,59 +2879,83 @@ METADATA_HOSTS = frozenset({
|
|
|
2860
2879
|
"100.100.100.200",
|
|
2861
2880
|
})
|
|
2862
2881
|
DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2882
|
+
WEB_FETCH_MAX_BYTES = 10 * 1024 * 1024
|
|
2883
|
+
WEB_FETCH_MAX_REDIRECTS = 5
|
|
2884
|
+
WEB_FETCH_READ_CHUNK = 64 * 1024
|
|
2885
|
+
TEXT_CONTENT_TYPE_RE = re.compile(
|
|
2886
|
+
r"^(text/|application/(json|xml|xhtml\+xml|javascript|x-javascript|ecmascript)$|[a-z]+/[^;]*\+(json|xml)$)"
|
|
2887
|
+
)
|
|
2888
|
+
|
|
2889
|
+
|
|
2890
|
+
def _is_public_ip(address: str) -> bool:
|
|
2891
|
+
"""Una dirección es pública si es global y no multicast; las IPv4 mapeadas en IPv6 se evalúan como IPv4."""
|
|
2892
|
+
ip = ipaddress.ip_address(address.split("%", 1)[0])
|
|
2893
|
+
if ip.version == 6 and ip.ipv4_mapped:
|
|
2894
|
+
ip = ip.ipv4_mapped
|
|
2895
|
+
return ip.is_global and not ip.is_multicast
|
|
2896
|
+
|
|
2897
|
+
|
|
2898
|
+
def is_dangerous_host(hostname: str) -> bool:
|
|
2899
|
+
"""Riesgo de SSRF: metadatos cloud, nombres locales o cualquier dirección resuelta no pública."""
|
|
2900
|
+
clean_host = (hostname or "").strip().lower().strip("[]").rstrip(".")
|
|
2901
|
+
if not clean_host or clean_host in METADATA_HOSTS or clean_host == "localhost":
|
|
2868
2902
|
return True
|
|
2869
|
-
clean_host
|
|
2870
|
-
if clean_host in METADATA_HOSTS:
|
|
2903
|
+
if clean_host.endswith((".localhost", ".local", ".internal")):
|
|
2871
2904
|
return True
|
|
2872
|
-
|
|
2873
2905
|
try:
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
if not allow_local and (ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_unspecified):
|
|
2878
|
-
return True
|
|
2906
|
+
infos = socket.getaddrinfo(clean_host, None, proto=socket.IPPROTO_TCP)
|
|
2907
|
+
except (socket.gaierror, UnicodeError):
|
|
2908
|
+
# Sin resolución no hay conexión posible: la petición fallará por sí misma.
|
|
2879
2909
|
return False
|
|
2880
|
-
|
|
2881
|
-
pass
|
|
2910
|
+
return any(not _is_public_ip(info[4][0]) for info in infos)
|
|
2882
2911
|
|
|
2883
|
-
if clean_host.isdigit():
|
|
2884
|
-
try:
|
|
2885
|
-
num = int(clean_host)
|
|
2886
|
-
if 0 <= num <= 4294967295:
|
|
2887
|
-
ip = ipaddress.IPv4Address(num)
|
|
2888
|
-
if ip.is_link_local:
|
|
2889
|
-
return True
|
|
2890
|
-
if not allow_local and (ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_unspecified):
|
|
2891
|
-
return True
|
|
2892
|
-
return False
|
|
2893
|
-
except ValueError:
|
|
2894
|
-
pass
|
|
2895
2912
|
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
except ValueError:
|
|
2907
|
-
pass
|
|
2913
|
+
def _validate_web_url(url: str) -> str:
|
|
2914
|
+
"""Valida esquema y host de una URL de descarga (también de cada redirección)."""
|
|
2915
|
+
parsed = urllib.parse.urlparse(url)
|
|
2916
|
+
if parsed.scheme not in ("http", "https"):
|
|
2917
|
+
raise ValueError(f"Protocolo no permitido: '{parsed.scheme}'. Solo se admite http y https.")
|
|
2918
|
+
if not parsed.hostname:
|
|
2919
|
+
raise ValueError("URL sin nombre de host válido.")
|
|
2920
|
+
if is_dangerous_host(parsed.hostname):
|
|
2921
|
+
raise PermissionError("Acceso bloqueado: intento de acceso a servicios internos o metadatos de infraestructura (SSRF).")
|
|
2922
|
+
return url
|
|
2908
2923
|
|
|
2909
|
-
if not allow_local:
|
|
2910
|
-
if clean_host in ("localhost", "0.0.0.0", "127.0.0.1", "::1"):
|
|
2911
|
-
return True
|
|
2912
|
-
if clean_host.endswith(".local") or clean_host.endswith(".internal"):
|
|
2913
|
-
return True
|
|
2914
2924
|
|
|
2915
|
-
|
|
2925
|
+
class _ValidatingRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
2926
|
+
"""Sigue redirecciones solo si el destino supera la misma validación anti-SSRF que la URL pedida."""
|
|
2927
|
+
max_redirections = WEB_FETCH_MAX_REDIRECTS
|
|
2928
|
+
|
|
2929
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
2930
|
+
_validate_web_url(newurl)
|
|
2931
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
2932
|
+
|
|
2933
|
+
|
|
2934
|
+
_WEB_OPENER = urllib.request.build_opener(_ValidatingRedirectHandler)
|
|
2935
|
+
|
|
2936
|
+
|
|
2937
|
+
def _read_limited_body(stream, content_encoding: str, max_bytes: int) -> tuple[bytes, bool]:
|
|
2938
|
+
"""Lee el cuerpo ya descomprimido hasta `max_bytes`; indica si había más (truncado)."""
|
|
2939
|
+
encoding = (content_encoding or "").strip().lower()
|
|
2940
|
+
# MAX_WBITS | 32 detecta la cabecera gzip o zlib.
|
|
2941
|
+
decompressor = zlib.decompressobj(zlib.MAX_WBITS | 32) if encoding in ("gzip", "x-gzip", "deflate") else None
|
|
2942
|
+
data = bytearray()
|
|
2943
|
+
while len(data) <= max_bytes:
|
|
2944
|
+
chunk = stream.read(WEB_FETCH_READ_CHUNK)
|
|
2945
|
+
if not chunk:
|
|
2946
|
+
break
|
|
2947
|
+
if decompressor:
|
|
2948
|
+
# La salida se limita al hueco restante: un cuerpo comprimido no puede crecer sin tope.
|
|
2949
|
+
chunk = decompressor.decompress(chunk, max_bytes + 1 - len(data))
|
|
2950
|
+
data += chunk
|
|
2951
|
+
truncated = len(data) > max_bytes
|
|
2952
|
+
return bytes(data[:max_bytes]), truncated
|
|
2953
|
+
|
|
2954
|
+
|
|
2955
|
+
def is_text_content_type(content_type: str) -> bool:
|
|
2956
|
+
"""Tipos que se devuelven como texto; el resto viaja en base64."""
|
|
2957
|
+
media_type = (content_type or "").split(";", 1)[0].strip().lower()
|
|
2958
|
+
return not media_type or bool(TEXT_CONTENT_TYPE_RE.match(media_type))
|
|
2916
2959
|
|
|
2917
2960
|
|
|
2918
2961
|
def fetch_web_resource(params: dict) -> dict:
|
|
@@ -2920,97 +2963,48 @@ def fetch_web_resource(params: dict) -> dict:
|
|
|
2920
2963
|
url = params.get("url")
|
|
2921
2964
|
if not isinstance(url, str) or not url.strip():
|
|
2922
2965
|
raise ValueError("URL no válida o ausente.")
|
|
2923
|
-
url = url.strip()
|
|
2924
|
-
parsed = urllib.parse.urlparse(url)
|
|
2925
|
-
if parsed.scheme not in ("http", "https"):
|
|
2926
|
-
raise ValueError(f"Protocolo no permitido: '{parsed.scheme}'. Solo se admite http y https.")
|
|
2927
|
-
hostname = (parsed.hostname or "").lower().strip("[]")
|
|
2928
|
-
if not hostname:
|
|
2929
|
-
raise ValueError("URL sin nombre de host válido.")
|
|
2966
|
+
url = _validate_web_url(url.strip())
|
|
2930
2967
|
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
raise PermissionError("Acceso bloqueado: intento de acceso a servicios internos o metadatos de infraestructura (SSRF).")
|
|
2934
|
-
|
|
2935
|
-
timeout_s = float(params.get("timeout_s", 10.0))
|
|
2936
|
-
timeout_s = max(0.5, min(timeout_s, 30.0))
|
|
2937
|
-
max_bytes = int(params.get("max_bytes", 2 * 1024 * 1024))
|
|
2938
|
-
max_bytes = max(1024, min(max_bytes, 10 * 1024 * 1024))
|
|
2968
|
+
timeout_s = max(0.5, min(float(params.get("timeout_s", 10.0)), 30.0))
|
|
2969
|
+
max_bytes = max(1024, min(int(params.get("max_bytes", 2 * 1024 * 1024)), WEB_FETCH_MAX_BYTES))
|
|
2939
2970
|
as_binary = bool(params.get("binary", False))
|
|
2940
|
-
|
|
2971
|
+
accept = params.get("accept")
|
|
2941
2972
|
req_headers = {
|
|
2942
2973
|
"User-Agent": DEFAULT_USER_AGENT,
|
|
2943
|
-
"Accept":
|
|
2974
|
+
"Accept": accept if isinstance(accept, str) and accept else "*/*",
|
|
2944
2975
|
"Accept-Encoding": "gzip, deflate",
|
|
2945
2976
|
}
|
|
2946
|
-
custom_headers = params.get("headers")
|
|
2947
|
-
if isinstance(custom_headers, dict):
|
|
2948
|
-
for k, v in custom_headers.items():
|
|
2949
|
-
if isinstance(k, str) and isinstance(v, str) and k.lower() != "host":
|
|
2950
|
-
req_headers[k] = v
|
|
2951
2977
|
|
|
2952
2978
|
req = urllib.request.Request(url, headers=req_headers)
|
|
2953
2979
|
try:
|
|
2954
|
-
with
|
|
2955
|
-
status = resp.status
|
|
2980
|
+
with _WEB_OPENER.open(req, timeout=timeout_s) as resp:
|
|
2956
2981
|
content_type = resp.headers.get("Content-Type", "")
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
pass
|
|
2973
|
-
|
|
2974
|
-
resp_headers = {k: v for k, v in resp.getheaders()}
|
|
2975
|
-
|
|
2976
|
-
if as_binary:
|
|
2977
|
-
return {
|
|
2978
|
-
"ok": True,
|
|
2979
|
-
"status": status,
|
|
2980
|
-
"content_type": content_type,
|
|
2981
|
-
"headers": resp_headers,
|
|
2982
|
-
"base64": base64.b64encode(raw_data).decode("ascii"),
|
|
2983
|
-
"byte_size": len(raw_data),
|
|
2984
|
-
"truncated": truncated
|
|
2985
|
-
}
|
|
2986
|
-
|
|
2982
|
+
raw_data, truncated = _read_limited_body(resp, resp.headers.get("Content-Encoding", ""), max_bytes)
|
|
2983
|
+
# Las cabeceras de transporte ya no describen el cuerpo descomprimido.
|
|
2984
|
+
resp_headers = {k: v for k, v in resp.getheaders() if k.lower() not in ("content-encoding", "content-length")}
|
|
2985
|
+
result = {
|
|
2986
|
+
"ok": True,
|
|
2987
|
+
"status": resp.status,
|
|
2988
|
+
"url": resp.geturl(),
|
|
2989
|
+
"content_type": content_type,
|
|
2990
|
+
"headers": resp_headers,
|
|
2991
|
+
"byte_size": len(raw_data),
|
|
2992
|
+
"truncated": truncated
|
|
2993
|
+
}
|
|
2994
|
+
if as_binary or not is_text_content_type(content_type):
|
|
2995
|
+
result["base64"] = base64.b64encode(raw_data).decode("ascii")
|
|
2996
|
+
return result
|
|
2987
2997
|
charset = resp.headers.get_content_charset() or "utf-8"
|
|
2988
2998
|
try:
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
"content_type": content_type,
|
|
2994
|
-
"headers": resp_headers,
|
|
2995
|
-
"text": text_content,
|
|
2996
|
-
"byte_size": len(raw_data),
|
|
2997
|
-
"truncated": truncated
|
|
2998
|
-
}
|
|
2999
|
-
except (UnicodeDecodeError, LookupError):
|
|
3000
|
-
return {
|
|
3001
|
-
"ok": True,
|
|
3002
|
-
"status": status,
|
|
3003
|
-
"content_type": content_type,
|
|
3004
|
-
"headers": resp_headers,
|
|
3005
|
-
"base64": base64.b64encode(raw_data).decode("ascii"),
|
|
3006
|
-
"byte_size": len(raw_data),
|
|
3007
|
-
"truncated": truncated
|
|
3008
|
-
}
|
|
2999
|
+
result["text"] = raw_data.decode(charset, errors="replace")
|
|
3000
|
+
except LookupError:
|
|
3001
|
+
result["text"] = raw_data.decode("utf-8", errors="replace")
|
|
3002
|
+
return result
|
|
3009
3003
|
except urllib.error.HTTPError as he:
|
|
3010
3004
|
err_body = ""
|
|
3011
3005
|
try:
|
|
3012
3006
|
err_body = he.read(2048).decode("utf-8", errors="replace")
|
|
3013
|
-
except
|
|
3007
|
+
except OSError:
|
|
3014
3008
|
pass
|
|
3015
3009
|
return {
|
|
3016
3010
|
"ok": False,
|
|
@@ -3432,8 +3426,7 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
|
|
|
3432
3426
|
return None, {"code": -32602, "message": str(exc)}, ""
|
|
3433
3427
|
if method == "zerochat/diff/open":
|
|
3434
3428
|
try:
|
|
3435
|
-
|
|
3436
|
-
return open_file_diff(params.get("path"), params.get("tool"), commit_fallback), None, ""
|
|
3429
|
+
return open_file_diff(params.get("path"), bool(params.get("commit_fallback"))), None, ""
|
|
3437
3430
|
except (ValueError, RuntimeError) as exc:
|
|
3438
3431
|
return None, {"code": -32602, "message": str(exc)}, ""
|
|
3439
3432
|
except Exception as exc:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|