"""Private artifact download ports; object paths never enter public JSON.""" from __future__ import annotations import hashlib import os import stat from dataclasses import dataclass from pathlib import Path, PurePosixPath from typing import Protocol from arr_web.contracts import PortalError MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024 ALLOWED_DOWNLOAD_KINDS = frozenset( {"daily_xlsx", "monthly_xlsx", "company_ten_day_xlsx"} ) @dataclass(frozen=True) class ArtifactDescriptor: file_kind: str original_filename: str storage_key: str sha256: str byte_size: int mime_type: str def validate(self) -> None: path = PurePosixPath(self.storage_key) if ( self.file_kind not in ALLOWED_DOWNLOAD_KINDS or not self.original_filename or PurePosixPath(self.original_filename).name != self.original_filename or "/" in self.original_filename or "\\" in self.original_filename or "\x00" in self.original_filename or not self.storage_key or path.is_absolute() or "." in path.parts or ".." in path.parts or "\\" in self.storage_key or len(self.sha256) != 64 or any(character not in "0123456789abcdef" for character in self.sha256) or not 0 < self.byte_size <= MAX_DOWNLOAD_BYTES or not self.mime_type ): raise PortalError("DOWNLOAD_REFERENCE_INVALID", "文件身份无效", 500) class ArtifactReader(Protocol): def read(self, descriptor: ArtifactDescriptor) -> bytes: ... class UnavailableArtifactReader: def read(self, descriptor: ArtifactDescriptor) -> bytes: raise PortalError("DOWNLOAD_UNAVAILABLE", "文件读取服务暂不可用", 503) class ControlledProjectArtifactReader: """Development/local publication reader constrained to one project root.""" def __init__(self, project_root: Path) -> None: self._project_root = project_root.resolve() def read(self, descriptor: ArtifactDescriptor) -> bytes: descriptor.validate() path = (self._project_root / descriptor.storage_key).resolve() try: path.relative_to(self._project_root) metadata = path.lstat() except (ValueError, OSError): raise PortalError("DOWNLOAD_UNAVAILABLE", "文件读取服务暂不可用", 503) from None if path.is_symlink() or not stat.S_ISREG(metadata.st_mode): raise PortalError("DOWNLOAD_REFERENCE_INVALID", "文件身份无效", 500) if metadata.st_size != descriptor.byte_size: raise PortalError("DOWNLOAD_IDENTITY_MISMATCH", "文件完整性校验失败", 503) digest = hashlib.sha256() chunks: list[bytes] = [] descriptor_fd = -1 total = 0 try: descriptor_fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) with os.fdopen(descriptor_fd, "rb") as source: descriptor_fd = -1 while True: chunk = source.read(1024 * 1024) if not chunk: break total += len(chunk) if total > MAX_DOWNLOAD_BYTES: raise PortalError("DOWNLOAD_TOO_LARGE", "文件超过下载限制", 413) digest.update(chunk) chunks.append(chunk) except PortalError: raise except OSError: raise PortalError("DOWNLOAD_UNAVAILABLE", "文件读取服务暂不可用", 503) from None finally: if descriptor_fd >= 0: os.close(descriptor_fd) if total != descriptor.byte_size or digest.hexdigest() != descriptor.sha256: raise PortalError("DOWNLOAD_IDENTITY_MISMATCH", "文件完整性校验失败", 503) return b"".join(chunks)