Files
flutter_lua_runtime/lib/runtime/packages/package_verifier.dart
2026-06-09 12:49:01 +08:00

121 lines
3.7 KiB
Dart

import 'dart:io';
import 'package:path/path.dart' as p;
import 'game_package.dart';
import 'game_package_manifest.dart';
class PackageVerifier {
const PackageVerifier({required this.runtimeApiVersion});
final int runtimeApiVersion;
Future<void> verify(GamePackage package) async {
_verifyManifest(package);
await _verifyEntry(package);
await _verifyDeclaredModules(package);
await _verifyDeclaredResources(package);
}
void _verifyManifest(GamePackage package) {
final manifest = package.manifest;
if (manifest.runtimeApiVersion > runtimeApiVersion) {
throw FormatException(
'Package runtimeApiVersion ${manifest.runtimeApiVersion} is newer than runtime $runtimeApiVersion',
);
}
if (manifest.gameId.isEmpty ||
manifest.version.isEmpty ||
manifest.entry.isEmpty) {
throw const FormatException('Package manifest is incomplete');
}
}
Future<void> _verifyEntry(GamePackage package) async {
final script = await package.readText(package.manifest.entry);
if (!script.contains('function init')) {
throw const FormatException('Lua package must define function init(ctx)');
}
if (!script.contains('function on_event')) {
throw const FormatException(
'Lua package must define function on_event(event)',
);
}
if (!script.contains('function smoke_test')) {
throw const FormatException(
'Lua package must define function smoke_test(ctx)',
);
}
}
Future<void> _verifyDeclaredModules(GamePackage package) async {
for (final entry in package.manifest.modules.entries) {
final name = entry.key;
final path = entry.value;
if (!_isSafeModuleName(name)) {
throw FormatException('Unsafe Lua module name: $name');
}
if (!_isSafeModulePath(path)) {
throw FormatException(
'Lua module path must be scripts/*.lua or runtime:*.lua: $path',
);
}
await package.readText(path);
}
}
bool _isSafeModuleName(String value) {
return RegExp(r'^[A-Za-z0-9_.-]+$').hasMatch(value) &&
!value.contains('..') &&
!value.startsWith('.') &&
!value.endsWith('.');
}
bool _isSafeModulePath(String path) {
if (path.startsWith(GamePackage.runtimeLuaPrefix)) {
final name = path.substring(GamePackage.runtimeLuaPrefix.length);
return name.isNotEmpty &&
name.endsWith('.lua') &&
!name.contains('/') &&
!name.contains('..');
}
return path.startsWith('scripts/') &&
path.endsWith('.lua') &&
!path.contains('..');
}
Future<void> _verifyDeclaredResources(GamePackage package) async {
for (final resource in package.manifest.resources.values) {
final paths = _resourcePaths(resource);
for (final path in paths) {
if (path.contains('..')) {
throw const FormatException('Resource path must not contain ..');
}
if (package.isAsset) {
await package.readBytes(path);
continue;
}
final root = p.normalize(package.rootPath);
final target = p.normalize(p.join(root, path));
if (!p.isWithin(root, target) && target != root) {
throw const FormatException('Resource path escapes package root');
}
if (!File(target).existsSync()) {
throw FormatException('Missing declared resource: $path');
}
}
}
}
Iterable<String> _resourcePaths(GameResource resource) {
if (resource.type == GameResourceType.spine) {
return [resource.atlas!, resource.skeleton!];
}
if (resource.type == GameResourceType.image && resource.atlas != null) {
return [resource.path, resource.atlas!];
}
return [resource.path];
}
}