reeve 0.6.0 → 0.8.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +293 -3
- data/README.md +276 -11
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +52 -5
- data/lib/generators/reeve/install/templates/initializer.rb.tt +10 -0
- data/lib/generators/reeve/upgrade/templates/add_key_epochs_to_reeve_audit_entries.rb.tt +35 -0
- data/lib/generators/reeve/upgrade/templates/add_ledger_retention_to_reeve_audit_entries.rb.tt +37 -0
- data/lib/generators/reeve/upgrade/upgrade_generator.rb +19 -25
- data/lib/generators/reeve/upgrade/upgrade_notes.rb +62 -0
- data/lib/reeve/audit/chain/anchoring.rb +91 -0
- data/lib/reeve/audit/chain/canonical.rb +38 -7
- data/lib/reeve/audit/chain/checkpoint.rb +228 -0
- data/lib/reeve/audit/chain/checkpoint_check.rb +156 -0
- data/lib/reeve/audit/chain/entrypoints.rb +29 -0
- data/lib/reeve/audit/chain/epoch_rules.rb +159 -0
- data/lib/reeve/audit/chain/epochs.rb +83 -0
- data/lib/reeve/audit/chain/head.rb +55 -2
- data/lib/reeve/audit/chain/keyring.rb +6 -0
- data/lib/reeve/audit/chain/report.rb +232 -32
- data/lib/reeve/audit/chain/retention/archive.rb +98 -0
- data/lib/reeve/audit/chain/retention/archive_check.rb +179 -0
- data/lib/reeve/audit/chain/retention/check.rb +94 -0
- data/lib/reeve/audit/chain/retention/connection.rb +65 -0
- data/lib/reeve/audit/chain/retention/digest.rb +29 -0
- data/lib/reeve/audit/chain/retention/plan.rb +100 -0
- data/lib/reeve/audit/chain/retention/preflight.rb +48 -0
- data/lib/reeve/audit/chain/retention/purge.rb +155 -0
- data/lib/reeve/audit/chain/retention/purge_result.rb +134 -0
- data/lib/reeve/audit/chain/retention/records.rb +23 -0
- data/lib/reeve/audit/chain/rotation.rb +124 -0
- data/lib/reeve/audit/chain/schema.rb +59 -0
- data/lib/reeve/audit/chain/verification.rb +53 -10
- data/lib/reeve/audit/chain/verifier.rb +75 -22
- data/lib/reeve/audit/chain/walk.rb +24 -4
- data/lib/reeve/audit/chain.rb +61 -13
- data/lib/reeve/audit/chain_epoch.rb +24 -0
- data/lib/reeve/audit/chain_record.rb +14 -8
- data/lib/reeve/audit/file_anchor.rb +49 -0
- data/lib/reeve/audit/retention_record.rb +24 -0
- data/lib/reeve/audit.rb +30 -3
- data/lib/reeve/configuration.rb +25 -0
- data/lib/reeve/errors.rb +15 -0
- data/lib/reeve/railtie.rb +2 -1
- data/lib/reeve/tasks/descriptions.rb +22 -0
- data/lib/reeve/tasks/purge_options.rb +54 -0
- data/lib/reeve/tasks.rb +104 -7
- data/lib/reeve/testing/checks/contract_version.rb +2 -2
- data/lib/reeve/testing/checks/ledger_chain.rb +56 -2
- data/lib/reeve/testing/ledger.rb +19 -3
- data/lib/reeve/version.rb +1 -1
- metadata +28 -2
|
@@ -18,6 +18,17 @@ module Reeve
|
|
|
18
18
|
# engines: SQLite cannot upgrade a read lock while another writer waits, and ignores
|
|
19
19
|
# SELECT ... FOR UPDATE.
|
|
20
20
|
class Head
|
|
21
|
+
LOCKING_READ_ADAPTERS = /mysql|trilogy/i
|
|
22
|
+
|
|
23
|
+
# A read made under the head's lock must see the latest committed rows. PostgreSQL
|
|
24
|
+
# and SQLite read them anyway; InnoDB at REPEATABLE READ serves a plain SELECT from
|
|
25
|
+
# the snapshot of the caller's transaction, which a writer that committed while this
|
|
26
|
+
# one queued for the lock is not in (code review, specs/003). A locking read is
|
|
27
|
+
# current; LOCK IN SHARE MODE is the spelling every MySQL version accepts.
|
|
28
|
+
def self.current(connection, sql)
|
|
29
|
+
connection.adapter_name.match?(LOCKING_READ_ADAPTERS) ? "#{sql} LOCK IN SHARE MODE" : sql
|
|
30
|
+
end
|
|
31
|
+
|
|
21
32
|
def initialize(connection:, entry_class:)
|
|
22
33
|
@connection = connection
|
|
23
34
|
@entry_class = entry_class
|
|
@@ -35,7 +46,7 @@ module Reeve
|
|
|
35
46
|
# So a started chain can exist with nothing in it yet (head 0) — if the first chained
|
|
36
47
|
# write then fails, say. The verifier reports that as no chain, not as a pass.
|
|
37
48
|
def start!(signer)
|
|
38
|
-
return if
|
|
49
|
+
return if started?
|
|
39
50
|
|
|
40
51
|
genesis = genesis_for(signer)
|
|
41
52
|
digest = Canonical.genesis_digest(signer.signing_secret, genesis)
|
|
@@ -62,14 +73,56 @@ module Reeve
|
|
|
62
73
|
[Integer(head["head_sequence"]), head["head_digest"]]
|
|
63
74
|
end
|
|
64
75
|
|
|
76
|
+
# Takes the head's lock without consuming a sequence (specs/003 FR-008) and returns the
|
|
77
|
+
# head: a rotation queues at the same point as a write, so the epoch it opens begins
|
|
78
|
+
# exactly one past the last row any writer committed. A write that changes nothing
|
|
79
|
+
# still takes the row lock on PostgreSQL and MySQL and the write lock on SQLite.
|
|
80
|
+
def lock!
|
|
81
|
+
updated = connection.update("UPDATE #{TABLE} SET head_sequence = head_sequence " \
|
|
82
|
+
"WHERE #{named}")
|
|
83
|
+
raise ActiveRecord::StatementInvalid, "the ledger chain record vanished" if updated.zero?
|
|
84
|
+
|
|
85
|
+
sql = "SELECT head_sequence FROM #{TABLE} WHERE #{named}"
|
|
86
|
+
Integer(connection.select_value(Head.current(connection, sql)))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A Checkpoint of the head and the latest epoch, read under #lock!, which a write holds
|
|
90
|
+
# while it moves the head and opens its epoch, so the two are one committed state
|
|
91
|
+
# (specs/004 FR-005, research R2). Nil when the chain has not started or holds nothing.
|
|
92
|
+
def checkpoint(signer)
|
|
93
|
+
return nil unless started?
|
|
94
|
+
|
|
95
|
+
entry_class.transaction(requires_new: true) do
|
|
96
|
+
next nil unless lock!.positive?
|
|
97
|
+
|
|
98
|
+
Checkpoint.build(state: state, epoch: Epochs.new(connection: connection).latest,
|
|
99
|
+
keyring: signer)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The chain record's genesis and head as one current read.
|
|
104
|
+
def state
|
|
105
|
+
sql = "SELECT genesis_digest, pre_chain_count, pre_chain_unchained_count, " \
|
|
106
|
+
"head_sequence, head_digest FROM #{TABLE} WHERE #{named}"
|
|
107
|
+
connection.select_one(Head.current(connection, sql))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def started?
|
|
111
|
+
!connection.select_value("SELECT 1 FROM #{TABLE} WHERE #{named}").nil?
|
|
112
|
+
end
|
|
113
|
+
|
|
65
114
|
def point_at!(digest)
|
|
66
115
|
connection.update("UPDATE #{TABLE} SET head_digest = #{connection.quote(digest)} " \
|
|
67
116
|
"WHERE #{named}")
|
|
68
117
|
end
|
|
69
118
|
|
|
119
|
+
# The connection this append runs on; Epochs uses it so the epoch an append opens
|
|
120
|
+
# commits or rolls back with the append.
|
|
121
|
+
attr_reader :connection
|
|
122
|
+
|
|
70
123
|
private
|
|
71
124
|
|
|
72
|
-
attr_reader :
|
|
125
|
+
attr_reader :entry_class
|
|
73
126
|
|
|
74
127
|
def increment
|
|
75
128
|
connection.update("UPDATE #{TABLE} SET head_sequence = head_sequence + 1 WHERE #{named}")
|
|
@@ -16,6 +16,12 @@ module Reeve
|
|
|
16
16
|
new(signing: config.ledger_key, retired: config.retired_ledger_keys)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# The configured keys, or only `keys` when given: how an auditor verifies with their
|
|
20
|
+
# own copy.
|
|
21
|
+
def self.verifying(keys)
|
|
22
|
+
keys ? new(signing: nil, retired: keys) : from(Reeve.config)
|
|
23
|
+
end
|
|
24
|
+
|
|
19
25
|
def initialize(signing:, retired: [])
|
|
20
26
|
@signing_secret = signing
|
|
21
27
|
@by_id = {}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
3
5
|
module Reeve
|
|
4
6
|
module Audit
|
|
5
7
|
class Chain
|
|
@@ -15,6 +17,7 @@ module Reeve
|
|
|
15
17
|
LABEL_WIDTH = 14
|
|
16
18
|
KIND_WIDTH = 17
|
|
17
19
|
PREFIX = "reeve ledger:"
|
|
20
|
+
NOTHING_AFTER_PURGE = "no entries after the purge to verify"
|
|
18
21
|
|
|
19
22
|
def self.number(value)
|
|
20
23
|
digits = value.abs.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
|
|
@@ -27,10 +30,11 @@ module Reeve
|
|
|
27
30
|
|
|
28
31
|
def to_s(limit:)
|
|
29
32
|
listed = @v.breaks.reject { |found| found.kind == :unknown_key }
|
|
30
|
-
|
|
33
|
+
epochs = EpochLines.new(@v).to_a
|
|
34
|
+
return [summary, *epochs].join("\n") if listed.empty?
|
|
31
35
|
|
|
32
36
|
lines = listed.first(limit).map { |found| line_for(found) }
|
|
33
|
-
[summary, totals, *lines, more(listed, limit)].compact.join("\n")
|
|
37
|
+
[summary, totals, *epochs, *lines, more(listed, limit)].compact.join("\n")
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
private
|
|
@@ -45,48 +49,50 @@ module Reeve
|
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
def intact
|
|
48
|
-
|
|
49
|
-
"
|
|
52
|
+
if @v.walked_from
|
|
53
|
+
return "#{PREFIX} INTACT — #{anchor_text.walked_from}; #{uncovered}; #{anchor_text}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
walked = @v.covered ? "sequences #{range(@v.covered)} verified" : NOTHING_AFTER_PURGE
|
|
57
|
+
"#{PREFIX} INTACT — #{walked} (head at start: #{number(@v.head_at_start)}); " \
|
|
58
|
+
"#{uncovered}; #{anchor_text}"
|
|
50
59
|
end
|
|
51
60
|
|
|
52
61
|
def broken
|
|
53
62
|
count = @v.breaks.size
|
|
63
|
+
breaks = "#{number(count)} break#{'s' unless count == 1}"
|
|
64
|
+
if anchor_text.emptied?
|
|
65
|
+
return "#{PREFIX} BROKEN — #{breaks} in the ledger; #{anchor_text.emptied}"
|
|
66
|
+
end
|
|
67
|
+
|
|
54
68
|
where = @v.covered ? "in sequences #{range(@v.covered)}" : "in the ledger"
|
|
55
|
-
"#{PREFIX} BROKEN — #{
|
|
69
|
+
"#{PREFIX} BROKEN — #{breaks} #{where}; #{uncovered}; #{anchor_text}"
|
|
56
70
|
end
|
|
57
71
|
|
|
72
|
+
def anchor_text = (@anchor_text ||= AnchorText.new(@v))
|
|
73
|
+
|
|
58
74
|
def no_chain
|
|
59
75
|
"#{PREFIX} NO CHAIN — #{number(@v.uncovered_now + @v.unchained_now)} entries, " \
|
|
60
76
|
"none chained; nothing was verified"
|
|
61
77
|
end
|
|
62
78
|
|
|
63
|
-
def no_key
|
|
64
|
-
"#{PREFIX} UNVERIFIABLE — no ledger key configured; nothing was verified"
|
|
65
|
-
end
|
|
79
|
+
def no_key = "#{PREFIX} UNVERIFIABLE — no ledger key configured; nothing was verified"
|
|
66
80
|
|
|
81
|
+
# The purge clause leads here too (FR-011), but the uncovered count stays as 0.6.0 wrote it.
|
|
67
82
|
def unverifiable
|
|
68
|
-
|
|
69
|
-
|
|
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"
|
|
83
|
+
"#{PREFIX} UNVERIFIABLE — #{UnverifiableText.new(@v)}; #{RetentionText.new(@v).clause}" \
|
|
84
|
+
"#{number(@v.uncovered_now)} earlier entries not covered; #{anchor_text}"
|
|
84
85
|
end
|
|
85
86
|
|
|
87
|
+
# After a purge, what it removed comes first (specs/005 FR-011): no line says what was
|
|
88
|
+
# verified without saying what was purged before it.
|
|
86
89
|
def uncovered
|
|
87
|
-
|
|
90
|
+
retention = RetentionText.new(@v)
|
|
91
|
+
parts = [retention.clause, "#{number(@v.uncovered_now)} earlier entries not covered"]
|
|
88
92
|
at_genesis = @v.uncovered_at_genesis
|
|
89
|
-
|
|
93
|
+
if at_genesis
|
|
94
|
+
parts << " (#{number(at_genesis)} when the chain started#{retention.pre_chain})"
|
|
95
|
+
end
|
|
90
96
|
unless @v.unchained_at_genesis.to_i.zero?
|
|
91
97
|
parts << "; #{number(@v.unchained_at_genesis)} unchained entries from before the " \
|
|
92
98
|
"chain started, not covered"
|
|
@@ -94,9 +100,7 @@ module Reeve
|
|
|
94
100
|
parts.join
|
|
95
101
|
end
|
|
96
102
|
|
|
97
|
-
def totals
|
|
98
|
-
" totals: #{@v.totals.map { |kind, count| "#{kind} #{count}" }.join(', ')}"
|
|
99
|
-
end
|
|
103
|
+
def totals = " totals: #{@v.totals.map { |kind, count| "#{kind} #{count}" }.join(', ')}"
|
|
100
104
|
|
|
101
105
|
def more(listed, limit)
|
|
102
106
|
remaining = listed.size - limit
|
|
@@ -105,14 +109,24 @@ module Reeve
|
|
|
105
109
|
" … and #{number(remaining)} more (the totals above are complete)"
|
|
106
110
|
end
|
|
107
111
|
|
|
112
|
+
# One space at least between columns, so a label or kind longer than its column
|
|
113
|
+
# (`checkpoint #48,000`, `checkpoint_not_reached`) never runs into the next.
|
|
108
114
|
def line_for(found)
|
|
109
|
-
|
|
110
|
-
|
|
115
|
+
label = label(found).ljust(LABEL_WIDTH - 1)
|
|
116
|
+
kind = found.kind.to_s.ljust(KIND_WIDTH - 1)
|
|
117
|
+
" #{label} #{kind} #{found.detail}".rstrip
|
|
111
118
|
end
|
|
112
119
|
|
|
113
120
|
def label(found)
|
|
114
|
-
sequence = found.sequence
|
|
115
121
|
return "invocation #{found.invocation_id}" if found.invocation_id
|
|
122
|
+
return "checkpoint ##{number(found.checkpoint)}" if found.checkpoint
|
|
123
|
+
return "retention #{found.retention}" if found.retention
|
|
124
|
+
return "epoch #{found.epoch}" if found.sequence.nil? && found.epoch
|
|
125
|
+
|
|
126
|
+
position(found.sequence)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def position(sequence)
|
|
116
130
|
return "—" if sequence.nil?
|
|
117
131
|
return "##{range(sequence)}" if sequence.is_a?(Range)
|
|
118
132
|
|
|
@@ -129,6 +143,192 @@ module Reeve
|
|
|
129
143
|
self.class.number(value.to_i)
|
|
130
144
|
end
|
|
131
145
|
end
|
|
146
|
+
|
|
147
|
+
# What an unverifiable run could not check, and what it did (the UNVERIFIABLE line). Rows
|
|
148
|
+
# signed by a key that is not configured are counted; a checkpoint whose key is missing is
|
|
149
|
+
# named apart from them and is not one (specs/004).
|
|
150
|
+
class UnverifiableText
|
|
151
|
+
def initialize(verification)
|
|
152
|
+
@v = verification
|
|
153
|
+
unknown = verification.breaks.select { |found| found.kind == :unknown_key }
|
|
154
|
+
@checkpoints, unknown = unknown.partition(&:checkpoint)
|
|
155
|
+
@records, @signed = unknown.partition(&:retention)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def to_s
|
|
159
|
+
rows = @signed.count { |found| entry?(found.sequence) }
|
|
160
|
+
checked = @v.covered ? @v.covered.size - rows : 0
|
|
161
|
+
"#{unchecked(rows)}; the other #{Report.number(checked)} in #{range} show no breaks, " \
|
|
162
|
+
"which is not a pass"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def unchecked(rows)
|
|
168
|
+
parts = []
|
|
169
|
+
parts << "#{Report.number(rows)} entries signed by #{missing(@signed)}" if @signed.any?
|
|
170
|
+
parts << other("checkpoint", @checkpoints) if @checkpoints.any?
|
|
171
|
+
parts << other("retention record", @records) if @records.any?
|
|
172
|
+
parts.join("; ")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Not entries, so named apart and not counted as rows.
|
|
176
|
+
def other(noun, found)
|
|
177
|
+
count = found.size
|
|
178
|
+
"#{Report.number(count)} #{noun}#{'s' unless count == 1} signed by #{missing(found)}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The genesis is position 0 and is not an entry.
|
|
182
|
+
def entry?(sequence)
|
|
183
|
+
sequence.is_a?(Integer) && sequence.positive?
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def missing(unknown)
|
|
187
|
+
keys = unknown.map(&:key_id).compact.uniq
|
|
188
|
+
named = keys.one? ? "key #{keys.first}, which is" : "keys #{keys.join(', ')}, which are"
|
|
189
|
+
"#{named} not configured"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def range
|
|
193
|
+
return "—" if @v.covered.nil?
|
|
194
|
+
|
|
195
|
+
"#{Report.number(@v.covered.first)}–#{Report.number(@v.covered.last)}"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# What the ledger's purges removed, in words (specs/005 FR-011): the clause that leads the
|
|
200
|
+
# coverage on every walked summary line, and the pre-chain rows a purge removed.
|
|
201
|
+
class RetentionText
|
|
202
|
+
def initialize(verification)
|
|
203
|
+
@retention = verification.retention
|
|
204
|
+
@awaiting = verification.awaiting_purge.to_i
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def clause
|
|
208
|
+
boundary = @retention&.boundary
|
|
209
|
+
return "" if boundary.nil?
|
|
210
|
+
|
|
211
|
+
text = "1–#{Report.number(boundary.through_sequence)} purged (retention record " \
|
|
212
|
+
"#{boundary.purge_number}, #{boundary.purged_at.utc.strftime('%Y-%m-%d')}); "
|
|
213
|
+
text += "#{Report.number(@awaiting)} entries awaiting purge; " if @awaiting.positive?
|
|
214
|
+
text
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def pre_chain
|
|
218
|
+
purged = if @retention
|
|
219
|
+
@retention.summaries.reject { |found| found.status == :altered }
|
|
220
|
+
.sum(&:removed_pre_chain)
|
|
221
|
+
else
|
|
222
|
+
0
|
|
223
|
+
end
|
|
224
|
+
purged.positive? ? ", #{Report.number(purged)} purged" : ""
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# What a run was anchored by, in words (specs/004 FR-014, FR-015): the clause every walked
|
|
229
|
+
# summary line ends with, and the wording for a walk from a checkpoint and for an emptied
|
|
230
|
+
# ledger that checkpoints saw.
|
|
231
|
+
class AnchorText
|
|
232
|
+
# A checkpoint's time for people: "2026-10-25 14:00 UTC". A time that does not parse is
|
|
233
|
+
# shown as written; verification never depends on it.
|
|
234
|
+
def self.made_on(made_at)
|
|
235
|
+
Time.iso8601(made_at).utc.strftime("%Y-%m-%d %H:%M UTC")
|
|
236
|
+
rescue ArgumentError
|
|
237
|
+
made_at
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def initialize(verification)
|
|
241
|
+
@v = verification
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# The highest checkpoint that checked, and how many entries it does not cover.
|
|
245
|
+
def to_s
|
|
246
|
+
anchor = @v.anchored_at
|
|
247
|
+
return "not anchored" if anchor.nil?
|
|
248
|
+
|
|
249
|
+
after = @v.beyond_anchor.to_i
|
|
250
|
+
"anchored at ##{number(anchor.head_sequence)} (checkpoint #{made_on(anchor)}), " \
|
|
251
|
+
"#{number(after)} #{after == 1 ? 'entry' : 'entries'} after it"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def walked_from
|
|
255
|
+
start = @v.walked_from
|
|
256
|
+
from = "checkpoint ##{number(start.head_sequence)} (#{made_on(start)})"
|
|
257
|
+
walked = if @v.covered
|
|
258
|
+
"sequences #{range} verified from #{from}"
|
|
259
|
+
else
|
|
260
|
+
"no entries after #{from}"
|
|
261
|
+
end
|
|
262
|
+
"#{walked}; #{number(start.head_sequence)} earlier chained entries not walked by " \
|
|
263
|
+
"this run"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# No walk, and checkpoints that saw a chain.
|
|
267
|
+
def emptied?
|
|
268
|
+
@v.head_at_start.nil? && @v.checkpoints.any?
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def emptied
|
|
272
|
+
count = @v.checkpoints.size
|
|
273
|
+
"the ledger has no chain, but #{number(count)} checkpoint#{'s' unless count == 1} " \
|
|
274
|
+
"name#{'s' if count == 1} one"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
private
|
|
278
|
+
|
|
279
|
+
def number(value)
|
|
280
|
+
Report.number(value)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def made_on(checkpoint)
|
|
284
|
+
AnchorText.made_on(checkpoint.made_at)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def range
|
|
288
|
+
"#{number(@v.covered.first)}–#{number(@v.covered.last)}"
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# The key epochs, one line each (specs/003 FR-013), shown once they say something the
|
|
293
|
+
# summary does not: more than one, one that is not verified, or rows chained before the
|
|
294
|
+
# first. A ledger with one verified epoch and nothing before it reads as 0.6.0's did.
|
|
295
|
+
class EpochLines
|
|
296
|
+
STATUS_WORDS = {
|
|
297
|
+
verified: "verified", key_not_configured: "key not configured", altered: "altered"
|
|
298
|
+
}.freeze
|
|
299
|
+
|
|
300
|
+
def initialize(verification)
|
|
301
|
+
@epochs = verification.epochs
|
|
302
|
+
@before = verification.before_first_epoch.to_i
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def to_a
|
|
306
|
+
return [] unless shown?
|
|
307
|
+
|
|
308
|
+
lines = @epochs.map { |epoch| line(epoch) }
|
|
309
|
+
return lines unless @before.positive?
|
|
310
|
+
|
|
311
|
+
[" before epoch 0 #{entries(@before)} chained before key epochs began", *lines]
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
private
|
|
315
|
+
|
|
316
|
+
def shown?
|
|
317
|
+
return false if @epochs.empty?
|
|
318
|
+
|
|
319
|
+
@epochs.size > 1 || @epochs.first.status != :verified || @before.positive?
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def line(epoch)
|
|
323
|
+
" epoch #{epoch.epoch} key #{epoch.key_id} " \
|
|
324
|
+
"from sequence #{Report.number(epoch.first_sequence)} " \
|
|
325
|
+
"#{entries(epoch.entry_count)} #{STATUS_WORDS.fetch(epoch.status)}"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def entries(count)
|
|
329
|
+
"#{Report.number(count)} #{count == 1 ? 'entry' : 'entries'}"
|
|
330
|
+
end
|
|
331
|
+
end
|
|
132
332
|
end
|
|
133
333
|
end
|
|
134
334
|
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Reeve
|
|
8
|
+
module Audit
|
|
9
|
+
class Chain
|
|
10
|
+
module Retention
|
|
11
|
+
# The archive a purge writes before it removes anything (specs/005 FR-007,
|
|
12
|
+
# contracts/archive-v1.md): one JSON line of header, then every purged row with every
|
|
13
|
+
# column, chain columns included, so the range can be verified on its own.
|
|
14
|
+
#
|
|
15
|
+
# Written to `path.tmp`, flushed and renamed, so a partial archive is never at `path`.
|
|
16
|
+
module Archive
|
|
17
|
+
FORMAT = "reeve-ledger-archive-v1"
|
|
18
|
+
BATCH = 1_000
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Writes the archive for `plan` and returns its SHA-256.
|
|
23
|
+
def write(path, plan, entry_class:)
|
|
24
|
+
partial = "#{path}.tmp"
|
|
25
|
+
File.open(partial, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file|
|
|
26
|
+
file.puts(JSON.generate(header(plan)))
|
|
27
|
+
each_row(plan, entry_class) { |row| file.puts(JSON.generate(line(row))) }
|
|
28
|
+
file.flush
|
|
29
|
+
file.fsync
|
|
30
|
+
end
|
|
31
|
+
File.rename(partial, path)
|
|
32
|
+
::Digest::SHA256.file(path).hexdigest
|
|
33
|
+
rescue StandardError
|
|
34
|
+
FileUtils.rm_f(partial)
|
|
35
|
+
raise
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def header(plan)
|
|
39
|
+
{
|
|
40
|
+
"format" => FORMAT, "ledger" => ChainRecord::LEDGER, "purge" => plan.purge,
|
|
41
|
+
"from_sequence" => plan.from, "through_sequence" => plan.through,
|
|
42
|
+
"through_checkpoint" => plan.checkpoint.to_line, "start" => plan.start || genesis,
|
|
43
|
+
"chained" => plan.removed_chained, "pre_chain" => plan.removed_pre_chain,
|
|
44
|
+
"reeve_version" => Reeve::VERSION
|
|
45
|
+
}.sort.to_h
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Purge 0 starts at the genesis, so the archive carries it.
|
|
49
|
+
def genesis
|
|
50
|
+
record = ChainRecord.ledger
|
|
51
|
+
fields = Canonical::GENESIS_FIELDS + ["genesis_digest"]
|
|
52
|
+
{ "genesis" => fields.to_h { |field| [field, value(record[field])] } }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Pre-chain rows first, then the chained range in sequence order, a batch at a time.
|
|
56
|
+
def each_row(plan, entry_class, &block)
|
|
57
|
+
entry_class.where("contract_version < 3").order(:id).find_each(batch_size: BATCH,
|
|
58
|
+
&block)
|
|
59
|
+
after = plan.from - 1
|
|
60
|
+
loop do
|
|
61
|
+
batch = entry_class.where(chain_sequence: (after + 1)..plan.through)
|
|
62
|
+
.order(:chain_sequence).limit(BATCH).to_a
|
|
63
|
+
batch.each(&block)
|
|
64
|
+
break if batch.size < BATCH
|
|
65
|
+
|
|
66
|
+
after = batch.last.chain_sequence
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The header of the archive at `path`, refusing one in another format: a verifier
|
|
71
|
+
# never guesses at a format it does not know.
|
|
72
|
+
def read_header(path)
|
|
73
|
+
header = JSON.parse(File.foreach(path).first.to_s)
|
|
74
|
+
return header if header.is_a?(Hash) && header["format"] == FORMAT
|
|
75
|
+
|
|
76
|
+
found = header.is_a?(Hash) ? header["format"].inspect : "unreadable"
|
|
77
|
+
raise ArgumentError, "#{path} is not a #{FORMAT} archive (format #{found})"
|
|
78
|
+
rescue JSON::ParserError
|
|
79
|
+
raise ArgumentError, "#{path} is not a #{FORMAT} archive (its first line is not JSON)"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The header's genesis, with its time as a Time again, for Canonical.genesis_digest.
|
|
83
|
+
def genesis_values(genesis)
|
|
84
|
+
genesis.merge("genesis_at" => Time.iso8601(genesis["genesis_at"]))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def line(row)
|
|
88
|
+
row.attributes.except("id").transform_values { |item| value(item) }.sort.to_h
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def value(item)
|
|
92
|
+
item.respond_to?(:utc) ? item.utc.iso8601(6) : item
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|