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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# What `bin/rails reeve:purge_ledger` did, or why it did nothing (specs/005 data-model,
|
|
7
|
+
# contracts/retention.md). A value, so a script can act on it without reading the text.
|
|
8
|
+
#
|
|
9
|
+
# :dry_run — what a run with CONFIRM=1 would remove; nothing removed
|
|
10
|
+
# :purged — removed through `through`, recorded as retention record `purge`
|
|
11
|
+
# :nothing_to_purge — no checkpoint above the current boundary qualifies
|
|
12
|
+
# :refused — nothing removed; `reason` says why
|
|
13
|
+
class PurgeResult
|
|
14
|
+
PREFIX = "reeve ledger purge:"
|
|
15
|
+
AGAIN = "run again with CONFIRM=1"
|
|
16
|
+
|
|
17
|
+
FIELDS = %i[status reason detail from through through_made_at removed_chained
|
|
18
|
+
removed_pre_chain archive archive_sha256 purge resumed warnings].freeze
|
|
19
|
+
|
|
20
|
+
REFUSALS = {
|
|
21
|
+
no_key: "no ledger key configured (config.ledger_key)",
|
|
22
|
+
no_anchor: "no anchor configured (config.ledger_anchor): a purge must stop at a " \
|
|
23
|
+
"checkpoint kept outside the database",
|
|
24
|
+
no_retention_connection: "no retention connection configured " \
|
|
25
|
+
"(config.ledger_retention_connection)",
|
|
26
|
+
other_database: "config.ledger_retention_connection reaches another database than " \
|
|
27
|
+
"the ledger's",
|
|
28
|
+
application_role: "config.ledger_retention_connection connects as the application's " \
|
|
29
|
+
"own database user; the purge must run on a role the application " \
|
|
30
|
+
"does not use",
|
|
31
|
+
no_checkpoint: "no checkpoint the anchor holds is at that sequence (nearest below: %s, " \
|
|
32
|
+
"above: %s)",
|
|
33
|
+
not_intact: "the ledger does not verify intact, and a purge never removes what it " \
|
|
34
|
+
"cannot prove: %s",
|
|
35
|
+
no_archive_choice: "give ARCHIVE=path to keep the purged entries, or NO_ARCHIVE=1 to " \
|
|
36
|
+
"delete them without one",
|
|
37
|
+
archive_exists: "the archive %s already exists; an archive is never overwritten",
|
|
38
|
+
archive_unwritable: "the archive %s cannot be written there",
|
|
39
|
+
purge_running: "another purge took the next retention record number; run again once " \
|
|
40
|
+
"it is done"
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
attr_reader(*FIELDS)
|
|
44
|
+
|
|
45
|
+
def self.refused(reason, detail: nil)
|
|
46
|
+
new(status: :refused, reason: reason, detail: detail)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.nothing(reason, current:)
|
|
50
|
+
new(status: :nothing_to_purge, reason: reason, through: current)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.from(plan, status, **fields)
|
|
54
|
+
new(status: status, from: plan.from, through: plan.through,
|
|
55
|
+
through_made_at: plan.checkpoint.made_at, removed_chained: plan.removed_chained,
|
|
56
|
+
removed_pre_chain: plan.removed_pre_chain, **fields)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.resumed(record, removed, warnings:)
|
|
60
|
+
new(status: :purged, from: record.previous_through + 1, through: record.through_sequence,
|
|
61
|
+
purge: record.purge_number, removed_chained: record.removed_chained,
|
|
62
|
+
removed_pre_chain: record.removed_pre_chain, resumed: removed,
|
|
63
|
+
archive_sha256: record.archive_sha256, warnings: warnings)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def initialize(status:, warnings: [], **fields)
|
|
67
|
+
@status = status
|
|
68
|
+
@warnings = warnings.freeze
|
|
69
|
+
(FIELDS - %i[status warnings]).each do |name|
|
|
70
|
+
instance_variable_set(:"@#{name}", fields[name])
|
|
71
|
+
end
|
|
72
|
+
freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def dry_run? = status == :dry_run
|
|
76
|
+
def purged? = status == :purged
|
|
77
|
+
def refused? = status == :refused
|
|
78
|
+
|
|
79
|
+
def to_s
|
|
80
|
+
[first_line, *warnings.map { |warning| "warning: #{warning}" }].join("\n")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def first_line
|
|
86
|
+
case status
|
|
87
|
+
when :dry_run then "#{PREFIX} WOULD PURGE — #{removal}; #{archive_text}; #{AGAIN}"
|
|
88
|
+
when :purged then "#{PREFIX} PURGED — #{purged_text}"
|
|
89
|
+
when :nothing_to_purge then "#{PREFIX} NOTHING TO PURGE — #{nothing_text}"
|
|
90
|
+
else "#{PREFIX} REFUSED — #{refusal}; nothing was removed"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def purged_text
|
|
95
|
+
record = "#{removal}; retention record #{purge}"
|
|
96
|
+
return "#{record} (resumed; #{n(resumed)} rows removed this run)" if resumed
|
|
97
|
+
|
|
98
|
+
"#{record}; #{archive_text}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def removal
|
|
102
|
+
made = through_made_at ? " (#{AnchorText.made_on(through_made_at)})" : ""
|
|
103
|
+
"sequences #{n(from || 1)}–#{n(through)} through checkpoint ##{n(through)}#{made}: " \
|
|
104
|
+
"#{n(removed_chained)} chained entries and #{n(removed_pre_chain)} pre-chain rows"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def archive_text
|
|
108
|
+
return "no archive written (NO_ARCHIVE=1)" if archive.nil?
|
|
109
|
+
return "archive to #{archive}" if dry_run?
|
|
110
|
+
|
|
111
|
+
"archive #{archive} (sha256 #{archive_sha256})"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def nothing_text
|
|
115
|
+
where = "above the current boundary (##{n(through)})"
|
|
116
|
+
return "no checkpoint old enough #{where}" unless reason == :at_or_below
|
|
117
|
+
|
|
118
|
+
"the ledger is already purged through that sequence #{where}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def refusal
|
|
122
|
+
template = REFUSALS.fetch(reason)
|
|
123
|
+
return template unless template.include?("%s")
|
|
124
|
+
|
|
125
|
+
format(template, *Array(detail))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def n(value)
|
|
129
|
+
Report.number(value.to_i)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
class Chain
|
|
6
|
+
# The ledger's purges (specs/005), as verification and the purge read them.
|
|
7
|
+
module Retention
|
|
8
|
+
# Every retention record, oldest first, in one query. None for a ledger never purged,
|
|
9
|
+
# or one whose table has not been migrated: it verifies exactly as before.
|
|
10
|
+
module Records
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def read(entry_class:)
|
|
14
|
+
present = entry_class.connection_pool.with_connection do |connection|
|
|
15
|
+
connection.table_exists?(RetentionRecord.table_name)
|
|
16
|
+
end
|
|
17
|
+
present ? RetentionRecord.uncached { RetentionRecord.order(:purge_number).to_a } : []
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
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
|
|
@@ -15,13 +15,33 @@ module Reeve
|
|
|
15
15
|
# `sequence` is an Integer or a Range (a gap), or nil for a break that has no
|
|
16
16
|
# position; an unchained row has none and is named by `invocation_id` instead.
|
|
17
17
|
# `key_id` is set for unknown_key: the fingerprint of the key the verifier lacked.
|
|
18
|
-
|
|
18
|
+
# `epoch` is set for a break about a key epoch's record (specs/003), whose sequence is nil.
|
|
19
|
+
# `checkpoint` is the head sequence of the checkpoint a break is about (specs/004); such a
|
|
20
|
+
# break has no sequence of its own.
|
|
21
|
+
# `retention` is the number of the retention record a break is about (specs/005).
|
|
22
|
+
Break = Struct.new(:kind, :sequence, :detail, :invocation_id, :key_id, :epoch, :checkpoint,
|
|
23
|
+
:retention, keyword_init: true)
|
|
24
|
+
|
|
25
|
+
# One key epoch as verification found it (specs/003 FR-013). `entry_count` counts the
|
|
26
|
+
# walked rows that named it; `status` is :verified, :key_not_configured or :altered.
|
|
27
|
+
EpochSummary = Struct.new(:epoch, :key_id, :first_sequence, :entry_count, :status,
|
|
28
|
+
keyword_init: true) do
|
|
29
|
+
def as_json(*)
|
|
30
|
+
{ "epoch" => epoch, "key_id" => key_id, "first_sequence" => first_sequence,
|
|
31
|
+
"entries" => entry_count, "status" => status.to_s }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
EPOCH_STATUSES = %i[verified key_not_configured altered].freeze
|
|
19
36
|
|
|
20
37
|
DEFAULT_LIMIT = 100
|
|
21
38
|
|
|
22
39
|
KINDS = %i[
|
|
23
40
|
digest_mismatch missing duplicate unchained head_mismatch beyond_head
|
|
24
41
|
genesis_altered chain_record_missing unknown_key
|
|
42
|
+
epoch_missing epoch_mismatch epoch_altered
|
|
43
|
+
checkpoint_not_reached checkpoint_mismatch checkpoint_altered
|
|
44
|
+
retention_altered
|
|
25
45
|
].freeze
|
|
26
46
|
|
|
27
47
|
# The status rules (data-model), for a verification that had a key and found a
|
|
@@ -38,11 +58,15 @@ module Reeve
|
|
|
38
58
|
|
|
39
59
|
attr_reader :status, :covered, :breaks, :head_at_start, :head_at_end,
|
|
40
60
|
:uncovered_now, :uncovered_at_genesis, :unchained_now, :unchained_at_genesis,
|
|
41
|
-
:reason
|
|
61
|
+
:reason, :epochs, :before_first_epoch,
|
|
62
|
+
:checkpoints, :anchored_at, :beyond_anchor, :walked_from,
|
|
63
|
+
:retention, :awaiting_purge
|
|
42
64
|
|
|
43
65
|
def initialize(status:, covered: nil, breaks: [], head_at_start: nil, head_at_end: nil,
|
|
44
66
|
uncovered_now: 0, uncovered_at_genesis: nil, unchained_now: 0,
|
|
45
|
-
unchained_at_genesis: nil, reason: nil
|
|
67
|
+
unchained_at_genesis: nil, reason: nil, epochs: [], before_first_epoch: nil,
|
|
68
|
+
checkpoints: [], anchored_at: nil, beyond_anchor: nil, walked_from: nil,
|
|
69
|
+
retention: nil, awaiting_purge: 0)
|
|
46
70
|
raise ArgumentError, "unknown status #{status.inspect}" unless STATUSES.include?(status)
|
|
47
71
|
|
|
48
72
|
@status = status
|
|
@@ -55,12 +79,18 @@ module Reeve
|
|
|
55
79
|
@unchained_now = unchained_now
|
|
56
80
|
@unchained_at_genesis = unchained_at_genesis
|
|
57
81
|
@reason = reason
|
|
82
|
+
@epochs = epochs.freeze
|
|
83
|
+
@before_first_epoch = before_first_epoch
|
|
84
|
+
@checkpoints = checkpoints.freeze
|
|
85
|
+
@anchored_at = anchored_at
|
|
86
|
+
@beyond_anchor = beyond_anchor
|
|
87
|
+
@walked_from = walked_from
|
|
88
|
+
@retention = retention
|
|
89
|
+
@awaiting_purge = awaiting_purge
|
|
58
90
|
freeze
|
|
59
91
|
end
|
|
60
92
|
|
|
61
|
-
def intact?
|
|
62
|
-
status == :intact
|
|
63
|
-
end
|
|
93
|
+
def intact? = status == :intact
|
|
64
94
|
|
|
65
95
|
# Never capped, whatever the display does with the list.
|
|
66
96
|
def totals
|
|
@@ -86,18 +116,31 @@ module Reeve
|
|
|
86
116
|
"uncovered_now" => uncovered_now, "uncovered_at_genesis" => uncovered_at_genesis,
|
|
87
117
|
"unchained_now" => unchained_now, "unchained_at_genesis" => unchained_at_genesis,
|
|
88
118
|
"unverifiable_count" => unverifiable_count,
|
|
89
|
-
"totals" => totals.transform_keys(&:to_s), "reason" => reason&.to_s
|
|
90
|
-
|
|
91
|
-
}
|
|
119
|
+
"totals" => totals.transform_keys(&:to_s), "reason" => reason&.to_s
|
|
120
|
+
}.merge(listing_json)
|
|
92
121
|
end
|
|
93
122
|
|
|
94
123
|
private
|
|
95
124
|
|
|
125
|
+
def listing_json
|
|
126
|
+
{ "breaks" => breaks.map { |b| break_json(b) }, "epochs" => epochs.map(&:as_json),
|
|
127
|
+
"before_first_epoch" => before_first_epoch, "checkpoints" => checkpoints.map(&:as_json),
|
|
128
|
+
"anchored_at" => point_json(anchored_at), "beyond_anchor" => beyond_anchor,
|
|
129
|
+
"walked_from" => point_json(walked_from), "retention" => retention&.as_json,
|
|
130
|
+
"awaiting_purge" => awaiting_purge }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def point_json(checkpoint)
|
|
134
|
+
checkpoint && { "head_sequence" => checkpoint.head_sequence,
|
|
135
|
+
"made_at" => checkpoint.made_at }
|
|
136
|
+
end
|
|
137
|
+
|
|
96
138
|
def break_json(found)
|
|
97
139
|
{
|
|
98
140
|
"kind" => found.kind.to_s, "sequence" => range_json(found.sequence),
|
|
99
141
|
"detail" => found.detail, "invocation_id" => found.invocation_id,
|
|
100
|
-
"key_id" => found.key_id
|
|
142
|
+
"key_id" => found.key_id, "epoch" => found.epoch, "checkpoint" => found.checkpoint,
|
|
143
|
+
"retention" => found.retention
|
|
101
144
|
}
|
|
102
145
|
end
|
|
103
146
|
|
|
@@ -23,12 +23,16 @@ module Reeve
|
|
|
23
23
|
BATCH_SIZE = 1_000
|
|
24
24
|
|
|
25
25
|
RECORD_MISSING = "chained rows exist but the chain record does not"
|
|
26
|
-
UNCHAINED_ROW = "a contract-3 row outside the chain, written after it started (by clock)"
|
|
26
|
+
UNCHAINED_ROW = "a contract-3+ row outside the chain, written after it started (by clock)"
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
# `checkpoints` are Checkpoints read from outside the database, and `from` one of them to
|
|
29
|
+
# walk from instead of the genesis (specs/004; Anchoring).
|
|
30
|
+
def initialize(entry_class:, keyring:, batch_size: BATCH_SIZE, checkpoints: [], from: nil)
|
|
29
31
|
@entry_class = entry_class
|
|
30
32
|
@keyring = keyring
|
|
31
33
|
@batch_size = batch_size
|
|
34
|
+
@anchoring = Anchoring.new(entry_class: entry_class, keyring: keyring,
|
|
35
|
+
checkpoints: checkpoints, from: from)
|
|
32
36
|
end
|
|
33
37
|
|
|
34
38
|
def call
|
|
@@ -36,31 +40,70 @@ module Reeve
|
|
|
36
40
|
|
|
37
41
|
ChainRecord.require_same_database!(entry_class)
|
|
38
42
|
|
|
43
|
+
@breaks = []
|
|
39
44
|
record = read_head
|
|
40
|
-
return
|
|
45
|
+
return verify_chain(record) if record&.head_sequence&.positive? || chained_rows?
|
|
46
|
+
return unwalked(:no_chain) unless anchoring.any?
|
|
41
47
|
|
|
42
|
-
|
|
48
|
+
# A ledger with no chain that checkpoints saw: broken, not "no chain" (FR-013).
|
|
49
|
+
unwalked(checkpoints: anchoring.emptied(record, report: method(:add)))
|
|
43
50
|
end
|
|
44
51
|
|
|
45
52
|
private
|
|
46
53
|
|
|
47
|
-
attr_reader :entry_class, :keyring, :batch_size, :breaks
|
|
54
|
+
attr_reader :entry_class, :keyring, :batch_size, :breaks, :anchoring
|
|
48
55
|
|
|
56
|
+
# The epochs are read after the head: every row the walk visits committed, with any
|
|
57
|
+
# epoch it opened, before that read. They are checked against the head read *after*
|
|
58
|
+
# the walk, which is later than they were read, so an epoch opened by a rotation
|
|
59
|
+
# during the run is never mistaken for one placed beyond the head.
|
|
49
60
|
def verify_chain(record)
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
epochs = EpochRules.read(keyring: keyring, entry_class: entry_class)
|
|
62
|
+
retention = check_retention(record)
|
|
63
|
+
failed = anchoring.failed_start(record, epochs.records, report: method(:add))
|
|
64
|
+
return unwalked(record: record, checkpoints: failed) if failed
|
|
65
|
+
|
|
66
|
+
walk = walk(record, epochs)
|
|
67
|
+
head_at_end = check_around(record, walk, epochs)
|
|
68
|
+
anchored = anchoring.check(record, head_at_end || record, epochs.records,
|
|
69
|
+
report: method(:add))
|
|
70
|
+
|
|
71
|
+
result(record, head_at_end, walk.last_sequence, epochs,
|
|
72
|
+
**anchored, retention: retention, awaiting_purge: awaiting_purge)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The purges, if any (specs/005): the latest that holds is where the walk starts.
|
|
76
|
+
def check_retention(record)
|
|
77
|
+
records = Retention::Records.read(entry_class: entry_class)
|
|
78
|
+
return nil if records.empty?
|
|
79
|
+
|
|
80
|
+
checked = Retention::Check.new(keyring: keyring, report: method(:add))
|
|
81
|
+
.call(records, genesis_digest: record&.genesis_digest)
|
|
82
|
+
anchoring.floor = checked.boundary
|
|
83
|
+
checked
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Rows still at or below the boundary: a purge stopped midway, or a restore (FR-010).
|
|
87
|
+
def awaiting_purge
|
|
88
|
+
floor = anchoring.floor
|
|
89
|
+
floor ? entry_class.where("chain_sequence <= ?", floor.through_sequence).count : 0
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The chain record, rows past the head, unchained rows and the epochs, each checked
|
|
93
|
+
# against what the walk found; returns the head as read after the walk.
|
|
94
|
+
def check_around(record, walk, epochs)
|
|
52
95
|
check_record(record, walk)
|
|
53
96
|
head_at_end = record && read_head
|
|
54
97
|
check_beyond_head(head_at_end || record)
|
|
55
98
|
check_unchained(record)
|
|
56
|
-
|
|
57
|
-
|
|
99
|
+
epochs.check!(record, head_at_end || record, report: method(:add))
|
|
100
|
+
head_at_end
|
|
58
101
|
end
|
|
59
102
|
|
|
60
|
-
def walk(record)
|
|
103
|
+
def walk(record, epochs)
|
|
61
104
|
Walk.new(entry_class: entry_class, keyring: keyring, batch_size: batch_size,
|
|
62
|
-
link: record
|
|
63
|
-
|
|
105
|
+
link: anchoring.link(record), report: method(:add), epochs: epochs,
|
|
106
|
+
start: anchoring.start).call(record&.head_sequence)
|
|
64
107
|
end
|
|
65
108
|
|
|
66
109
|
# A seam as well as a read: the specs wrap it to land a write mid-walk.
|
|
@@ -121,15 +164,20 @@ module Reeve
|
|
|
121
164
|
end
|
|
122
165
|
end
|
|
123
166
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
167
|
+
# `epoch` names the key epoch, and `checkpoint` the checkpoint, a break is about when it has
|
|
168
|
+
# no sequence (specs/003, specs/004).
|
|
169
|
+
def add(kind, sequence, detail = nil, invocation_id = nil, key_id = nil, epoch: nil,
|
|
170
|
+
checkpoint: nil, retention: nil)
|
|
171
|
+
found = { kind: kind, sequence: sequence, detail: detail, invocation_id: invocation_id }
|
|
172
|
+
breaks << Verification::Break.new(**found, key_id: key_id, epoch: epoch,
|
|
173
|
+
checkpoint: checkpoint, retention: retention)
|
|
127
174
|
end
|
|
128
175
|
|
|
129
|
-
def result(record, head_at_end, last_sequence)
|
|
176
|
+
def result(record, head_at_end, last_sequence, epochs, **found)
|
|
130
177
|
Verification.new(
|
|
131
178
|
status: Verification.status_for(breaks), breaks: breaks,
|
|
132
|
-
covered: covered(record, last_sequence),
|
|
179
|
+
covered: covered(record, last_sequence), walked_from: anchoring.from, **found,
|
|
180
|
+
epochs: epochs.summaries, before_first_epoch: epochs.before_first_epoch,
|
|
133
181
|
head_at_start: record&.head_sequence, head_at_end: head_at_end&.head_sequence,
|
|
134
182
|
uncovered_now: count_uncovered, uncovered_at_genesis: record&.pre_chain_count,
|
|
135
183
|
unchained_now: count_unchained, unchained_at_genesis: record&.pre_chain_unchained_count
|
|
@@ -138,13 +186,18 @@ module Reeve
|
|
|
138
186
|
|
|
139
187
|
def covered(record, last_sequence)
|
|
140
188
|
last = record ? record.head_sequence : last_sequence
|
|
141
|
-
|
|
189
|
+
first = anchoring.start + 1
|
|
190
|
+
last >= first ? first..last : nil
|
|
142
191
|
end
|
|
143
192
|
|
|
144
|
-
# An answer given before any walk: no key to walk with,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
193
|
+
# An answer given before any walk: no key to walk with, no chain to walk, or checkpoints
|
|
194
|
+
# whose own breaks say why there was nothing to walk (specs/004). Without a status, the
|
|
195
|
+
# breaks decide it.
|
|
196
|
+
def unwalked(status = nil, reason = nil, record: nil, checkpoints: [])
|
|
197
|
+
Verification.new(status: status || Verification.status_for(breaks), reason: reason,
|
|
198
|
+
breaks: breaks || [], head_at_start: record&.head_sequence,
|
|
199
|
+
checkpoints: checkpoints, uncovered_now: count_uncovered,
|
|
200
|
+
unchained_now: count_unchained)
|
|
148
201
|
end
|
|
149
202
|
|
|
150
203
|
def chained_rows?
|
|
@@ -20,13 +20,20 @@ module Reeve
|
|
|
20
20
|
|
|
21
21
|
# `link` is the digest the first row must link to (the genesis), or nil when there
|
|
22
22
|
# is no chain record to take it from. `report` receives (kind, sequence, detail).
|
|
23
|
-
|
|
23
|
+
# `epochs` is the EpochRules the rows are checked against (specs/003 FR-011).
|
|
24
|
+
#
|
|
25
|
+
# `start` is the sequence of a checkpoint the walk begins after (specs/004 FR-015), with
|
|
26
|
+
# `link` its digest: the walk is then exactly the one from the genesis, resumed there.
|
|
27
|
+
def initialize(entry_class:, keyring:, batch_size:, link:, report:, epochs:, start: 0)
|
|
24
28
|
@entry_class = entry_class
|
|
25
29
|
@keyring = keyring
|
|
26
30
|
@batch_size = batch_size
|
|
27
31
|
@report = report
|
|
28
|
-
@
|
|
29
|
-
@
|
|
32
|
+
@epochs = epochs
|
|
33
|
+
@start = start
|
|
34
|
+
@expected = start + 1
|
|
35
|
+
@last_sequence = start
|
|
36
|
+
@last_digest = start.positive? ? link : nil
|
|
30
37
|
@link = link
|
|
31
38
|
@verify_first_link = !link.nil?
|
|
32
39
|
end
|
|
@@ -39,7 +46,7 @@ module Reeve
|
|
|
39
46
|
|
|
40
47
|
private
|
|
41
48
|
|
|
42
|
-
attr_reader :entry_class, :keyring, :batch_size
|
|
49
|
+
attr_reader :entry_class, :keyring, :batch_size, :epochs
|
|
43
50
|
|
|
44
51
|
def visit(entry)
|
|
45
52
|
sequence = entry.chain_sequence
|
|
@@ -71,6 +78,10 @@ module Reeve
|
|
|
71
78
|
end
|
|
72
79
|
|
|
73
80
|
def verify(entry, previous)
|
|
81
|
+
# A row whose key or epoch contradicts the epoch records is a break on its own; its
|
|
82
|
+
# digest would only repeat it.
|
|
83
|
+
return if epoch_break?(entry)
|
|
84
|
+
|
|
74
85
|
# With no chain record there is no genesis for the first row to link to. That is
|
|
75
86
|
# reported once, as chain_record_missing, not again here as a mismatch.
|
|
76
87
|
return if !@verify_first_link && entry.chain_sequence == 1
|
|
@@ -88,6 +99,14 @@ module Reeve
|
|
|
88
99
|
@report.call(:digest_mismatch, entry.chain_sequence, e.message)
|
|
89
100
|
end
|
|
90
101
|
|
|
102
|
+
def epoch_break?(entry)
|
|
103
|
+
kind, detail = epochs.judge(entry)
|
|
104
|
+
return false unless kind
|
|
105
|
+
|
|
106
|
+
@report.call(kind, entry.chain_sequence, detail)
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
|
|
91
110
|
def each_chained(limit, &block)
|
|
92
111
|
after = nil
|
|
93
112
|
loop do
|
|
@@ -110,6 +129,7 @@ module Reeve
|
|
|
110
129
|
# against SC-004's 300. SQLite derived the bound on its own, which hid it there.
|
|
111
130
|
def batch_scope(after, limit)
|
|
112
131
|
scope = entry_class.where.not(chain_sequence: nil)
|
|
132
|
+
scope = scope.where("chain_sequence > ?", @start) if @start.positive? && after.nil?
|
|
113
133
|
scope = scope.where("chain_sequence <= ?", limit) if limit
|
|
114
134
|
if after
|
|
115
135
|
scope = scope.where("chain_sequence >= ?", after[0])
|