"""ストア(登録したファイルの実体)のハッシュ・コピー・照合.
ストアのファイルは中身の SHA-256 を名前にし,一度置いたら書き換えない・消さない
(SPEC_01 基本方針 2「登録したファイルは変えない」).置き場所は
`store/<SHA-256 の先頭 2 文字>/<SHA-256>.<拡張子>` で中身から決まるため,目録には保存しない
(SPEC_02「assets」).
例外メッセージ・ログにはパス(データの場所)や元のファイル名を出さず,SHA-256 で示す.
"""
import hashlib
import logging
import os
import re
import stat
import tempfile
from enum import StrEnum
from pathlib import Path
from typing import NamedTuple
logger = logging.getLogger(__name__)
# 大きいファイルでもメモリを使い切らないよう分割して読む
CHUNK_SIZE = 1024 * 1024
# 書きかけの一時ファイルの名前の接頭辞.正式な名前(<sha256>.<ext>)と区別でき,
# 点検(`tiasdata check`)で書きかけの残りを見分けられるようにする
TEMP_PREFIX = ".tmp-"
_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
_EXT_PATTERN = re.compile(r"^[a-z0-9]+$")
class StoredFile(NamedTuple):
"""ストアへの取り込みの結果.
Attributes:
sha256: 中身の SHA-256(小文字の 16 進 64 文字).
ext: 拡張子(小文字.先頭の `.` なし).
size_bytes: サイズ.
copied: 今回コピーしたか(同じ中身が既にあったときは False).
"""
sha256: str
ext: str
size_bytes: int
copied: bool
class StoredFileStatus(StrEnum):
"""ストアのファイルの照合の結果."""
OK = "ok"
MISSING = "missing"
MISMATCH = "mismatch"
def normalize_ext(ext: str) -> str:
"""拡張子を小文字・先頭の `.` なしの形にそろえる.
Args:
ext: 拡張子(`.PNG`・`png` 等).
Returns:
小文字の拡張子(例: `png`).
Raises:
ValueError: 空,または英数字以外を含む.
"""
normalized = ext.removeprefix(".").lower()
if not _EXT_PATTERN.match(normalized):
# 拡張子そのものは出さない(元のファイル名の一部のため)
raise ValueError("拡張子が無い,または英数字以外を含むファイルはストアに置けない")
return normalized
def store_path(store_dir: Path, sha256: str, ext: str) -> Path:
"""ストア上のパスを決める(ファイルには触れない純関数).
Args:
store_dir: ストアのフォルダ(`<catalog.root>/store`).
sha256: 中身の SHA-256(小文字の 16 進 64 文字).
ext: 拡張子(`.PNG` 等も小文字・先頭の `.` なしにそろえる).
Returns:
`store_dir/<sha256 の先頭 2 文字>/<sha256>.<ext>`.
Raises:
ValueError: SHA-256 の形が誤っている,または拡張子が不正.
"""
if not _SHA256_PATTERN.match(sha256):
raise ValueError("SHA-256 は小文字の 16 進 64 文字で指定する")
return store_dir / sha256[:2] / f"{sha256}.{normalize_ext(ext)}"
def sha256_file(path: Path) -> str:
"""ファイルの SHA-256 を計算する(読み取り専用で開き,分割して読む).
Args:
path: 対象のファイル.
Returns:
SHA-256(小文字の 16 進 64 文字).
"""
digest = hashlib.sha256()
with path.open("rb") as f:
while chunk := f.read(CHUNK_SIZE):
digest.update(chunk)
return digest.hexdigest()
def verify_stored(store_dir: Path, sha256: str, ext: str) -> StoredFileStatus:
"""ストアのファイルが期待する SHA-256 と一致するかを照合する(点検用).
Args:
store_dir: ストアのフォルダ.
sha256: 期待する SHA-256.
ext: 拡張子.
Returns:
一致すれば `OK`,ファイルが無ければ `MISSING`,中身が違えば `MISMATCH`.
Raises:
ValueError: SHA-256 の形が誤っている,または拡張子が不正.
"""
path = store_path(store_dir, sha256, ext)
if not path.is_file():
return StoredFileStatus.MISSING
if sha256_file(path) != sha256:
return StoredFileStatus.MISMATCH
return StoredFileStatus.OK
def add_file(store_dir: Path, source: Path) -> StoredFile:
"""取り込み元のファイルをストアに置く.
取り込み元は読み取り専用で開くだけで,中身・更新日時・属性を変えない.
同じ中身が既にあれば(同じパスにあり照合が合えば)コピーしない.
無ければ同じフォルダの一時ファイルへコピーし,書き込んだファイルの SHA-256 を照合してから
正式な名前に変える(書きかけのファイルが正式な名前で残らないようにするため).
置いたファイルには読み取り専用の属性を付ける.既存のファイルは上書きしない
(照合が合って再利用するときは,属性だけを読み取り専用に付け直す).
Args:
store_dir: ストアのフォルダ(`tiasdata init` で作成済みであること).
source: 取り込み元のファイル.
Returns:
SHA-256・拡張子・サイズ・今回コピーしたか.
Raises:
ValueError: ストアのフォルダが無い,取り込み元がファイルでない,拡張子が不正,
コピー中に取り込み元が変わった,コピーしたファイルの照合が合わない,
または同じパスに中身の違うファイルが既にある.
"""
if not store_dir.is_dir():
raise ValueError("ストアのフォルダが無い(先に tiasdata init を実行する)")
if not source.is_file():
raise ValueError("取り込み元のファイルが無い,またはファイルではない")
ext = normalize_ext(source.suffix)
sha256 = sha256_file(source)
target = store_path(store_dir, sha256, ext)
if target.exists():
return _reuse_existing(target, sha256, ext)
target.parent.mkdir(exist_ok=True)
tmp_path, copied_sha256, size_bytes = _copy_to_temp(source, target.parent)
try:
# 1 回目の計算からコピーまでの間に取り込み元が変わっていないか
if copied_sha256 != sha256:
raise ValueError(f"コピー中に取り込み元の中身が変わった(SHA-256 {sha256})")
# 書き込んだファイルを読み直して照合する(ディスクへの書き込みの誤りを検出するため)
if sha256_file(tmp_path) != sha256:
raise ValueError(f"ストアにコピーしたファイルの照合が合わない(SHA-256 {sha256})")
published = _publish(tmp_path, target)
finally:
tmp_path.unlink(missing_ok=True)
if not published:
# 照合の間に同じ名前のファイルが置かれた(別の取り込みと同時に実行した等)
return _reuse_existing(target, sha256, ext)
_make_read_only(target)
logger.debug("ストアに追加した: %s", sha256)
return StoredFile(sha256=sha256, ext=ext, size_bytes=size_bytes, copied=True)
def _reuse_existing(target: Path, sha256: str, ext: str) -> StoredFile:
# 同じ名前のファイルは同じ中身のはず.違えばストアが壊れているので上書きせず止める
if not target.is_file() or sha256_file(target) != sha256:
raise ValueError(
f"ストアに同じ名前で中身の違うファイルがある(SHA-256 {sha256})."
"上書きせずに停止した.tiasdata check で点検する"
)
# 復元・手作業等で読み取り専用が外れた正しいファイルを,取り込みのたびに守り直すため
_make_read_only(target)
return StoredFile(sha256=sha256, ext=ext, size_bytes=target.stat().st_size, copied=False)
def _copy_to_temp(source: Path, directory: Path) -> tuple[Path, str, int]:
# 一時ファイルは正式な名前と同じフォルダに作る(同じドライブ内の rename にするため)
digest = hashlib.sha256()
size_bytes = 0
fd, tmp_name = tempfile.mkstemp(prefix=TEMP_PREFIX, dir=directory)
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "wb") as dst, source.open("rb") as src:
while chunk := src.read(CHUNK_SIZE):
digest.update(chunk)
dst.write(chunk)
size_bytes += len(chunk)
dst.flush()
os.fsync(dst.fileno())
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
return tmp_path, digest.hexdigest(), size_bytes
def _publish(tmp_path: Path, target: Path) -> bool:
"""一時ファイルを正式な名前にする.既に同じ名前があれば何もせず False を返す."""
try:
if os.name == "nt":
# Windows の rename は移動先があると FileExistsError になり,上書きしない
os.rename(tmp_path, target)
else:
# POSIX の rename は黙って上書きするため,既存があれば失敗する link を使う
os.link(tmp_path, target)
except FileExistsError:
return False
return True
def _make_read_only(path: Path) -> None:
# 書き換え・削除の誤操作を防ぐ(Windows では読み取り専用の属性になる)
mode = path.stat().st_mode
path.chmod(mode & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH))
class StoreEntry(NamedTuple):
"""ストアの正式な名前のファイル 1 つ(点検用).
Attributes:
sha256: ファイル名の SHA-256(中身と一致するかは確かめていない).
ext: 拡張子.
writable: 読み取り専用の属性が外れているか.
"""
sha256: str
ext: str
writable: bool
class StoreScan(NamedTuple):
"""ストアのフォルダを走査した結果(点検用).
Attributes:
entries: 正式な名前(`<先頭 2 文字>/<sha256>.<ext>`)のファイル.
temp_files: 書きかけの一時ファイル(`TEMP_PREFIX` で始まる名前)の数.
unexpected: 上のどちらにも当たらないファイル・フォルダの数.
"""
entries: list[StoreEntry]
temp_files: int
unexpected: int
_STORED_NAME_PATTERN = re.compile(r"^([0-9a-f]{64})\.([a-z0-9]+)$")
_PREFIX_DIR_PATTERN = re.compile(r"^[0-9a-f]{2}$")
def scan_store(store_dir: Path) -> StoreScan:
"""ストアのフォルダを走査し,ファイルを正式な名前・一時ファイル・想定外に分ける.
中身は読まない(ハッシュの照合は `verify_stored` で行う).
Args:
store_dir: ストアのフォルダ.
Returns:
走査の結果(正式な名前のファイルは SHA-256 の順).
Raises:
ValueError: ストアのフォルダが無い.
"""
if not store_dir.is_dir():
raise ValueError("ストアのフォルダが無い(先に tiasdata init を実行する)")
entries: list[StoreEntry] = []
temp_files = 0
unexpected = 0
for prefix_dir in store_dir.iterdir():
if not prefix_dir.is_dir() or not _PREFIX_DIR_PATTERN.match(prefix_dir.name):
unexpected += 1
continue
for path in prefix_dir.iterdir():
if path.name.startswith(TEMP_PREFIX):
temp_files += 1
continue
match = _STORED_NAME_PATTERN.match(path.name)
if not path.is_file() or match is None or match.group(1)[:2] != prefix_dir.name:
unexpected += 1
continue
entries.append(StoreEntry(match.group(1), match.group(2), is_writable(path)))
entries.sort()
return StoreScan(entries=entries, temp_files=temp_files, unexpected=unexpected)
def is_writable(path: Path) -> bool:
"""ストアのファイルの読み取り専用の属性が外れているか(所有者の書き込み権限があるか).
Args:
path: ストアのファイル.
Returns:
書き込みできる属性なら True(Windows では読み取り専用の属性が無いとき).
"""
return bool(path.stat().st_mode & stat.S_IWUSR)