Files
Genarrative/scripts/gitea_cache_snapshot.py
T
lhk229 4380d453d7
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
修复 CI 缓存对象误判与产物下载完整性检查
按 ZIP 成员内容和元数据识别等价缓存对象,保留真实冲突拒绝规则。
核对产物大小与 ZIP 完整性,为截断和临时传输失败增加有界重试。
补充回归测试并同步部署文档与共享记忆。
通过 80 项缓存测试、17 项工作流测试及真实产物和 HTTP 截断验证。
2026-09-22 14:40:55 +00:00

748 lines
31 KiB
Python

#!/usr/bin/env python3
"""Validate and merge Gitea CI sccache artifacts into one object snapshot."""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
import tarfile
import tempfile
from typing import BinaryIO, Mapping, Sequence
import zipfile
GIB = 1024 ** 3
DEFAULT_MAX_COMBINED_BYTES = 4 * GIB
MAX_INPUT_OBJECT_BYTES = 4 * GIB
MAX_MANIFEST_BYTES = 16 * 1024 ** 2
MAX_OBJECTS = 100_000
MAX_BASE_OBJECTS = 1_000_000
MAX_TAR_OVERHEAD_BYTES = 128 * 1024 ** 2
OBJECT_PATH = re.compile(r"objects/([0-9a-f])/([0-9a-f])/([0-9a-f]{64})\Z")
SHA256 = re.compile(r"[0-9a-f]{64}\Z")
TEMP_PREFIX = ".gitea-cache-snapshot-"
class SnapshotError(ValueError):
"""The downloaded snapshot does not satisfy the trusted artifact contract."""
@dataclass(frozen=True)
class ArtifactIdentity:
repository: str
run_id: int
run_attempt: int
job: str
source_sha: str
@dataclass(frozen=True)
class ArtifactInput:
archive: Path
expected: ArtifactIdentity
@dataclass(frozen=True)
class MergeResult:
object_count: int
total_bytes: int
source_sha: str
rustc: str
workspace: str
base_image: str | None
sccache_version: str
@dataclass(frozen=True)
class _Object:
path: str
size: int
sha256: str | None
mtime_ns: int
source_index: int | None
@dataclass(frozen=True)
class _Touch:
path: str
mtime_ns: int
@dataclass(frozen=True)
class _Archive:
input: ArtifactInput
manifest: Mapping[str, object]
objects: tuple[_Object, ...]
touched: tuple[_Touch, ...]
signature: tuple[int, int, int]
def _is_int(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def _require_string(value: object, field: str) -> str:
if not isinstance(value, str) or not value or "\0" in value:
raise SnapshotError(f"manifest {field} must be a non-empty string")
return value
def _strict_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
result: dict[str, object] = {}
for key, value in pairs:
if key in result:
raise SnapshotError(f"manifest contains duplicate JSON key: {key}")
result[key] = value
return result
def _archive_signature(path: Path) -> tuple[int, int, int]:
try:
status = path.stat()
except OSError as error:
raise SnapshotError(f"cannot stat artifact archive: {path}") from error
if path.is_symlink() or not path.is_file():
raise SnapshotError(f"artifact archive must be a regular file: {path}")
return status.st_size, status.st_mtime_ns, status.st_ino
def _safe_zip_path(name: str) -> tuple[str, ...]:
if not name or "\0" in name or "\\" in name or name.startswith("/"):
raise SnapshotError(f"unsafe ZIP member path: {name!r}")
parts = PurePosixPath(name.rstrip("/")).parts
if not parts or any(part in {"", ".", ".."} for part in parts):
raise SnapshotError(f"unsafe ZIP member path: {name!r}")
return parts
def _snapshot_member(archive: Path) -> tuple[zipfile.ZipFile, zipfile.ZipInfo]:
try:
bundle = zipfile.ZipFile(archive)
except (OSError, zipfile.BadZipFile) as error:
raise SnapshotError(f"invalid artifact ZIP: {archive}") from error
try:
infos = bundle.infolist()
if len({info.filename for info in infos}) != len(infos):
raise SnapshotError("ZIP contains duplicate members")
files: list[zipfile.ZipInfo] = []
directories: list[tuple[str, ...]] = []
for info in infos:
parts = _safe_zip_path(info.filename)
mode = info.external_attr >> 16
if info.flag_bits & 1:
raise SnapshotError("encrypted ZIP members are not accepted")
if stat.S_ISLNK(mode):
raise SnapshotError("ZIP links are not accepted")
if info.is_dir():
directories.append(parts)
else:
files.append(info)
if len(files) != 1:
raise SnapshotError("ZIP must contain exactly one snapshot.tar file")
member = files[0]
parts = _safe_zip_path(member.filename)
if not (parts == ("snapshot.tar",) or
(len(parts) == 2 and parts[1] == "snapshot.tar")):
raise SnapshotError("ZIP member must be snapshot.tar or <artifact>/snapshot.tar")
expected_directories = set() if len(parts) == 1 else {(parts[0],)}
if set(directories) - expected_directories:
raise SnapshotError("ZIP contains unexpected directory members")
maximum_tar_size = (
MAX_INPUT_OBJECT_BYTES + MAX_MANIFEST_BYTES + MAX_TAR_OVERHEAD_BYTES
)
if member.file_size > maximum_tar_size:
raise SnapshotError("snapshot.tar exceeds the per-job input bound")
if member.compress_type != zipfile.ZIP_STORED:
raise SnapshotError("snapshot.tar ZIP member must use ZIP_STORED")
return bundle, member
except Exception:
bundle.close()
raise
def validate_artifact_zip(archive: Path) -> None:
"""Validate the bounded stored ZIP envelope before consuming an artifact."""
bundle, _ = _snapshot_member(archive)
with bundle:
if bundle.testzip() is not None:
raise SnapshotError("artifact ZIP CRC check failed")
def _tar_stream(archive: Path):
bundle, member = _snapshot_member(archive)
try:
raw = bundle.open(member, "r")
except Exception:
bundle.close()
raise
try:
tar = tarfile.open(fileobj=raw, mode="r|")
except (OSError, tarfile.TarError) as error:
raw.close()
bundle.close()
raise SnapshotError(f"invalid snapshot.tar in {archive}") from error
return bundle, raw, tar
def _hash_stream(stream: BinaryIO, expected_size: int) -> tuple[int, str]:
digest = hashlib.sha256()
total = 0
while True:
chunk = stream.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > expected_size:
raise SnapshotError("tar member contains more bytes than declared")
digest.update(chunk)
if total != expected_size:
raise SnapshotError("tar member is truncated")
return total, digest.hexdigest()
def _validate_object_path(path: str) -> None:
match = OBJECT_PATH.fullmatch(path)
if not match or match[1] != match[3][0] or match[2] != match[3][1]:
raise SnapshotError(f"invalid sccache object path: {path!r}")
def _parse_manifest(
contents: bytes,
expected: ArtifactIdentity,
expected_inherited_source_sha: str,
) -> dict[str, object]:
try:
manifest = json.loads(contents, object_pairs_hook=_strict_object)
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise SnapshotError("manifest.json is not valid UTF-8 JSON") from error
if not isinstance(manifest, dict):
raise SnapshotError("manifest.json must contain an object")
required = {
"schema", "repository", "run_id", "run_attempt", "job", "source_sha",
"rustc", "workspace", "sccache_version", "mode", "inherited_source_sha",
"objects", "touched",
}
allowed = required | {"base_image"}
if set(manifest) != required and set(manifest) != allowed:
raise SnapshotError("manifest.json has missing or unexpected fields")
if not _is_int(manifest["schema"]) or manifest["schema"] != 1:
raise SnapshotError("manifest schema must be 1")
for field in ("repository", "job", "source_sha", "rustc", "workspace",
"sccache_version", "mode", "inherited_source_sha"):
_require_string(manifest[field], field)
if manifest["mode"] != "delta":
raise SnapshotError("manifest mode must be delta")
if manifest["inherited_source_sha"] != expected_inherited_source_sha:
raise SnapshotError("manifest inherited_source_sha does not match expected source image")
for field in ("run_id", "run_attempt"):
if not _is_int(manifest[field]) or manifest[field] < 1:
raise SnapshotError(f"manifest {field} must be a positive integer")
if "base_image" in manifest:
_require_string(manifest["base_image"], "base_image")
for field in ("repository", "run_id", "run_attempt", "job", "source_sha"):
if manifest[field] != getattr(expected, field):
raise SnapshotError(f"manifest {field} does not match expected identity")
if not isinstance(manifest["objects"], list):
raise SnapshotError("manifest objects must be a list")
if not isinstance(manifest["touched"], list):
raise SnapshotError("manifest touched must be a list")
return manifest
def _manifest_objects(manifest: Mapping[str, object], source_index: int) -> tuple[_Object, ...]:
rows = manifest["objects"]
assert isinstance(rows, list)
if len(rows) > MAX_OBJECTS:
raise SnapshotError("manifest contains too many objects")
objects: list[_Object] = []
seen: set[str] = set()
for row in rows:
if not isinstance(row, dict) or set(row) != {"path", "size", "sha256", "mtime_ns"}:
raise SnapshotError("manifest object has missing or unexpected fields")
path = row["path"]
checksum = row["sha256"]
if not isinstance(path, str):
raise SnapshotError("manifest object path must be a string")
_validate_object_path(path)
if path in seen:
raise SnapshotError(f"manifest contains duplicate object: {path}")
seen.add(path)
if not _is_int(row["size"]) or row["size"] < 0:
raise SnapshotError(f"manifest object size is invalid: {path}")
if not isinstance(checksum, str) or not SHA256.fullmatch(checksum):
raise SnapshotError(f"manifest object sha256 is invalid: {path}")
if not _is_int(row["mtime_ns"]) or row["mtime_ns"] < 0:
raise SnapshotError(f"manifest object mtime_ns is invalid: {path}")
objects.append(_Object(
path=path,
size=row["size"],
sha256=checksum,
mtime_ns=row["mtime_ns"],
source_index=source_index,
))
return tuple(objects)
def _manifest_touches(manifest: Mapping[str, object]) -> tuple[_Touch, ...]:
rows = manifest["touched"]
assert isinstance(rows, list)
if len(rows) > MAX_OBJECTS:
raise SnapshotError("manifest contains too many touched objects")
touched: list[_Touch] = []
seen: set[str] = set()
for row in rows:
if not isinstance(row, dict) or set(row) != {"path", "mtime_ns"}:
raise SnapshotError("manifest touched object has missing or unexpected fields")
path = row["path"]
if not isinstance(path, str):
raise SnapshotError("manifest touched path must be a string")
_validate_object_path(path)
if path in seen:
raise SnapshotError(f"manifest contains duplicate touched object: {path}")
seen.add(path)
if not _is_int(row["mtime_ns"]) or row["mtime_ns"] < 0:
raise SnapshotError(f"manifest touched mtime_ns is invalid: {path}")
touched.append(_Touch(path=path, mtime_ns=row["mtime_ns"]))
return tuple(touched)
def _validate_archive(
item: ArtifactInput,
source_index: int,
expected_inherited_source_sha: str,
) -> _Archive:
path = Path(item.archive)
signature = _archive_signature(path)
bundle, raw, tar = _tar_stream(path)
actual: dict[str, tuple[int, str]] = {}
manifest_contents: bytes | None = None
total_bytes = 0
member_count = 0
try:
try:
for member in tar:
member_count += 1
if member_count > MAX_OBJECTS + 1:
raise SnapshotError("snapshot.tar contains too many members")
if manifest_contents is not None:
raise SnapshotError("manifest.json must be the last tar member")
if not member.isreg():
raise SnapshotError(f"tar member must be a regular file: {member.name!r}")
if member.name == "manifest.json":
if member.size < 0 or member.size > MAX_MANIFEST_BYTES:
raise SnapshotError("manifest.json exceeds its size bound")
stream = tar.extractfile(member)
if stream is None:
raise SnapshotError("cannot read manifest.json")
manifest_contents = stream.read(MAX_MANIFEST_BYTES + 1)
if len(manifest_contents) != member.size:
raise SnapshotError("manifest.json is truncated")
continue
_validate_object_path(member.name)
if member.size < 0:
raise SnapshotError(f"tar member has a negative size: {member.name}")
if member.name in actual:
raise SnapshotError(f"snapshot.tar contains duplicate member: {member.name}")
total_bytes += member.size
if total_bytes > MAX_INPUT_OBJECT_BYTES:
raise SnapshotError("snapshot exceeds the per-job object byte bound")
stream = tar.extractfile(member)
if stream is None:
raise SnapshotError(f"cannot read tar member: {member.name}")
actual[member.name] = _hash_stream(stream, member.size)
except (OSError, tarfile.TarError, zipfile.BadZipFile) as error:
raise SnapshotError(f"cannot stream snapshot archive: {path}") from error
finally:
tar.close()
raw.close()
bundle.close()
if manifest_contents is None:
raise SnapshotError("snapshot.tar is missing final manifest.json")
manifest = _parse_manifest(
manifest_contents, item.expected, expected_inherited_source_sha,
)
objects = _manifest_objects(manifest, source_index)
touched = _manifest_touches(manifest)
if {obj.path for obj in objects} & {touch.path for touch in touched}:
raise SnapshotError("an object cannot be both uploaded and touched")
described = {obj.path: (obj.size, obj.sha256) for obj in objects}
if actual != described:
raise SnapshotError("manifest object list, sizes, or checksums do not match snapshot.tar")
return _Archive(
input=item, manifest=manifest, objects=objects, touched=touched,
signature=signature,
)
def _consistent_metadata(archives: Sequence[_Archive]) -> tuple[str, str, str, str, str | None]:
first = archives[0].manifest
fields = (
"source_sha", "rustc", "workspace", "sccache_version", "base_image",
"inherited_source_sha",
)
expected = tuple(first.get(field) for field in fields)
for archive in archives[1:]:
if tuple(archive.manifest.get(field) for field in fields) != expected:
raise SnapshotError("artifact compiler, workspace, source, or base-image metadata differs")
source_sha, rustc, workspace, sccache_version, base_image, _ = expected
assert isinstance(source_sha, str)
assert isinstance(rustc, str)
assert isinstance(workspace, str)
assert isinstance(sccache_version, str)
assert base_image is None or isinstance(base_image, str)
return source_sha, rustc, workspace, sccache_version, base_image
def _validated_base_objects(base_objects: os.PathLike[str] | str) -> tuple[Path, dict[str, _Object]]:
requested = Path(base_objects)
if not requested.is_absolute():
raise SnapshotError("base_objects must be absolute")
try:
root = requested.resolve(strict=True)
except OSError as error:
raise SnapshotError("base_objects must exist") from error
if requested != root or requested.is_symlink() or not root.is_dir():
raise SnapshotError("base_objects must be a real canonical directory")
objects: dict[str, _Object] = {}
for first in root.iterdir():
if first.is_symlink() or not first.is_dir() or not re.fullmatch(r"[0-9a-f]", first.name):
raise SnapshotError(f"noncanonical entry in base objects: {first}")
for second in first.iterdir():
if second.is_symlink() or not second.is_dir() or not re.fullmatch(r"[0-9a-f]", second.name):
raise SnapshotError(f"noncanonical entry in base objects: {second}")
for entry in second.iterdir():
relative = f"objects/{first.name}/{second.name}/{entry.name}"
_validate_object_path(relative)
if entry.is_symlink() or not entry.is_file():
raise SnapshotError(f"base object must be a regular file: {entry}")
status = entry.stat()
objects[relative] = _Object(
path=relative,
size=status.st_size,
sha256=None,
mtime_ns=status.st_mtime_ns,
source_index=None,
)
if len(objects) > MAX_BASE_OBJECTS:
raise SnapshotError("base_objects contains too many files")
return root, objects
def _file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
while True:
chunk = source.read(1024 * 1024)
if not chunk:
return digest.hexdigest()
digest.update(chunk)
def _object_zip_signature(stream: BinaryIO, expected: _Object) -> tuple:
"""只允许 sccache 对象的 ZIP 成员排列不同,不放宽内容或元数据校验。"""
with tempfile.TemporaryFile() as temporary:
digest = hashlib.sha256()
total = 0
while chunk := stream.read(1024 * 1024):
total += len(chunk)
if total > expected.size:
raise SnapshotError("cache object grew while comparing ZIP contents")
digest.update(chunk)
temporary.write(chunk)
if (total, digest.hexdigest()) != (expected.size, expected.sha256):
raise SnapshotError("cache object checksum changed while comparing ZIP contents")
temporary.seek(0)
with zipfile.ZipFile(temporary) as bundle:
infos = bundle.infolist()
if (not infos or len(infos) > MAX_OBJECTS
or len({info.filename for info in infos}) != len(infos)
or sum(info.file_size for info in infos) > expected.size):
raise SnapshotError("invalid sccache ZIP member set")
members = []
for info in sorted(infos, key=lambda entry: entry.filename):
_safe_zip_path(info.filename)
mode = info.external_attr >> 16
if (info.is_dir() or info.flag_bits & 1 or stat.S_ISLNK(mode)
or stat.S_IFMT(mode) not in (0, stat.S_IFREG)
or info.compress_type != zipfile.ZIP_STORED):
raise SnapshotError("unsupported sccache ZIP member")
with bundle.open(info) as contents:
size, checksum = _hash_stream(contents, info.file_size)
members.append((
info.filename, size, checksum, info.CRC, info.compress_size,
info.compress_type, info.date_time, info.flag_bits,
info.external_attr, info.internal_attr, info.create_system,
info.create_version, info.extract_version, info.reserved,
info.extra, info.comment,
))
return bundle.comment, tuple(members)
def _equivalent_delta_paths(archives: Sequence[_Archive]) -> set[str]:
variants: dict[str, set[tuple[int, str | None]]] = {}
for archive in archives:
for obj in archive.objects:
variants.setdefault(obj.path, set()).add((obj.size, obj.sha256))
conflicts = {path for path, versions in variants.items() if len(versions) > 1}
if not conflicts:
return conflicts
for path in conflicts:
if len({size for size, _ in variants[path]}) != 1:
raise SnapshotError(f"conflicting content for duplicate object: {path}")
signatures = {}
# 每份归档最多额外顺序读取一次,仅将冲突对象暂存到磁盘供 ZIP 随机读取。
for archive in archives:
wanted = {obj.path: obj for obj in archive.objects if obj.path in conflicts}
if not wanted:
continue
if _archive_signature(archive.input.archive) != archive.signature:
raise SnapshotError("artifact archive changed while comparing cache objects")
bundle, raw, tar = _tar_stream(archive.input.archive)
try:
for member in tar:
expected = wanted.get(member.name)
if expected is None:
continue
if not member.isreg() or member.size != expected.size:
raise SnapshotError("cache object changed while comparing ZIP contents")
stream = tar.extractfile(member)
if stream is None:
raise SnapshotError("cache object is missing while comparing ZIP contents")
try:
with stream:
signature = _object_zip_signature(stream, expected)
except (SnapshotError, zipfile.BadZipFile, NotImplementedError) as error:
raise SnapshotError(f"conflicting content for duplicate object: {member.name}") from error
if member.name in signatures and signatures[member.name] != signature:
raise SnapshotError(f"conflicting content for duplicate object: {member.name}")
signatures[member.name] = signature
del wanted[member.name]
if wanted:
raise SnapshotError("cache objects disappeared while comparing ZIP contents")
finally:
tar.close()
raw.close()
bundle.close()
return conflicts
def _select_objects(
archives: Sequence[_Archive],
base_root: Path,
base: Mapping[str, _Object],
maximum: int,
) -> tuple[_Object, ...]:
merged = dict(base)
equivalent_paths = _equivalent_delta_paths(archives)
delta_paths = {obj.path for archive in archives for obj in archive.objects}
touched_paths = {touch.path for archive in archives for touch in archive.touched}
overlap = delta_paths & touched_paths
if overlap:
raise SnapshotError(f"object appears as both delta and touched: {min(overlap)}")
for archive in archives:
for touch in archive.touched:
current = merged.get(touch.path)
if current is None or current.source_index is not None:
raise SnapshotError(f"touched object is absent from inherited base: {touch.path}")
if touch.mtime_ns > current.mtime_ns:
merged[touch.path] = _Object(
path=current.path,
size=current.size,
sha256=current.sha256,
mtime_ns=touch.mtime_ns,
source_index=None,
)
base_hashes: dict[str, str] = {}
for archive in archives:
for candidate in archive.objects:
current = merged.get(candidate.path)
if current is None:
merged[candidate.path] = candidate
elif current.source_index is None:
if current.size != candidate.size:
raise SnapshotError(f"conflicting content for duplicate object: {candidate.path}")
checksum = base_hashes.get(candidate.path)
if checksum is None:
checksum = _file_sha256(
base_root.joinpath(*PurePosixPath(candidate.path).parts[1:])
)
base_hashes[candidate.path] = checksum
if checksum != candidate.sha256:
raise SnapshotError(f"conflicting content for duplicate object: {candidate.path}")
merged[candidate.path] = _Object(
path=current.path,
size=current.size,
sha256=checksum,
mtime_ns=max(current.mtime_ns, candidate.mtime_ns),
source_index=None,
)
elif ((current.size, current.sha256) != (candidate.size, candidate.sha256)
and candidate.path not in equivalent_paths):
raise SnapshotError(f"conflicting content for duplicate object: {candidate.path}")
elif candidate.mtime_ns > current.mtime_ns:
merged[candidate.path] = _Object(
path=current.path,
size=current.size,
sha256=current.sha256,
mtime_ns=candidate.mtime_ns,
source_index=current.source_index,
)
selected: list[_Object] = []
total = 0
for candidate in sorted(merged.values(), key=lambda obj: (-obj.mtime_ns, obj.path)):
if candidate.size <= maximum - total:
selected.append(candidate)
total += candidate.size
return tuple(selected)
def _copy_selected(archive: _Archive, selected: Mapping[str, _Object], root: Path) -> None:
if _archive_signature(Path(archive.input.archive)) != archive.signature:
raise SnapshotError(f"artifact archive changed while merging: {archive.input.archive}")
bundle, raw, tar = _tar_stream(Path(archive.input.archive))
remaining = set(selected)
try:
try:
for member in tar:
target_object = selected.get(member.name)
if target_object is None:
continue
if not member.isreg() or member.size != target_object.size:
raise SnapshotError(f"selected object changed while merging: {member.name}")
source = tar.extractfile(member)
if source is None:
raise SnapshotError(f"cannot read selected object: {member.name}")
destination = root.joinpath(*PurePosixPath(member.name).parts)
destination.parent.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256()
written = 0
with destination.open("xb") as output:
while True:
chunk = source.read(1024 * 1024)
if not chunk:
break
written += len(chunk)
if written > target_object.size:
raise SnapshotError(f"selected object grew while merging: {member.name}")
digest.update(chunk)
output.write(chunk)
if written != target_object.size or digest.hexdigest() != target_object.sha256:
raise SnapshotError(f"selected object checksum changed while merging: {member.name}")
os.utime(destination, ns=(target_object.mtime_ns, target_object.mtime_ns))
remaining.remove(member.name)
except (OSError, tarfile.TarError, zipfile.BadZipFile) as error:
raise SnapshotError(f"cannot copy selected objects from {archive.input.archive}") from error
finally:
tar.close()
raw.close()
bundle.close()
if remaining:
raise SnapshotError(f"selected objects disappeared from {archive.input.archive}")
def _copy_base(base_root: Path, selected: Mapping[str, _Object], root: Path) -> None:
for relative, obj in selected.items():
source = base_root.joinpath(*PurePosixPath(relative).parts[1:])
if source.is_symlink() or not source.is_file() or source.stat().st_size != obj.size:
raise SnapshotError(f"inherited base object changed while merging: {relative}")
destination = root.joinpath(*PurePosixPath(relative).parts)
destination.parent.mkdir(parents=True, exist_ok=True)
with source.open("rb") as input_stream, destination.open("xb") as output_stream:
shutil.copyfileobj(input_stream, output_stream, length=1024 * 1024)
if destination.stat().st_size != obj.size:
raise SnapshotError(f"inherited base object was truncated while merging: {relative}")
os.utime(destination, ns=(obj.mtime_ns, obj.mtime_ns))
def _write_metadata(path: Path, value: str) -> None:
path.write_text(value if value.endswith("\n") else value + "\n", encoding="utf-8")
def _validated_output(output_dir: os.PathLike[str] | str) -> tuple[Path, Path]:
output = Path(output_dir)
if not output.is_absolute():
raise SnapshotError("output_dir must be absolute")
if output.exists() or output.is_symlink():
raise SnapshotError("output_dir must not already exist")
try:
parent = output.parent.resolve(strict=True)
except OSError as error:
raise SnapshotError("output_dir parent must already exist") from error
if output.parent.is_symlink() or not parent.is_dir():
raise SnapshotError("output_dir parent must be a real directory")
if output.parent != parent:
raise SnapshotError("output_dir parent must use its canonical absolute path")
return output, parent
def merge_snapshots(
inputs: Sequence[ArtifactInput],
output_dir: os.PathLike[str] | str,
*,
base_objects: os.PathLike[str] | str,
expected_inherited_source_sha: str,
max_combined_bytes: int = DEFAULT_MAX_COMBINED_BYTES,
) -> MergeResult:
"""Validate artifacts and atomically create a bounded sccache object snapshot."""
if not inputs:
raise SnapshotError("at least one artifact is required")
if not _is_int(max_combined_bytes) or max_combined_bytes < 0:
raise SnapshotError("max_combined_bytes must be a non-negative integer")
_require_string(expected_inherited_source_sha, "expected_inherited_source_sha")
output, parent = _validated_output(output_dir)
base_root, base = _validated_base_objects(base_objects)
archives = tuple(
_validate_archive(item, index, expected_inherited_source_sha)
for index, item in enumerate(inputs)
)
source_sha, rustc, workspace, sccache_version, base_image = _consistent_metadata(archives)
selected = _select_objects(archives, base_root, base, max_combined_bytes)
by_source: dict[int, dict[str, _Object]] = {}
from_base: dict[str, _Object] = {}
for obj in selected:
if obj.source_index is None:
from_base[obj.path] = obj
else:
by_source.setdefault(obj.source_index, {})[obj.path] = obj
temporary = Path(tempfile.mkdtemp(prefix=TEMP_PREFIX, dir=parent))
try:
snapshot = temporary / "snapshot"
(snapshot / "objects").mkdir(parents=True)
_copy_base(base_root, from_base, snapshot)
for source_index, objects in by_source.items():
_copy_selected(archives[source_index], objects, snapshot)
_write_metadata(snapshot / "rustc.txt", rustc)
_write_metadata(snapshot / "workspace.txt", workspace)
_write_metadata(snapshot / "source-commit.txt", source_sha)
if base_image is not None:
_write_metadata(snapshot / "base-image.txt", base_image)
snapshot.replace(output)
finally:
if temporary.parent != parent or not temporary.name.startswith(TEMP_PREFIX):
raise RuntimeError("refusing to clean an unexpected temporary directory")
shutil.rmtree(temporary, ignore_errors=False)
return MergeResult(
object_count=len(selected),
total_bytes=sum(obj.size for obj in selected),
source_sha=source_sha,
rustc=rustc,
workspace=workspace,
base_image=base_image,
sccache_version=sccache_version,
)