"""pull の回収ホワイトリスト(SPEC_01「回収ホワイトリスト」)の判定と,受け取った tar の検査.
回収できるのは `runs/<RUN_ID>/` 配下で名前が一致するファイルのみ.拡張子や中身では判断せず,
列挙外はすべて回収しない.判定は 2 か所で使う(二重の防御.SPEC_03「pull」).
1. サーバの一覧(`build_pull_list_command` の出力)から送らせるファイルを絞り込む(`plan_pull`)
2. 手元で受け取った tar を展開前に全メンバー検査する(`check_pull_members`)
エラーメッセージ・戻り値の表示用情報には件数だけを載せ,ファイル名は出さない
(患者情報を含む名前の可能性があるため).
"""
from __future__ import annotations
import re
import tarfile
from collections.abc import Iterable
from dataclasses import dataclass
# runs/<RUN_ID>/ 直下で回収するファイル名
PULL_ROOT_FILES = frozenset(
{
"config.yaml",
"history.csv",
"metrics.json",
"confusion_matrix.csv",
"log.txt",
"dataset_summary.json",
}
)
PULL_FIGURES_DIR = "figures"
PULL_FIGURE_SUFFIX = ".png"
PULL_CHECKPOINTS_DIR = "checkpoints"
PULL_CHECKPOINT_SUFFIX = ".pt"
# find -printf '%y' の通常ファイル
_FIND_TYPE_FILE = "f"
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:")
@dataclass(frozen=True)
class PullPlan:
"""サーバから送らせるファイルの計画.
Attributes:
files: 送らせるファイル(`runs/` 基準の POSIX パス.表示しない).
skipped: 回収しなかったファイルの件数(リンク等の通常ファイル以外を含む).
skipped_checkpoints: そのうち `--with-checkpoints` を付ければ回収できた件数.
"""
files: tuple[str, ...]
skipped: int
skipped_checkpoints: int
def validate_run_id(run_id: str) -> str:
"""RUN_ID(`runs/` 直下のディレクトリ名)を検証して返す.
Args:
run_id: 実験 ID.
Returns:
検証済みの RUN_ID.
Raises:
ValueError: 空・`/`・`\\`・`..`・制御文字を含む,`.`/`-` で始まる場合.
"""
if (
not run_id
or "/" in run_id
or "\\" in run_id
or ".." in run_id
or run_id.startswith((".", "-"))
or any(ord(c) < 0x20 or c == "\x7f" for c in run_id)
or run_id != run_id.strip()
):
raise ValueError(
f"RUN_ID が不正である: {run_id!r}(runs/ 直下のディレクトリ名を指定する."
"/・\\・.. は使えない)"
)
return run_id
def _is_safe_part(part: str) -> bool:
"""パスの 1 要素が `..`・`.`・空・バックスラッシュ・ドライブ文字を含まないか."""
return part not in ("", ".", "..") and "\\" not in part and not _WINDOWS_DRIVE_RE.match(part)
def _is_safe_relative(path: str) -> bool:
"""POSIX 区切りの相対パスとして安全か(絶対パス・パス外へ出る要素を含まない)."""
return not path.startswith("/") and all(_is_safe_part(part) for part in path.split("/"))
def is_pull_allowed(rel_path: str, *, with_checkpoints: bool) -> bool:
"""`runs/<RUN_ID>/` 基準の相対パスが回収ホワイトリストに一致するかを返す.
Args:
rel_path: `runs/<RUN_ID>/` 基準の POSIX 相対パス.
with_checkpoints: `checkpoints/*.pt` も回収対象にするか.
Returns:
回収してよければ True.
"""
if not _is_safe_relative(rel_path):
return False
parts = rel_path.split("/")
if len(parts) == 1:
return parts[0] in PULL_ROOT_FILES
if len(parts) != 2:
return False
directory, name = parts
if directory == PULL_FIGURES_DIR:
return name.endswith(PULL_FIGURE_SUFFIX) and len(name) > len(PULL_FIGURE_SUFFIX)
if directory == PULL_CHECKPOINTS_DIR and with_checkpoints:
return name.endswith(PULL_CHECKPOINT_SUFFIX) and len(name) > len(PULL_CHECKPOINT_SUFFIX)
return False
def _strip_run_prefix(path: str, run_id: str) -> str | None:
"""`<RUN_ID>/<rel>` から `<rel>` を取り出す(RUN_ID 配下でなければ None)."""
prefix = f"{run_id}/"
return path[len(prefix) :] if path.startswith(prefix) else None
def parse_find_listing(data: bytes) -> list[tuple[str, str]]:
"""`build_pull_list_command` の出力(`<種類> <パス>` の NUL 区切り)を分解する.
Linux のファイル名は任意のバイト列なので,往復で失われないよう surrogateescape で扱う.
Args:
data: サーバの出力.
Returns:
(種類, `runs/` 基準のパス) の一覧.
Raises:
ValueError: 書式が崩れている場合(件数のみ示す).
"""
entries: list[tuple[str, str]] = []
for number, record in enumerate((r for r in data.split(b"\0") if r), start=1):
kind, sep, path = record.decode("utf-8", "surrogateescape").partition(" ")
if not sep or not kind or not path:
raise ValueError(f"サーバのファイル一覧の {number} 件目を解釈できない")
entries.append((kind, path))
return entries
def plan_pull(
listing: Iterable[tuple[str, str]], run_id: str, *, with_checkpoints: bool
) -> PullPlan:
"""サーバのファイル一覧から,送らせるファイル(ホワイトリストに一致する通常ファイル)を選ぶ.
Args:
listing: `parse_find_listing` の結果.
run_id: 検証済みの RUN_ID.
with_checkpoints: `checkpoints/*.pt` も回収するか.
Returns:
送らせるファイルと,回収しなかった件数.
"""
files: list[str] = []
skipped = 0
skipped_checkpoints = 0
for kind, path in listing:
rel = _strip_run_prefix(path, run_id)
if kind == _FIND_TYPE_FILE and rel is not None:
if is_pull_allowed(rel, with_checkpoints=with_checkpoints):
files.append(path)
continue
if not with_checkpoints and is_pull_allowed(rel, with_checkpoints=True):
skipped_checkpoints += 1
skipped += 1
return PullPlan(tuple(sorted(files)), skipped, skipped_checkpoints)
def check_pull_members(
members: Iterable[tarfile.TarInfo], run_id: str, *, with_checkpoints: bool
) -> int:
"""受け取った tar の全メンバーを展開前に検査する.
1 つでも問題があれば例外を送出する(呼び出し側は何も展開しない).受け入れるのは
`<RUN_ID>/<ホワイトリストに一致する名前>` の通常ファイルのみで,ディレクトリ・リンク
(シンボリック・ハード)・特殊ファイルは拒否する(サーバはファイル名だけを tar に渡すため,
正常な応答にディレクトリのメンバーは含まれない).
Args:
members: tar のメンバー(`TarFile.getmembers()`).
run_id: 検証済みの RUN_ID.
with_checkpoints: `checkpoints/*.pt` を受け入れるか.
Returns:
受け入れたメンバーの件数.
Raises:
ValueError: ホワイトリスト外の名前・パス外に出る名前(`..`・絶対パス・ドライブ文字・
バックスラッシュ)・リンク・通常ファイル以外が含まれる場合(件数のみを示す).
"""
accepted = 0
outside = 0
links = 0
not_regular = 0
not_allowed = 0
for member in members:
if not _is_safe_relative(member.name):
outside += 1
elif member.issym() or member.islnk():
links += 1
elif not member.isreg():
not_regular += 1
else:
rel = _strip_run_prefix(member.name, run_id)
if rel is None or not is_pull_allowed(rel, with_checkpoints=with_checkpoints):
not_allowed += 1
else:
accepted += 1
if outside or links or not_regular or not_allowed:
raise ValueError(
"受け取ったアーカイブに回収できないメンバーが含まれるため,何も展開せず中止した"
f"(ホワイトリスト外 {not_allowed} 件・パス外 {outside} 件・リンク {links} 件・"
f"通常ファイル以外 {not_regular} 件)"
)
return accepted