add # 新增对资源文件的维护

This commit is contained in:
2026-10-26 18:54:19 +08:00
parent 72e54c3573
commit b41db58ea5
790 changed files with 14534 additions and 2 deletions
Binary file not shown.
+138
View File
@@ -0,0 +1,138 @@
"""Independent Service-side render contract; never imports a Farm checkout."""
import math
from pathlib import PurePosixPath
SOIL_MEMBERS = {"soil/红土地.png", "soil/黑土地.png", "soil/金土地.png"}
REQUIRED_IMAGES = {"webgl/seed.png", "webgl/wither.png", "background/ripe.png",
"soil/草土地.png", "soil/普通土地.png"}
def numbers(values, length):
if (type(values) is not list or len(values) != length
or any(type(v) not in (int, float) or abs(v) > 10000000
or not math.isfinite(v) for v in values)):
raise ValueError("invalid render coordinates")
return values
def image_size(values):
numbers(values, 2)
if (any(type(v) is not int or not 0 < v <= 8192 for v in values)
or values[0] * values[1] > 32 * 1024 * 1024):
raise ValueError("invalid image dimensions")
def rectangle(values):
numbers(values, 4)
if values[2] <= 0 or values[3] <= 0:
raise ValueError("invalid render rectangle")
def safe_member(name):
if (not isinstance(name, str) or not name or len(name) > 512
or "\\" in name or ":" in name):
raise ValueError("invalid image path")
path = PurePosixPath(name)
if path.is_absolute() or any(p in ("", ".", "..") for p in name.split("/")):
raise ValueError("invalid image path")
return name
def validate_metadata(value, rows):
"""Validate every renderer-required field; keep legacy phase placeholders."""
try:
if type(value["schema"]) is not int or value["schema"] != 1:
raise ValueError("unknown render metadata schema")
image_size(value["canvas"])
image_size(value["soil_size"])
if type(value["positions"]) is not list or len(value["positions"]) != 30:
raise ValueError("expected 30 plot positions")
for position in value["positions"]:
numbers([position["x"], position["y"]], 2)
images = value["images"]
if type(images) is not dict or not 0 < len(images) <= 10000:
raise ValueError("invalid image manifest")
for name, item in images.items():
safe_member(name)
image_size(item["size"])
checksum = item["sha256"]
if (not isinstance(checksum, str) or len(checksum) != 64
or any(c not in "0123456789abcdef" for c in checksum)):
raise ValueError("invalid image checksum")
plants = value["plants"]
if type(plants) is not dict or set(plants) != {row["name"] for row in rows}:
raise ValueError("catalogue and render plants differ")
for row in rows:
if plants[row["name"]]["phase"] != row["phase"]:
raise ValueError("catalogue and render phases differ")
placement = value["placement"]
if (type(placement["schema"]) is not int or placement["schema"] != 4
or placement["mode"] != "visible_contact_center"):
raise ValueError("unknown crop placement")
for key in ("common_scale", "growth_scale", "reference_soil_height",
"reference_soil_width"):
numbers([placement[key]], 1)
if placement[key] <= 0:
raise ValueError("invalid crop scale")
numbers([placement["contact_alpha_threshold"]], 1)
numbers([placement["contact_band_fraction"]], 1)
if (not 0 <= placement["contact_alpha_threshold"] <= 255
or not 0 < placement["contact_band_fraction"] <= 1):
raise ValueError("invalid ground contact rule")
matrix = placement["scene_transform"]
if type(matrix) is not list or len(matrix) != 2:
raise ValueError("invalid scene transform")
a, b = numbers(matrix[0], 2)
c, d = numbers(matrix[1], 2)
if abs(a * d - b * c) < 1e-8:
raise ValueError("degenerate scene transform")
pest = value["pest"]
image_size(pest["display_size"])
frames = pest["frames"]
if (type(frames) is not list or len(frames) < 3
or len(set(frames)) != len(frames)):
raise ValueError("invalid pest frames")
if any(name not in images for name in frames):
raise ValueError("missing pest image")
if type(pest["positions"]) is not list or len(pest["positions"]) < 3:
raise ValueError("missing pest positions")
for position in pest["positions"]:
numbers(position, 2)
panel = value["panel"]
image_size(panel["reference_canvas"])
rectangle(panel["avatar"])
fields = {"name", "experience", "level", "point", "vip_point"}
if (type(panel["fields"]) is not list
or {entry["field"] for entry in panel["fields"]} != fields):
raise ValueError("invalid panel fields")
for entry in panel["fields"]:
rectangle(entry["rect"])
numbers(entry["color"], 3)
if (any(not 0 <= v <= 255 for v in entry["color"])
or type(entry["alignment"]) is not int
or entry["alignment"] not in (3, 4)):
raise ValueError("invalid panel text")
if type(panel["sprites"]) is not list:
raise ValueError("invalid panel sprites")
for entry in panel["sprites"]:
rectangle(entry["rect"])
if entry["sprite"] not in images:
raise ValueError("missing panel sprite")
if set(value["decorations"]) != {"default", "spring", "summer",
"autumn", "winter", "bee"}:
raise ValueError("invalid decoration themes")
for entries in value["decorations"].values():
if type(entries) is not list:
raise ValueError("invalid decorations")
for entry in entries:
numbers(entry["position"], 2)
numbers(entry["size"], 2)
if (min(entry["size"]) <= 0
or entry["layer"] not in ("background", "foreground")
or entry["sprite"] not in images):
raise ValueError("invalid decoration")
if not REQUIRED_IMAGES <= set(images):
raise ValueError("missing required farm sprite")
except (KeyError, TypeError, IndexError, AttributeError) as error:
raise ValueError("invalid render metadata") from error
+280
View File
@@ -0,0 +1,280 @@
"""Build immutable crop releases from this repository's independent source."""
import argparse
import hashlib
from io import BytesIO
import json
import os
from pathlib import Path
import re
import shutil
import tempfile
import zipfile
from PIL import Image
from catalogue_validation import SOIL_MEMBERS, image_size, safe_member, validate_metadata
from publication_lock import publication_lock
LIMITS = {"plants.json": 2 * 1024**2, "metadata.json": 4 * 1024**2,
"assets.zip": 64 * 1024**2, "land-soils.zip": 64 * 1024**2}
MAX_EXPANDED = 256 * 1024**2
MAX_VALUE = 2**63 - 1
DISPLAY = ("general", "offsetH", "offsetW", "offsetX", "offsetY", "phase")
def json_bytes(value):
return (json.dumps(value, ensure_ascii=False, sort_keys=True,
indent=2, allow_nan=False) + "\n").encode("utf-8")
def digest(content):
return hashlib.sha256(content).hexdigest()
def read_bounded(path, maximum):
with path.open("rb") as stream:
content = stream.read(maximum + 1)
if not content or len(content) > maximum:
raise ValueError("empty or oversized file: " + path.name)
return content
def validate_zip(content):
total = 0
names = set()
with zipfile.ZipFile(BytesIO(content)) as archive:
for info in archive.infolist():
safe_member(info.filename)
if (info.filename in names or info.is_dir() or info.flag_bits & 1
or info.file_size > 32 * 1024**2
or (info.external_attr >> 16) & 0o170000 == 0o120000):
raise ValueError("invalid ZIP member")
names.add(info.filename)
total += info.file_size
if total > MAX_EXPANDED:
raise ValueError("ZIP expanded size exceeds limit")
picture = archive.read(info)
with Image.open(BytesIO(picture)) as image:
image_size(list(image.size))
image.verify()
if names != SOIL_MEMBERS:
raise ValueError("soil ZIP requires exactly the three soil sprites")
return total
def validate_plants(document):
if (not isinstance(document, dict) or type(document.get("schema")) is not int
or document["schema"] != 1 or set(document) != {"schema", "plants"}
or not isinstance(document["plants"], list) or not document["plants"]):
raise ValueError("invalid plants document")
identities, names = {}, set()
for row in document["plants"]:
if not isinstance(row, dict):
raise ValueError("invalid plant")
for key in ("id", "level", "buy", "isVip", "vipBuy", "experience",
"harvest", "price", "time", "crop"):
if type(row.get(key)) is not int or not 0 <= row[key] <= MAX_VALUE:
raise ValueError("invalid plant integer: " + key)
if any(row[key] < 1 for key in ("id", "harvest", "time", "crop")):
raise ValueError("invalid positive plant integer")
if row["time"] * 3600 > MAX_VALUE:
raise ValueError("growth seconds exceed client domain limit")
for key in ("isVip", "isBuy", "enabled"):
if type(row.get(key, 1)) is not int or row.get(key, 1) not in (0, 1):
raise ValueError("invalid plant flag")
if (type(row.get("crop_type", 1)) is not int
or not 1 <= row.get("crop_type", 1) <= MAX_VALUE):
raise ValueError("invalid crop type")
name = row.get("name")
if (not isinstance(name, str) or not name.strip() or len(name) > 128
or any(ord(c) < 32 for c in name) or name in names
or name in (".", "..") or "/" in name or "\\" in name
or row["id"] in identities):
raise ValueError("invalid or duplicate plant identity")
phase = row.get("phase")
if not isinstance(phase, str) or not re.fullmatch(r"[0-9]+(,[0-9]+)+", phase):
raise ValueError("invalid phase")
phases = list(dict.fromkeys(int(p) for p in phase.split(",")))
if len(phases) < 2 or min(phases) < 1 or phases != sorted(phases):
raise ValueError("invalid phase order")
if max(phases) > MAX_VALUE or sum(phases[1:]) > MAX_VALUE:
raise ValueError("phase seconds exceed client domain limit")
for key in DISPLAY[:-1]:
if type(row.get(key)) is not int:
raise ValueError("invalid display integer")
identities[row["id"]] = name
names.add(name)
return identities
def validate_manifest(document):
if (set(document) != {"schema", "version", "release", "files"}
or type(document["schema"]) is not int or document["schema"] != 1
or not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", document["version"])
or set(document["files"]) != set(LIMITS)):
raise ValueError("invalid manifest")
for name, descriptor in document["files"].items():
if (set(descriptor) != {"size_bytes", "sha256"}
or type(descriptor["size_bytes"]) is not int
or not 0 < descriptor["size_bytes"] <= LIMITS[name]
or not re.fullmatch(r"[a-f0-9]{64}", descriptor["sha256"])):
raise ValueError("invalid file descriptor")
canonical = json.dumps(document["files"], sort_keys=True,
separators=(",", ":")).encode("utf-8")
if document["release"] != digest(canonical):
raise ValueError("invalid release digest")
def verified_current(output):
latest = output / "manifest.json"
if not latest.exists():
return None
content = read_bounded(latest, 16 * 1024)
manifest = json.loads(content)
validate_manifest(manifest)
directory = output / "releases" / manifest["release"]
if read_bounded(directory / "manifest.json", 16 * 1024) != content:
raise ValueError("latest manifest differs from immutable release")
for name, descriptor in manifest["files"].items():
value = read_bounded(directory / name, LIMITS[name])
if len(value) != descriptor["size_bytes"] or digest(value) != descriptor["sha256"]:
raise ValueError("current release is corrupt")
return directory
def build(source, version):
if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", version):
raise ValueError("invalid version")
plants = json.loads(read_bounded(source / "plants.json", LIMITS["plants.json"]))
validate_plants(plants)
metadata = json.loads(read_bounded(source / "metadata.json", LIMITS["metadata.json"]))
if type(metadata.get("schema")) is not int or metadata["schema"] != 1:
raise ValueError("invalid metadata schema")
metadata["plants"] = {row["name"]: {key: row[key] for key in DISPLAY}
for row in plants["plants"]}
pictures = {}
metadata["images"] = {}
total = 0
for path in sorted((source / "images").rglob("*")):
if path.is_symlink():
raise ValueError("symlink in image source")
if not path.is_file():
continue
name = safe_member(path.relative_to(source / "images").as_posix())
content = read_bounded(path, min(MAX_EXPANDED, 32 * 1024**2))
total += len(content)
if total > MAX_EXPANDED:
raise ValueError("images exceed expanded size limit")
with Image.open(BytesIO(content)) as image:
size = list(image.size)
image_size(size)
image.verify()
pictures[name] = content
metadata["images"][name] = {"size": size, "sha256": digest(content)}
if not pictures:
raise ValueError("empty images directory")
validate_metadata(metadata, plants["plants"])
buffer = BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_STORED) as archive:
for name, content in sorted(pictures.items()):
info = zipfile.ZipInfo(name, (2020, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_STORED
info.create_system = 3
info.external_attr = 0o100644 << 16
archive.writestr(info, content)
soils = read_bounded(source / "land-soils.zip", LIMITS["land-soils.zip"])
if total + validate_zip(soils) > MAX_EXPANDED:
raise ValueError("combined ZIP expanded size exceeds limit")
files = {"plants.json": json_bytes(plants), "metadata.json": json_bytes(metadata),
"assets.zip": buffer.getvalue(), "land-soils.zip": soils}
descriptors = {}
for name, content in files.items():
if len(content) > LIMITS[name]:
raise ValueError("release file exceeds size limit")
descriptors[name] = {"size_bytes": len(content), "sha256": digest(content)}
canonical = json.dumps(descriptors, sort_keys=True, separators=(",", ":")).encode()
manifest = {"schema": 1, "version": version, "release": digest(canonical),
"files": descriptors}
files["manifest.json"] = json_bytes(manifest)
return manifest, files
def prepare(source, output, version, check=False):
if check:
# Read-only checks never create a lock file or an output directory.
return _prepare(source, output, version, True)
with publication_lock(output):
return _prepare(source, output, version, False)
def _prepare(source, output, version, check):
current = verified_current(output)
manifest, files = build(source, version)
for history in (output / "releases").glob("*/manifest.json"):
if history.parent.name.startswith(".prepare-"):
continue
if not re.fullmatch(r"[a-f0-9]{64}", history.parent.name):
raise ValueError("invalid published release directory")
old = json.loads(read_bounded(history, 16 * 1024))
validate_manifest(old)
if old["release"] != history.parent.name:
raise ValueError("published manifest differs from release directory")
if old["version"] == version and old["release"] != manifest["release"]:
raise ValueError("published version label cannot be reused for changed files")
if current:
old_plants = json.loads((current / "plants.json").read_bytes())
identities = validate_plants(json.loads(files["plants.json"]))
if any(identities.get(key) != name for key, name in validate_plants(old_plants).items()):
raise ValueError("cannot delete or rename a published crop ID")
old_images = json.loads((current / "metadata.json").read_bytes())["images"]
if not set(old_images) <= set(json.loads(files["metadata.json"])["images"]):
raise ValueError("cannot remove published image paths")
destination = output / "releases" / manifest["release"]
if destination.exists():
if any(not (destination / name).is_file() or
(destination / name).read_bytes() != content for name, content in files.items()):
raise ValueError("immutable release already exists with different content or label")
if check:
if current != destination or not destination.is_dir():
raise ValueError("source differs from latest; publish explicitly")
return manifest
output.mkdir(parents=True, exist_ok=True)
destination.parent.mkdir(parents=True, exist_ok=True)
staging = Path(tempfile.mkdtemp(prefix=".prepare-", dir=destination.parent))
try:
for name, content in files.items():
(staging / name).write_bytes(content)
if not destination.exists():
os.rename(staging, destination)
with tempfile.NamedTemporaryFile(prefix=".manifest-", dir=output, delete=False) as stream:
pointer = Path(stream.name)
stream.write(files["manifest.json"])
stream.flush()
os.fsync(stream.fileno())
try:
os.replace(pointer, output / "manifest.json")
finally:
pointer.unlink(missing_ok=True)
finally:
shutil.rmtree(staging, ignore_errors=True)
return manifest
def main():
root = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, default=root / "catalogue/source")
parser.add_argument("--output", type=Path, default=root / "catalogue")
parser.add_argument("--version", required=True)
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
try:
manifest = prepare(args.source, args.output, args.version, args.check)
except (OSError, ValueError, KeyError, TypeError, zipfile.BadZipFile) as exc:
parser.exit(1, f"Catalogue rejected: {exc}\n")
print(f"Catalogue {'checked' if args.check else 'prepared'}: {manifest['release']}")
if __name__ == "__main__":
main()
+50
View File
@@ -0,0 +1,50 @@
"""Serialize publishers for one output; process exit releases OS locks."""
from contextlib import contextmanager
import errno
import os
import threading
import time
_LOCKS = {}
_REGISTRY_GUARD = threading.Lock()
@contextmanager
def publication_lock(output):
key = os.path.normcase(str(output.resolve()))
with _REGISTRY_GUARD:
local = _LOCKS.setdefault(key, threading.Lock())
# Some OS record locks are process-scoped, so threads also need a mutex.
with local:
output.mkdir(parents=True, exist_ok=True)
# Keep this inode/path permanently: unlinking it could split waiters
# between different locks. It contains no publication state or secrets.
with (output / ".publish.lock").open("a+b") as stream:
if os.name == "nt":
import msvcrt
if os.fstat(stream.fileno()).st_size == 0:
stream.write(b"\0")
stream.flush()
stream.seek(0)
while True:
try:
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
break
except OSError as error:
if error.errno not in (errno.EACCES, errno.EAGAIN,
errno.EDEADLK):
raise
time.sleep(0.05)
try:
yield
finally:
stream.seek(0)
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(stream.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)