web: protect config schema wrapper

This commit is contained in:
2026-08-14 23:38:51 +09:00
parent 83a99541b2
commit 99b08e0f0c
6 changed files with 275 additions and 2 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts", "test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
"build": "deno run -A npm:vite@7.2.7 build", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview" "preview": "deno run -A npm:vite@7.2.7 preview"
}, },
@@ -14,6 +14,8 @@
} from "./types.ts"; } from "./types.ts";
const MAIN_ENTRYPOINT = "main.dcdl"; const MAIN_ENTRYPOINT = "main.dcdl";
const WORKSPACE_SCHEMA_ASSERTION = "WorkspaceConfigSchema";
const WORKSPACE_SCHEMA_SUFFIX = `} as ${WORKSPACE_SCHEMA_ASSERTION}`;
let { workspaceId }: { workspaceId: string } = $props(); let { workspaceId }: { workspaceId: string } = $props();
let treeState = $state<WorkspaceConfigTreeResponse | null>(null); let treeState = $state<WorkspaceConfigTreeResponse | null>(null);
@@ -40,6 +42,7 @@
treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined, treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined,
); );
const mainSelected = $derived(selectedPath === MAIN_ENTRYPOINT); const mainSelected = $derived(selectedPath === MAIN_ENTRYPOINT);
const mainSchemaWrapped = $derived(mainSelected && hasWorkspaceSchemaWrapper(source));
const dirty = $derived(draftChanges.length > 0 || (selected ? source !== selected.content : source.length > 0)); const dirty = $derived(draftChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
const commitReady = $derived(dirty && preflightDigest === treeState?.snapshot.digest); const commitReady = $derived(dirty && preflightDigest === treeState?.snapshot.digest);
@@ -114,6 +117,35 @@
return [MAIN_ENTRYPOINT]; return [MAIN_ENTRYPOINT];
} }
function hasWorkspaceSchemaWrapper(value: string): boolean {
const sourceWithoutTrailingWhitespace = value.trimEnd();
return value.startsWith("{") && sourceWithoutTrailingWhitespace.endsWith(WORKSPACE_SCHEMA_SUFFIX);
}
async function wrapMainWithWorkspaceSchema() {
if (!toolchain || !mainSelected || mainSchemaWrapped || busy) return;
const candidate = source.trim();
if (!candidate.startsWith("{") || !candidate.endsWith("}")) {
status = "WorkspaceConfigSchema can only wrap a top-level object source.";
return;
}
busy = true;
try {
source = await toolchain.format(`${candidate} as ${WORKSPACE_SCHEMA_ASSERTION}`);
diagnostics = await toolchain.analyze(selectedPath, source);
preflightDigest = "";
candidateContract = null;
conflict = false;
status = diagnostics.length === 0
? "WorkspaceConfigSchema wrapper staged. Preview and Commit to persist it."
: `${diagnostics.length} diagnostic(s) after adding WorkspaceConfigSchema.`;
} catch (error) {
status = String(error);
} finally {
busy = false;
}
}
async function analyze() { async function analyze() {
if (!toolchain || !treeState || !selectedPath) return; if (!toolchain || !treeState || !selectedPath) return;
diagnostics = await toolchain.analyze(selectedPath, source); diagnostics = await toolchain.analyze(selectedPath, source);
@@ -312,6 +344,9 @@
<div class="config-source-actions"> <div class="config-source-actions">
<input aria-label="Rename path" bind:value={renamePath} disabled={!selected || mainSelected || busy} /> <input aria-label="Rename path" bind:value={renamePath} disabled={!selected || mainSelected || busy} />
<button type="button" onclick={renameEntry} disabled={!selected || mainSelected || renamePath === selectedPath || busy}>Rename</button> <button type="button" onclick={renameEntry} disabled={!selected || mainSelected || renamePath === selectedPath || busy}>Rename</button>
{#if mainSelected && !mainSchemaWrapped}
<button type="button" onclick={wrapMainWithWorkspaceSchema} disabled={!selected || busy}>Wrap with WorkspaceConfigSchema</button>
{/if}
<button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button> <button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button>
<button type="button" onclick={analyze} disabled={!selectedPath || busy}>Analyze</button> <button type="button" onclick={analyze} disabled={!selectedPath || busy}>Analyze</button>
<button type="button" onclick={preview} disabled={!dirty || busy}>Preview</button> <button type="button" onclick={preview} disabled={!dirty || busy}>Preview</button>
@@ -322,6 +357,7 @@
<DecodalSourceEditor <DecodalSourceEditor
value={source} value={source}
readonly={!selectedPath || busy} readonly={!selectedPath || busy}
fixedSchemaWrapper={mainSchemaWrapped}
onChange={(value) => source = value} onChange={(value) => source = value}
onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)} onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)}
/> />
@@ -0,0 +1,88 @@
import {
EditorSelection,
EditorState,
type Extension,
Prec,
} from "@codemirror/state";
import { Decoration, EditorView, keymap } from "@codemirror/view";
const WORKSPACE_SCHEMA_SUFFIX = "} as WorkspaceConfigSchema";
const fixedWrapperMark = Decoration.mark({
class: "cm-fixed-schema-wrapper",
});
export type FixedWrapperBounds = {
bodyFrom: number;
bodyTo: number;
};
export function fixedWrapperBounds(
state: EditorState,
): FixedWrapperBounds | null {
const source = state.doc.toString();
if (!source.startsWith("{")) return null;
const sourceWithoutTrailingWhitespace = source.trimEnd();
if (!sourceWithoutTrailingWhitespace.endsWith(WORKSPACE_SCHEMA_SUFFIX)) {
return null;
}
const bodyTo = sourceWithoutTrailingWhitespace.length -
WORKSPACE_SCHEMA_SUFFIX.length;
if (bodyTo < 1) return null;
return { bodyFrom: 1, bodyTo };
}
function fixedWrapperDecorations(state: EditorState) {
const bounds = fixedWrapperBounds(state);
if (!bounds) return Decoration.none;
return Decoration.set([
fixedWrapperMark.range(0, bounds.bodyFrom),
fixedWrapperMark.range(bounds.bodyTo, state.doc.length),
]);
}
export function fixedSchemaWrapperExtension(): Extension {
return [
EditorView.decorations.of((editor) =>
fixedWrapperDecorations(editor.state)
),
EditorView.atomicRanges.of((editor) =>
fixedWrapperDecorations(editor.state)
),
EditorState.transactionFilter.of((transaction) => {
const bounds = fixedWrapperBounds(transaction.startState);
if (!bounds || !transaction.docChanged) return transaction;
let allowed = true;
transaction.changes.iterChangedRanges((from, to) => {
if (from < bounds.bodyFrom || to > bounds.bodyTo) allowed = false;
});
return allowed ? transaction : [];
}),
Prec.highest(
keymap.of([{
key: "Mod-a",
run: (editor) => {
const bounds = fixedWrapperBounds(editor.state);
if (!bounds) return false;
editor.dispatch({
selection: EditorSelection.range(bounds.bodyFrom, bounds.bodyTo),
});
return true;
},
}]),
),
];
}
export function moveSelectionIntoFixedWrapper(editor: EditorView) {
const bounds = fixedWrapperBounds(editor.state);
if (!bounds) return;
const selectionInsideBody = editor.state.selection.ranges.every((range) =>
range.from >= bounds.bodyFrom && range.to <= bounds.bodyTo
);
if (!selectionInsideBody) {
editor.dispatch({
selection: EditorSelection.cursor(bounds.bodyFrom),
filter: false,
});
}
}
@@ -6,17 +6,23 @@
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view'; import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
import { tags } from '@lezer/highlight'; import { tags } from '@lezer/highlight';
import { decodal } from 'decodal-codemirror'; import { decodal } from 'decodal-codemirror';
import {
fixedSchemaWrapperExtension,
moveSelectionIntoFixedWrapper,
} from '$lib/workspace/config-source/fixed-schema-wrapper.ts';
let { let {
value = '', value = '',
readonly = false, readonly = false,
ariaLabel = 'Decodal source', ariaLabel = 'Decodal source',
fixedSchemaWrapper = false,
onChange = (_value: string) => {}, onChange = (_value: string) => {},
onComplete = undefined, onComplete = undefined,
}: { }: {
value?: string; value?: string;
readonly?: boolean; readonly?: boolean;
ariaLabel?: string; ariaLabel?: string;
fixedSchemaWrapper?: boolean;
onChange?: (value: string) => void; onChange?: (value: string) => void;
onComplete?: (source: string, utf16Offset: number, explicit: boolean) => Promise<CompletionResult | null>; onComplete?: (source: string, utf16Offset: number, explicit: boolean) => Promise<CompletionResult | null>;
} = $props(); } = $props();
@@ -24,6 +30,7 @@
let host = $state<HTMLDivElement | null>(null); let host = $state<HTMLDivElement | null>(null);
let view = $state.raw<EditorView | null>(null); let view = $state.raw<EditorView | null>(null);
const readonlyCompartment = new Compartment(); const readonlyCompartment = new Compartment();
const fixedSchemaWrapperCompartment = new Compartment();
const syntaxTheme = HighlightStyle.define([ const syntaxTheme = HighlightStyle.define([
{ tag: tags.keyword, color: 'var(--accent)', fontWeight: '700' }, { tag: tags.keyword, color: 'var(--accent)', fontWeight: '700' },
@@ -47,6 +54,11 @@
'&.cm-focused': { outline: '1px solid var(--accent-muted)', outlineOffset: '-1px' }, '&.cm-focused': { outline: '1px solid var(--accent-muted)', outlineOffset: '-1px' },
'.cm-scroller': { fontFamily: 'var(--font-mono)', minHeight: '24rem' }, '.cm-scroller': { fontFamily: 'var(--font-mono)', minHeight: '24rem' },
'.cm-content': { padding: '0.75rem 0', caretColor: 'var(--text-strong)' }, '.cm-content': { padding: '0.75rem 0', caretColor: 'var(--text-strong)' },
'.cm-fixed-schema-wrapper': {
color: 'var(--text-muted)',
backgroundColor: 'var(--interactive-muted)',
fontWeight: '600',
},
'.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--text-strong)', borderLeftWidth: '2px' }, '.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--text-strong)', borderLeftWidth: '2px' },
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': { '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'var(--interactive-selected)', backgroundColor: 'var(--interactive-selected)',
@@ -61,6 +73,7 @@
if (!host || untrack(() => view)) return; if (!host || untrack(() => view)) return;
const initialValue = untrack(() => value); const initialValue = untrack(() => value);
const initialReadonly = untrack(() => readonly); const initialReadonly = untrack(() => readonly);
const initialFixedSchemaWrapper = untrack(() => fixedSchemaWrapper);
const handleChange = untrack(() => onChange); const handleChange = untrack(() => onChange);
const handleComplete = untrack(() => onComplete); const handleComplete = untrack(() => onComplete);
const editor = new EditorView({ const editor = new EditorView({
@@ -78,6 +91,9 @@
return await handleComplete(doc, context.pos, context.explicit); return await handleComplete(doc, context.pos, context.explicit);
}] })] : []), }] })] : []),
keymap.of([]), keymap.of([]),
fixedSchemaWrapperCompartment.of(
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
),
readonlyCompartment.of([ readonlyCompartment.of([
EditorState.readOnly.of(initialReadonly), EditorState.readOnly.of(initialReadonly),
EditorView.editable.of(!initialReadonly), EditorView.editable.of(!initialReadonly),
@@ -90,6 +106,7 @@
}), }),
}); });
view = editor; view = editor;
if (initialFixedSchemaWrapper) moveSelectionIntoFixedWrapper(editor);
return () => { return () => {
editor.destroy(); editor.destroy();
view = null; view = null;
@@ -108,11 +125,27 @@
}); });
}); });
$effect(() => {
const editor = view;
const enabled = fixedSchemaWrapper;
if (!editor) return;
editor.dispatch({
effects: fixedSchemaWrapperCompartment.reconfigure(
enabled ? fixedSchemaWrapperExtension() : [],
),
});
if (enabled) moveSelectionIntoFixedWrapper(editor);
});
$effect(() => { $effect(() => {
if (!view) return; if (!view) return;
const current = view.state.doc.toString(); const current = view.state.doc.toString();
if (current !== value) { if (current !== value) {
view.dispatch({ changes: { from: 0, to: current.length, insert: value } }); view.dispatch({
changes: { from: 0, to: current.length, insert: value },
filter: false,
});
if (fixedSchemaWrapper) moveSelectionIntoFixedWrapper(view);
} }
}); });
</script> </script>
@@ -72,4 +72,32 @@ Deno.test("Decodal editor follows readonly prop changes after mount", async () =
!source.includes("--border-subtle"), !source.includes("--border-subtle"),
"CodeMirror theme must use workspace tokens that actually exist", "CodeMirror theme must use workspace tokens that actually exist",
); );
assert(
source.includes("fixedSchemaWrapperCompartment.reconfigure") &&
source.includes("fixedSchemaWrapperExtension()") &&
source.includes("filter: false"),
"fixed schema wrapper protection should react to the main source and permit authoritative synchronization",
);
});
Deno.test("main config wrapper is an explicit canonical draft conversion", async () => {
const source = await Deno.readTextFile(
new URL(
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte",
import.meta.url,
),
);
assert(
source.includes("Wrap with WorkspaceConfigSchema") &&
source.includes("wrapMainWithWorkspaceSchema") &&
source.includes("fixedSchemaWrapper={mainSchemaWrapped}"),
"legacy main sources should offer an explicit conversion before fixed wrapper mode is enabled",
);
assert(
source.includes("source.trim()") &&
source.includes("toolchain.format") &&
source.includes("wrapper staged. Preview and Commit"),
"conversion should remain a formatted local draft until the normal commit flow",
);
}); });
@@ -0,0 +1,88 @@
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
};
import { EditorState } from "@codemirror/state";
import {
fixedSchemaWrapperExtension,
fixedWrapperBounds,
} from "../../src/lib/workspace/config-source/fixed-schema-wrapper.ts";
function assertEquals(actual: unknown, expected: unknown, message: string) {
if (actual !== expected) {
throw new Error(
`${message}: expected ${String(expected)}, got ${String(actual)}`,
);
}
}
function wrappedState(source = "{} as WorkspaceConfigSchema\n") {
return EditorState.create({
doc: source,
extensions: [fixedSchemaWrapperExtension()],
});
}
Deno.test("fixed schema wrapper allows edits only inside the asserted object", () => {
const state = wrappedState();
const bounds = fixedWrapperBounds(state);
if (!bounds) throw new Error("canonical wrapper should be recognized");
assertEquals(bounds.bodyFrom, 1, "body should start after the opening brace");
assertEquals(
bounds.bodyTo,
1,
"empty body should end before the closing brace",
);
const inserted = state.update({
changes: { from: bounds.bodyFrom, insert: " profile = {}; " },
});
assertEquals(
inserted.newDoc.toString(),
"{ profile = {}; } as WorkspaceConfigSchema\n",
"body insertion should be accepted",
);
const prefixEdit = state.update({ changes: { from: 0, to: 1, insert: "[" } });
assertEquals(
prefixEdit.newDoc.toString(),
state.doc.toString(),
"opening wrapper edit should be rejected",
);
const suffixEdit = state.update({
changes: { from: bounds.bodyTo, to: bounds.bodyTo + 1, insert: "]" },
});
assertEquals(
suffixEdit.newDoc.toString(),
state.doc.toString(),
"schema assertion edit should be rejected",
);
const replaceAll = state.update({
changes: { from: 0, to: state.doc.length, insert: "{}" },
});
assertEquals(
replaceAll.newDoc.toString(),
state.doc.toString(),
"a replacement touching fixed and editable ranges should be rejected atomically",
);
});
Deno.test("authoritative synchronization can bypass the fixed wrapper filter", () => {
const state = wrappedState();
const synchronized = state.update({
changes: { from: 0, to: state.doc.length, insert: "{}" },
filter: false,
});
assertEquals(
synchronized.newDoc.toString(),
"{}",
"programmatic source replacement should bypass user transaction filters",
);
assertEquals(
fixedWrapperBounds(synchronized.state),
null,
"bare source should not expose fixed wrapper ranges",
);
});