收紧资源布局 revision IPC 精度边界
统一限制资源布局 revision 到 JavaScript 最大安全整数 在 Rust serde、Tauri 输入与前端 IPC 三层拒绝非安全 revision 补充最大值往返、上限失败、超限零副作用与不可信响应回归 同步更新资源画布合同、技术决策与竞态经验
This commit is contained in:
@@ -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();
|
||||
|
||||
+27
-6
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user