reeve 0.5.0 → 0.7.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +281 -3
  3. data/README.md +151 -6
  4. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +63 -3
  5. data/lib/generators/reeve/install/templates/initializer.rb.tt +20 -0
  6. data/lib/generators/reeve/upgrade/templates/add_key_epochs_to_reeve_audit_entries.rb.tt +35 -0
  7. data/lib/generators/reeve/upgrade/templates/add_ledger_chain_to_reeve_audit_entries.rb.tt +51 -0
  8. data/lib/generators/reeve/upgrade/upgrade_generator.rb +25 -2
  9. data/lib/generators/reeve/upgrade/upgrade_notes.rb +51 -0
  10. data/lib/reeve/audit/chain/canonical.rb +144 -0
  11. data/lib/reeve/audit/chain/epoch_rules.rb +159 -0
  12. data/lib/reeve/audit/chain/epochs.rb +77 -0
  13. data/lib/reeve/audit/chain/head.rb +122 -0
  14. data/lib/reeve/audit/chain/keyring.rb +53 -0
  15. data/lib/reeve/audit/chain/normalizer.rb +88 -0
  16. data/lib/reeve/audit/chain/report.rb +177 -0
  17. data/lib/reeve/audit/chain/rotation.rb +124 -0
  18. data/lib/reeve/audit/chain/schema.rb +59 -0
  19. data/lib/reeve/audit/chain/verification.rb +131 -0
  20. data/lib/reeve/audit/chain/verifier.rb +168 -0
  21. data/lib/reeve/audit/chain/walk.rb +137 -0
  22. data/lib/reeve/audit/chain.rb +207 -0
  23. data/lib/reeve/audit/chain_epoch.rb +24 -0
  24. data/lib/reeve/audit/chain_record.rb +66 -0
  25. data/lib/reeve/audit/isolated_recorder.rb +5 -0
  26. data/lib/reeve/audit/recorder.rb +32 -19
  27. data/lib/reeve/audit.rb +37 -6
  28. data/lib/reeve/configuration.rb +66 -2
  29. data/lib/reeve/errors.rb +15 -0
  30. data/lib/reeve/ledger_key.rb +47 -0
  31. data/lib/reeve/railtie.rb +16 -0
  32. data/lib/reeve/tasks.rb +91 -0
  33. data/lib/reeve/testing/checks/contract_version.rb +2 -1
  34. data/lib/reeve/testing/checks/ledger_chain.rb +116 -0
  35. data/lib/reeve/testing/checks.rb +29 -4
  36. data/lib/reeve/testing/compliance_assertions.rb +3 -2
  37. data/lib/reeve/testing/compliance_suite.rb +2 -2
  38. data/lib/reeve/testing/ledger.rb +11 -1
  39. data/lib/reeve/testing/report.rb +8 -0
  40. data/lib/reeve/testing.rb +1 -1
  41. data/lib/reeve/version.rb +1 -1
  42. data/lib/reeve.rb +2 -0
  43. metadata +24 -2
@@ -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,177 @@
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
+ epochs = EpochLines.new(@v).to_a
31
+ return [summary, *epochs].join("\n") if listed.empty?
32
+
33
+ lines = listed.first(limit).map { |found| line_for(found) }
34
+ [summary, totals, *epochs, *lines, more(listed, limit)].compact.join("\n")
35
+ end
36
+
37
+ private
38
+
39
+ def summary
40
+ case @v.status
41
+ when :intact then intact
42
+ when :broken then broken
43
+ when :no_chain then no_chain
44
+ else @v.reason == :no_key ? no_key : unverifiable
45
+ end
46
+ end
47
+
48
+ def intact
49
+ "#{PREFIX} INTACT — sequences #{range(@v.covered)} verified " \
50
+ "(head at start: #{number(@v.head_at_start)}); #{uncovered}"
51
+ end
52
+
53
+ def broken
54
+ count = @v.breaks.size
55
+ where = @v.covered ? "in sequences #{range(@v.covered)}" : "in the ledger"
56
+ "#{PREFIX} BROKEN — #{number(count)} break#{'s' unless count == 1} #{where}; #{uncovered}"
57
+ end
58
+
59
+ def no_chain
60
+ "#{PREFIX} NO CHAIN — #{number(@v.uncovered_now + @v.unchained_now)} entries, " \
61
+ "none chained; nothing was verified"
62
+ end
63
+
64
+ def no_key
65
+ "#{PREFIX} UNVERIFIABLE — no ledger key configured; nothing was verified"
66
+ end
67
+
68
+ def unverifiable
69
+ unknown = @v.breaks.select { |found| found.kind == :unknown_key }
70
+ rows = unknown.count { |found| entry_position?(found.sequence) }
71
+ checked = @v.covered ? @v.covered.size - rows : 0
72
+ "#{PREFIX} UNVERIFIABLE — #{number(rows)} entries signed by #{missing_keys(unknown)} " \
73
+ "not configured; the other #{number(checked)} in #{range(@v.covered)} show no " \
74
+ "breaks, which is not a pass; #{number(@v.uncovered_now)} earlier entries not covered"
75
+ end
76
+
77
+ # The genesis is position 0 and is not an entry.
78
+ def entry_position?(sequence)
79
+ sequence.is_a?(Integer) && sequence.positive?
80
+ end
81
+
82
+ def missing_keys(unknown)
83
+ keys = unknown.map(&:key_id).compact.uniq
84
+ keys.one? ? "key #{keys.first}, which is" : "keys #{keys.join(', ')}, which are"
85
+ end
86
+
87
+ def uncovered
88
+ parts = ["#{number(@v.uncovered_now)} earlier entries not covered"]
89
+ at_genesis = @v.uncovered_at_genesis
90
+ parts << " (#{number(at_genesis)} when the chain started)" if at_genesis
91
+ unless @v.unchained_at_genesis.to_i.zero?
92
+ parts << "; #{number(@v.unchained_at_genesis)} unchained entries from before the " \
93
+ "chain started, not covered"
94
+ end
95
+ parts.join
96
+ end
97
+
98
+ def totals
99
+ " totals: #{@v.totals.map { |kind, count| "#{kind} #{count}" }.join(', ')}"
100
+ end
101
+
102
+ def more(listed, limit)
103
+ remaining = listed.size - limit
104
+ return nil unless limit.positive? && remaining.positive?
105
+
106
+ " … and #{number(remaining)} more (the totals above are complete)"
107
+ end
108
+
109
+ def line_for(found)
110
+ kind = found.kind.to_s.ljust(KIND_WIDTH)
111
+ " #{label(found).ljust(LABEL_WIDTH)}#{kind}#{found.detail}".rstrip
112
+ end
113
+
114
+ def label(found)
115
+ sequence = found.sequence
116
+ return "invocation #{found.invocation_id}" if found.invocation_id
117
+ return "epoch #{found.epoch}" if sequence.nil? && found.epoch
118
+ return "—" if sequence.nil?
119
+ return "##{range(sequence)}" if sequence.is_a?(Range)
120
+
121
+ sequence.zero? ? "genesis" : "##{number(sequence)}"
122
+ end
123
+
124
+ def range(value)
125
+ return "—" if value.nil?
126
+
127
+ "#{number(value.first)}–#{number(value.last)}"
128
+ end
129
+
130
+ def number(value)
131
+ self.class.number(value.to_i)
132
+ end
133
+ end
134
+
135
+ # The key epochs, one line each (specs/003 FR-013), shown once they say something the
136
+ # summary does not: more than one, one that is not verified, or rows chained before the
137
+ # first. A ledger with one verified epoch and nothing before it reads as 0.6.0's did.
138
+ class EpochLines
139
+ STATUS_WORDS = {
140
+ verified: "verified", key_not_configured: "key not configured", altered: "altered"
141
+ }.freeze
142
+
143
+ def initialize(verification)
144
+ @epochs = verification.epochs
145
+ @before = verification.before_first_epoch.to_i
146
+ end
147
+
148
+ def to_a
149
+ return [] unless shown?
150
+
151
+ lines = @epochs.map { |epoch| line(epoch) }
152
+ return lines unless @before.positive?
153
+
154
+ [" before epoch 0 #{entries(@before)} chained before key epochs began", *lines]
155
+ end
156
+
157
+ private
158
+
159
+ def shown?
160
+ return false if @epochs.empty?
161
+
162
+ @epochs.size > 1 || @epochs.first.status != :verified || @before.positive?
163
+ end
164
+
165
+ def line(epoch)
166
+ " epoch #{epoch.epoch} key #{epoch.key_id} " \
167
+ "from sequence #{Report.number(epoch.first_sequence)} " \
168
+ "#{entries(epoch.entry_count)} #{STATUS_WORDS.fetch(epoch.status)}"
169
+ end
170
+
171
+ def entries(count)
172
+ "#{Report.number(count)} #{count == 1 ? 'entry' : 'entries'}"
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ class Chain
6
+ # What `bin/rails reeve:rotate_ledger_key` did, or why it did nothing (specs/003 FR-006,
7
+ # FR-009, contracts/key-rotation.md). A value, so a deploy script can act on it without
8
+ # reading the text. Names keys by fingerprint only, never by value (FR-007).
9
+ #
10
+ # :rotated — opened `epoch` with the configured key, from `first_sequence`
11
+ # :current — the configured key opened the latest epoch already; nothing written
12
+ # :refused — nothing written; `reason` is :no_key, :key_reused or :outgoing_key_missing
13
+ class Rotation
14
+ PREFIX = "reeve ledger key:"
15
+
16
+ FIELDS = %i[status reason epoch key_id first_sequence previous_epoch previous_key_id
17
+ warnings].freeze
18
+
19
+ attr_reader(*FIELDS)
20
+
21
+ # Run under the chain record's lock (Chain#rotate_key!). `epochs` writes; `keyring`
22
+ # is this host's — the configured key and the retired ones.
23
+ def self.decide(epochs, keyring, head_sequence:, accept_unverifiable:)
24
+ all = epochs.all
25
+ own = all.find { |epoch| epoch[:key_id] == keyring.signing_id }
26
+ # `own` first: with no epochs at all, nil would equal the missing latest one.
27
+ return current(all, keyring) if own && all.last.equal?(own)
28
+ return refused(:key_reused, epoch: own[:epoch], key_id: own[:key_id]) if own
29
+
30
+ open_next(epochs, keyring, all.last, head_sequence: head_sequence,
31
+ accept_unverifiable: accept_unverifiable)
32
+ end
33
+
34
+ def self.refused(reason, **fields)
35
+ new(status: :refused, reason: reason, **fields)
36
+ end
37
+
38
+ def self.open_next(epochs, keyring, latest, head_sequence:, accept_unverifiable:)
39
+ lost = latest && keyring.for(latest[:key_id]).nil?
40
+ return refused(:outgoing_key_missing, **previous(latest)) if lost && !accept_unverifiable
41
+
42
+ opened = epochs.open!(keyring, first_sequence: head_sequence + 1)
43
+ new(status: :rotated, epoch: opened[:epoch], key_id: opened[:key_id],
44
+ first_sequence: opened[:first_sequence], **previous(latest),
45
+ warnings: lost ? [unverifiable(latest)] : [])
46
+ end
47
+
48
+ def self.current(all, keyring)
49
+ latest = all.last
50
+ before = all[-2]
51
+ lost = before && keyring.for(before[:key_id]).nil?
52
+ new(status: :current, epoch: latest[:epoch], key_id: latest[:key_id],
53
+ first_sequence: latest[:first_sequence], **previous(before),
54
+ warnings: lost ? [unverifiable(before)] : [])
55
+ end
56
+
57
+ def self.previous(epoch)
58
+ epoch ? { previous_epoch: epoch[:epoch], previous_key_id: epoch[:key_id] } : {}
59
+ end
60
+
61
+ def self.unverifiable(epoch)
62
+ "epoch #{epoch[:epoch]}'s key #{epoch[:key_id]} is not configured on this host, so " \
63
+ "its entries are unverifiable here"
64
+ end
65
+
66
+ private_class_method :open_next, :current, :previous, :unverifiable
67
+
68
+ def initialize(status:, warnings: [], **fields)
69
+ @status = status
70
+ @warnings = warnings.freeze
71
+ (FIELDS - %i[status warnings]).each do |name|
72
+ instance_variable_set(:"@#{name}", fields[name])
73
+ end
74
+ freeze
75
+ end
76
+
77
+ def rotated? = status == :rotated
78
+ def current? = status == :current
79
+ def refused? = status == :refused
80
+
81
+ def to_s
82
+ [first_line, *warnings.map { |warning| "warning: #{warning}" }].join("\n")
83
+ end
84
+
85
+ private
86
+
87
+ def first_line
88
+ case status
89
+ when :rotated then "#{PREFIX} ROTATED — #{rotated_text}"
90
+ when :current
91
+ "#{PREFIX} CURRENT — epoch #{epoch} already signs with the configured key " \
92
+ "#{key_id}; nothing to do"
93
+ else "#{PREFIX} REFUSED — #{refusal}; nothing was written"
94
+ end
95
+ end
96
+
97
+ def rotated_text
98
+ text = "epoch #{epoch} opened with key #{key_id} from sequence " \
99
+ "#{Report.number(first_sequence)}"
100
+ return text if previous_epoch.nil?
101
+
102
+ previous = "epoch #{previous_epoch} (key #{previous_key_id})"
103
+ return "#{text}; the previous epoch was #{previous}" if warnings.any?
104
+
105
+ "#{text}; epoch #{previous_epoch} (key #{previous_key_id}) stays verifiable while " \
106
+ "that key is in retired_ledger_keys"
107
+ end
108
+
109
+ def refusal
110
+ case reason
111
+ when :no_key then "no ledger key configured (config.ledger_key)"
112
+ when :key_reused
113
+ "the configured key #{key_id} already opened epoch #{epoch}; a key opens one " \
114
+ "epoch only — configure a new key"
115
+ else
116
+ "epoch #{previous_epoch}'s key #{previous_key_id} is not configured on this host, " \
117
+ "so its entries would become unverifiable here — add it to " \
118
+ "config.retired_ledger_keys, or run with ACCEPT_UNVERIFIABLE=1 if that key is lost"
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ class Chain
6
+ # Whether the ledger has the shape this reeve writes: every chain column and, from
7
+ # contract 4, the epochs table (specs/003 FR-018). A table one contract behind fails
8
+ # with the fix named.
9
+ #
10
+ # The columns are checked before every write — ActiveRecord caches them, so that costs
11
+ # nothing. The epochs table is not: asking the catalog would be a query per guarded
12
+ # call (FR-019b), so its absence is diagnosed only once the database has refused a
13
+ # write that needed it.
14
+ class Schema
15
+ def initialize(entry_class)
16
+ @entry_class = entry_class
17
+ end
18
+
19
+ def require_columns!
20
+ missing = COLUMNS - entry_class.column_names
21
+ outdated!("#{entry_class.table_name} is missing #{missing.join(', ')}") if missing.any?
22
+ end
23
+
24
+ # Runs the write; if the database refuses it, names a missing epochs table as the
25
+ # cause. Recorder builds a Chain per write, so asking up front would be a catalog
26
+ # query per guarded call.
27
+ def explaining
28
+ yield
29
+ rescue ActiveRecord::StatementInvalid => e
30
+ explain!(e)
31
+ end
32
+
33
+ # Re-raises `error` unless the epochs table is what the ledger lacks. A ledger with no
34
+ # entries table at all is not an older contract but a missing ledger, and keeps the
35
+ # database's own error.
36
+ def explain!(error)
37
+ missing = entry_class.connection_pool.with_connection do |connection|
38
+ !connection.table_exists?(EPOCHS_TABLE) &&
39
+ connection.table_exists?(entry_class.table_name)
40
+ end
41
+ raise error unless missing
42
+
43
+ outdated!("there is no #{EPOCHS_TABLE} table")
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :entry_class
49
+
50
+ def outdated!(problem)
51
+ raise ConfigurationError,
52
+ "#{problem}: the ledger is at an older audit-entry contract than this reeve " \
53
+ "writes (#{CONTRACT_VERSION}). Run `bin/rails generate reeve:upgrade` and " \
54
+ "`bin/rails db:migrate`."
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,131 @@
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
+ # `epoch` is set for a break about a key epoch's record (specs/003), whose sequence is nil.
19
+ Break = Struct.new(:kind, :sequence, :detail, :invocation_id, :key_id, :epoch,
20
+ keyword_init: true)
21
+
22
+ # One key epoch as verification found it (specs/003 FR-013). `entry_count` counts the
23
+ # walked rows that named it; `status` is :verified, :key_not_configured or :altered.
24
+ EpochSummary = Struct.new(:epoch, :key_id, :first_sequence, :entry_count, :status,
25
+ keyword_init: true) do
26
+ def as_json(*)
27
+ { "epoch" => epoch, "key_id" => key_id, "first_sequence" => first_sequence,
28
+ "entries" => entry_count, "status" => status.to_s }
29
+ end
30
+ end
31
+
32
+ EPOCH_STATUSES = %i[verified key_not_configured altered].freeze
33
+
34
+ DEFAULT_LIMIT = 100
35
+
36
+ KINDS = %i[
37
+ digest_mismatch missing duplicate unchained head_mismatch beyond_head
38
+ genesis_altered chain_record_missing unknown_key
39
+ epoch_missing epoch_mismatch epoch_altered
40
+ ].freeze
41
+
42
+ # The status rules (data-model), for a verification that had a key and found a
43
+ # chain; the no-key and no-chain answers are given before a walk ever starts. Any
44
+ # break but an unknown key is a broken chain. Rows only an absent key could check
45
+ # leave it unverifiable — never intact, and never called tampered.
46
+ def self.status_for(breaks)
47
+ kinds = breaks.map(&:kind)
48
+ return :broken if (kinds - [:unknown_key]).any?
49
+ return :unverifiable if kinds.include?(:unknown_key)
50
+
51
+ :intact
52
+ end
53
+
54
+ attr_reader :status, :covered, :breaks, :head_at_start, :head_at_end,
55
+ :uncovered_now, :uncovered_at_genesis, :unchained_now, :unchained_at_genesis,
56
+ :reason, :epochs, :before_first_epoch
57
+
58
+ def initialize(status:, covered: nil, breaks: [], head_at_start: nil, head_at_end: nil,
59
+ uncovered_now: 0, uncovered_at_genesis: nil, unchained_now: 0,
60
+ unchained_at_genesis: nil, reason: nil, epochs: [], before_first_epoch: nil)
61
+ raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
62
+
63
+ @status = status
64
+ @covered = covered
65
+ @breaks = breaks.freeze
66
+ @head_at_start = head_at_start
67
+ @head_at_end = head_at_end
68
+ @uncovered_now = uncovered_now
69
+ @uncovered_at_genesis = uncovered_at_genesis
70
+ @unchained_now = unchained_now
71
+ @unchained_at_genesis = unchained_at_genesis
72
+ @reason = reason
73
+ @epochs = epochs.freeze
74
+ @before_first_epoch = before_first_epoch
75
+ freeze
76
+ end
77
+
78
+ def intact?
79
+ status == :intact
80
+ end
81
+
82
+ # Never capped, whatever the display does with the list.
83
+ def totals
84
+ breaks.each_with_object(Hash.new(0)) { |b, counts| counts[b.kind] += 1 }
85
+ end
86
+
87
+ def unverifiable_count
88
+ totals[:unknown_key]
89
+ end
90
+
91
+ # The report a person reads (contracts/ledger-chain.md "Output"). The first line
92
+ # states the status and what it covers together; no line says "intact" or "pass"
93
+ # without the covered range and the uncovered count beside it (SC-006). `limit`
94
+ # caps the breaks listed, never the totals.
95
+ def to_s(limit: DEFAULT_LIMIT)
96
+ Report.new(self).to_s(limit: limit)
97
+ end
98
+
99
+ def as_json(*)
100
+ {
101
+ "status" => status.to_s, "covered" => range_json(covered),
102
+ "head_at_start" => head_at_start, "head_at_end" => head_at_end,
103
+ "uncovered_now" => uncovered_now, "uncovered_at_genesis" => uncovered_at_genesis,
104
+ "unchained_now" => unchained_now, "unchained_at_genesis" => unchained_at_genesis,
105
+ "unverifiable_count" => unverifiable_count,
106
+ "totals" => totals.transform_keys(&:to_s), "reason" => reason&.to_s
107
+ }.merge(listing_json)
108
+ end
109
+
110
+ private
111
+
112
+ def listing_json
113
+ { "breaks" => breaks.map { |b| break_json(b) }, "epochs" => epochs.map(&:as_json),
114
+ "before_first_epoch" => before_first_epoch }
115
+ end
116
+
117
+ def break_json(found)
118
+ {
119
+ "kind" => found.kind.to_s, "sequence" => range_json(found.sequence),
120
+ "detail" => found.detail, "invocation_id" => found.invocation_id,
121
+ "key_id" => found.key_id, "epoch" => found.epoch
122
+ }
123
+ end
124
+
125
+ def range_json(value)
126
+ value.is_a?(Range) ? { "first" => value.first, "last" => value.last } : value
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end