"""tar ストリームの書き出しと展開(ssh の標準入出力につなぐ).
Windows には rsync が無いため,Python の tarfile でストリームを作り
`ssh <host> 'tar -x ...'` の標準入力へ流す(push),または `ssh <host> 'tar -c ...'` の
標準出力を展開する(pull).手元の tar に依存しないので PowerShell からも Git Bash からも
同じに動く.
- 書き出しは PAX 形式(日本語ファイル名を UTF-8 で確実に運ぶ)
- 展開は,メンバー名の構造検証(`..`・`.`・空の要素・バックスラッシュ・ドライブ文字を拒否)→
リンク(シンボリック・ハード)の一律拒否 → 要求したパスとの前方一致 → Python 3.12 の
`filter="data"`(展開先の外・特殊ファイル)の順に確かめ,どれかに当たれば中断する
"""
from __future__ import annotations
import re
import tarfile
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import BinaryIO
from sm_tias_lightsim.remote.files import PushEntry
# (処理済みバイト数, 総バイト数.不明なら 0)
ProgressCallback = Callable[[int, int], None]
_ENCODING = "utf-8"
# Windows のドライブ指定(`C:` 形式).os.path.join がこれを見ると展開先を無視して付け替える
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:")
def _normalize_tarinfo(info: tarfile.TarInfo) -> tarfile.TarInfo:
"""所有者とパーミッションを送信元 OS に依存しない値にそろえる.
Windows の stat は所有者を持たず実行ビットも拡張子から推測した値なので,
展開結果がそれに引きずられないよう固定する(実行ビットが立っているものだけ 755).
"""
info.uid = info.gid = 0
info.uname = info.gname = ""
info.mode = 0o755 if info.isdir() or info.mode & 0o111 else 0o644
return info
def write_tar_stream(
entries: Sequence[PushEntry],
fileobj: BinaryIO,
*,
compress: bool,
progress: ProgressCallback | None = None,
) -> int:
"""送信対象を tar ストリームとして fileobj へ書き出す.
fileobj は閉じない(呼び出し側が ssh の標準入力を閉じて EOF を伝える).
Args:
entries: 送信対象.
fileobj: 書き込み先のバイナリストリーム(ssh の標準入力).
compress: True なら gzip 圧縮する.
progress: ファイル 1 件ごとに呼ぶ進捗コールバック(任意).
Returns:
書き出したファイルの合計バイト数(圧縮前).
Raises:
OSError: 書き込み先が閉じられた(ssh が異常終了した)場合など.
"""
total = sum(entry.size for entry in entries)
done = 0
mode = "w|gz" if compress else "w|"
with tarfile.open(
fileobj=fileobj,
mode=mode,
format=tarfile.PAX_FORMAT,
encoding=_ENCODING,
dereference=True,
) as tar:
for entry in entries:
tar.add(entry.path, arcname=entry.arcname, recursive=False, filter=_normalize_tarinfo)
done += entry.size
if progress is not None:
progress(done, total)
return done
def _reject(reason: str, name: str) -> tarfile.FilterError:
"""展開を中止するための例外を作る(問題のメンバー名と確認先を必ず含める)."""
return tarfile.FilterError(
f"{reason}: 「{name}」(展開を中止しました.サーバ側の output/ を確認してください)"
)
def _validate_member_name(name: str) -> None:
"""メンバー名の構造を検証する(要求パスとの前方一致判定の前提).
`filter="data"` は展開先ディレクトリの外に出るものしか拒否しないため,
`output/foo/../../src/...` のように展開先の内側で要求パスの外へ出る名前が通ってしまう.
文字列の前方一致だけでは防げないので,要素単位で危険な形を拒否する.
バックスラッシュは Windows の `os.path.join` が区切りとして解釈するため
(`output/foo/..\\..\\src\\x.py` で抜けられる),`posixpath.normpath` で丸める方式は採らない.
Raises:
tarfile.FilterError: `..`・`.`・空の要素(先頭 `/` や `//`)・バックスラッシュ・
ドライブ文字(`C:` 形式)を含む場合.
"""
# tarfile はディレクトリ名の末尾の / を落とすが,念のため 1 つだけ許す
trimmed = name[:-1] if name.endswith("/") else name
for part in trimmed.split("/"):
if part in ("", ".", "..") or "\\" in part or _WINDOWS_DRIVE_RE.match(part):
raise _reject(
"アーカイブのメンバー名が不正です"
"('..'・'.'・空の要素・バックスラッシュ・ドライブ文字は受け付けません)",
name,
)
def _is_under(name: str, prefixes: Sequence[str]) -> bool:
"""name が prefixes のいずれか自身,またはその配下か(構造検証済みの名前に使う)."""
name = name.rstrip("/")
return any(name == prefix or name.startswith(prefix + "/") for prefix in prefixes)
def extract_tar_stream(
fileobj: BinaryIO,
dest: Path,
allowed_prefixes: Sequence[str],
*,
progress: ProgressCallback | None = None,
) -> tuple[int, int]:
"""tar ストリーム(非圧縮・gzip 自動判別)を dest へ安全に展開する.
アーカイブの末尾(終端ブロック後のパディング)は読み残すことがあるため,
ssh プロセスを待つ前に呼び出し側で fileobj を読み切ること(読み残すと ssh が
パイプへの書き込みで止まり,待ち合わせがデッドロックする).
Args:
fileobj: 読み出し元のバイナリストリーム(ssh の標準出力).
dest: 展開先ディレクトリ(無ければ作る).
allowed_prefixes: 受け入れるメンバーのパス(要求したパスそのもの).
progress: ファイル 1 件ごとに呼ぶ進捗コールバック(任意.総バイト数は 0).
Returns:
(展開したファイル数, 合計バイト数).
検証の順序: メンバー名の構造 → リンクの拒否 → 要求パスとの前方一致 → `data_filter`
(展開先の外・特殊ファイル・危険なパーミッション).中断時,それまでに展開した
ファイルは残る.
Raises:
tarfile.TarError: ストリームが空・壊れている,または不正な名前・リンク・要求外・
危険なメンバーを含む場合(名前・リンク・要求外は `tarfile.FilterError`).
OSError: 書き込みに失敗した場合.
"""
counts = {"files": 0, "bytes": 0}
def _filter(member: tarfile.TarInfo, path: str) -> tarfile.TarInfo | None:
_validate_member_name(member.name)
# data_filter はリンク先が展開先の内側なら通すため,同名の通常ファイルを後から
# 送られるとハードリンク経由で src/ 等を上書きできる(Windows でも特権なしで作れる).
# pull の対象(output/ の PNG・npy・csv)にリンクは無い前提で一律に拒否する
if member.issym() or member.islnk():
raise _reject(
f"アーカイブにリンクが含まれています(リンク先 {member.linkname})", member.name
)
if not _is_under(member.name, allowed_prefixes):
raise _reject("要求していないパスがアーカイブに含まれています", member.name)
filtered = tarfile.data_filter(member, path)
if filtered is not None and filtered.isfile():
counts["files"] += 1
counts["bytes"] += filtered.size
if progress is not None:
progress(counts["bytes"], 0)
return filtered
dest.mkdir(parents=True, exist_ok=True)
with tarfile.open(fileobj=fileobj, mode="r|*", encoding=_ENCODING) as tar:
tar.extractall(dest, filter=_filter)
return counts["files"], counts["bytes"]