standard_audit 0.12.1 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -193,24 +193,28 @@ module StandardAudit
193
193
 
194
194
  # Recomputes the checksum from the record's current field values and the
195
195
  # given previous checksum. Useful for verification without saving.
196
- def compute_checksum_value(previous_checksum: nil)
196
+ # The algorithm (StandardAudit::Checksum::LEGACY or CANONICAL) follows the
197
+ # row's `created_at` against `config.canonical_checksum_since`; pass
198
+ # `version:` to force one.
199
+ def compute_checksum_value(previous_checksum: nil, version: nil)
197
200
  self.class.compute_checksum_value(
198
201
  attributes.slice(*CHECKSUM_FIELDS),
199
- previous_checksum: previous_checksum
202
+ previous_checksum: previous_checksum,
203
+ version: version || StandardAudit::Checksum.algorithm_for(created_at)
200
204
  )
201
205
  end
202
206
 
203
- def self.compute_checksum_value(attrs, previous_checksum: nil)
204
- canonical = CHECKSUM_FIELDS.map { |f|
205
- value = attrs[f]
206
- value = value.to_json if value.is_a?(Hash)
207
- value = value.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ") if value.respond_to?(:strftime) && value.respond_to?(:utc)
208
- "#{f}=#{value}"
209
- }.join("|")
207
+ # `attrs` may carry "created_at" / :created_at, which picks the algorithm
208
+ # as for a stored row; without it, the algorithm a write now would use.
209
+ def self.compute_checksum_value(attrs, previous_checksum: nil, version: nil)
210
+ version ||= StandardAudit::Checksum.algorithm_for(attrs["created_at"] || attrs[:created_at])
210
211
 
211
- canonical = "#{previous_checksum}|#{canonical}" if previous_checksum.present?
212
-
213
- OpenSSL::Digest::SHA256.hexdigest(canonical)
212
+ StandardAudit::Checksum.digest(
213
+ attrs,
214
+ fields: CHECKSUM_FIELDS,
215
+ previous_checksum: previous_checksum,
216
+ version: version
217
+ )
214
218
  end
215
219
 
216
220
  # Runs the configured `before_checksum` hooks against a row that will be
@@ -240,10 +244,65 @@ module StandardAudit
240
244
  column_names.include?("previous_checksum")
241
245
  end
242
246
 
243
- # Verifies the integrity of the audit log. Returns a result hash with
244
- # :valid (boolean), :verified (count), :recovered (count), :redacted
245
- # (count) and :failures (array of hashes carrying :id, :event_type,
246
- # :created_at, :expected, :actual and :reason).
247
+ # Verifies the integrity of the audit log. Returns a result hash:
248
+ #
249
+ # valid: true when :failures is empty (see "Legacy rows")
250
+ # verified: rows whose digest was checked (every checksummed,
251
+ # non-anonymized row)
252
+ # recovered: rows verified against a searched-for parent
253
+ # reordered: legacy rows verified by reconstructing the
254
+ # metadata key order they were signed with
255
+ # redacted: anonymized rows (not digest-checked)
256
+ # legacy_unverifiable: count of :unverifiable
257
+ # unverifiable: legacy rows whose digest can't be reproduced
258
+ # because the key order they were signed with is
259
+ # lost — same shape as a failure, reason
260
+ # :legacy_key_order_unverifiable
261
+ # failures: hashes with :id, :event_type, :created_at,
262
+ # :expected, :actual and :reason
263
+ #
264
+ # == Checksum algorithms and the cutover
265
+ #
266
+ # A row created at or after `config.canonical_checksum_since` (default
267
+ # StandardAudit::CANONICAL_CHECKSUM_CUTOVER) is signed with the canonical
268
+ # digest, which does not depend on how the database orders JSON object
269
+ # keys, and is verified STRICTLY with it: a mismatch is :digest_mismatch,
270
+ # never a legacy classification. An earlier row is legacy: its digest
271
+ # hashed `metadata.to_json` in Ruby insertion order, which `jsonb`
272
+ # discards (fundbright/delivery-ops#689). See StandardAudit::Checksum.
273
+ # The decision is recomputed from each row's stored `created_at`, the
274
+ # same value the writer used.
275
+ #
276
+ # == Legacy rows
277
+ #
278
+ # A legacy row is checked with the legacy digest, exactly as before —
279
+ # declared parent, else the preceding row, else the recovery search
280
+ # below. One that still does not reproduce is, in this order:
281
+ #
282
+ # 1. searched for the metadata key order it was signed with. A key order
283
+ # that reproduces the stored digest is a witness, like the parent
284
+ # search; the row counts in :reordered and is valid. Bounded by
285
+ # `key_order_search_limit` orderings per row
286
+ # (Checksum::KeyOrderSearch), against the declared parent or else the
287
+ # preceding row and "no parent";
288
+ # 2. reported `:digest_mismatch` when that search was EXHAUSTIVE against
289
+ # the parent the row declares (no key order explains it), or when the
290
+ # row has no JSON object with more than one key (key order cannot be
291
+ # why it fails);
292
+ # 3. reported `:missing_parent` when its declared parent is absent;
293
+ # 4. otherwise listed in :unverifiable with reason
294
+ # `:legacy_key_order_unverifiable`.
295
+ #
296
+ # `:legacy_key_order_unverifiable` means "cannot be proven either way":
297
+ # an edited legacy row looks exactly like one whose key order was lost.
298
+ # Such rows do NOT make `valid` false on their own — the gem cannot tell,
299
+ # and the policy for them belongs to the host — but they are never
300
+ # silent: they are counted in :legacy_unverifiable and listed in
301
+ # :unverifiable. Pass `fail_on_legacy_unverifiable: true` to report them
302
+ # as failures instead. After the cutover no new legacy row can be
303
+ # written, so under honest operation :legacy_unverifiable never grows;
304
+ # alert if it does (an edited pre-cutover row, or a `created_at` moved
305
+ # back across the cutover).
247
306
  #
248
307
  # A row stamped `anonymized_at` (GDPR erasure via `anonymize_actor!`) is
249
308
  # counted in :redacted instead of being digest-checked: its checksummed
@@ -273,7 +332,12 @@ module StandardAudit
273
332
  # actually reproduces this row's checksum. That recovers the true parent
274
333
  # of a forked row without re-signing anything. It does not weaken tamper
275
334
  # detection: a row whose fields were altered reproduces no candidate's
276
- # digest, so it still fails.
335
+ # digest, so it still fails. (The key-order search is not combined with
336
+ # this window search, so a legacy row that was both forked and
337
+ # reordered stays unverifiable.)
338
+ #
339
+ # The chain links across the cutover like anywhere else: the first
340
+ # canonical row's parent is the last legacy row's stored checksum.
277
341
  #
278
342
  # A row whose parent digest is absent from the log is reported with
279
343
  # `reason: :missing_parent` — a row was removed. Two exemptions:
@@ -291,20 +355,46 @@ module StandardAudit
291
355
  # `created_at`. Rows whose two timestamps disagree (a backdated
292
356
  # `occurred_at`) can leave a hole rather than a prefix, and a hole is
293
357
  # reported — truthfully, since rows really are missing.
294
- def self.verify_chain(scope: nil, batch_size: 1000, recovery_window: 256, strict: false)
358
+ def self.verify_chain(scope: nil, batch_size: 1000, recovery_window: 256, strict: false,
359
+ key_order_search_limit: StandardAudit::Checksum::KeyOrderSearch::DEFAULT_LIMIT,
360
+ fail_on_legacy_unverifiable: false)
295
361
  relation = scope ? where(scope_gid: scope.to_global_id.to_s) : all
296
362
  check_parents = scope.nil?
297
363
  declared_parents = chain_parent_column?
364
+ key_orders = StandardAudit::Checksum::KeyOrderSearch.new(limit: key_order_search_limit)
298
365
 
299
366
  previous_checksum = nil
300
367
  verified = 0
301
368
  recovered = 0
369
+ reordered = 0
302
370
  redacted = 0
371
+ unverifiable = []
303
372
  failures = []
304
373
  window = []
305
374
  first_row = true
306
375
  pruned_parents = []
307
376
 
377
+ # Records a :missing_parent failure when `declared` is absent from the
378
+ # log, unless the walk opened on it (a pruned start). True when reported.
379
+ report_missing_parent = lambda do |record, declared, expected|
380
+ next false unless declared.present? && check_parents && !parent_present?(declared, window, relation)
381
+
382
+ if first_row
383
+ # The walk opens on a row whose parent is already gone, so the log
384
+ # has had its start removed — retention pruning, typically. That
385
+ # parent is unknowable, and it can have several children (which is
386
+ # what a concurrent append leaves behind), so the exemption is
387
+ # remembered per digest rather than for one row.
388
+ pruned_parents << declared
389
+ false
390
+ elsif pruned_parents.include?(declared)
391
+ false
392
+ else
393
+ failures << chain_failure(record, expected: expected, reason: :missing_parent)
394
+ true
395
+ end
396
+ end
397
+
308
398
  each_in_chain_order(relation, batch_size: batch_size) do |record|
309
399
  if record.checksum.blank?
310
400
  previous_checksum = nil
@@ -312,44 +402,45 @@ module StandardAudit
312
402
  next
313
403
  end
314
404
 
315
- declared = record.previous_checksum if declared_parents
405
+ declared = declared_parents ? record.previous_checksum : nil
316
406
 
317
407
  if record.anonymized?
318
408
  redacted += 1
319
409
  # The digest cannot be checked, but the parent it declares can.
320
- if declared.present? && check_parents && !parent_present?(declared, window, relation)
321
- if first_row
322
- pruned_parents << declared
323
- elsif !pruned_parents.include?(declared)
324
- failures << chain_failure(record, expected: nil, reason: :missing_parent)
325
- end
326
- end
327
- elsif declared.present?
328
- verified += 1
329
- expected = record.compute_checksum_value(previous_checksum: declared)
330
-
331
- if record.checksum != expected
332
- failures << chain_failure(record, expected: expected, reason: :digest_mismatch)
333
- elsif check_parents && !parent_present?(declared, window, relation)
334
- if first_row
335
- # The walk opens on a row whose parent is already gone, so the
336
- # log has had its start removed — retention pruning, typically.
337
- # That parent is unknowable, and it can have several children
338
- # (which is what a concurrent append leaves behind), so the
339
- # exemption is remembered per digest rather than for one row.
340
- pruned_parents << declared
341
- elsif !pruned_parents.include?(declared)
342
- failures << chain_failure(record, expected: expected, reason: :missing_parent)
343
- end
344
- end
410
+ report_missing_parent.call(record, declared, nil)
345
411
  else
346
412
  verified += 1
347
- expected = record.compute_checksum_value(previous_checksum: previous_checksum)
413
+ algorithm = StandardAudit::Checksum.algorithm_for(record.created_at)
414
+ parent = declared.presence || previous_checksum
415
+ expected = walk_digester(record, algorithm).call(parent)
348
416
 
349
417
  if record.checksum == expected
350
- # Links to the row before it, as a linear chain does.
351
- elsif !strict && recover_parent(record, window)
418
+ # Links to its declared parent, or to the row before it as a
419
+ # linear chain does.
420
+ report_missing_parent.call(record, declared, expected)
421
+ elsif declared.blank? && !strict && recover_parent(record, window, algorithm)
352
422
  recovered += 1
423
+ elsif algorithm == StandardAudit::Checksum::LEGACY && legacy_key_order_ambiguous?(record)
424
+ attrs = record.attributes.slice(*CHECKSUM_FIELDS)
425
+ parents = declared.present? || strict ? [parent] : [parent, nil].uniq
426
+
427
+ if key_orders.search(attrs, fields: CHECKSUM_FIELDS, checksum: record.checksum, parents: parents)
428
+ reordered += 1
429
+ # The row verified under a reconstructed order, so the stored-
430
+ # order `expected` means nothing for a missing-parent report.
431
+ report_missing_parent.call(record, declared, nil)
432
+ elsif declared.present? && key_orders.exhaustive?(attrs, fields: CHECKSUM_FIELDS)
433
+ # Every key order was tried against the parent the row itself
434
+ # declares, so key order does not explain this row.
435
+ failures << chain_failure(record, expected: expected, reason: :digest_mismatch)
436
+ elsif report_missing_parent.call(record, declared, nil)
437
+ # Reported as a removed row, which is the stronger finding. No
438
+ # `expected`: no digest was established for this row.
439
+ else
440
+ entry = chain_failure(record, expected: expected, reason: :legacy_key_order_unverifiable)
441
+ unverifiable << entry
442
+ failures << entry if fail_on_legacy_unverifiable
443
+ end
353
444
  else
354
445
  failures << chain_failure(record, expected: expected, reason: :digest_mismatch)
355
446
  end
@@ -361,7 +452,16 @@ module StandardAudit
361
452
  window.shift if window.size > recovery_window
362
453
  end
363
454
 
364
- { valid: failures.empty?, verified: verified, recovered: recovered, redacted: redacted, failures: failures }
455
+ {
456
+ valid: failures.empty?,
457
+ verified: verified,
458
+ recovered: recovered,
459
+ reordered: reordered,
460
+ redacted: redacted,
461
+ legacy_unverifiable: unverifiable.size,
462
+ unverifiable: unverifiable,
463
+ failures: failures
464
+ }
365
465
  end
366
466
 
367
467
  # Records, for every row that does not already carry one, the parent digest
@@ -426,28 +526,54 @@ module StandardAudit
426
526
  private_class_method :chain_failure
427
527
 
428
528
  # Searches `window` (most recent first, then "no parent at all") for the
429
- # digest that reproduces the record's stored checksum. Returns a one-element
430
- # array holding the parent — which may itself be nil, for a row written
431
- # against an empty table — or nil when nothing reproduces the digest.
529
+ # digest that reproduces the record's stored checksum under `algorithm`.
530
+ # Returns a one-element array holding the parent — which may
531
+ # itself be nil, for a row written against an empty table — or nil when
532
+ # nothing reproduces the digest.
432
533
  #
433
534
  # SHA-256 preimage resistance is what makes this safe: a row whose fields
434
535
  # were altered reproduces no candidate's digest, so it is still reported.
435
- def self.recover_parent(record, window)
536
+ def self.recover_parent(record, window, algorithm)
436
537
  window.reverse_each do |candidate|
437
- return [candidate] if record.checksum == record.compute_checksum_value(previous_checksum: candidate)
538
+ return [candidate] if digest_matches?(record, candidate, algorithm)
438
539
  end
439
540
 
440
- [nil] if record.checksum == record.compute_checksum_value(previous_checksum: nil)
541
+ [nil] if digest_matches?(record, nil, algorithm)
441
542
  end
442
543
  private_class_method :recover_parent
443
544
 
444
545
  def self.resolve_parent(record, previous_checksum, window)
445
- return [previous_checksum] if record.checksum == record.compute_checksum_value(previous_checksum: previous_checksum)
546
+ algorithm = StandardAudit::Checksum.algorithm_for(record.created_at)
547
+ return [previous_checksum] if digest_matches?(record, previous_checksum, algorithm)
446
548
 
447
- recover_parent(record, window)
549
+ recover_parent(record, window, algorithm)
448
550
  end
449
551
  private_class_method :resolve_parent
450
552
 
553
+ def self.digest_matches?(record, parent, algorithm)
554
+ record.checksum == walk_digester(record, algorithm).call(parent)
555
+ end
556
+ private_class_method :digest_matches?
557
+
558
+ # The row's `parent -> digest` function for `algorithm`, memoised on the
559
+ # loaded record: the parent searches try hundreds of parents per row, and
560
+ # this makes each try one SHA-256 instead of re-serialising the row. Only
561
+ # for records the walk loaded and never mutates.
562
+ def self.walk_digester(record, algorithm)
563
+ memo = record.instance_variable_get(:@walk_digesters) || record.instance_variable_set(:@walk_digesters, {})
564
+ memo[algorithm] ||= StandardAudit::Checksum.digester(
565
+ record.attributes.slice(*CHECKSUM_FIELDS), fields: CHECKSUM_FIELDS, version: algorithm
566
+ )
567
+ end
568
+ private_class_method :walk_digester
569
+
570
+ # True when the row's hashed JSON could have been reordered by the store:
571
+ # some object in it has more than one key.
572
+ def self.legacy_key_order_ambiguous?(record)
573
+ CHECKSUM_FIELDS.any? { |f| StandardAudit::Checksum.key_order_ambiguous?(record[f]) }
574
+ end
575
+ private_class_method :legacy_key_order_ambiguous?
576
+
451
577
  def self.parent_present?(digest, window, relation)
452
578
  window.include?(digest) || relation.exists?(checksum: digest)
453
579
  end
@@ -465,10 +591,8 @@ module StandardAudit
465
591
  next
466
592
  end
467
593
 
468
- new_checksum = compute_checksum_value(
469
- record.attributes.slice(*CHECKSUM_FIELDS),
470
- previous_checksum: previous_checksum
471
- )
594
+ # The row's own created_at picks the algorithm, as verification will.
595
+ new_checksum = record.compute_checksum_value(previous_checksum: previous_checksum)
472
596
  columns = { checksum: new_checksum }
473
597
  columns[:previous_checksum] = previous_checksum if chain_parent_column?
474
598
  record.update_columns(columns)
@@ -550,7 +674,12 @@ module StandardAudit
550
674
  #
551
675
  # `previous_checksum` needs no protection of its own: it is an input to
552
676
  # this row's own digest, so editing it invalidates the row.
677
+ #
678
+ # The algorithm is chosen from `created_at`, which is fixed here (Active
679
+ # Record keeps a timestamp that is already set) so the decision is made
680
+ # from exactly the value that is stored and that verification re-reads.
553
681
  def compute_checksum
682
+ self.created_at ||= Time.current if has_attribute?(:created_at)
554
683
  previous = self.class.chain_tip_checksum
555
684
  self.previous_checksum = previous if self.class.chain_parent_column?
556
685
  self.checksum = compute_checksum_value(previous_checksum: previous)
@@ -597,13 +726,7 @@ module StandardAudit
597
726
  # persist — so the hook would not actually be "skipped".
598
727
  restore_attributes_from(snapshot)
599
728
  Rails.logger.warn("[StandardAudit] before_checksum hook failed: #{e.class}: #{e.message}")
600
- if Rails.respond_to?(:error) && Rails.error
601
- Rails.error.report(
602
- e,
603
- handled: true,
604
- context: { StandardAudit.config.audit_error_context_key => event_type }
605
- )
606
- end
729
+ StandardAudit.report_error(e, { StandardAudit.config.audit_error_context_key => event_type })
607
730
  nil
608
731
  end
609
732
  end
@@ -1,16 +1,15 @@
1
+ require "rails/generators"
2
+ require "generators/standard_audit/migration_number"
3
+
1
4
  module StandardAudit
2
5
  module Generators
3
6
  # Adds `audit_logs.anonymized_at` (0.12.0). With it, rows erased by
4
7
  # `AuditLog.anonymize_actor!` are reported by `verify_chain` as `redacted`
5
8
  # rather than as `digest_mismatch` failures.
6
9
  class AddAnonymizedAtGenerator < Rails::Generators::Base
7
- include Rails::Generators::Migration
10
+ include StandardAudit::Generators::MigrationNumber
8
11
  source_root File.expand_path("templates", __dir__)
9
12
 
10
- def self.next_migration_number(dirname)
11
- Time.now.utc.strftime("%Y%m%d%H%M%S")
12
- end
13
-
14
13
  def copy_migration
15
14
  migration_template "add_anonymized_at_to_audit_logs.rb.erb",
16
15
  "db/migrate/add_anonymized_at_to_audit_logs.rb"
@@ -1,13 +1,12 @@
1
+ require "rails/generators"
2
+ require "generators/standard_audit/migration_number"
3
+
1
4
  module StandardAudit
2
5
  module Generators
3
6
  class AddPreviousChecksumGenerator < Rails::Generators::Base
4
- include Rails::Generators::Migration
7
+ include StandardAudit::Generators::MigrationNumber
5
8
  source_root File.expand_path("templates", __dir__)
6
9
 
7
- def self.next_migration_number(dirname)
8
- Time.now.utc.strftime("%Y%m%d%H%M%S")
9
- end
10
-
11
10
  def copy_migration
12
11
  migration_template "add_previous_checksum_to_audit_logs.rb.erb",
13
12
  "db/migrate/add_previous_checksum_to_audit_logs.rb"
@@ -1,4 +1,5 @@
1
1
  require "rails/generators"
2
+ require "generators/standard_audit/migration_number"
2
3
 
3
4
  module StandardAudit
4
5
  module Generators
@@ -11,7 +12,7 @@ module StandardAudit
11
12
  # installed. Pass `--skip-*` flags to opt out of individual steps and
12
13
  # `--force` to overwrite an existing initializer.
13
14
  class InstallGenerator < Rails::Generators::Base
14
- include Rails::Generators::Migration
15
+ include StandardAudit::Generators::MigrationNumber
15
16
  source_root File.expand_path("templates", __dir__)
16
17
 
17
18
  desc <<~DESC
@@ -32,10 +33,6 @@ module StandardAudit
32
33
  class_option :force, type: :boolean, default: false,
33
34
  desc: "Overwrite config/initializers/standard_audit.rb if it already exists"
34
35
 
35
- def self.next_migration_number(dirname)
36
- Time.now.utc.strftime("%Y%m%d%H%M%S")
37
- end
38
-
39
36
  def copy_migration
40
37
  if options[:skip_migration]
41
38
  say_status("skip", "db/migrate/*_create_audit_logs.rb (--skip-migration)", :yellow)
@@ -0,0 +1,40 @@
1
+ require "rails/generators/migration"
2
+ require "time"
3
+
4
+ module StandardAudit
5
+ module Generators
6
+ # Migration numbering shared by the gem's migration generators.
7
+ #
8
+ # A plain `Time.now` stamp sorts BEFORE a host's future-dated migrations
9
+ # (several consumers date theirs ahead of the clock), so the generated
10
+ # migration would run out of order and look already-applied to tools that
11
+ # compare against the latest version. This picks whichever is later: now,
12
+ # or one second after the newest migration already in the target
13
+ # directory. (ActiveRecord's own generators add 1 to the number, which
14
+ # can produce an invalid timestamp such as ...235960; this adds a second.)
15
+ module MigrationNumber
16
+ def self.included(base)
17
+ base.include Rails::Generators::Migration
18
+ base.extend ClassMethods
19
+ end
20
+
21
+ module ClassMethods
22
+ FORMAT = "%Y%m%d%H%M%S".freeze
23
+
24
+ def next_migration_number(dirname)
25
+ [Time.now.utc.strftime(FORMAT), one_second_after(current_migration_number(dirname))].max
26
+ end
27
+
28
+ private
29
+
30
+ def one_second_after(number)
31
+ stamp = Kernel.format("%.14d", number)
32
+ (Time.strptime("#{stamp} +0000", "#{FORMAT} %z") + 1).utc.strftime(FORMAT)
33
+ rescue ArgumentError
34
+ # Not a timestamp (no migrations yet, or a sequential numbering).
35
+ Kernel.format("%.14d", number + 1)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,153 @@
1
+ module StandardAudit
2
+ module Checksum
3
+ # Reconstructs the key order a legacy row was signed with.
4
+ #
5
+ # A legacy digest covers `metadata.to_json` in the writer's Ruby
6
+ # insertion order. A `jsonb` column has since discarded that order, and it
7
+ # cannot be derived from the stored row: the storage order is a function
8
+ # of the key set alone, so every insertion order maps to the same stored
9
+ # value. What CAN be done is to try the orders. If some ordering of the
10
+ # stored keys, hashed with the row's own parent, reproduces the digest the
11
+ # row has held since it was written, that is a witness in the same sense
12
+ # as the parent recovery search: SHA-256 preimage resistance means a row
13
+ # whose values were altered reproduces no ordering. Trying N orderings
14
+ # costs log2(N) bits of a 256-bit margin.
15
+ #
16
+ # The search is bounded. The number of orderings is the product of `n!`
17
+ # over every object in the value, so it is only enumerated in full when
18
+ # that product is at most `limit`. Orders that reproduced an earlier row
19
+ # with the same key structure are remembered and tried first, because a
20
+ # given event is almost always built by the same code path in the same
21
+ # order — that is what makes the search cheap across a whole log.
22
+ #
23
+ # One instance lives for one verify_chain walk. It never writes anything.
24
+ class KeyOrderSearch
25
+ DEFAULT_LIMIT = 720 # 6 keys in one object, or e.g. 3 × 3! nested
26
+ REMEMBERED_ORDERS = 8
27
+
28
+ attr_reader :limit
29
+
30
+ def initialize(limit: DEFAULT_LIMIT)
31
+ @limit = limit.to_i
32
+ @learned = Hash.new { |h, k| h[k] = [] }
33
+ end
34
+
35
+ # Searches the orderings of every Hash-valued field in `attrs` for one
36
+ # that reproduces `checksum` under the legacy digest with one of `parents`.
37
+ # Returns `{ parent:, attrs: }` on a match, nil otherwise.
38
+ def search(attrs, fields:, checksum:, parents:)
39
+ hashed = fields.select { |f| attrs[f].is_a?(Hash) }
40
+ return nil if hashed.empty? || limit <= 0
41
+
42
+ signature = hashed.map { |f| [f, self.class.signature(attrs[f])] }
43
+
44
+ # Remembered orders first: one try each, however large the object.
45
+ @learned[signature].dup.each do |templates|
46
+ candidate = attrs.merge(hashed.zip(templates).to_h { |f, t| [f, self.class.apply(t, attrs[f])] })
47
+ match = try(candidate, fields, checksum, parents)
48
+ return remember(signature, hashed, candidate, match) if match
49
+ end
50
+
51
+ return nil unless exhaustive?(attrs, fields: fields)
52
+
53
+ per_field = hashed.map { |f| self.class.variants(attrs[f]) }
54
+ combos = per_field.first.product(*per_field.drop(1))
55
+
56
+ combos.each do |values|
57
+ candidate = attrs.merge(hashed.zip(values).to_h)
58
+ match = try(candidate, fields, checksum, parents)
59
+ return remember(signature, hashed, candidate, match) if match
60
+ end
61
+
62
+ nil
63
+ end
64
+
65
+ # True when `search` enumerates EVERY ordering of this row, so a miss
66
+ # means no key order explains the digest — with a known parent, the row
67
+ # does not match its signed content.
68
+ def exhaustive?(attrs, fields:)
69
+ count = fields.select { |f| attrs[f].is_a?(Hash) }.reduce(1) { |acc, f| acc * self.class.variant_count(attrs[f]) }
70
+ count <= limit
71
+ end
72
+
73
+ class << self
74
+ def variant_count(node)
75
+ case node
76
+ when Hash then (1..node.size).reduce(1, :*) * node.each_value.reduce(1) { |acc, v| acc * variant_count(v) }
77
+ when Array then node.reduce(1) { |acc, v| acc * variant_count(v) }
78
+ else 1
79
+ end
80
+ end
81
+
82
+ # Every ordering of every object inside `node`. The first is the
83
+ # stored order. Callers bound the size with variant_count.
84
+ def variants(node)
85
+ case node
86
+ when Hash
87
+ keys = node.keys
88
+ children = keys.to_h { |k| [k, variants(node[k])] }
89
+ keys.permutation.flat_map do |perm|
90
+ choices = perm.map { |k| children[k] }
91
+ product(choices).map { |values| perm.zip(values).to_h }
92
+ end
93
+ when Array
94
+ product(node.map { |v| variants(v) })
95
+ else
96
+ [node]
97
+ end
98
+ end
99
+
100
+ # The key structure of a value, independent of order.
101
+ def signature(node)
102
+ case node
103
+ when Hash then [:h, node.keys.map(&:to_s).sort.map { |k| [k, signature(node[k])] }]
104
+ when Array then [:a, node.map { |v| signature(v) }]
105
+ end
106
+ end
107
+
108
+ # The ordering of `node`, as a template `apply` can replay.
109
+ def template(node)
110
+ case node
111
+ when Hash then [:h, node.map { |k, v| [k, template(v)] }]
112
+ when Array then [:a, node.map { |v| template(v) }]
113
+ end
114
+ end
115
+
116
+ def apply(template, node)
117
+ case template&.first
118
+ when :h then template.last.to_h { |k, t| [k, apply(t, node[k])] }
119
+ when :a then node.each_with_index.map { |v, i| apply(template.last[i], v) }
120
+ else node
121
+ end
122
+ end
123
+
124
+ private
125
+
126
+ def product(lists)
127
+ return [[]] if lists.empty?
128
+
129
+ lists.first.product(*lists.drop(1))
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def try(candidate, fields, checksum, parents)
136
+ parents.each do |parent|
137
+ digest = Checksum.legacy_digest(candidate, fields: fields, previous_checksum: parent)
138
+ return { parent: parent } if digest == checksum
139
+ end
140
+ nil
141
+ end
142
+
143
+ def remember(signature, hashed, candidate, match)
144
+ templates = hashed.map { |f| self.class.template(candidate[f]) }
145
+ list = @learned[signature]
146
+ list.delete(templates)
147
+ list.unshift(templates)
148
+ list.pop while list.size > REMEMBERED_ORDERS
149
+ match.merge(attrs: candidate)
150
+ end
151
+ end
152
+ end
153
+ end