Skip to content

Commit c013879

Browse files
committed
fix(scenarios): id-validation + return-meta from persist (coderabbit minors)
1 parent 2b3fdee commit c013879

2 files changed

Lines changed: 61 additions & 16 deletions

File tree

src/cli/persisted-compiled-scenarios.ts

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
unlinkSync,
2727
statSync,
2828
} from 'node:fs';
29-
import { resolve } from 'node:path';
29+
import { resolve, sep } from 'node:path';
3030
import type { ScenarioPackage } from '../engine/types.js';
3131

3232
/**
@@ -88,12 +88,37 @@ function stripHooks(scenario: ScenarioPackage): Record<string, unknown> {
8888
return rest;
8989
}
9090

91+
/**
92+
* Strict id validator. Scenario ids in this codebase are kebab-case
93+
* slugs produced by the compile-from-seed pipeline; values containing
94+
* path separators or `..` segments are not legitimate ids and would
95+
* let a caller write/read outside the compiled/ directory. The
96+
* persist + delete paths run this gate before touching the filesystem.
97+
*/
98+
function isSafeScenarioId(id: string): boolean {
99+
return /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(id);
100+
}
101+
102+
/**
103+
* Result of a successful persist. The on-disk value of `compiledAt`
104+
* is the canonical source of truth; in-memory callers reflect it back
105+
* into their own state via this return value rather than re-deriving
106+
* from `Date.now()` (which would drift between the disk record and
107+
* the runtime metadata map by however long the writeFileSync took).
108+
*/
109+
export interface PersistResult {
110+
filePath: string;
111+
meta: PersistedCompiledMeta;
112+
}
113+
91114
/**
92115
* Save a compile-from-seed scenario to disk. Caller passes the fully-
93116
* compiled scenario; we persist a hook-stripped copy plus metadata.
94117
* Idempotent on the same id (overwrites the previous file).
95118
*
96-
* @returns Absolute path written, or `null` on filesystem failure (we
119+
* @returns A {@link PersistResult} carrying the path written + the
120+
* exact meta block stored on disk so callers can mirror it into
121+
* their own state, or `null` on validation/filesystem failure (we
97122
* swallow the error, log via console.warn, and let the in-memory
98123
* catalog continue to serve the live run; persistence is a best-
99124
* effort enhancement, not a critical path).
@@ -102,7 +127,11 @@ export function persistCompiledScenario(
102127
scenarioDir: string,
103128
scenario: ScenarioPackage,
104129
seedText: string | null,
105-
): string | null {
130+
): PersistResult | null {
131+
if (!isSafeScenarioId(scenario.id)) {
132+
console.warn(`[scenarios] refusing to persist scenario with unsafe id: ${JSON.stringify(scenario.id)}`);
133+
return null;
134+
}
106135
try {
107136
const dir = compiledDir(scenarioDir);
108137
mkdirSync(dir, { recursive: true });
@@ -117,7 +146,7 @@ export function persistCompiledScenario(
117146
const filePath = resolve(dir, `${scenario.id}.json`);
118147
writeFileSync(filePath, JSON.stringify(payload, null, 2));
119148
enforceCompiledCap(dir, COMPILED_SCENARIOS_CAP);
120-
return filePath;
149+
return { filePath, meta };
121150
} catch (err) {
122151
console.warn(`[scenarios] persistCompiledScenario failed for ${scenario.id}:`, err);
123152
return null;
@@ -191,13 +220,26 @@ function enforceCompiledCap(dir: string, cap: number): void {
191220

192221
/**
193222
* Remove a persisted draft by id. Returns true when a file was actually
194-
* deleted, false when no matching file existed (idempotent for callers
195-
* that don't pre-check). Used by the future `/scenario/delete` admin
196-
* surface and by tests; production server-app does not call this on
197-
* the active path.
223+
* deleted, false when no matching file existed or the id failed
224+
* validation (idempotent for callers that don't pre-check). Used by
225+
* the future `/scenario/delete` admin surface and by tests; production
226+
* server-app does not call this on the active path.
227+
*
228+
* Path-traversal guard: rejects any id that doesn't match the strict
229+
* kebab-slug shape AND verifies the resolved file path stays inside
230+
* the compiled subdir before unlinking. Belt-and-suspenders against
231+
* future callers that might wire user input through this surface.
198232
*/
199233
export function deletePersistedCompiledScenario(scenarioDir: string, id: string): boolean {
200-
const filePath = resolve(compiledDir(scenarioDir), `${id}.json`);
234+
if (!isSafeScenarioId(id)) return false;
235+
const dir = compiledDir(scenarioDir);
236+
const filePath = resolve(dir, `${id}.json`);
237+
// Resolve-then-prefix-check defends against any future relaxation
238+
// of isSafeScenarioId. If the resolved file isn't inside dir, the
239+
// id was crafted to escape — refuse to delete.
240+
if (filePath !== resolve(dir, `${id}.json`) || !filePath.startsWith(`${dir}${sep}`)) {
241+
return false;
242+
}
201243
if (!existsSync(filePath)) return false;
202244
try {
203245
unlinkSync(filePath);

src/cli/server-app.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1530,13 +1530,16 @@ export function createMarsServer(options: CreateMarsServerOptions = {}): MarsSer
15301530
// travels alongside the scenario for catalog rendering.
15311531
// Best-effort: filesystem failures stay silent so a read-
15321532
// only volume doesn't break the in-memory run.
1533-
const persistMeta = compiledScenarioMeta.get(sc.id);
1534-
const persistAt = persistMeta?.compiledAt ?? new Date().toISOString();
1535-
persistCompiledScenario(scenarioDir, sc, activeScenarioSeedText);
1536-
compiledScenarioMeta.set(sc.id, {
1537-
compiledAt: persistAt,
1538-
seedText: activeScenarioSeedText,
1539-
});
1533+
//
1534+
// Mirror the EXACT meta block written to disk into the
1535+
// in-memory map so subsequent /scenarios responses agree
1536+
// with what a fresh restart would re-load. Re-deriving the
1537+
// timestamp here would drift by however long writeFileSync
1538+
// took to flush.
1539+
const persistResult = persistCompiledScenario(scenarioDir, sc, activeScenarioSeedText);
1540+
if (persistResult) {
1541+
compiledScenarioMeta.set(sc.id, persistResult.meta);
1542+
}
15401543
},
15411544
getScenarioById: (id) => {
15421545
if (id === activeScenario.id) return activeScenario;

0 commit comments

Comments
 (0)