77
88using System ;
99using System . Collections . Generic ;
10+ using System . Diagnostics . CodeAnalysis ;
1011using System . Linq ;
1112using Datadog . Trace . AppSec . Rcm . Models . Asm ;
1213using Datadog . Trace . AppSec . Rcm . Models . AsmData ;
@@ -26,8 +27,10 @@ namespace Datadog.Trace.AppSec.Rcm;
2627/// <summary>
2728/// This class represents the state of RCM for ASM.
2829/// It has 2 possible status:
29- /// - ASM is not activated, and _fileUpdates/_fileRemoves contain some pending non-deserialized changes to apply when ASM_FEATURES activate ASM. Every time an RC payload is received here, pending changes are reset to the last ones
30- /// - ASM is activated, stored configs in _fileUpdates/_fileRemoves are applied every time.
30+ /// - ASM is not activated, and _pendingByProduct accumulates pending non-deserialized changes (updates and removes)
31+ /// across RC polls until ASM_FEATURES activates ASM, at which point they are applied. RCM only forwards deltas
32+ /// (new/changed/removed configs), so the pending state must persist between polls.
33+ /// - ASM is activated, the pending state in _pendingByProduct is applied every time.
3134/// </summary>
3235internal sealed record ConfigurationState
3336{
@@ -41,8 +44,13 @@ internal sealed record ConfigurationState
4144
4245 private readonly string ? _rulesPath ;
4346 private readonly bool _canBeToggled ;
44- private readonly Dictionary < string , List < RemoteConfiguration > > _fileUpdates = new ( ) ;
45- private readonly Dictionary < string , List < RemoteConfigurationPath > > _fileRemoves = new ( ) ;
47+
48+ // Pending RCM operations, grouped by product. Inner key is the full config path; the value is
49+ // either an update payload or a remove marker. Using a dictionary keyed by path means each path
50+ // can only hold one pending operation at a time — a later update or remove for the same path
51+ // overwrites whatever was there, which is exactly the RCM "latest delta wins" semantics.
52+ private readonly Dictionary < string , Dictionary < string , PendingOperation > > _pendingByProduct = new ( ) ;
53+
4654 private bool _defaultRulesetApplied ;
4755
4856 public ConfigurationState ( SecuritySettings settings , IConfigurationTelemetry telemetry , bool wafIsNull )
@@ -136,9 +144,19 @@ internal RemoteConfigWafFiles GetWafConfigurations(bool updating = false)
136144 var configurations = new Dictionary < string , object > ( ) ;
137145 List < string > ? removes = null ;
138146
139- if ( updating && _fileRemoves is { Count : > 0 } )
147+ if ( updating )
140148 {
141- removes = _fileRemoves . SelectMany ( p => p . Value ) . Where ( p => p . Product != RcmProducts . AsmFeatures ) . Select ( v => v . Path ) . ToList ( ) ;
149+ foreach ( var entry in _pendingByProduct )
150+ {
151+ if ( entry . Key == RcmProducts . AsmFeatures ) { continue ; }
152+ foreach ( var op in entry . Value . Values )
153+ {
154+ if ( op . IsRemove )
155+ {
156+ ( removes ??= new ( ) ) . Add ( op . Path . Path ) ;
157+ }
158+ }
159+ }
142160 }
143161
144162 if ( AsmConfigs is { Count : > 0 } )
@@ -192,11 +210,12 @@ internal RemoteConfigWafFiles GetWafConfigurations(bool updating = false)
192210
193211 return new ( configurations . Count > 0 ? configurations : null , removes ) ;
194212
213+ // True if `path` has a pending update (not a remove) in any product slot.
195214 bool IsNewUpdate ( string path )
196215 {
197- foreach ( var productUpdates in _fileUpdates )
216+ foreach ( var pending in _pendingByProduct . Values )
198217 {
199- if ( productUpdates . Value . Any ( u => u . Path . Path == path ) )
218+ if ( pending . TryGetValue ( path , out var op ) && ! op . IsRemove )
200219 {
201220 return true ;
202221 }
@@ -207,99 +226,111 @@ bool IsNewUpdate(string path)
207226 }
208227
209228 /// <summary>
210- /// Calls each product updater to deserialize all remote config payloads and store them properly in dictionaries which might involve various logical merges
211- /// This method deserializes everything stored in _fileUpdates. ConfigurationStatus will have a *bigger* memory footprint.
229+ /// Calls each product updater to deserialize all remote config payloads and store them properly in dictionaries which might involve various logical merges.
212230 /// </summary>
213231 public void ApplyStoredFiles ( )
214232 {
215- // no need to clear _fileUpdates / _fileRemoves after they've been applied, as when we receive a new config, `StoreLastConfigState` method will clear anything remaining anyway.
233+ // The pending state is NOT cleared here — GetWafConfigurations still reads it to identify which
234+ // configs are new for the WAF update path. Re-application is idempotent: each product updater
235+ // writes into its destination collection keyed by config path, so replaying the same pending
236+ // entry on a later turn produces the same end state.
216237 foreach ( var updater in _productConfigUpdaters )
217238 {
218- var fileRemoves = _fileRemoves . TryGetValue ( updater . Key , out var removes ) ;
219- var fileUpdates = _fileUpdates . TryGetValue ( updater . Key , out var updates ) ;
220- if ( fileRemoves || fileUpdates )
239+ if ( ! _pendingByProduct . TryGetValue ( updater . Key , out var pending ) || pending . Count == 0 )
221240 {
222- updater . Value . ProcessUpdates ( this , removes , updates ) ;
241+ continue ;
223242 }
243+
244+ var ( removes , updates ) = SplitPending ( pending ) ;
245+ updater . Value . ProcessUpdates ( this , removes , updates ) ;
224246 }
225247 }
226248
227249 /// <summary>
228- /// This method just stores the config state without deserializing anything, this state will be ready to use and deserialized if ASM is enabled later on.
229- /// This method considers that RC sends us everything again, the whole state together. That's why it's clearing all unapplied updates / removals before processing the last ones received.
230- /// In case ASM remained disabled, we discard previous updates and removals stored here that were never applied.
250+ /// Merges an incoming RCM delta into the per-product pending state without deserializing payloads.
251+ /// If ASM is enabled (or this delta turns it on), the merged state is applied immediately; otherwise it stays pending until ASM activates.
231252 /// </summary>
232- /// <param name="configsByProduct">configsByProduct</param>
233- /// <param name="removedConfigs">removedConfigs</param>
253+ /// <remarks>
254+ /// Background: RCM forwards only the DELTA versus what we last acknowledged — not the full backend state.
255+ /// A config received in an earlier poll is NOT re-sent on the next poll if it hasn't changed.
256+ /// So while ASM is disabled, we must accumulate deltas across polls, otherwise configs received
257+ /// before activation would be silently dropped (they're already in RCM's _appliedConfigurations
258+ /// and won't be re-forwarded). Each path holds exactly one pending op in _pendingByProduct;
259+ /// a later update or remove for the same path overwrites whatever was there.
260+ /// </remarks>
234261 public void ReceivedNewConfig ( Dictionary < string , List < RemoteConfiguration > > configsByProduct , Dictionary < string , List < RemoteConfigurationPath > > ? removedConfigs )
235262 {
236- _fileUpdates . Clear ( ) ;
237- _fileRemoves . Clear ( ) ;
238- // if we just have asm features, it might only be to toggle appsec. Other products bring actual configurations to the waf.
239- var hasUpdateConfigurations = false ;
240- var anyChange = configsByProduct . Count > 0 || removedConfigs ? . Count > 0 ;
241- if ( anyChange )
263+ if ( configsByProduct . Count == 0 && ( removedConfigs is null || removedConfigs . Count == 0 ) )
242264 {
243- if ( removedConfigs != null )
244- {
245- foreach ( var configByProductToRemove in removedConfigs )
246- {
247- if ( configByProductToRemove . Key != RcmProducts . AsmFeatures )
248- {
249- hasUpdateConfigurations = true ;
250- }
265+ return ;
266+ }
251267
252- if ( _fileRemoves . TryGetValue ( configByProductToRemove . Key , out var remove ) )
253- {
254- remove . AddRange ( configByProductToRemove . Value ) ;
255- }
256- else
257- {
258- _fileRemoves [ configByProductToRemove . Key ] = configByProductToRemove . Value ;
259- }
260- }
261- }
268+ // Tracks whether this delta touches any product OTHER than ASM_FEATURES. A pure ASM_FEATURES
269+ // delta only toggles AppSec on/off — it doesn't carry WAF rules — so when that's all we got,
270+ // there's no reason to push a config update through to the WAF below.
271+ var hasUpdateConfigurations = false ;
262272
263- foreach ( var configByProduct in configsByProduct )
273+ if ( removedConfigs is not null )
274+ {
275+ foreach ( var entry in removedConfigs )
264276 {
265- if ( configByProduct . Key != RcmProducts . AsmFeatures )
277+ var productKey = entry . Key ;
278+ hasUpdateConfigurations |= productKey != RcmProducts . AsmFeatures ;
279+ if ( ! _pendingByProduct . TryGetValue ( productKey , out var pending ) )
266280 {
267- hasUpdateConfigurations = true ;
281+ pending = new Dictionary < string , PendingOperation > ( ) ;
282+ _pendingByProduct [ productKey ] = pending ;
268283 }
269284
270- if ( _fileUpdates . TryGetValue ( configByProduct . Key , out var update ) )
271- {
272- update . AddRange ( configByProduct . Value ) ;
273- }
274- else
285+ foreach ( var removed in entry . Value )
275286 {
276- _fileUpdates [ configByProduct . Key ] = configByProduct . Value ;
287+ // Overwrites any pending update OR pending remove for this path. Later op wins.
288+ pending [ removed . Path ] = new PendingOperation ( removed , update : null ) ;
277289 }
278290 }
291+ }
279292
280- ApplyAsmFeatures ( AppsecEnabled ) ;
281- IncomingUpdateState . ShouldUpdateAppsec = ! IncomingUpdateState . ShouldInitAppsec && AppsecEnabled && hasUpdateConfigurations ;
293+ foreach ( var entry in configsByProduct )
294+ {
295+ var productKey = entry . Key ;
296+ hasUpdateConfigurations |= productKey != RcmProducts . AsmFeatures ;
297+ if ( ! _pendingByProduct . TryGetValue ( productKey , out var pending ) )
298+ {
299+ pending = new Dictionary < string , PendingOperation > ( ) ;
300+ _pendingByProduct [ productKey ] = pending ;
301+ }
282302
283- if ( IncomingUpdateState . ShouldUpdateAppsec || IncomingUpdateState . ShouldInitAppsec )
303+ foreach ( var update in entry . Value )
284304 {
285- ApplyStoredFiles ( ) ;
305+ // Same overwrite semantics — replaces any prior op for this path.
306+ pending [ update . Path . Path ] = new PendingOperation ( update . Path , update ) ;
286307 }
287308 }
309+
310+ // ASM_FEATURES is processed eagerly because it can flip AppsecEnabled and decide whether
311+ // we initialize the WAF, update an already-running WAF, or do nothing.
312+ ApplyAsmFeatures ( AppsecEnabled ) ;
313+ IncomingUpdateState . ShouldUpdateAppsec = ! IncomingUpdateState . ShouldInitAppsec && AppsecEnabled && hasUpdateConfigurations ;
314+
315+ // If ASM just turned on, or it was already on and we got real WAF config, drain pending state
316+ // into the per-product output dictionaries (RulesetConfigs, AsmConfigs, AsmDataConfigs).
317+ if ( IncomingUpdateState . ShouldUpdateAppsec || IncomingUpdateState . ShouldInitAppsec )
318+ {
319+ ApplyStoredFiles ( ) ;
320+ }
288321 }
289322
290323 private void ApplyAsmFeatures ( bool appsecCurrentlyEnabled )
291324 {
292325 // only deserialize and apply asm_features as it will decide if asm gets toggled on and if we deserialize all the others
293326 // (the enable of auto user instrumentation as added to asm_features)
294- var change = _fileRemoves . TryGetValue ( RcmProducts . AsmFeatures , out var removals ) ;
295- change |= _fileUpdates . TryGetValue ( RcmProducts . AsmFeatures , out var updates ) ;
296-
297- if ( change )
327+ if ( ! _pendingByProduct . TryGetValue ( RcmProducts . AsmFeatures , out var pending ) || pending . Count == 0 )
298328 {
299- _asmFeatureProduct . ProcessUpdates ( this , removals , updates ) ;
329+ return ;
300330 }
301331
302- if ( ! change ) { return ; }
332+ var ( removals , updates ) = SplitPending ( pending ) ;
333+ _asmFeatureProduct . ProcessUpdates ( this , removals , updates ) ;
303334
304335 // normally CanBeToggled should not need a check as asm_features capacity is only sent if AppSec env var is null, but still guards it in case
305336 if ( _canBeToggled )
@@ -341,6 +372,41 @@ private void ApplyAsmFeatures(bool appsecCurrentlyEnabled)
341372 }
342373 }
343374
375+ // Splits the per-product pending map into the (removes, updates) shape that IAsmConfigUpdater.ProcessUpdates expects.
376+ // Either list may be null when that side is empty — matching the interface contract.
377+ private static ( List < RemoteConfigurationPath > ? Removes , List < RemoteConfiguration > ? Updates ) SplitPending ( Dictionary < string , PendingOperation > pending )
378+ {
379+ List < RemoteConfigurationPath > ? removes = null ;
380+ List < RemoteConfiguration > ? updates = null ;
381+ foreach ( var op in pending . Values )
382+ {
383+ if ( op . IsRemove )
384+ {
385+ removes ??= [ ] ;
386+ removes . Add ( op . Path ) ;
387+ }
388+ else
389+ {
390+ updates ??= [ ] ;
391+ updates . Add ( op . Update ) ;
392+ }
393+ }
394+
395+ return ( removes , updates ) ;
396+ }
397+
398+ // A pending RCM operation for one config path: either an update carrying a fresh payload,
399+ // or a removal marker. The Update field is null iff this entry represents a removal.
400+ private readonly struct PendingOperation ( RemoteConfigurationPath path , RemoteConfiguration ? update )
401+ {
402+ public RemoteConfigurationPath Path { get ; } = path ;
403+
404+ public RemoteConfiguration ? Update { get ; } = update ;
405+
406+ [ MemberNotNullWhen ( false , nameof ( Update ) ) ]
407+ public bool IsRemove => Update is null ;
408+ }
409+
344410 internal sealed record IncomingUpdateStatus : IDisposable
345411 {
346412 internal bool ShouldInitAppsec { get ; set ; }
0 commit comments