mirror of
https://github.com/Shu-Ying/farm_service.git
synced 2026-11-02 18:50:01 +08:00
add # 新增对资源文件的维护
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user