@@ -150,6 +150,100 @@ async fn collect_files(base: &Path, current: &Path) -> Result<Vec<PathBuf>> {
150150 Ok ( files)
151151}
152152
153+ /// Outcome of processing a single memory file.
154+ enum FileOutcome {
155+ Copied ( u64 ) ,
156+ Skipped ( String ) ,
157+ }
158+
159+ /// Validate containment, safety, extension, size, and content for one file;
160+ /// copy it to `dest_file` if all checks pass.
161+ ///
162+ /// All validation failures return `Ok(FileOutcome::Skipped(reason))` so the
163+ /// caller can keep a tally without complex nested control flow.
164+ async fn process_memory_file (
165+ relative_path : & Path ,
166+ source_file : & Path ,
167+ canonical_base : & Path ,
168+ dest_file : & Path ,
169+ config : & MemoryConfig ,
170+ total_size : u64 ,
171+ ) -> Result < FileOutcome > {
172+ // Defense-in-depth: verify the fully-resolved source path is still within
173+ // the memory base directory, guarding against TOCTOU symlink races.
174+ match tokio:: fs:: canonicalize ( source_file) . await {
175+ Ok ( canonical_source) => {
176+ if !canonical_source. starts_with ( canonical_base) {
177+ warn ! (
178+ "Skipping path that escapes memory directory (possible symlink attack): {}" ,
179+ relative_path. display( )
180+ ) ;
181+ return Ok ( FileOutcome :: Skipped ( format ! (
182+ "{}: resolved path escapes memory directory" ,
183+ relative_path. display( )
184+ ) ) ) ;
185+ }
186+ }
187+ Err ( e) => {
188+ warn ! (
189+ "Skipping unresolvable path '{}': {}" ,
190+ relative_path. display( ) ,
191+ e
192+ ) ;
193+ return Ok ( FileOutcome :: Skipped ( format ! (
194+ "{}: cannot resolve path ({})" ,
195+ relative_path. display( ) ,
196+ e
197+ ) ) ) ;
198+ }
199+ }
200+
201+ if let Err ( e) = validate_memory_path ( relative_path) {
202+ warn ! ( "Skipping unsafe path '{}': {}" , relative_path. display( ) , e) ;
203+ return Ok ( FileOutcome :: Skipped ( format ! (
204+ "{}: {}" ,
205+ relative_path. display( ) ,
206+ e
207+ ) ) ) ;
208+ }
209+
210+ if !config. is_extension_allowed ( relative_path) {
211+ warn ! ( "Skipping disallowed extension: {}" , relative_path. display( ) ) ;
212+ return Ok ( FileOutcome :: Skipped ( format ! (
213+ "{}: extension not in allowed list" ,
214+ relative_path. display( )
215+ ) ) ) ;
216+ }
217+
218+ let metadata = tokio:: fs:: metadata ( source_file) . await ?;
219+ if total_size + metadata. len ( ) > MAX_MEMORY_SIZE_BYTES {
220+ warn ! (
221+ "Memory size limit ({} bytes) would be exceeded, skipping: {}" ,
222+ MAX_MEMORY_SIZE_BYTES ,
223+ relative_path. display( )
224+ ) ;
225+ return Ok ( FileOutcome :: Skipped ( format ! (
226+ "{}: would exceed {} byte size limit" ,
227+ relative_path. display( ) ,
228+ MAX_MEMORY_SIZE_BYTES
229+ ) ) ) ;
230+ }
231+
232+ if !validate_memory_file_content ( source_file) . await ? {
233+ return Ok ( FileOutcome :: Skipped ( format ! (
234+ "{}: contains ##vso[ command" ,
235+ relative_path. display( )
236+ ) ) ) ;
237+ }
238+
239+ if let Some ( parent) = dest_file. parent ( ) {
240+ tokio:: fs:: create_dir_all ( parent) . await ?;
241+ }
242+ tokio:: fs:: copy ( source_file, dest_file) . await ?;
243+ debug ! ( "Copied memory file: {}" , relative_path. display( ) ) ;
244+ Ok ( FileOutcome :: Copied ( metadata. len ( ) ) )
245+ }
246+
153247/// Process agent memory from the safe output directory.
154248///
155249/// Validates and copies sanitized memory files from `source_dir/agent_memory/`
@@ -192,9 +286,7 @@ pub async fn process_agent_memory(
192286
193287 if files. is_empty ( ) {
194288 info ! ( "Agent memory directory is empty" ) ;
195- return Ok ( ExecutionResult :: success (
196- "Agent memory directory is empty" ,
197- ) ) ;
289+ return Ok ( ExecutionResult :: success ( "Agent memory directory is empty" ) ) ;
198290 }
199291
200292 info ! ( "Found {} file(s) in agent_memory" , files. len( ) ) ;
@@ -217,109 +309,35 @@ pub async fn process_agent_memory(
217309
218310 for relative_path in & files {
219311 let source_file = memory_source. join ( relative_path) ;
220-
221- // Defense-in-depth: verify the fully-resolved source path is still within
222- // the memory base directory, guarding against TOCTOU symlink races.
223- match tokio:: fs:: canonicalize ( & source_file) . await {
224- Ok ( canonical_source) => {
225- if !canonical_source. starts_with ( & canonical_base) {
226- warn ! (
227- "Skipping path that escapes memory directory (possible symlink attack): {}" ,
228- relative_path. display( )
229- ) ;
230- skipped_count += 1 ;
231- skipped_reasons. push ( format ! (
232- "{}: resolved path escapes memory directory" ,
233- relative_path. display( )
234- ) ) ;
235- continue ;
236- }
312+ let dest_file = memory_output. join ( relative_path) ;
313+ match process_memory_file (
314+ relative_path,
315+ & source_file,
316+ & canonical_base,
317+ & dest_file,
318+ config,
319+ total_size,
320+ )
321+ . await ?
322+ {
323+ FileOutcome :: Copied ( size) => {
324+ total_size += size;
325+ copied_count += 1 ;
237326 }
238- Err ( e) => {
239- warn ! (
240- "Skipping unresolvable path '{}': {}" ,
241- relative_path. display( ) ,
242- e
243- ) ;
327+ FileOutcome :: Skipped ( reason) => {
244328 skipped_count += 1 ;
245- skipped_reasons. push ( format ! (
246- "{}: cannot resolve path ({})" ,
247- relative_path. display( ) ,
248- e
249- ) ) ;
250- continue ;
329+ skipped_reasons. push ( reason) ;
251330 }
252331 }
253-
254- // Validate path safety
255- if let Err ( e) = validate_memory_path ( relative_path) {
256- warn ! ( "Skipping unsafe path '{}': {}" , relative_path. display( ) , e) ;
257- skipped_count += 1 ;
258- skipped_reasons. push ( format ! ( "{}: {}" , relative_path. display( ) , e) ) ;
259- continue ;
260- }
261-
262- // Validate file extension
263- if !config. is_extension_allowed ( relative_path) {
264- warn ! (
265- "Skipping disallowed extension: {}" ,
266- relative_path. display( )
267- ) ;
268- skipped_count += 1 ;
269- skipped_reasons. push ( format ! (
270- "{}: extension not in allowed list" ,
271- relative_path. display( )
272- ) ) ;
273- continue ;
274- }
275-
276- // Check file size contribution
277- let metadata = tokio:: fs:: metadata ( & source_file) . await ?;
278- if total_size + metadata. len ( ) > MAX_MEMORY_SIZE_BYTES {
279- warn ! (
280- "Memory size limit ({} bytes) would be exceeded, skipping: {}" ,
281- MAX_MEMORY_SIZE_BYTES ,
282- relative_path. display( )
283- ) ;
284- skipped_count += 1 ;
285- skipped_reasons. push ( format ! (
286- "{}: would exceed {} byte size limit" ,
287- relative_path. display( ) ,
288- MAX_MEMORY_SIZE_BYTES
289- ) ) ;
290- continue ;
291- }
292-
293- // Validate content for ##vso[ injection
294- if !validate_memory_file_content ( & source_file) . await ? {
295- skipped_count += 1 ;
296- skipped_reasons. push ( format ! (
297- "{}: contains ##vso[ command" ,
298- relative_path. display( )
299- ) ) ;
300- continue ;
301- }
302-
303- // Copy the file
304- let dest_file = memory_output. join ( relative_path) ;
305- if let Some ( parent) = dest_file. parent ( ) {
306- tokio:: fs:: create_dir_all ( parent) . await ?;
307- }
308- tokio:: fs:: copy ( & source_file, & dest_file) . await ?;
309- total_size += metadata. len ( ) ;
310- copied_count += 1 ;
311- debug ! ( "Copied memory file: {}" , relative_path. display( ) ) ;
312332 }
313333
314334 let message = format ! (
315335 "Agent memory processed: {} file(s) copied ({} bytes), {} skipped" ,
316336 copied_count, total_size, skipped_count
317337 ) ;
318338
319- if !skipped_reasons. is_empty ( ) {
320- for reason in & skipped_reasons {
321- info ! ( "Skipped: {}" , reason) ;
322- }
339+ for reason in & skipped_reasons {
340+ info ! ( "Skipped: {}" , reason) ;
323341 }
324342
325343 info ! ( "{}" , message) ;
0 commit comments