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 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 _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 _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 _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 _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]; } }