Files
wyndham-ARR/arr_web/downloads.py
2026-07-29 16:38:05 +08:00

107 lines
3.8 KiB
Python

"""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)