fix(07-10): 写入路径越界校验与读流跨读档过期

- insert/delete/set 在 disabled 早退后、任何缓冲读写前校验 index,越界不修改任何缓冲区并告警新码 179(携带操作名/索引/当前长度)
- insert(index === length) 判为合法追加,按 add 语义写入(paramUsed 起、indexArray[length] = paramUsed),insert 合法区间变为 [0, length]
- setReplayArray 在 rebuildIndexArray 后补 expireStreams(),活跃读流跨 ReplaySystem.loadState 标记过期(#06-17-3 / 审计 H)
- types.ts 的 insert/delete/set jsDoc 写明合法区间与越界契约
- logger.json 新增 warn 179(当前最大 warn 码 178 之后)
- array.test.ts 新增 5 条边界回归(含越界前后逐字节不变量);saveLoad.test.ts 新增活跃读流跨读档告警 156 回归
This commit is contained in:
unanmed 2026-09-17 12:50:45 +08:00
parent 9ae28c68cc
commit 1f9fb18cf5
5 changed files with 158 additions and 5 deletions

View File

@ -167,6 +167,25 @@ function expectRouteStream(
expect(stream.index).toBe(expectedSteps.length);
}
// 执行越界编辑并对比前后全部可观测状态:长度、两个缓冲区字节与每一步解码结果,返回本次告警码
function expectArrayUnchanged(array: ReplayArray, run: () => void): number[] {
const length = array.length;
const commands = new Uint8Array(array.getCommandArray()).slice();
const params = new Uint8Array(array.getParamArray()).slice();
const steps = Array.from({ length }, (_, i) => array.get(i));
const { info } = logger.catch(run);
expect(array.length).toBe(length);
expect(new Uint8Array(array.getCommandArray())).toEqual(commands);
expect(new Uint8Array(array.getParamArray())).toEqual(params);
expect(
Array.from({ length: array.length }, (_, i) => array.get(i))
).toEqual(steps);
return info.map(v => v.code!);
}
describe('ReplayArray single operations', () => {
// 验证 add 追加一条录像步并在原索引读回指令与参数
it('appends one step and reads it back at the original index', () => {
@ -357,6 +376,76 @@ describe('ReplayArray set index maintenance', () => {
});
});
describe('ReplayArray index bounds', () => {
// 验证 insert 传入当前总步数时按末尾追加处理,追加后按索引与读流读回结果一致
it('appends when the insert index equals the current length', () => {
const array = createArray();
array.add(1, [10]);
array.add(2, [20]);
array.insert(array.length, 3, [30]);
expect(array.length).toBe(3);
expect(array.get(2)).toEqual({ command: 3, params: [30], index: 2 });
const stream = array.createReadStream(0);
expectStepTyped(stream.read()!, 1, [10], 1);
expectStepTyped(stream.read()!, 2, [20], 2);
expectStepTyped(stream.read()!, 3, [30], 3);
expect(stream.read()).toBeNull();
});
// 验证 insert 负索引与超出总步数的索引触发告警码 179 且不修改任何缓冲区内容
it('warns code 179 and keeps the buffers unchanged for an out-of-range insert', () => {
const array = createHeterogeneousArray();
for (const index of [-1, array.length + 1]) {
const codes = expectArrayUnchanged(array, () =>
array.insert(index, 9, [true, 5])
);
expect(codes).toContain(179);
}
});
// 验证 delete 负索引与等于总步数的索引触发告警码 179 且不修改任何缓冲区内容
it('warns code 179 and keeps the buffers unchanged for an out-of-range delete', () => {
const array = createHeterogeneousArray();
for (const index of [-1, array.length]) {
const codes = expectArrayUnchanged(array, () =>
array.delete(index)
);
expect(codes).toContain(179);
}
});
// 验证 set 负索引与等于总步数的索引触发告警码 179 且不修改任何缓冲区内容
it('warns code 179 and keeps the buffers unchanged for an out-of-range set', () => {
const array = createHeterogeneousArray();
for (const index of [-1, array.length]) {
const codes = expectArrayUnchanged(array, () =>
array.set(index, 9, [true, 5])
);
expect(codes).toContain(179);
}
});
// 验证空录像上的 delete 与 set 一律按越界处理,既不告警 179 之外的内容也不产生录像步
it('treats every index as out of range on an empty route', () => {
const array = createArray();
for (const index of [0, -1]) {
expect(
expectArrayUnchanged(array, () => array.delete(index))
).toContain(179);
}
expect(
expectArrayUnchanged(array, () => array.set(0, 1, [10]))
).toContain(179);
expect(array.length).toBe(0);
});
});
describe('ReplayArray param codec', () => {
// 验证 boolean 参数按 type 0 写入并读回布尔值
it('round-trips boolean parameters', () => {

View File

@ -436,14 +436,36 @@ export class ReplayArray implements IReplayArray {
insert(index: number, command: number, params: ReplayParamValue[]): void {
if (this.disabled > 0) return;
if (index < 0 || index > this.length) {
logger.warn(
179,
'insert',
index.toString(),
this.length.toString()
);
return;
}
const normalized = this.normalizeParamList(params);
const length = this.calculateParamsLength(normalized);
this.checkBufferExpand(length);
const commandSize = this.getCommandSize();
const commandStart = index * commandSize;
// 插入到末尾等同于追加,此时无需任何位移
if (index === this.length) {
this.setCommandArray(commandStart, params.length, command);
this.setParamArray(this.paramUsed, normalized);
this.indexArray[this.length] = this.paramUsed;
this.length++;
this.paramUsed += length;
this.expireStreams();
return;
}
// 先进行位移
const commandStart = index * commandSize;
const paramStart = this.indexArray[index];
this.commandArray.copyWithin(commandStart + commandSize, commandStart);
this.paramArray.copyWithin(paramStart + length, paramStart);
@ -466,6 +488,15 @@ export class ReplayArray implements IReplayArray {
delete(index: number): void {
if (this.disabled > 0) return;
if (index < 0 || index >= this.length) {
logger.warn(
179,
'delete',
index.toString(),
this.length.toString()
);
return;
}
const commandSize = this.getCommandSize();
const commandStart = index * commandSize;
const range = this.getParamRange(index);
@ -501,6 +532,10 @@ export class ReplayArray implements IReplayArray {
set(index: number, command: number, params: ReplayParamValue[]): void {
if (this.disabled > 0) return;
if (index < 0 || index >= this.length) {
logger.warn(179, 'set', index.toString(), this.length.toString());
return;
}
const normalized = this.normalizeParamList(params);
const length = this.calculateParamsLength(normalized);
// 末步的参数终点取 paramUsed,与 delete 同口径,避免读到未初始化的 indexArray[index + 1]
@ -825,6 +860,9 @@ export class ReplayArray implements IReplayArray {
this.length = length;
this.rebuildIndexArray();
// 缓冲区被整体替换后,此前创建的读流偏移已失效,需要与其它写路径一样标记过期
this.expireStreams();
}
//#endregion

View File

@ -1,4 +1,5 @@
// 测试录像存读档:ReplaySystem 同实例往返与多类型参数读回
// 测试录像存读档:ReplaySystem 同实例往返、多类型参数读回与活跃读流跨读档过期
import { logger } from '@motajs/common';
import { afterAll, describe, expect, it, vi } from 'vitest';
import { ReplaySystem } from './system';
@ -63,4 +64,24 @@ describe('ReplaySystem save and load round trips', () => {
expect(system.route.getParamArray()).toBe(snapshot.paramArray);
expect(system.route.get(0).command).toBe(2);
});
// 验证读档整体替换缓冲区后,此前创建的活跃沙箱读流被标记过期并触发告警码 156(#06-17-3)
it('expires an active sandbox read stream when a snapshot is loaded', () => {
const system = createSystem();
system.registerCommand(1, { execute: async () => true });
system.record(1, 7);
const snapshot = system.saveState();
system.record(1, 9);
const sandbox = system.createReplaySandbox({
route: system.route,
reseter: { reset: () => {} }
});
system.loadState(snapshot);
const { info } = logger.catch(() => sandbox.play());
expect(info.map(v => v.code)).toContain(156);
});
});

View File

@ -196,8 +196,10 @@ export interface IReplayArray {
/**
* 在指定索引处插入一条录像步,后续录像会自动后移。
* 索引可传当前总步数,此时等同于在末尾追加一条录像步。
* 索引越界时不会产生任何修改,并触发告警码 179。
* 由于此操作会涉及大量的内存迁移,耗时较长,因此不建议频繁调用。
* @param index 插入位置
* @param index 插入位置,合法区间为 `[0, length]`
* @param command 指令标识
* @param params 参数列表
*/
@ -205,16 +207,18 @@ export interface IReplayArray {
/**
* 删除指定索引处的录像步,此行为不会产生空槽。
* 索引越界时不会产生任何修改,并触发告警码 179。
* 由于此操作会涉及大量的内存迁移,耗时较长,因此不建议频繁调用。
* @param index 要删除的步索引
* @param index 要删除的步索引,合法区间为 `[0, length)`
*/
delete(index: number): void;
/**
* 修改指定索引处的录像步。新的参数列表允许与原有参数编码长度不同,
* 此情形下其后所有录像步的参数起始索引会同步平移。
* 索引越界时不会产生任何修改,并触发告警码 179。
* 由于此操作会涉及大量的内存迁移,耗时较长,因此不建议频繁调用。
* @param index 要修改的步索引
* @param index 要修改的步索引,合法区间为 `[0, length)`
* @param command 新的指令标识
* @param params 新的参数列表
*/

View File

@ -253,6 +253,7 @@
"176": "Cannot add move step to replay system since only 'up,right,down,left' directions are acceptable.",
"177": "Save data for saveable content $1 is needed, but there's no data in save data. Maybe a version issue for it? System will try to load other contents to make game run normally.",
"178": "Save data with keys of '$1' are saved but not be loaded, is there some issue for it?",
"179": "Replay $1 received an out-of-range index $2, the current replay length is $3. The operation will be ignored.",
"1001": "Event(setBlock): Unknown tile '$1' when setting block."
}
}