Limit corrupted battle save payloads

This commit is contained in:
2026-07-05 11:40:49 +09:00
parent 08e669aca5
commit 1b6a8aea4c
2 changed files with 76 additions and 3 deletions

View File

@@ -39,9 +39,13 @@ try {
['invalid rosterTab', { rosterTab: 'neutral' }], ['invalid rosterTab', { rosterTab: 'neutral' }],
['invalid actedUnitIds', { actedUnitIds: 'liu-bei' }], ['invalid actedUnitIds', { actedUnitIds: 'liu-bei' }],
['unknown acted unit id', { actedUnitIds: ['ghost-unit'] }], ['unknown acted unit id', { actedUnitIds: ['ghost-unit'] }],
['duplicate acted unit id', { actedUnitIds: ['liu-bei', 'liu-bei'] }],
['invalid battleLog', { battleLog: 'hit' }], ['invalid battleLog', { battleLog: 'hit' }],
['too many battle log entries', { battleLog: Array.from({ length: 11 }, (_, index) => `Log ${index}`) }],
['too long battle log entry', { battleLog: ['x'.repeat(97)] }],
['invalid attackIntents', { attackIntents: [{ attackerId: 'liu-bei' }] }], ['invalid attackIntents', { attackIntents: [{ attackerId: 'liu-bei' }] }],
['unknown attack intent unit id', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'ghost-unit' }] }], ['unknown attack intent unit id', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'ghost-unit' }] }],
['duplicate attack intent attacker id', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'rebel-1' }, { attackerId: 'liu-bei', targetId: 'rebel-1' }] }],
['invalid units array', { units: {} }], ['invalid units array', { units: {} }],
['invalid unit hp', { units: patchUnit(0, { hp: 99, maxHp: 30 }) }], ['invalid unit hp', { units: patchUnit(0, { hp: 99, maxHp: 30 }) }],
['invalid unit x', { units: patchUnit(0, { x: 12 }) }], ['invalid unit x', { units: patchUnit(0, { x: 12 }) }],
@@ -55,8 +59,10 @@ try {
['unknown item stock item id', { itemStocks: { 'liu-bei': { phantomItem: 1 } } }], ['unknown item stock item id', { itemStocks: { 'liu-bei': { phantomItem: 1 } } }],
['invalid buff turns', { battleBuffs: [{ ...validState.battleBuffs[0], turns: 0 }] }], ['invalid buff turns', { battleBuffs: [{ ...validState.battleBuffs[0], turns: 0 }] }],
['unknown buff unit id', { battleBuffs: [{ ...validState.battleBuffs[0], unitId: 'ghost-unit' }] }], ['unknown buff unit id', { battleBuffs: [{ ...validState.battleBuffs[0], unitId: 'ghost-unit' }] }],
['duplicate buff unit id', { battleBuffs: [validState.battleBuffs[0], { ...validState.battleBuffs[0] }] }],
['invalid status kind', { battleStatuses: [{ ...validState.battleStatuses[0], kind: 'poison' }] }], ['invalid status kind', { battleStatuses: [{ ...validState.battleStatuses[0], kind: 'poison' }] }],
['unknown status unit id', { battleStatuses: [{ ...validState.battleStatuses[0], unitId: 'ghost-unit' }] }], ['unknown status unit id', { battleStatuses: [{ ...validState.battleStatuses[0], unitId: 'ghost-unit' }] }],
['duplicate status unit kind', { battleStatuses: [validState.battleStatuses[0], { ...validState.battleStatuses[0] }] }],
['invalid stats shape', { battleStats: { 'liu-bei': { damageDealt: 10 } } }], ['invalid stats shape', { battleStats: { 'liu-bei': { damageDealt: 10 } } }],
['unknown stats unit id', { battleStats: { 'ghost-unit': validState.battleStats['liu-bei'] } }], ['unknown stats unit id', { battleStats: { 'ghost-unit': validState.battleStats['liu-bei'] } }],
['invalid enemy usable keys', { enemyUsableUseKeys: [1] }], ['invalid enemy usable keys', { enemyUsableUseKeys: [1] }],
@@ -64,6 +70,7 @@ try {
['wrong battle enemy usable key', { enemyUsableUseKeys: ['other-battle:rebel-1:roar'] }], ['wrong battle enemy usable key', { enemyUsableUseKeys: ['other-battle:rebel-1:roar'] }],
['unknown enemy usable unit id', { enemyUsableUseKeys: ['first-battle-zhuo-commandery:ghost-unit:roar'] }], ['unknown enemy usable unit id', { enemyUsableUseKeys: ['first-battle-zhuo-commandery:ghost-unit:roar'] }],
['unknown enemy usable id', { enemyUsableUseKeys: ['first-battle-zhuo-commandery:rebel-1:phantom'] }], ['unknown enemy usable id', { enemyUsableUseKeys: ['first-battle-zhuo-commandery:rebel-1:phantom'] }],
['duplicate enemy usable key', { enemyUsableUseKeys: ['first-battle-zhuo-commandery:rebel-1:roar', 'first-battle-zhuo-commandery:rebel-1:roar'] }],
['invalid triggered events', { triggeredBattleEvents: [1] }] ['invalid triggered events', { triggeredBattleEvents: [1] }]
]; ];

View File

@@ -77,6 +77,10 @@ type BattleSaveValidationOptions = {
}; };
const unitDirections = new Set<UnitDirection>(['south', 'east', 'north', 'west']); const unitDirections = new Set<UnitDirection>(['south', 'east', 'north', 'west']);
const maxBattleLogEntries = 10;
const maxBattleLogEntryLength = 96;
const defaultBattleSaveArrayLimit = 128;
const maxStatusKindsPerUnit = 2;
export function normalizeBattleSaveSlot(slot: number, slotCount: number) { export function normalizeBattleSaveSlot(slot: number, slotCount: number) {
const safeSlot = Number.isFinite(slot) ? Math.floor(slot) : 1; const safeSlot = Number.isFinite(slot) ? Math.floor(slot) : 1;
@@ -174,7 +178,12 @@ function isStringArray(value: unknown) {
} }
function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions) { function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions) {
return Array.isArray(value) && value.every((entry) => typeof entry === 'string' && isKnownUnitId(entry, options)); return (
Array.isArray(value) &&
value.length <= unitReferenceLimit(options) &&
hasUniqueStrings(value) &&
value.every((entry) => typeof entry === 'string' && isKnownUnitId(entry, options))
);
} }
function isOptionalStringArray(value: unknown) { function isOptionalStringArray(value: unknown) {
@@ -182,12 +191,18 @@ function isOptionalStringArray(value: unknown) {
} }
function isBattleLog(value: unknown) { function isBattleLog(value: unknown) {
return Array.isArray(value) && value.every((entry) => typeof entry === 'string'); return (
Array.isArray(value) &&
value.length <= maxBattleLogEntries &&
value.every((entry) => typeof entry === 'string' && entry.length <= maxBattleLogEntryLength)
);
} }
function isAttackIntentArray(value: unknown, options: BattleSaveValidationOptions) { function isAttackIntentArray(value: unknown, options: BattleSaveValidationOptions) {
return ( return (
Array.isArray(value) && Array.isArray(value) &&
value.length <= unitReferenceLimit(options) &&
hasUniqueRecordStrings(value, 'attackerId') &&
value.every( value.every(
(intent) => (intent) =>
isRecord(intent) && isRecord(intent) &&
@@ -277,10 +292,15 @@ function isOptionalItemStockRecord(value: unknown, options: BattleSaveValidation
return false; return false;
} }
if (Object.keys(value).length > unitReferenceLimit(options)) {
return false;
}
return Object.entries(value).every( return Object.entries(value).every(
([unitId, stocks]) => ([unitId, stocks]) =>
isKnownUnitId(unitId, options) && isKnownUnitId(unitId, options) &&
isRecord(stocks) && isRecord(stocks) &&
Object.keys(stocks).length <= itemReferenceLimit(options) &&
Object.entries(stocks).every( Object.entries(stocks).every(
([itemId, count]) => ([itemId, count]) =>
(!options.validItemIds || options.validItemIds.has(itemId)) && Number.isInteger(count) && Number(count) >= 0 (!options.validItemIds || options.validItemIds.has(itemId)) && Number.isInteger(count) && Number(count) >= 0
@@ -292,6 +312,8 @@ function isOptionalBuffArray(value: unknown, options: BattleSaveValidationOption
return ( return (
value === undefined || value === undefined ||
(Array.isArray(value) && (Array.isArray(value) &&
value.length <= unitReferenceLimit(options) &&
hasUniqueRecordStrings(value, 'unitId') &&
value.every( value.every(
(buff) => (buff) =>
isRecord(buff) && isRecord(buff) &&
@@ -310,6 +332,8 @@ function isOptionalStatusArray(value: unknown, options: BattleSaveValidationOpti
return ( return (
value === undefined || value === undefined ||
(Array.isArray(value) && (Array.isArray(value) &&
value.length <= unitReferenceLimit(options) * maxStatusKindsPerUnit &&
hasUniqueStatusKeys(value) &&
value.every( value.every(
(status) => (status) =>
isRecord(status) && isRecord(status) &&
@@ -332,6 +356,10 @@ function isOptionalStatsRecord(value: unknown, options: BattleSaveValidationOpti
return false; return false;
} }
if (Object.keys(value).length > unitReferenceLimit(options)) {
return false;
}
return Object.entries(value).every( return Object.entries(value).every(
([unitId, stats]) => ([unitId, stats]) =>
isKnownUnitId(unitId, options) && isKnownUnitId(unitId, options) &&
@@ -345,7 +373,13 @@ function isOptionalStatsRecord(value: unknown, options: BattleSaveValidationOpti
} }
function isOptionalEnemyUsableUseKeyArray(value: unknown, battleId: string, options: BattleSaveValidationOptions) { function isOptionalEnemyUsableUseKeyArray(value: unknown, battleId: string, options: BattleSaveValidationOptions) {
return value === undefined || (Array.isArray(value) && value.every((entry) => isEnemyUsableUseKey(entry, battleId, options))); return (
value === undefined ||
(Array.isArray(value) &&
value.length <= enemyUsableUseKeyLimit(options) &&
hasUniqueStrings(value) &&
value.every((entry) => isEnemyUsableUseKey(entry, battleId, options)))
);
} }
function isEnemyUsableUseKey(value: unknown, battleId: string, options: BattleSaveValidationOptions) { function isEnemyUsableUseKey(value: unknown, battleId: string, options: BattleSaveValidationOptions) {
@@ -369,3 +403,35 @@ function isEnemyUsableUseKey(value: unknown, battleId: string, options: BattleSa
function isKnownUnitId(unitId: string, options: BattleSaveValidationOptions) { function isKnownUnitId(unitId: string, options: BattleSaveValidationOptions) {
return !options.validUnitIds || options.validUnitIds.has(unitId); return !options.validUnitIds || options.validUnitIds.has(unitId);
} }
function unitReferenceLimit(options: BattleSaveValidationOptions) {
return options.validUnitIds?.size ?? defaultBattleSaveArrayLimit;
}
function itemReferenceLimit(options: BattleSaveValidationOptions) {
return options.validItemIds?.size ?? defaultBattleSaveArrayLimit;
}
function enemyUsableUseKeyLimit(options: BattleSaveValidationOptions) {
if (options.validUnitIds && options.validUsableIds) {
return options.validUnitIds.size * options.validUsableIds.size;
}
return defaultBattleSaveArrayLimit;
}
function hasUniqueStrings(values: unknown[]) {
return new Set(values.filter((entry) => typeof entry === 'string')).size === values.length;
}
function hasUniqueRecordStrings(values: unknown[], field: string) {
const strings = values.map((value) => (isRecord(value) && typeof value[field] === 'string' ? value[field] : undefined));
return strings.every((value) => value !== undefined) && new Set(strings).size === strings.length;
}
function hasUniqueStatusKeys(values: unknown[]) {
const keys = values.map((value) =>
isRecord(value) && typeof value.unitId === 'string' && typeof value.kind === 'string' ? `${value.unitId}:${value.kind}` : undefined
);
return keys.every((key) => key !== undefined) && new Set(keys).size === keys.length;
}