11namespace Microsoft . ComponentDetection . Detectors . Linux ;
22
33using System ;
4+ using System . Collections . Concurrent ;
45using System . Collections . Generic ;
56using System . Linq ;
67using System . Text . Json ;
@@ -31,6 +32,13 @@ internal class LinuxScanner : ILinuxScanner
3132
3233 private static readonly SemaphoreSlim ContainerSemaphore = new SemaphoreSlim ( 2 ) ;
3334
35+ /// <summary>
36+ /// Caches in-flight and completed syft runs keyed by (source, scope).
37+ /// When multiple detectors scan the same image concurrently, the second
38+ /// caller awaits the already-running task instead of launching a new container.
39+ /// </summary>
40+ private static readonly ConcurrentDictionary < ( string Source , LinuxScannerScope Scope ) , Task < string > > SyftRunCache = new ( ) ;
41+
3442 private static readonly int SemaphoreTimeout = Convert . ToInt32 (
3543 TimeSpan . FromHours ( 1 ) . TotalMilliseconds
3644 ) ;
@@ -253,6 +261,8 @@ private IEnumerable<LayerMappedLinuxComponents> ProcessSyftOutputWithTelemetry(
253261
254262 /// <summary>
255263 /// Runs the Syft scanner container and returns the stdout output.
264+ /// For Docker image scans (no additional binds), results are cached so that
265+ /// concurrent callers scanning the same image+scope share a single container run.
256266 /// </summary>
257267 private async Task < string > RunSyftAsync (
258268 string syftSource ,
@@ -261,6 +271,50 @@ private async Task<string> RunSyftAsync(
261271 LinuxScannerTelemetryRecord record ,
262272 LinuxScannerSyftTelemetryRecord syftTelemetryRecord ,
263273 CancellationToken cancellationToken )
274+ {
275+ // Local image scans use additional binds specific to each call, so they
276+ // cannot be deduplicated safely — run them directly.
277+ if ( additionalBinds is { Count : > 0 } )
278+ {
279+ return await this . RunSyftCoreAsync ( syftSource , scope , additionalBinds , record , syftTelemetryRecord , cancellationToken ) ;
280+ }
281+
282+ var cacheKey = ( syftSource , scope ) ;
283+ var tcs = new TaskCompletionSource < string > ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
284+ var existingTask = SyftRunCache . GetOrAdd ( cacheKey , tcs . Task ) ;
285+
286+ if ( existingTask != tcs . Task )
287+ {
288+ // Another caller is already running syft for this image+scope — await their result.
289+ return await existingTask ;
290+ }
291+
292+ // We own this cache entry — run syft and propagate the result.
293+ try
294+ {
295+ var result = await this . RunSyftCoreAsync ( syftSource , scope , additionalBinds , record , syftTelemetryRecord , cancellationToken ) ;
296+ tcs . SetResult ( result ) ;
297+ return result ;
298+ }
299+ catch ( Exception ex )
300+ {
301+ // Remove the failed entry so a retry can start fresh.
302+ SyftRunCache . TryRemove ( cacheKey , out _ ) ;
303+ tcs . SetException ( ex ) ;
304+ throw ;
305+ }
306+ }
307+
308+ /// <summary>
309+ /// Executes the Syft scanner container and returns the stdout output.
310+ /// </summary>
311+ private async Task < string > RunSyftCoreAsync (
312+ string syftSource ,
313+ LinuxScannerScope scope ,
314+ IList < string > additionalBinds ,
315+ LinuxScannerTelemetryRecord record ,
316+ LinuxScannerSyftTelemetryRecord syftTelemetryRecord ,
317+ CancellationToken cancellationToken )
264318 {
265319 var acquired = false ;
266320 var stdout = string . Empty ;
@@ -357,4 +411,9 @@ HashSet<IArtifactComponentFactory> enabledFactories
357411 var layerIds = artifact . Locations ? . Select ( location => location . LayerId ) . Distinct ( ) ?? [ ] ;
358412 return ( component , layerIds ) ;
359413 }
414+
415+ /// <summary>
416+ /// Clears the syft run cache. Intended for test isolation only.
417+ /// </summary>
418+ internal static void ResetCache ( ) => SyftRunCache . Clear ( ) ;
360419}
0 commit comments