11import { createMockFs } from '@codebuff/common/testing/mocks/filesystem'
22import { describe , expect , mock , test } from 'bun:test'
3+ import { mkdtempSync , mkdirSync , symlinkSync , rmSync } from 'fs'
4+ import { realpath as fsRealpath } from 'fs/promises'
5+ import { tmpdir } from 'os'
6+ import { join } from 'path'
37
48import { createExecutionPolicy } from '../execution-policy'
59import { handleToolCall } from '../../run'
610
7- async function dispatchApplyPatch ( path : string ) {
11+ async function dispatchApplyPatch (
12+ operation : unknown ,
13+ options : { projectRoot : string ; cwd : string ; canonicalize ?: ( p : string ) => Promise < string > } ,
14+ ) {
815 const writeOverride = mock ( async ( ) => [
916 { type : 'json' as const , value : { message : 'patch dispatched' } } ,
1017 ] )
@@ -14,39 +21,90 @@ async function dispatchApplyPatch(path: string) {
1421 requestId : crypto . randomUUID ( ) ,
1522 userInputId : 'test-input' ,
1623 toolName : 'apply_patch' ,
17- input : {
18- operation : {
19- type : 'create_file' ,
20- path,
21- diff : '@@\n+content\n' ,
22- } ,
23- } ,
24+ input : { operation } ,
2425 timeout : undefined ,
2526 } ,
2627 overrides : { write_file : writeOverride } ,
2728 customToolDefinitions : { } ,
28- cwd : '/project' ,
29+ cwd : options . cwd ,
2930 fs : createMockFs ( ) ,
3031 policy : createExecutionPolicy ( {
31- projectRoot : '/project' ,
32+ projectRoot : options . projectRoot ,
3233 projectOnly : true ,
34+ allowExternalTools : true ,
35+ ...( options . canonicalize ? { canonicalize : options . canonicalize } : { } ) ,
3336 } ) ,
3437 } )
3538 return { result, writeOverride }
3639}
3740
3841describe ( 'apply_patch project-only target resolution' , ( ) => {
42+ const withProject = async (
43+ body : ( paths : { base : string ; root : string ; outside : string } ) => Promise < void > ,
44+ ) => {
45+ const base = mkdtempSync ( join ( tmpdir ( ) , 'apply-patch-test-' ) )
46+ const root = join ( base , 'project' )
47+ const outside = join ( base , 'outside' )
48+ mkdirSync ( root )
49+ mkdirSync ( outside )
50+ try {
51+ await body ( { base, root, outside } )
52+ } finally {
53+ rmSync ( base , { recursive : true , force : true } )
54+ }
55+ }
56+
3957 test ( 'dispatches a patch targeting a file inside the project' , async ( ) => {
40- const { result, writeOverride } = await dispatchApplyPatch ( 'src/new.ts' )
58+ await withProject ( async ( { root } ) => {
59+ const { result, writeOverride } = await dispatchApplyPatch (
60+ { type : 'create_file' , path : 'src/new.ts' , diff : '@@\n+content\n' } ,
61+ { projectRoot : root , cwd : root , canonicalize : fsRealpath } ,
62+ )
4163
42- expect ( writeOverride ) . toHaveBeenCalledTimes ( 1 )
43- expect ( JSON . stringify ( result . output ) ) . toContain ( 'patch dispatched' )
64+ expect ( writeOverride ) . toHaveBeenCalledTimes ( 1 )
65+ expect ( JSON . stringify ( result . output ) ) . toContain ( 'patch dispatched' )
66+ } )
4467 } )
4568
4669 test ( 'rejects a patch targeting a file outside the project' , async ( ) => {
47- const { result, writeOverride } = await dispatchApplyPatch ( '../outside.txt' )
70+ await withProject ( async ( { root } ) => {
71+ const { result, writeOverride } = await dispatchApplyPatch (
72+ { type : 'create_file' , path : '../outside.txt' , diff : '@@\n+content\n' } ,
73+ { projectRoot : root , cwd : root , canonicalize : fsRealpath } ,
74+ )
75+
76+ expect ( writeOverride ) . not . toHaveBeenCalled ( )
77+ expect ( JSON . stringify ( result . output ) ) . toContain ( 'outside project root' )
78+ } )
79+ } )
80+
81+ test ( 'rejects a patch whose in-project path is a symlink to outside the project' , async ( ) => {
82+ await withProject ( async ( { root, outside } ) => {
83+ symlinkSync ( outside , join ( root , 'link' ) )
84+ const { result, writeOverride } = await dispatchApplyPatch (
85+ { type : 'create_file' , path : 'link/evil.txt' , diff : '@@\n+content\n' } ,
86+ { projectRoot : root , cwd : root , canonicalize : fsRealpath } ,
87+ )
88+
89+ expect ( writeOverride ) . not . toHaveBeenCalled ( )
90+ expect ( JSON . stringify ( result . output ) ) . toContain ( 'outside project root' )
91+ } )
92+ } )
93+
94+ test ( 'rejects a malformed patch with no target path' , async ( ) => {
95+ await withProject ( async ( { root } ) => {
96+ for ( const operation of [ { type : 'create_file' } , undefined , { path : '' } ] ) {
97+ const { result, writeOverride } = await dispatchApplyPatch ( operation , {
98+ projectRoot : root ,
99+ cwd : root ,
100+ canonicalize : fsRealpath ,
101+ } )
48102
49- expect ( writeOverride ) . not . toHaveBeenCalled ( )
50- expect ( JSON . stringify ( result . output ) ) . toContain ( 'outside project root' )
103+ expect ( writeOverride ) . not . toHaveBeenCalled ( )
104+ expect ( JSON . stringify ( result . output ) ) . toContain (
105+ 'apply_patch target could not be verified' ,
106+ )
107+ }
108+ } )
51109 } )
52110} )
0 commit comments