121 lines
3.7 KiB
Dart
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];
|
|
}
|
|
}
|