Files
Nexus/server/utils/posix_paths.py
2026-07-08 22:31:31 +08:00

179 lines
6.2 KiB
Python

"""Linux/POSIX path helpers for remote SSH paths and Nexus-host Unix paths.
Use this module whenever manipulating path *strings* for Linux targets.
Do not use ``os.path`` for remote paths — on Windows dev hosts ``os.path.join``
produces backslashes and breaks SSH/SFTP commands.
For real filesystem I/O on the Nexus host (``open``, ``os.scandir``), keep
``os.path.realpath`` / ``os.walk`` after normalizing with :func:`to_posix`.
"""
from __future__ import annotations
import os
import posixpath
UPLOAD_STAGING_PREFIX = "/tmp/nexus_upload_"
# Baota default web root — push/sync fallback when server.target_path is empty.
DEFAULT_PUSH_TARGET_PATH = "/www/wwwroot"
FORBIDDEN_NEXUS_SOURCE_PREFIXES = (
"/etc",
"/root",
"/home",
"/var",
"/boot",
"/dev",
"/proc",
"/sys",
"/run",
"/srv",
)
class PosixPathError(ValueError):
"""Invalid or unsafe POSIX path."""
def to_posix(path: str) -> str:
"""Normalize slashes to forward slash (does not resolve ``..``)."""
return (path or "").strip().replace("\\", "/")
def reject_windows_separators(path: str, *, field: str = "path") -> None:
if "\\" in path or "\0" in path:
raise PosixPathError(f"{field} 必须使用 Linux 路径(不允许反斜杠)")
def normalize_remote_abs_path(path: str) -> str:
"""Validate and normalize a remote absolute path (SSH/SFTP)."""
raw_in = (path or "").strip()
reject_windows_separators(raw_in, field="path")
raw = to_posix(raw_in)
if not raw.startswith("/"):
raise PosixPathError("路径必须为绝对路径")
parts = [p for p in raw.split("/") if p and p != "."]
if ".." in parts:
raise PosixPathError("路径不允许包含 '..'")
normalized = "/" + "/".join(parts) if parts else "/"
if len(normalized) > 1 and normalized.endswith("/"):
normalized = normalized.rstrip("/")
return normalized or "/"
def posix_join(*parts: str) -> str:
"""Join path segments using POSIX rules (safe on any dev OS)."""
if not parts:
return "/"
acc = to_posix(parts[0])
for part in parts[1:]:
seg = to_posix(part)
if not seg or seg == "/":
continue
acc = posixpath.join(acc, seg.lstrip("/"))
return posixpath.normpath(acc) or "/"
def posix_basename(path: str) -> str:
return posixpath.basename(to_posix(path.rstrip("/")))
def posix_dirname(path: str) -> str:
parent = posixpath.dirname(to_posix(path.rstrip("/")))
return parent or "/"
def remote_join(directory: str, name: str) -> str:
"""Join remote directory + file name (both logical Linux paths)."""
base = normalize_remote_abs_path(directory)
segment = to_posix(name).strip("/")
reject_windows_separators(segment, field="name")
if ".." in segment.split("/"):
raise PosixPathError("名称不能包含 '..'")
if not segment:
return base
return posix_join(base, segment)
def resolve_nexus_host_path(path: str) -> str:
"""Resolve a path on the Nexus host filesystem (Linux deployment)."""
posix = to_posix(path)
reject_windows_separators(posix, field="path")
return os.path.realpath(posix)
def resolve_nexus_push_source_path(path: str) -> str:
"""Resolve and validate a Nexus host push source directory."""
stripped = path.strip()
if ".." in to_posix(stripped).split("/"):
raise PosixPathError("路径不允许包含 '..'")
real_path = resolve_nexus_host_path(stripped)
for prefix in FORBIDDEN_NEXUS_SOURCE_PREFIXES:
if real_path == prefix or real_path.startswith(prefix + "/"):
raise PosixPathError(f"不允许使用系统路径: {prefix}")
if not os.path.isdir(real_path):
raise PosixPathError(f"源路径不存在: {real_path}")
return real_path
def ensure_under_nexus_upload(path: str) -> str:
"""Resolve *path* and ensure it stays under ``/tmp/nexus_upload_*``."""
real = resolve_nexus_host_path(path)
if not to_posix(real).startswith(UPLOAD_STAGING_PREFIX):
raise PosixPathError("仅允许访问上传临时目录")
return real
def assert_zip_member_safe(extract_dir: str, member_name: str) -> str:
"""Zip-slip check; returns normalized member path under *extract_dir*."""
base = normalize_remote_abs_path(extract_dir)
member = to_posix(member_name).lstrip("/")
if not member or member == ".":
return base
if member == ".." or member.startswith("../") or "/../" in f"/{member}/":
raise PosixPathError(f"ZIP 包含非法路径: {member_name}")
candidate = posixpath.normpath(posixpath.join(base, member))
if candidate != base and not candidate.startswith(base + "/"):
raise PosixPathError(f"ZIP 包含非法路径: {member_name}")
return candidate
def is_path_under_prefix(resolved: str, prefix: str) -> bool:
"""True if *resolved* is *prefix* or a child (POSIX string compare after norm)."""
r = normalize_remote_abs_path(to_posix(resolved))
p = normalize_remote_abs_path(to_posix(prefix))
return r == p or r.startswith(p + "/")
def normalize_remote_dest(path: str | None, *, default: str = DEFAULT_PUSH_TARGET_PATH) -> str:
"""Normalize remote target directory (push/rsync/diagnose); uses *default* when empty."""
raw = (path or "").strip() or default
return normalize_remote_abs_path(raw)
def resolve_push_target_path(
server_target_path: str | None,
override: str | None = None,
) -> str:
"""Resolve rsync push destination: override → server.target_path → ``/www/wwwroot``.
Independent of :func:`is_unset_target_path`: empty or placeholder ``/www/wwwroot`` in DB
still counts as「未设路径」in UI, but push may target ``/www/wwwroot`` (Baota web root).
"""
if override is not None and str(override).strip():
return normalize_remote_abs_path(str(override).strip())
raw = (server_target_path or "").strip()
if raw:
return normalize_remote_abs_path(raw)
return DEFAULT_PUSH_TARGET_PATH
def is_unset_target_path(path: str | None) -> bool:
"""True when push target path is empty or still the BT default placeholder.
``/www/wwwroot/`` (trailing slash) is *not* unset — only exact ``/www/wwwroot`` after trim.
"""
p = (path or "").strip()
return p == "" or p == "/www/wwwroot"