- Rate-limit failed logins (8 per 15 min per IP or username, 429 + Retry-After) - Add ruff/mypy config and GitHub Actions workflow (ruff, pytest, tsc, tests, build) - Apply ruff autofixes (import order, pyupgrade) - Extract rpy_helpers.py, engine-util.ts, TranslationParts.tsx and translation-types.ts from oversized modules - Add dependency-free React UI tests - Refresh README and ARCHITECTURE (Naninovel/Tomie, status and plans) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
154 lines
8.2 KiB
Python
154 lines
8.2 KiB
Python
"""Fail-closed validation of the command surface implemented by the Tomie VM.
|
|
|
|
The assembly hash selects the adapter; it does not exempt mutable script assets
|
|
from validation. Unknown command arguments are rejected along with unknown ops.
|
|
"""
|
|
import re
|
|
|
|
from app.converter.compatibility import Issue
|
|
from app.converter.nani_expr import assignment, parse
|
|
from app.converter.tomie_profile import FUNCTIONS, validate_expressions
|
|
|
|
ARGUMENTS = {
|
|
"ModifyBackground": "AppearanceAndTransition Duration Scale TintColor",
|
|
"ModifyTextPrinter": "Duration HideOther IdAndAppearance MakeDefault ScenePosition",
|
|
"ModifyCharacter": "Duration IdAndAppearance LookDirection Scale ScenePosition TintColor Transition Visible",
|
|
"PrintText": "AuthorId LineBreaks ResetPrinter RevealSpeed Text WaitForInput",
|
|
"SkipInput": "", "AddChoice": "AutoPlay ButtonPath ButtonPosition ChoiceSummary Duration GotoPath HandlerId OnSelected ShowHandler",
|
|
"Stop": "", "SetCustomVariable": "Expression", "HideAllCharacters": "Duration",
|
|
"BeginIf": "Expression", "Else": "", "EndIf": "", "AppendLineBreak": "Count",
|
|
"Spawn": "Params Path", "Wait": "WaitMode", "ElseIf": "Expression",
|
|
"Gosub": "Path", "Goto": "Path", "Return": "", "DestroySpawned": "Path",
|
|
"HideActors": "ActorIds Duration", "PlaySfx": "Duration FadeInDuration Loop SfxPath Volume",
|
|
"WaitForInput": "", "HidePrinter": "", "AddItem": "Amount ItemId",
|
|
"PlayVoice": "VoicePath Volume", "ClearChoiceHandler": "HandlerId Hide",
|
|
"StopBgm": "FadeOutDuration", "PlayBgm": "BgmPath Duration FadeInDuration Loop Volume",
|
|
"ShowToastUI": "Appearance Duration Text", "ModifyCamera": "Duration Zoom", "Skip": "Enable",
|
|
"HideUI": "AllowToggle Duration UINames", "ProcessInput": "InputEnabled",
|
|
"AutoSave": "", "ExitToTitle": "", "StopSfx": "FadeOutDuration",
|
|
"RemoveItem": "Amount ItemId", "ShowUI": "UINames", "ShowPrinter": "",
|
|
"InputCustomVariable": "PlayOnSubmit Summary VariableName", "ClearBacklog": "", "AppendText": "Text",
|
|
}
|
|
EFFECTS = set("ShakeCamera ShakePrinter ShakeCharacter DigitalGlitch PinkFlash RedFlash WhiteFlash Hands1 Hands2 Hands3 Rain EffectSuccess EffectFailed EffectHeart Gaze SkillUp LewdInswitterComment PregMiniGame CamShowUI".split())
|
|
UI_NAMES = {"MStat", "Calendar", "AppUI", "CGGalleryUI", "Inventory"}
|
|
|
|
|
|
def inline_commands(value):
|
|
"""Same bounded comma/quote splitting as the browser's choice interpreter."""
|
|
if isinstance(value, list):
|
|
if not all(isinstance(part, str) for part in value):
|
|
raise ValueError("Неверный список команд выбора")
|
|
return [part.strip() for part in value if part.strip()]
|
|
if not isinstance(value, str) or len(value) > 30000:
|
|
raise ValueError("Неверная команда выбора")
|
|
result, start, depth, quote, escaped = [], 0, 0, "", False
|
|
for index, char in enumerate(value):
|
|
if escaped:
|
|
escaped = False
|
|
continue
|
|
if quote:
|
|
if char == "\\":
|
|
escaped = True
|
|
elif char == quote:
|
|
quote = ""
|
|
elif char in "\"'":
|
|
quote = char
|
|
elif char in "({":
|
|
depth += 1
|
|
elif char in ")}":
|
|
depth -= 1
|
|
if depth < 0:
|
|
raise ValueError("Некорректные скобки команды выбора")
|
|
elif char == "," and depth == 0:
|
|
result.append(value[start:index].strip())
|
|
start = index + 1
|
|
if depth or quote:
|
|
raise ValueError("Незакрытая команда выбора")
|
|
result.append(value[start:].strip())
|
|
return [item for item in result if item]
|
|
|
|
|
|
def validate_runtime(data):
|
|
scripts, issues = data["scripts"], []
|
|
|
|
def issue(code, message, location):
|
|
issues.append(Issue(code, message, location))
|
|
|
|
def target(value, script, location):
|
|
if isinstance(value, dict):
|
|
if "template" in value:
|
|
return # expressions checked separately; the VM checks the resolved target
|
|
name, label = value.get("name") or script, value.get("value") or ""
|
|
elif isinstance(value, str):
|
|
name, _, label = value.partition(".")
|
|
name = name or script
|
|
else:
|
|
issue("invalid_target", "Неверный переход Naninovel", location)
|
|
return
|
|
if name not in scripts or (label and label not in scripts[name]["labels"]):
|
|
issue("missing_target", f"Не найден переход {name}.{label}", location)
|
|
|
|
def inline(value, script, location):
|
|
try:
|
|
if isinstance(value, dict) and isinstance(value.get("template"), str):
|
|
# The template is parsed after interpolation by the VM. Its
|
|
# expressions are validated by validate_expressions below.
|
|
value = value["template"]
|
|
for command in inline_commands(value):
|
|
if not command.startswith("@"):
|
|
continue
|
|
name, _, body = command.partition(" ")
|
|
if name not in {"@set", "@goto", "@gosub", "@return", "@hideChars", "@addItem", "@additem", "@removeItem", "@voice", "@hide"}:
|
|
raise ValueError(f"Неизвестная команда выбора {name}")
|
|
if name == "@set" and "{" not in body:
|
|
assignment(body, FUNCTIONS)
|
|
elif name in {"@goto", "@gosub"} and "{" not in body:
|
|
target(body, script, location)
|
|
for match in re.finditer(r"\{([^{}]+)\}", body):
|
|
parse(match[1], FUNCTIONS)
|
|
except ValueError as exc:
|
|
issue("unsupported_choice", str(exc), location)
|
|
|
|
for name, script in scripts.items():
|
|
for index, command in enumerate(script["commands"]):
|
|
op, args = command["op"], command["args"]
|
|
location = f"{name}:{command.get('line', -1)}"
|
|
if op not in ARGUMENTS:
|
|
issue("unsupported_command", f"Не поддерживается команда {op}", location)
|
|
continue
|
|
extra = set(args) - set(ARGUMENTS[op].split()) - {"Wait", "ConditionalExpression"}
|
|
if extra:
|
|
issue("unsupported_argument", f"Не поддерживаются параметры {op}: {', '.join(sorted(extra))}", location)
|
|
if op in {"Spawn", "DestroySpawned"}:
|
|
effect = args.get("Path")
|
|
if not isinstance(effect, str) or effect not in EFFECTS:
|
|
issue("unsupported_effect", "Не поддерживается эффект Tomie: " + str(effect), location)
|
|
if op in {"ShowUI", "HideUI"}:
|
|
names = args.get("UINames", [])
|
|
if not isinstance(names, list) or any(not isinstance(value, str) or value not in UI_NAMES for value in names):
|
|
issue("unsupported_ui", "Не поддерживается интерфейс Tomie: " + str(names), location)
|
|
if op in {"BeginIf", "Else", "ElseIf"}:
|
|
# Native Naninovel seeks in the playlist instead of retaining a
|
|
# branch stack. An unmatched EndIf is a valid no-op; a branch
|
|
# without a forward closing EndIf cannot be traversed safely.
|
|
depth = 0
|
|
for following in script["commands"][index + 1:]:
|
|
if following["op"] == "BeginIf":
|
|
depth += 1
|
|
elif following["op"] == "EndIf":
|
|
if depth == 0:
|
|
break
|
|
depth -= 1
|
|
else:
|
|
issue("invalid_branch", f"{op} не имеет завершающего EndIf", location)
|
|
if op in {"Goto", "Gosub"}:
|
|
target(args.get("Path"), name, location)
|
|
if op == "AddChoice":
|
|
if "GotoPath" in args:
|
|
target(args["GotoPath"], name, location)
|
|
inline(args.get("OnSelected", []), name, location)
|
|
expressions = validate_expressions(scripts)
|
|
issues.extend(Issue("unsupported_expression", item["message"], item["location"], item["count"])
|
|
for item in expressions["issues"])
|
|
return issues
|