reeve 0.4.0 → 0.6.0

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +176 -1
  3. data/README.md +202 -12
  4. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +41 -3
  5. data/lib/generators/reeve/install/templates/initializer.rb.tt +20 -0
  6. data/lib/generators/reeve/upgrade/templates/add_ledger_chain_to_reeve_audit_entries.rb.tt +51 -0
  7. data/lib/generators/reeve/upgrade/upgrade_generator.rb +40 -2
  8. data/lib/reeve/audit/chain/canonical.rb +129 -0
  9. data/lib/reeve/audit/chain/head.rb +94 -0
  10. data/lib/reeve/audit/chain/keyring.rb +53 -0
  11. data/lib/reeve/audit/chain/normalizer.rb +88 -0
  12. data/lib/reeve/audit/chain/report.rb +134 -0
  13. data/lib/reeve/audit/chain/verification.rb +110 -0
  14. data/lib/reeve/audit/chain/verifier.rb +160 -0
  15. data/lib/reeve/audit/chain/walk.rb +123 -0
  16. data/lib/reeve/audit/chain.rb +168 -0
  17. data/lib/reeve/audit/chain_record.rb +61 -0
  18. data/lib/reeve/audit/isolated_recorder.rb +5 -0
  19. data/lib/reeve/audit/recorder.rb +32 -19
  20. data/lib/reeve/audit.rb +24 -4
  21. data/lib/reeve/authorization.rb +1 -11
  22. data/lib/reeve/configuration.rb +90 -2
  23. data/lib/reeve/decision.rb +6 -1
  24. data/lib/reeve/errors.rb +28 -0
  25. data/lib/reeve/inventory/report.rb +146 -0
  26. data/lib/reeve/inventory.rb +212 -0
  27. data/lib/reeve/invocation.rb +3 -1
  28. data/lib/reeve/ledger_key.rb +47 -0
  29. data/lib/reeve/railtie.rb +15 -0
  30. data/lib/reeve/tasks.rb +62 -0
  31. data/lib/reeve/testing/assertions.rb +2 -2
  32. data/lib/reeve/testing/checks/contract_version.rb +2 -1
  33. data/lib/reeve/testing/checks/endpoint_coverage.rb +133 -0
  34. data/lib/reeve/testing/checks/ledger_chain.rb +116 -0
  35. data/lib/reeve/testing/checks.rb +43 -7
  36. data/lib/reeve/testing/compliance_assertions.rb +4 -1
  37. data/lib/reeve/testing/compliance_suite.rb +4 -1
  38. data/lib/reeve/testing/ledger.rb +11 -1
  39. data/lib/reeve/testing/matchers/pass_reeve_check.rb +1 -1
  40. data/lib/reeve/testing/report.rb +30 -6
  41. data/lib/reeve/testing/result.rb +28 -8
  42. data/lib/reeve/testing.rb +20 -4
  43. data/lib/reeve/version.rb +1 -1
  44. data/lib/reeve.rb +3 -0
  45. metadata +20 -2
@@ -33,15 +33,29 @@ module Reeve
33
33
  # contracts/audit-entry.md, which exists so that stays hypothetical: an append-only
34
34
  # ledger cannot be backfilled, so a column that is not additive has no honest value
35
35
  # to give the rows already written.
36
+ # `creates` names any table a step builds. A step is applied only when every column it
37
+ # adds and every table it creates exist: a ledger with one half and not the other is
38
+ # half-migrated, and calling it current would leave the host with no way forward.
36
39
  STEPS = [
37
40
  {
38
41
  contract: 2,
39
42
  adds: %w[contract_version],
43
+ creates: [],
40
44
  template: "add_contract_version_to_reeve_audit_entries.rb.tt",
41
45
  destination: "add_contract_version_to_reeve_audit_entries.rb"
46
+ },
47
+ {
48
+ contract: 3,
49
+ adds: %w[chain_sequence chain_key_id chain_digest],
50
+ creates: %w[reeve_audit_chains],
51
+ template: "add_ledger_chain_to_reeve_audit_entries.rb.tt",
52
+ destination: "add_ledger_chain_to_reeve_audit_entries.rb"
42
53
  }
43
54
  ].freeze
44
55
 
56
+ KEY_LINE = "config.ledger_key = " \
57
+ 'Rails.application.key_generator.generate_key("reeve audit ledger", 32)'
58
+
45
59
  source_root File.expand_path("templates", __dir__)
46
60
 
47
61
  desc "Brings an existing reeve audit ledger up to the current audit-entry contract."
@@ -93,7 +107,7 @@ module Reeve
93
107
  end
94
108
 
95
109
  def pending_message
96
- <<~NEXT
110
+ message = <<~NEXT
97
111
 
98
112
  #{@emitted.size} migration#{'s' if @emitted.size != 1} written, taking the ledger
99
113
  to audit-entry contract #{@emitted.last[:contract]}. Run:
@@ -103,13 +117,37 @@ module Reeve
103
117
  Existing rows are stamped with the contract they were actually written under,
104
118
  not with the current one.
105
119
  NEXT
120
+ message += chain_message if @emitted.any? { |step| step[:contract] == 3 }
121
+ message
122
+ end
123
+
124
+ # Contract 3 changes what the application must configure, not only the table: without
125
+ # a key, every guarded call fails closed once the migration runs. The host is reading
126
+ # this output at exactly the moment they need to know that.
127
+ def chain_message
128
+ <<~CHAIN
129
+
130
+ The ledger is now chained (contract 3). Add this line to config/initializers/reeve.rb
131
+ before you deploy, or every guarded call fails closed:
132
+
133
+ #{KEY_LINE}
134
+
135
+ It derives the key from secret_key_base, so rotating secret_key_base rotates the
136
+ ledger key: keep the old derived key in config.retired_ledger_keys, or entries it
137
+ signed become unverifiable. A dedicated key avoids that coupling.
138
+
139
+ Very large ledger on PostgreSQL? The migration builds a unique index that blocks
140
+ writes while it scans; the migration's header shows how to build it with
141
+ algorithm: :concurrently instead.
142
+ CHAIN
106
143
  end
107
144
 
108
145
  # Asked of the table rather than of a version the host recorded: the table is the
109
146
  # thing being changed, and it is the only party that cannot be out of date about
110
147
  # itself.
111
148
  def applied?(step)
112
- (step[:adds] - existing_columns).empty?
149
+ (step[:adds] - existing_columns).empty? &&
150
+ step[:creates].all? { |table| connection.table_exists?(table) }
113
151
  end
114
152
 
115
153
  def existing_columns
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "json"
5
+ require "openssl"
6
+
7
+ module Reeve
8
+ module Audit
9
+ class Chain
10
+ # The exact text a digest is computed over (specs/002 FR-002, FR-003; research R1, R2
11
+ # step 2; data-model "Canonical entry").
12
+ #
13
+ # Computed once when a row is written and again, from what the database returns,
14
+ # when it is verified — so everything here exists to make those two readings produce
15
+ # identical text:
16
+ #
17
+ # * object keys sorted at every depth, so a database that reorders JSON keys
18
+ # (PostgreSQL jsonb, MySQL JSON) changes nothing;
19
+ # * one spelling per number, so `1.0e+20` written and `100000000000000000000` read
20
+ # back are the same number;
21
+ # * times as integer microseconds since the epoch, so neither the time zone nor the
22
+ # driver's Time class matters;
23
+ # * strings, booleans and nil typed, so "1" and 1 never digest alike.
24
+ #
25
+ # The field list is written out rather than read from the table — a check that
26
+ # derives its expectation from the thing it checks checks nothing — and is keyed by
27
+ # contract version, so a future column joins the digest for rows written under that
28
+ # contract only. The tags keep an entry digest and a genesis digest from ever being
29
+ # interchangeable, and let a future canonical form be told apart from this one.
30
+ module Canonical
31
+ ENTRY_TAG = "reeve-ledger-entry-v1"
32
+ GENESIS_TAG = "reeve-ledger-genesis-v1"
33
+
34
+ ENTRY_FIELDS = {
35
+ 3 => %w[
36
+ chain_sequence chain_key_id contract_version
37
+ invocation_id occurred_at agent_id agent_name principal_type principal_id
38
+ tool_name arguments outcome rule detail record_type record_ids record_count
39
+ truncated derived guard duration_ms metadata
40
+ ].freeze
41
+ }.freeze
42
+
43
+ GENESIS_FIELDS = %w[genesis_at pre_chain_count pre_chain_unchained_count
44
+ genesis_key_id].freeze
45
+
46
+ TIME_FIELDS = %w[occurred_at genesis_at].freeze
47
+
48
+ module_function
49
+
50
+ def entry_digest(secret, row, previous_digest:)
51
+ hmac(secret, ENTRY_TAG, entry_message_body(row, previous_digest))
52
+ end
53
+
54
+ def genesis_digest(secret, genesis)
55
+ pairs = GENESIS_FIELDS.map { |field| [field, value(genesis, field)] }
56
+ hmac(secret, GENESIS_TAG, dump(pairs))
57
+ end
58
+
59
+ def entry_message_body(row, previous_digest)
60
+ version = value(row, "contract_version")
61
+ fields = ENTRY_FIELDS.fetch(version) do
62
+ raise ArgumentError,
63
+ "there is no canonical field list for audit-entry contract " \
64
+ "#{version.inspect}; only contract " \
65
+ "#{ENTRY_FIELDS.keys.join(', ')} rows are chained"
66
+ end
67
+
68
+ pairs = fields.map { |field| [field, value(row, field)] }
69
+ dump([["previous_digest", previous_digest], *pairs])
70
+ end
71
+
72
+ def microseconds(time)
73
+ (time.to_r * 1_000_000).floor
74
+ end
75
+
76
+ def dump(value)
77
+ case value
78
+ when Array then "[#{value.map { |item| dump(item) }.join(',')}]"
79
+ when Hash then object(value)
80
+ else scalar(value)
81
+ end
82
+ end
83
+
84
+ def scalar(value)
85
+ case value
86
+ when nil, true, false then value.nil? ? "null" : value.to_s
87
+ when Integer, Float, BigDecimal then number(value)
88
+ when String, Symbol then JSON.generate(value.to_s)
89
+ else
90
+ raise ArgumentError,
91
+ "#{value.class} has no canonical form; normalize it before it is digested"
92
+ end
93
+ end
94
+
95
+ def object(hash)
96
+ pairs = hash.map { |key, item| [key.to_s, item] }.sort_by(&:first)
97
+ "{#{pairs.map { |key, item| "#{JSON.generate(key)}:#{dump(item)}" }.join(',')}}"
98
+ end
99
+
100
+ # The shortest exact decimal: no exponent, no trailing zeros, no "-0".
101
+ def number(value)
102
+ if value.is_a?(Float) && !value.finite?
103
+ raise ArgumentError,
104
+ "a non-finite number has no canonical form; normalize it before it is digested"
105
+ end
106
+
107
+ decimal = value.is_a?(Float) ? BigDecimal(value.to_s) : BigDecimal(value)
108
+ return "0" if decimal.zero?
109
+ return decimal.to_i.to_s if decimal.frac.zero?
110
+
111
+ decimal.to_s("F")
112
+ end
113
+
114
+ def value(row, field)
115
+ raw = if row.is_a?(Hash)
116
+ row.key?(field) ? row[field] : row[field.to_sym]
117
+ else
118
+ row[field]
119
+ end
120
+ TIME_FIELDS.include?(field) && !raw.nil? ? microseconds(raw) : raw
121
+ end
122
+
123
+ def hmac(secret, tag, body)
124
+ OpenSSL::HMAC.hexdigest("SHA256", secret, "#{tag}\n#{body}")
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ class Chain
6
+ # The chain record's head, advanced under its lock (research R3).
7
+ #
8
+ # One instance per append, bound to the connection that append runs on; every
9
+ # statement here goes through that connection, so the lock, the row and the head move
10
+ # share one transaction and commit or roll back together.
11
+ #
12
+ # UPDATE head_sequence by one -- the lock, taken first
13
+ # SELECT the new head_sequence and the old head_digest
14
+ # ... the caller inserts the row ...
15
+ # UPDATE head_digest to the row's digest
16
+ #
17
+ # Taking the lock with a write is what makes this behave the same on all three
18
+ # engines: SQLite cannot upgrade a read lock while another writer waits, and ignores
19
+ # SELECT ... FOR UPDATE.
20
+ class Head
21
+ def initialize(connection:, entry_class:)
22
+ @connection = connection
23
+ @entry_class = entry_class
24
+ end
25
+
26
+ # Creates the chain record with its genesis — link 0, head 0 — unless it exists.
27
+ #
28
+ # Deliberately not part of the write that follows. Run first inside it, an UPDATE that
29
+ # matched no row took a gap lock on MySQL's unique index, and two writers starting the
30
+ # chain at once each tried to INSERT into the gap the other held: a deadlock, found
31
+ # by CI, invisible on PostgreSQL, which has no gap locks. On its own, two concurrent
32
+ # INSERTs of one unique name serialize: one wins and the other gets a duplicate key,
33
+ # which means the chain has started and is all it needs to know.
34
+ #
35
+ # So a started chain can exist with nothing in it yet (head 0) — if the first chained
36
+ # write then fails, say. The verifier reports that as no chain, not as a pass.
37
+ def start!(signer)
38
+ return if connection.select_value("SELECT 1 FROM #{TABLE} WHERE #{named}")
39
+
40
+ genesis = genesis_for(signer)
41
+ digest = Canonical.genesis_digest(signer.signing_secret, genesis)
42
+ values = genesis.merge(name: ChainRecord::LEDGER, genesis_digest: digest,
43
+ head_sequence: 0, head_digest: digest)
44
+ quoted = values.values.map { |value| connection.quote(value) }
45
+
46
+ entry_class.transaction(requires_new: true) do
47
+ connection.insert("INSERT INTO #{TABLE} (#{values.keys.join(', ')}) " \
48
+ "VALUES (#{quoted.join(', ')})")
49
+ end
50
+ rescue ActiveRecord::RecordNotUnique
51
+ nil
52
+ end
53
+
54
+ # Locks the head and returns the next sequence and the digest it links to. The chain
55
+ # must have been started.
56
+ def advance!
57
+ vanished = increment.zero?
58
+ raise ActiveRecord::StatementInvalid, "the ledger chain record vanished" if vanished
59
+
60
+ head = connection.select_one("SELECT head_sequence, head_digest FROM #{TABLE} " \
61
+ "WHERE #{named}")
62
+ [Integer(head["head_sequence"]), head["head_digest"]]
63
+ end
64
+
65
+ def point_at!(digest)
66
+ connection.update("UPDATE #{TABLE} SET head_digest = #{connection.quote(digest)} " \
67
+ "WHERE #{named}")
68
+ end
69
+
70
+ private
71
+
72
+ attr_reader :connection, :entry_class
73
+
74
+ def increment
75
+ connection.update("UPDATE #{TABLE} SET head_sequence = head_sequence + 1 WHERE #{named}")
76
+ end
77
+
78
+ def genesis_for(signer)
79
+ {
80
+ genesis_at: Time.now.utc.floor(6),
81
+ pre_chain_count: entry_class.where("contract_version < 3").count,
82
+ pre_chain_unchained_count:
83
+ entry_class.where(UNCHAINED).count,
84
+ genesis_key_id: signer.signing_id
85
+ }
86
+ end
87
+
88
+ def named
89
+ "name = #{connection.quote(ChainRecord::LEDGER)}"
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ class Chain
6
+ # The keys one chained write or one verification may use (specs/002 FR-011, R7).
7
+ #
8
+ # One key signs; any number verify. Rows name their key by fingerprint, so lookup is
9
+ # by fingerprint too, and a key the ring does not hold is simply absent — the verifier
10
+ # turns that into "unverifiable", never into "tampered".
11
+ #
12
+ # Keyring.from(Reeve.config) # what the recorder signs with
13
+ # Keyring.new(signing: nil, retired: [auditor_key]) # verify-only, e.g. an auditor
14
+ class Keyring
15
+ def self.from(config)
16
+ new(signing: config.ledger_key, retired: config.retired_ledger_keys)
17
+ end
18
+
19
+ def initialize(signing:, retired: [])
20
+ @signing_secret = signing
21
+ @by_id = {}
22
+ [signing, *retired].compact.each do |secret|
23
+ @by_id[LedgerKey.fingerprint(secret)] ||= secret
24
+ end
25
+ @by_id.freeze
26
+ freeze
27
+ end
28
+
29
+ attr_reader :signing_secret
30
+
31
+ def signing_id
32
+ signing_secret && LedgerKey.fingerprint(signing_secret)
33
+ end
34
+
35
+ def signing?
36
+ !signing_secret.nil?
37
+ end
38
+
39
+ def for(key_id)
40
+ @by_id[key_id]
41
+ end
42
+
43
+ def empty?
44
+ @by_id.empty?
45
+ end
46
+
47
+ def inspect
48
+ "#<#{self.class.name} signing=#{signing_id.inspect} keys=#{@by_id.keys.inspect}>"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ class Chain
6
+ # Puts a row into the form the database will hand back, before it is written
7
+ # (specs/002 FR-003, research R2 step 1).
8
+ #
9
+ # The digest is computed at write time and recomputed at verification from what the
10
+ # database returns. The two only agree if nothing is left for the database to change,
11
+ # so anything it would change is changed here first:
12
+ #
13
+ # * JSON values go through the column's own ActiveRecord type, encode and decode — the
14
+ # exact path a write takes. Symbol keys become strings, a Time inside the arguments
15
+ # becomes the string ActiveRecord stores, NaN becomes nil.
16
+ # * `occurred_at` is floored to the precision the column keeps, so the database has
17
+ # no fraction to round (MySQL rounds; it does not truncate), and moved to UTC.
18
+ #
19
+ # What remains — JSON key order and how a number is spelled — is the database's to
20
+ # change, and Canonical makes it irrelevant.
21
+ #
22
+ # It cannot recover a value the database alters for good: an integer beyond double
23
+ # precision in a MySQL JSON column is stored rounded. That shows up as a false break,
24
+ # never a false pass, and is documented.
25
+ class Normalizer
26
+ JSON_FIELDS = %i[arguments record_ids metadata].freeze
27
+ TIME_FIELD = :occurred_at
28
+
29
+ # Undeclared precision: MySQL's DATETIME is whole seconds, the others keep
30
+ # microseconds.
31
+ WHOLE_SECOND_ADAPTERS = /mysql|trilogy/i
32
+ DEFAULT_PRECISION = 6
33
+
34
+ def self.effective_precision(column, adapter_name)
35
+ return column.precision unless column.precision.nil?
36
+
37
+ adapter_name.to_s.match?(WHOLE_SECOND_ADAPTERS) ? 0 : DEFAULT_PRECISION
38
+ end
39
+
40
+ def initialize(entry_class)
41
+ @entry_class = entry_class
42
+ end
43
+
44
+ # A new hash; the row passed in is not modified.
45
+ def call(row)
46
+ # Symbol keys throughout, whatever the caller built: a host recorder (FR-007) may use
47
+ # strings, and every lookup below — and the replay check after it — is by symbol.
48
+ normalized = row.to_h.transform_keys(&:to_sym)
49
+ JSON_FIELDS.each do |field|
50
+ next unless normalized.key?(field)
51
+
52
+ normalized[field] = round_trip(field, normalized[field])
53
+ end
54
+ normalized[TIME_FIELD] = floor(normalized[TIME_FIELD]) if normalized[TIME_FIELD]
55
+ normalized
56
+ end
57
+
58
+ private
59
+
60
+ attr_reader :entry_class
61
+
62
+ def round_trip(field, value)
63
+ return nil if value.nil?
64
+
65
+ type = json_types[field]
66
+ type.deserialize(type.serialize(value))
67
+ end
68
+
69
+ def floor(time)
70
+ time.to_time.getutc.floor(precision)
71
+ end
72
+
73
+ def json_types
74
+ @json_types ||= JSON_FIELDS.to_h do |field|
75
+ [field, entry_class.type_for_attribute(field.to_s)]
76
+ end
77
+ end
78
+
79
+ def precision
80
+ @precision ||= self.class.effective_precision(
81
+ entry_class.columns_hash.fetch(TIME_FIELD.to_s),
82
+ entry_class.connection_db_config.adapter
83
+ )
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ class Chain
6
+ # A Verification in words (contracts/ledger-chain.md "Output").
7
+ #
8
+ # Every summary line carries its own coverage. A reader who copies only the first line
9
+ # into a ticket still copies how much of the ledger the word in it covers — "INTACT"
10
+ # never travels without "sequences 1–n" and the count of what was not covered.
11
+ #
12
+ # Unknown-key breaks are summarized, not listed: there is one per row a missing key
13
+ # signed, and the summary already names the key and the count.
14
+ class Report
15
+ LABEL_WIDTH = 14
16
+ KIND_WIDTH = 17
17
+ PREFIX = "reeve ledger:"
18
+
19
+ def self.number(value)
20
+ digits = value.abs.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
21
+ value.negative? ? "-#{digits}" : digits
22
+ end
23
+
24
+ def initialize(verification)
25
+ @v = verification
26
+ end
27
+
28
+ def to_s(limit:)
29
+ listed = @v.breaks.reject { |found| found.kind == :unknown_key }
30
+ return summary if listed.empty?
31
+
32
+ lines = listed.first(limit).map { |found| line_for(found) }
33
+ [summary, totals, *lines, more(listed, limit)].compact.join("\n")
34
+ end
35
+
36
+ private
37
+
38
+ def summary
39
+ case @v.status
40
+ when :intact then intact
41
+ when :broken then broken
42
+ when :no_chain then no_chain
43
+ else @v.reason == :no_key ? no_key : unverifiable
44
+ end
45
+ end
46
+
47
+ def intact
48
+ "#{PREFIX} INTACT — sequences #{range(@v.covered)} verified " \
49
+ "(head at start: #{number(@v.head_at_start)}); #{uncovered}"
50
+ end
51
+
52
+ def broken
53
+ count = @v.breaks.size
54
+ where = @v.covered ? "in sequences #{range(@v.covered)}" : "in the ledger"
55
+ "#{PREFIX} BROKEN — #{number(count)} break#{'s' unless count == 1} #{where}; #{uncovered}"
56
+ end
57
+
58
+ def no_chain
59
+ "#{PREFIX} NO CHAIN — #{number(@v.uncovered_now + @v.unchained_now)} entries, " \
60
+ "none chained; nothing was verified"
61
+ end
62
+
63
+ def no_key
64
+ "#{PREFIX} UNVERIFIABLE — no ledger key configured; nothing was verified"
65
+ end
66
+
67
+ def unverifiable
68
+ unknown = @v.breaks.select { |found| found.kind == :unknown_key }
69
+ rows = unknown.count { |found| entry_position?(found.sequence) }
70
+ checked = @v.covered ? @v.covered.size - rows : 0
71
+ "#{PREFIX} UNVERIFIABLE — #{number(rows)} entries signed by #{missing_keys(unknown)} " \
72
+ "not configured; the other #{number(checked)} in #{range(@v.covered)} show no " \
73
+ "breaks, which is not a pass; #{number(@v.uncovered_now)} earlier entries not covered"
74
+ end
75
+
76
+ # The genesis is position 0 and is not an entry.
77
+ def entry_position?(sequence)
78
+ sequence.is_a?(Integer) && sequence.positive?
79
+ end
80
+
81
+ def missing_keys(unknown)
82
+ keys = unknown.map(&:key_id).compact.uniq
83
+ keys.one? ? "key #{keys.first}, which is" : "keys #{keys.join(', ')}, which are"
84
+ end
85
+
86
+ def uncovered
87
+ parts = ["#{number(@v.uncovered_now)} earlier entries not covered"]
88
+ at_genesis = @v.uncovered_at_genesis
89
+ parts << " (#{number(at_genesis)} when the chain started)" if at_genesis
90
+ unless @v.unchained_at_genesis.to_i.zero?
91
+ parts << "; #{number(@v.unchained_at_genesis)} unchained entries from before the " \
92
+ "chain started, not covered"
93
+ end
94
+ parts.join
95
+ end
96
+
97
+ def totals
98
+ " totals: #{@v.totals.map { |kind, count| "#{kind} #{count}" }.join(', ')}"
99
+ end
100
+
101
+ def more(listed, limit)
102
+ remaining = listed.size - limit
103
+ return nil unless limit.positive? && remaining.positive?
104
+
105
+ " … and #{number(remaining)} more (the totals above are complete)"
106
+ end
107
+
108
+ def line_for(found)
109
+ kind = found.kind.to_s.ljust(KIND_WIDTH)
110
+ " #{label(found).ljust(LABEL_WIDTH)}#{kind}#{found.detail}".rstrip
111
+ end
112
+
113
+ def label(found)
114
+ sequence = found.sequence
115
+ return "invocation #{found.invocation_id}" if found.invocation_id
116
+ return "—" if sequence.nil?
117
+ return "##{range(sequence)}" if sequence.is_a?(Range)
118
+
119
+ sequence.zero? ? "genesis" : "##{number(sequence)}"
120
+ end
121
+
122
+ def range(value)
123
+ return "—" if value.nil?
124
+
125
+ "#{number(value.first)}–#{number(value.last)}"
126
+ end
127
+
128
+ def number(value)
129
+ self.class.number(value.to_i)
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ class Chain
6
+ # What a verification established, and — as importantly — what it did not
7
+ # (specs/002 FR-012–FR-014, data-model "Verification result").
8
+ #
9
+ # The status says whether the chain held; the coverage says over what. They are
10
+ # separate fields so that no reader can take one for the other: an intact chain that
11
+ # covers the last 300 rows of a ledger of 5,300 is intact *for those 300*.
12
+ class Verification
13
+ STATUSES = %i[intact broken unverifiable no_chain].freeze
14
+
15
+ # `sequence` is an Integer or a Range (a gap), or nil for a break that has no
16
+ # position; an unchained row has none and is named by `invocation_id` instead.
17
+ # `key_id` is set for unknown_key: the fingerprint of the key the verifier lacked.
18
+ Break = Struct.new(:kind, :sequence, :detail, :invocation_id, :key_id, keyword_init: true)
19
+
20
+ DEFAULT_LIMIT = 100
21
+
22
+ KINDS = %i[
23
+ digest_mismatch missing duplicate unchained head_mismatch beyond_head
24
+ genesis_altered chain_record_missing unknown_key
25
+ ].freeze
26
+
27
+ # The status rules (data-model), for a verification that had a key and found a
28
+ # chain; the no-key and no-chain answers are given before a walk ever starts. Any
29
+ # break but an unknown key is a broken chain. Rows only an absent key could check
30
+ # leave it unverifiable — never intact, and never called tampered.
31
+ def self.status_for(breaks)
32
+ kinds = breaks.map(&:kind)
33
+ return :broken if (kinds - [:unknown_key]).any?
34
+ return :unverifiable if kinds.include?(:unknown_key)
35
+
36
+ :intact
37
+ end
38
+
39
+ attr_reader :status, :covered, :breaks, :head_at_start, :head_at_end,
40
+ :uncovered_now, :uncovered_at_genesis, :unchained_now, :unchained_at_genesis,
41
+ :reason
42
+
43
+ def initialize(status:, covered: nil, breaks: [], head_at_start: nil, head_at_end: nil,
44
+ uncovered_now: 0, uncovered_at_genesis: nil, unchained_now: 0,
45
+ unchained_at_genesis: nil, reason: nil)
46
+ raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
47
+
48
+ @status = status
49
+ @covered = covered
50
+ @breaks = breaks.freeze
51
+ @head_at_start = head_at_start
52
+ @head_at_end = head_at_end
53
+ @uncovered_now = uncovered_now
54
+ @uncovered_at_genesis = uncovered_at_genesis
55
+ @unchained_now = unchained_now
56
+ @unchained_at_genesis = unchained_at_genesis
57
+ @reason = reason
58
+ freeze
59
+ end
60
+
61
+ def intact?
62
+ status == :intact
63
+ end
64
+
65
+ # Never capped, whatever the display does with the list.
66
+ def totals
67
+ breaks.each_with_object(Hash.new(0)) { |b, counts| counts[b.kind] += 1 }
68
+ end
69
+
70
+ def unverifiable_count
71
+ totals[:unknown_key]
72
+ end
73
+
74
+ # The report a person reads (contracts/ledger-chain.md "Output"). The first line
75
+ # states the status and what it covers together; no line says "intact" or "pass"
76
+ # without the covered range and the uncovered count beside it (SC-006). `limit`
77
+ # caps the breaks listed, never the totals.
78
+ def to_s(limit: DEFAULT_LIMIT)
79
+ Report.new(self).to_s(limit: limit)
80
+ end
81
+
82
+ def as_json(*)
83
+ {
84
+ "status" => status.to_s, "covered" => range_json(covered),
85
+ "head_at_start" => head_at_start, "head_at_end" => head_at_end,
86
+ "uncovered_now" => uncovered_now, "uncovered_at_genesis" => uncovered_at_genesis,
87
+ "unchained_now" => unchained_now, "unchained_at_genesis" => unchained_at_genesis,
88
+ "unverifiable_count" => unverifiable_count,
89
+ "totals" => totals.transform_keys(&:to_s), "reason" => reason&.to_s,
90
+ "breaks" => breaks.map { |b| break_json(b) }
91
+ }
92
+ end
93
+
94
+ private
95
+
96
+ def break_json(found)
97
+ {
98
+ "kind" => found.kind.to_s, "sequence" => range_json(found.sequence),
99
+ "detail" => found.detail, "invocation_id" => found.invocation_id,
100
+ "key_id" => found.key_id
101
+ }
102
+ end
103
+
104
+ def range_json(value)
105
+ value.is_a?(Range) ? { "first" => value.first, "last" => value.last } : value
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end