Skip to content

Commit 58cad78

Browse files
committed
Keep experiment destinations outside the source checkout
1 parent 1c9e053 commit 58cad78

3 files changed

Lines changed: 26 additions & 4 deletions

File tree

‎docs/implementation/read-only-review.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,5 @@ Review round 3 identified demo fixture config escapes and inherited Git environm
4646
Review round 4 had no inline findings but raised two summary concerns. Both were reproduced: planting accepted a non-ASCII path under case-insensitive identity even though the viewer refuses it; accepted cards retained an unplanned scope label. Planting now applies the same path guard, and accepted cards explicitly say “Accepted outside plan.” The underlying provenance remains intact. Unit/browser regressions cover both fixes; no concerns were declined.
4747

4848
Review round 5 found a symlinked-ancestor escape and raw-spelling collisions in planting guards. Both regression cases failed before the fix. Demo creation/reuse now checks every ancestor (canonicalizing only the OS temporary-directory prefix); planting compares canonical identities for declared files and every path in the base/commit trees before cloning. The summary's status-styling concern had no additional concrete example; accepted cards already use the explicit label added in round 4.
49+
50+
Review round 6 reproduced destination-inside-source mutation; planting now resolves the existing destination ancestor and rejects canonical source descendants before creating files, including `.git` and symlink aliases. The Git-environment finding was declined: `gitRaw` already passes `{cwd, env, ...}` to `execFileSync`. A new end-to-end test with inherited `GIT_DIR`/`GIT_WORK_TREE` passed before any production change. The summary's snapshot-race claim supplied no concrete interleaving; the service already checks revision/snapshot/review version before publishing a view and writes use atomic store CAS, covered by concurrent-write tests.

‎scripts/plant.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { isolatedGitEnvironment } from './git-environment.ts';
22
import { execFileSync } from 'node:child_process';
3-
import { existsSync, mkdirSync, writeFileSync, appendFileSync, lstatSync, readFileSync } from 'node:fs';
4-
import { resolve, join, dirname } from 'node:path';
3+
import { existsSync, mkdirSync, writeFileSync, appendFileSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
4+
import { resolve, join, dirname, basename, relative, isAbsolute } from 'node:path';
55
import { randomInt, randomUUID } from 'node:crypto';
66
import { pathToFileURL } from 'node:url';
77
import { Store } from '../runner/store.ts';
@@ -15,6 +15,11 @@ export interface PlantInput { declaredText: string; undeclaredText: string; unde
1515
export function plant(config: ReviewConfig, destination: string, input: PlantInput): string {
1616
const root=resolve(destination);
1717
if(existsSync(root))throw new Error('Experiment destination must not exist.');
18+
let parent=root; const missing:string[]=[];
19+
while(!lstatSync(parent,{throwIfNoEntry:false})) { missing.unshift(basename(parent)); parent=dirname(parent); }
20+
const canonicalDestination=resolve(realpathSync(parent),...missing);
21+
const sourceRelative=relative(realpathSync(config.repository),canonicalDestination);
22+
if(sourceRelative===''||(!sourceRelative.startsWith('..'+(process.platform==='win32'?'\\':'/'))&&sourceRelative!=='..'&&!isAbsolute(sourceRelative))) throw new Error('Experiment destination must be outside the source repository.');
1823
if((!isRepoPath(input.undeclaredPath)||input.undeclaredPath.includes('/'))||!input.declaredText?.trim()||!input.undeclaredText?.trim()||input.declaredText.length>4000||input.undeclaredText.length>4000)throw new Error('Invalid plant input.');
1924
const pathKey=(path:string)=>{
2025
if(!config.pathIdentity.caseSensitive && /[^\x20-\x7e]/.test(path)) throw new Error('Non-ASCII case-insensitive paths require a filesystem-specific identity adapter.');

‎test/plant.test.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { it,expect,afterEach } from 'vitest';
2-
import { mkdtempSync,readFileSync,rmSync } from 'node:fs';
1+
import { it,expect,afterEach,vi } from 'vitest';
2+
import { mkdtempSync,readFileSync,rmSync,symlinkSync } from 'node:fs';
33
import { tmpdir } from 'node:os';
44
import { join } from 'node:path';
55
import { execFileSync } from 'node:child_process';
@@ -25,3 +25,18 @@ it('rejects canonical declared and existing path collisions before creating a cl
2525
const config=createDemo(join(root,'source'));config.pathIdentity.caseSensitive=false;
2626
for (const path of ['RETRY.TS','RUN.SH']) expect(()=>plant(config,join(root,'experiment'),{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:path})).toThrow(/outside every declared file|already exists/);
2727
},15000);
28+
29+
it('rejects experiment destinations inside the source, including symlink aliases', () => {
30+
const root=mkdtempSync(join(tmpdir(),'codeboost-plant-destination-'));roots.push(root);
31+
const config=createDemo(join(root,'source'));symlinkSync(config.repository,join(root,'alias'));
32+
for(const destination of [join(config.repository,'experiment'),join(config.repository,'.git','experiment'),join(root,'alias','nested','experiment')]) {
33+
expect(()=>plant(config,destination,{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:'extra.txt'})).toThrow(/outside the source repository/);
34+
}
35+
},15000);
36+
it('passes its isolated environment to every planting Git process', () => {
37+
const root=mkdtempSync(join(tmpdir(),'codeboost-plant-env-'));roots.push(root);
38+
const config=createDemo(join(root,'source'));
39+
vi.stubEnv('GIT_DIR',join(root,'outside.git'));vi.stubEnv('GIT_WORK_TREE',join(root,'outside'));
40+
try {expect(plant(config,join(root,'experiment'),{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:'extra.txt'})).toBe(join(root,'experiment','review.json'));}
41+
finally {vi.unstubAllEnvs();}
42+
},15000);

0 commit comments

Comments
 (0)