收紧资源布局 revision IPC 精度边界

统一限制资源布局 revision 到 JavaScript 最大安全整数
在 Rust serde、Tauri 输入与前端 IPC 三层拒绝非安全 revision
补充最大值往返、上限失败、超限零副作用与不可信响应回归
同步更新资源画布合同、技术决策与竞态经验
This commit is contained in:
2026-07-31 11:27:23 +08:00
parent 0c817383b8
commit beec99a3b7
10 changed files with 283 additions and 18 deletions
@@ -1,4 +1,8 @@
use super::*;
use shared_contracts::game_creation_app::{
validate_project_resource_canvas_layout_revision,
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
};
use std::collections::HashSet;
use std::fs::File;
use std::sync::{Mutex, OnceLock};
@@ -356,6 +360,7 @@ fn validate_resource_layout(
if layout.mode != mode {
return Err("资源布局 mode 与文件不匹配".to_string());
}
validate_project_resource_canvas_layout_revision(layout.revision).map_err(str::to_string)?;
validate_resource_layout_positions(&layout.positions)
}
@@ -436,6 +441,7 @@ pub(crate) fn update_project_resource_canvas_layout_at(
expected_revision: u64,
positions: Vec<ProjectResourceCanvasPosition>,
) -> Result<UpdateProjectResourceCanvasLayoutResult, String> {
validate_project_resource_canvas_layout_revision(expected_revision).map_err(str::to_string)?;
validate_resource_layout_positions(&positions)?;
let expected_project_id = expected_project_id.trim();
if expected_project_id.is_empty() {
@@ -464,10 +470,10 @@ pub(crate) fn update_project_resource_canvas_layout_at(
layout: current,
});
}
let next_revision = current
.revision
.checked_add(1)
.ok_or_else(|| "资源布局 revision 已达到上限,无法继续保存".to_string())?;
if current.revision == GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION {
return Err("资源布局 revision 已达到 JavaScript 安全整数上限,无法继续保存".to_string());
}
let next_revision = current.revision + 1;
if current_resource_layout_project_id(root)? != project_id {
return Err("项目 manifest 在资源布局写入前发生变化,请重试".to_string());
}
@@ -703,7 +709,7 @@ mod tests {
}
#[test]
fn resource_layout_revision_exhaustion_fails_without_overwrite() {
fn resource_layout_safe_revision_exhaustion_fails_without_overwrite() {
let root = unique_resource_layout_project_path();
init_local_game_project_at(&root, "layout-revision-max", "布局 revision 上限")
.expect("init project");
@@ -715,7 +721,7 @@ mod tests {
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
project_id: "layout-revision-max".to_string(),
mode: ProjectResourceCanvasLayoutMode::Dependency,
revision: u64::MAX,
revision: GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
positions: vec![layout_position("asset-a", 10)],
updated_at: 1,
};
@@ -726,7 +732,7 @@ mod tests {
&root,
ProjectResourceCanvasLayoutMode::Dependency,
"layout-revision-max",
u64::MAX,
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
vec![layout_position("asset-a", 999)],
)
.expect_err("revision exhaustion must fail");
@@ -735,6 +741,48 @@ mod tests {
fs::remove_dir_all(root).ok();
}
#[test]
fn resource_layout_rejects_unsafe_revision_without_side_effects() {
let root = unique_resource_layout_project_path();
init_local_game_project_at(&root, "layout-unsafe-revision", "布局非安全 revision")
.expect("init project");
let expected_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
"layout-unsafe-revision",
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
vec![layout_position("asset-a", 10)],
)
.expect_err("unsafe expected revision must fail");
assert!(expected_error.contains("revision"));
assert!(!root.join(".agent/workbench").exists());
let path = root.join(resource_layout_relative_path(
ProjectResourceCanvasLayoutMode::Dependency,
));
fs::create_dir_all(path.parent().expect("layout parent")).expect("create layout parent");
let unsafe_payload = serde_json::json!({
"schemaVersion": GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
"projectId": "layout-unsafe-revision",
"mode": "dependency",
"revision": GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
"positions": [],
"updatedAt": 1
});
let original = serde_json::to_vec(&unsafe_payload).expect("serialize unsafe fixture");
fs::write(&path, &original).expect("write unsafe fixture");
let read_error = read_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Dependency,
)
.expect_err("unsafe persisted revision must fail");
assert!(read_error.contains("revision") || read_error.contains("安全整数"));
assert_eq!(fs::read(&path).expect("read unsafe fixture"), original);
fs::remove_dir_all(root).ok();
}
#[test]
fn resource_layout_rejects_duplicate_ids_and_project_identity_drift() {
let root = unique_resource_layout_project_path();
@@ -6,6 +6,7 @@ import type {
ProjectResourceCanvasSection,
UpdateProjectResourceCanvasLayoutResult,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { isSafeProjectResourceCanvasLayoutRevision } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
createEmptyResourceCanvasLayout,
moveResourceCanvasPosition,
@@ -277,6 +278,19 @@ export function useProjectResourceCanvasLayout({
return;
}
const expectedRevision = persistedLayoutRef.current.revision;
if (!isSafeProjectResourceCanvasLayoutRevision(expectedRevision)) {
removeWriteIntent(intent);
rebuildOptimisticLayout(scope.epoch);
setNotice(
intent.kind === 'resources'
? '布局保存失败,已保留当前会话布局'
: '布局保存失败,已恢复上次布局',
);
void Promise.resolve().then(() => pumpWritesRef.current());
return;
}
activeWriteIntentRef.current = intent;
if (mountedRef.current) {
setSaving(true);
@@ -287,7 +301,7 @@ export function useProjectResourceCanvasLayout({
projectPath: scope.projectPath,
expectedProjectId: scope.projectId,
mode: scope.mode,
expectedRevision: persistedLayoutRef.current.revision,
expectedRevision,
positions: candidate.positions,
},
)
@@ -296,6 +310,11 @@ export function useProjectResourceCanvasLayout({
if (currentScope.epoch !== intent.scopeEpoch) {
return;
}
if (
!isSafeProjectResourceCanvasLayoutRevision(result.layout.revision)
) {
throw new Error('layout response revision is not a safe integer');
}
if (!layoutMatchesScope(result.layout, currentScope)) {
throw new Error('layout response scope mismatch');
}
@@ -422,11 +441,13 @@ export function useProjectResourceCanvasLayout({
{ projectPath, mode },
)
.then((loaded) => {
if (
cancelled ||
scopeRef.current.epoch !== epoch ||
!layoutMatchesScope(loaded, scope)
) {
if (cancelled || scopeRef.current.epoch !== epoch) {
return;
}
if (!isSafeProjectResourceCanvasLayoutRevision(loaded.revision)) {
throw new Error('layout response revision is not a safe integer');
}
if (!layoutMatchesScope(loaded, scope)) {
return;
}
persistedLayoutRef.current = loaded;
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import {
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
isSafeProjectResourceCanvasLayoutRevision,
} from '../../../packages/shared/src/contracts/gameCreationApp';
describe('resource canvas layout contract', () => {
it('keeps revisions inside the JSON safe integer range', () => {
const maxRevision = GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION;
expect(JSON.parse(JSON.stringify({ revision: maxRevision }))).toEqual({
revision: maxRevision,
});
expect(isSafeProjectResourceCanvasLayoutRevision(0)).toBe(true);
expect(isSafeProjectResourceCanvasLayoutRevision(maxRevision)).toBe(true);
expect(isSafeProjectResourceCanvasLayoutRevision(maxRevision + 1)).toBe(
false,
);
expect(isSafeProjectResourceCanvasLayoutRevision(-1)).toBe(false);
expect(isSafeProjectResourceCanvasLayoutRevision(1.5)).toBe(false);
expect(isSafeProjectResourceCanvasLayoutRevision(Number.NaN)).toBe(false);
expect(
isSafeProjectResourceCanvasLayoutRevision(Number.POSITIVE_INFINITY),
).toBe(false);
expect(isSafeProjectResourceCanvasLayoutRevision('1')).toBe(false);
});
});
@@ -8,6 +8,7 @@ import type {
ProjectResourceCanvasLayoutMode,
ProjectResourceCanvasPosition,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import { GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION } from '../../../packages/shared/src/contracts/gameCreationApp';
import type { ResourceCanvasItem } from '../src/view/project-development/resourceCanvasLayoutModel';
import {
createResourceSignature,
@@ -70,6 +71,84 @@ describe('useProjectResourceCanvasLayout', () => {
);
});
it('rejects an unsafe revision from the initial IPC read without writing', async () => {
const resourceA = resource('resource-a');
const invoke = vi.fn(async (command: string) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout(
'dependency',
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
[position('resource-a', 10, 20)],
);
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources: [resourceA],
}),
);
await waitFor(() =>
expect(result.current.notice).toBe('布局读取失败,已使用当前会话布局'),
);
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
),
).toHaveLength(0);
});
it('rejects an unsafe revision from an update response and restores the trusted layout', async () => {
const resourceA = resource('resource-a');
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout('dependency', 1, [
position('resource-a', 10, 20),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: persistedLayout(
'dependency',
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
),
),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result } = renderHook(() =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources: [resourceA],
}),
);
await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10));
act(() => result.current.commitPosition('resource-a', 'document', 100, 30));
await waitFor(() =>
expect(result.current.notice).toBe('布局保存失败,已恢复上次布局'),
);
expect(result.current.layout.positions[0]).toMatchObject({ x: 10, y: 20 });
expect(result.current.saving).toBe(false);
});
it('uses the latest resource snapshot when the initial scope read resolves', async () => {
const resourceA = resource('resource-a');
const resourceB = resource('resource-b');