web: protect config schema wrapper
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"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",
|
||||
"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",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
} from "./types.ts";
|
||||
|
||||
const MAIN_ENTRYPOINT = "main.dcdl";
|
||||
const WORKSPACE_SCHEMA_ASSERTION = "WorkspaceConfigSchema";
|
||||
const WORKSPACE_SCHEMA_SUFFIX = `} as ${WORKSPACE_SCHEMA_ASSERTION}`;
|
||||
|
||||
let { workspaceId }: { workspaceId: string } = $props();
|
||||
let treeState = $state<WorkspaceConfigTreeResponse | null>(null);
|
||||
@@ -40,6 +42,7 @@
|
||||
treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined,
|
||||
);
|
||||
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 commitReady = $derived(dirty && preflightDigest === treeState?.snapshot.digest);
|
||||
|
||||
@@ -114,6 +117,35 @@
|
||||
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() {
|
||||
if (!toolchain || !treeState || !selectedPath) return;
|
||||
diagnostics = await toolchain.analyze(selectedPath, source);
|
||||
@@ -312,6 +344,9 @@
|
||||
<div class="config-source-actions">
|
||||
<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>
|
||||
{#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={analyze} disabled={!selectedPath || busy}>Analyze</button>
|
||||
<button type="button" onclick={preview} disabled={!dirty || busy}>Preview</button>
|
||||
@@ -322,6 +357,7 @@
|
||||
<DecodalSourceEditor
|
||||
value={source}
|
||||
readonly={!selectedPath || busy}
|
||||
fixedSchemaWrapper={mainSchemaWrapped}
|
||||
onChange={(value) => source = value}
|
||||
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 { tags } from '@lezer/highlight';
|
||||
import { decodal } from 'decodal-codemirror';
|
||||
import {
|
||||
fixedSchemaWrapperExtension,
|
||||
moveSelectionIntoFixedWrapper,
|
||||
} from '$lib/workspace/config-source/fixed-schema-wrapper.ts';
|
||||
|
||||
let {
|
||||
value = '',
|
||||
readonly = false,
|
||||
ariaLabel = 'Decodal source',
|
||||
fixedSchemaWrapper = false,
|
||||
onChange = (_value: string) => {},
|
||||
onComplete = undefined,
|
||||
}: {
|
||||
value?: string;
|
||||
readonly?: boolean;
|
||||
ariaLabel?: string;
|
||||
fixedSchemaWrapper?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
onComplete?: (source: string, utf16Offset: number, explicit: boolean) => Promise<CompletionResult | null>;
|
||||
} = $props();
|
||||
@@ -24,6 +30,7 @@
|
||||
let host = $state<HTMLDivElement | null>(null);
|
||||
let view = $state.raw<EditorView | null>(null);
|
||||
const readonlyCompartment = new Compartment();
|
||||
const fixedSchemaWrapperCompartment = new Compartment();
|
||||
|
||||
const syntaxTheme = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: 'var(--accent)', fontWeight: '700' },
|
||||
@@ -47,6 +54,11 @@
|
||||
'&.cm-focused': { outline: '1px solid var(--accent-muted)', outlineOffset: '-1px' },
|
||||
'.cm-scroller': { fontFamily: 'var(--font-mono)', minHeight: '24rem' },
|
||||
'.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-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: 'var(--interactive-selected)',
|
||||
@@ -61,6 +73,7 @@
|
||||
if (!host || untrack(() => view)) return;
|
||||
const initialValue = untrack(() => value);
|
||||
const initialReadonly = untrack(() => readonly);
|
||||
const initialFixedSchemaWrapper = untrack(() => fixedSchemaWrapper);
|
||||
const handleChange = untrack(() => onChange);
|
||||
const handleComplete = untrack(() => onComplete);
|
||||
const editor = new EditorView({
|
||||
@@ -78,6 +91,9 @@
|
||||
return await handleComplete(doc, context.pos, context.explicit);
|
||||
}] })] : []),
|
||||
keymap.of([]),
|
||||
fixedSchemaWrapperCompartment.of(
|
||||
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
|
||||
),
|
||||
readonlyCompartment.of([
|
||||
EditorState.readOnly.of(initialReadonly),
|
||||
EditorView.editable.of(!initialReadonly),
|
||||
@@ -90,6 +106,7 @@
|
||||
}),
|
||||
});
|
||||
view = editor;
|
||||
if (initialFixedSchemaWrapper) moveSelectionIntoFixedWrapper(editor);
|
||||
return () => {
|
||||
editor.destroy();
|
||||
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(() => {
|
||||
if (!view) return;
|
||||
const current = view.state.doc.toString();
|
||||
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>
|
||||
|
||||
@@ -72,4 +72,32 @@ Deno.test("Decodal editor follows readonly prop changes after mount", async () =
|
||||
!source.includes("--border-subtle"),
|
||||
"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",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user