specguard-ruby 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +49 -0
- data/.github/workflows/release.yml +100 -0
- data/LICENSE +21 -0
- data/README.md +1036 -0
- data/Rakefile +28 -0
- data/assets/built-with-yatfa.png +0 -0
- data/bin/specguard-ingest +40 -0
- data/bin/specguard-lint +34 -0
- data/lib/minitest/specguard_plugin.rb +25 -0
- data/lib/specguard/minitest/reporter.rb +244 -0
- data/lib/specguard/rspec/annotation_lookup.rb +363 -0
- data/lib/specguard/rspec/annotation_scanner.rb +190 -0
- data/lib/specguard/rspec/cli.rb +375 -0
- data/lib/specguard/rspec/configuration.rb +472 -0
- data/lib/specguard/rspec/file_selector.rb +276 -0
- data/lib/specguard/rspec/finding.rb +74 -0
- data/lib/specguard/rspec/formatter.rb +1110 -0
- data/lib/specguard/rspec/ingest_cli.rb +942 -0
- data/lib/specguard/rspec/ingest_reporter.rb +280 -0
- data/lib/specguard/rspec/json_reporter.rb +169 -0
- data/lib/specguard/rspec/linter.rb +156 -0
- data/lib/specguard/rspec/payload_normalizer.rb +143 -0
- data/lib/specguard/rspec/scanner.rb +235 -0
- data/lib/specguard/rspec/schemas/open-test-intent.v1.json +15 -0
- data/lib/specguard/rspec/transport.rb +449 -0
- data/lib/specguard/rspec/validator_backend.rb +1450 -0
- data/lib/specguard/rspec/version.rb +14 -0
- data/lib/specguard/rspec.rb +71 -0
- data/lib/specguard/version.rb +11 -0
- data/script/bump-version.sh +128 -0
- metadata +96 -0
|
@@ -0,0 +1,1450 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "open3"
|
|
8
|
+
|
|
9
|
+
module SpecGuard
|
|
10
|
+
module RSpec
|
|
11
|
+
# The Go validator backend: `validate-intent --source --json`, and since
|
|
12
|
+
# SPGD-867 the ONLY validator `specguard-lint` has.
|
|
13
|
+
#
|
|
14
|
+
# == What this is
|
|
15
|
+
#
|
|
16
|
+
# SPGD-96's Definition of Done ends "…and the Ruby gem invoking it for
|
|
17
|
+
# linting … with the Ruby hand-rolled validation logic removed". This file
|
|
18
|
+
# IS that sentence: {resolve} is default-on, obtaining a prebuilt
|
|
19
|
+
# `validate-intent` binary on first run ({Installer}) when
|
|
20
|
+
# `SPECGUARD_VALIDATE_INTENT` does not name one, and every run validates
|
|
21
|
+
# through the binary it resolved. There is no Ruby fallback — when no
|
|
22
|
+
# binary can be resolved the run exits 2 naming both remediations (see
|
|
23
|
+
# {Installer::REMEDIATIONS}).
|
|
24
|
+
#
|
|
25
|
+
# The reason the cutover waited was distribution, and that blocker is
|
|
26
|
+
# discharged: open-test-intent publishes prebuilt static binaries
|
|
27
|
+
# (`validate-intent-{linux,darwin}-{amd64,arm64}`, CGO_ENABLED=0, schema
|
|
28
|
+
# compiled in) plus a `SHA256SUMS` manifest on its GitHub releases,
|
|
29
|
+
# consumable via `scripts/install.sh` — so the gem can resolve or fetch a
|
|
30
|
+
# binary instead of failing every existing user's working linter.
|
|
31
|
+
#
|
|
32
|
+
# == Why a dedicated seam rather than {Configuration}
|
|
33
|
+
#
|
|
34
|
+
# The originating proposal put this on `Configuration`. That is the wrong
|
|
35
|
+
# object twice over: `Configuration` is the *telemetry* config — its only
|
|
36
|
+
# consumers are `Formatter` and `Transport`, and `CLI` does not reference
|
|
37
|
+
# it at all — so threading the lint backend through it would make the
|
|
38
|
+
# linter depend on the telemetry object solely to read one env var. It also
|
|
39
|
+
# memoizes process-wide (`SpecGuard::RSpec.configuration`), with
|
|
40
|
+
# `reset_configuration!` existing only for tests, which is a poor fit for
|
|
41
|
+
# something a spec wants to vary per example. {CLI} takes an `env:` instead
|
|
42
|
+
# and asks this module, so the seam is one hash away in a test and one
|
|
43
|
+
# method here in production.
|
|
44
|
+
#
|
|
45
|
+
# == The mapping, and the one thing the JSON document does not carry
|
|
46
|
+
#
|
|
47
|
+
# `RunSourceJSON` (open-test-intent, `cmd/validate-intent/report.go`)
|
|
48
|
+
# NORMALIZES the reference's `problem` and `errors` into a single `errors`
|
|
49
|
+
# list, saying so in a comment: "`problem` and `errors` are the same thing
|
|
50
|
+
# to a consumer… `kind` is what tells them apart". {Linter::Result} keeps
|
|
51
|
+
# them apart, and {CLI#report_failure} renders them differently — an em
|
|
52
|
+
# dash on one line for a `problem`, an indented `-> ` line per `reason`.
|
|
53
|
+
#
|
|
54
|
+
# So the split has to be *reconstructed from `kind`*, which is exactly what
|
|
55
|
+
# the Go comment says `kind` is for:
|
|
56
|
+
#
|
|
57
|
+
# schema -> reasons: errors
|
|
58
|
+
# extraction / parse / read -> problem: errors.first
|
|
59
|
+
# no-match -> problem, and see below
|
|
60
|
+
#
|
|
61
|
+
# For the three `problem` kinds an `errors` list of any length other than 1
|
|
62
|
+
# is a **contract change**, not something to paper over by joining the
|
|
63
|
+
# strings: the renderer would silently emit one line where the tool meant
|
|
64
|
+
# several. {Runner} raises, and the run exits 2.
|
|
65
|
+
#
|
|
66
|
+
# == `no-match`: the gem's arguments are PATHS, the binary's are GLOBS
|
|
67
|
+
#
|
|
68
|
+
# `validate-intent` expands its arguments as GLOB PATTERNS, recursive `**`
|
|
69
|
+
# included; `specguard-lint`'s are paths, checked as
|
|
70
|
+
# given (`CLI#select`, `Scanner.scan_file`). Handing a path straight through
|
|
71
|
+
# would silently re-expand it: `spec/fixtures/bracket[1]_spec.rb` becomes a
|
|
72
|
+
# character class, and a file containing `*` becomes a wildcard that may
|
|
73
|
+
# match *other* files. Every path is therefore escaped with {escape_glob},
|
|
74
|
+
# so each argument matches exactly the file it names or nothing at all.
|
|
75
|
+
#
|
|
76
|
+
# "Or nothing at all" is Go's `no-match` kind, which the gem has no
|
|
77
|
+
# `Finding::KIND_*` for. It is mapped to {Finding::KIND_READ}, because that
|
|
78
|
+
# is what it *means* on the gem's side of the seam: a named path that could
|
|
79
|
+
# not be opened. The classification, the dropped line number, the "N files
|
|
80
|
+
# could not be read" clause of the summary and the exit code all follow the
|
|
81
|
+
# Ruby path exactly. Two things about it are deliberate:
|
|
82
|
+
#
|
|
83
|
+
# * the reported file is the ORIGINAL path, not the escaped pattern —
|
|
84
|
+
# `[[]1]` is an artifact of this file and printing it would misreport
|
|
85
|
+
# what the caller asked for;
|
|
86
|
+
# * the wording is the gem's own, not Go's `no file(s) match <pattern>`,
|
|
87
|
+
# which is a statement about a *pattern* — a concept `specguard-lint`
|
|
88
|
+
# does not have, and whose text would carry the escaped form.
|
|
89
|
+
#
|
|
90
|
+
# This is one of the ratified differences between the backends, all of
|
|
91
|
+
# which are asserted in `spec/specguard/rspec/validator_backend_spec.rb`.
|
|
92
|
+
#
|
|
93
|
+
# The other three mechanisms are below. Only the first two are about TEXT —
|
|
94
|
+
# the backend passing the binary's wording through where the Ruby path
|
|
95
|
+
# spells it its own way. The third is not a wording difference at all,
|
|
96
|
+
# which is why this list does not call the group "text differences":
|
|
97
|
+
#
|
|
98
|
+
# * the read-failure tail — see {Scanner.scan_text}, which records the
|
|
99
|
+
# ratified difference and both spellings. Both refuse the file;
|
|
100
|
+
# PROTOCOL.md §1.1 requires that much and specifies no prose.
|
|
101
|
+
# * the parse-failure tail — see {Scanner#parse} (1). A payload that
|
|
102
|
+
# survives normalisation and still is not JSON is described by
|
|
103
|
+
# whichever JSON parser saw it, so the Ruby path carries Ruby's
|
|
104
|
+
# `JSON::ParserError#message` and the backend carries the binary's.
|
|
105
|
+
# This is the most commonly hit of the three: `parse` is one of the
|
|
106
|
+
# three things the linter exists to report.
|
|
107
|
+
# * THE ACCEPTANCE SET — see {Scanner#parse} (2), and note that this one
|
|
108
|
+
# is not a wording difference at all. It used to be a three-member set
|
|
109
|
+
# in which the BINARY was the permissive side; PROTOCOL.md §1.1 states
|
|
110
|
+
# the accepted JSON language now, and the binary refuses all three, so
|
|
111
|
+
# those have converged. What survives runs the other way: this gem's
|
|
112
|
+
# `JSON.parse` accepts a LONE LOW surrogate escape that §1.1(a)
|
|
113
|
+
# refuses, and its nesting boundary sits a little deeper than
|
|
114
|
+
# §1.1(c)'s 100. For such a payload the backend does not merely word
|
|
115
|
+
# the failure differently — it HAS one where the Ruby path does not,
|
|
116
|
+
# so the backend exits 1 where the Ruby path exits 0.
|
|
117
|
+
#
|
|
118
|
+
# That last case is the only known input on which the two backends return
|
|
119
|
+
# different verdicts for the same file, and it is ratified rather than
|
|
120
|
+
# closed for reasons {Scanner#parse} sets out. Nothing here can detect it:
|
|
121
|
+
# a document reporting no findings is exactly what a clean run looks like,
|
|
122
|
+
# so the mapping below is correct and the disagreement is upstream of it.
|
|
123
|
+
#
|
|
124
|
+
# It has NOT always been the only one, and the other one ran the DANGEROUS
|
|
125
|
+
# way round. Until SPGD-512 the binary's `@intent:` payload search was
|
|
126
|
+
# unbounded where {AnnotationScanner#payload_brace} bounds it at the next
|
|
127
|
+
# token, so on a line carrying a malformed token followed by a well-formed
|
|
128
|
+
# one the binary adopted the neighbour's literal, reported it valid and
|
|
129
|
+
# exited 0 where the Ruby path reported no-payload and exited 1 — backend
|
|
130
|
+
# permissive, Ruby strict, the reverse of the surviving case above. That is
|
|
131
|
+
# the direction that costs something: opting into the backend bought a
|
|
132
|
+
# false green. SPGD-512 ported the bound, which is why the claim above
|
|
133
|
+
# holds again; it is recorded rather than deleted because the shape can
|
|
134
|
+
# recur any time a scanner-stage rule lands in one implementation only, and
|
|
135
|
+
# a reader who sees only the surrogate case will not think to look for it.
|
|
136
|
+
#
|
|
137
|
+
# == Everything that can go wrong here is exit 2
|
|
138
|
+
#
|
|
139
|
+
# A missing binary, a binary that will not execute, a non-zero exit this
|
|
140
|
+
# cannot read a document out of, or unparseable output are all "the linter
|
|
141
|
+
# could not do its job". They must never reach exit 1, which the contract
|
|
142
|
+
# has already spent on "an annotation is malformed" ({CLI}). Hence
|
|
143
|
+
# {ValidatorError}, rescued beside {UsageError} so the message reads
|
|
144
|
+
# `specguard-lint: error: …` rather than the backstop's `internal error:`.
|
|
145
|
+
#
|
|
146
|
+
# == Naming what produced the verdicts — and why identity is NOT in that band
|
|
147
|
+
#
|
|
148
|
+
# The paragraph above is about failures to obtain a VERDICT. The binary's
|
|
149
|
+
# identity is not one, and the distinction is the whole of {#identity}.
|
|
150
|
+
#
|
|
151
|
+
# This file already refuses to let "which validator ran" be ambiguous in the
|
|
152
|
+
# two places it can be decided: a named-but-missing binary is a hard exit 2
|
|
153
|
+
# ({Runner#verify!}), and a bare command name is refused rather than
|
|
154
|
+
# PATH-resolved ({Runner#path_hint}) because "a run that succeeded against a
|
|
155
|
+
# different validator … nothing downstream can detect". Both close the hole
|
|
156
|
+
# for binaries that do not resolve. For every binary that DOES resolve, the
|
|
157
|
+
# run was still silent about it: a report produced by the port and a report
|
|
158
|
+
# produced by {Linter} were the same bytes.
|
|
159
|
+
#
|
|
160
|
+
# So {Runner#verify!} asks the binary who it is, once, before anything is
|
|
161
|
+
# selected or scanned — the same fail-early placement as the checks beside
|
|
162
|
+
# it — and {Runner#provenance} renders the answer for the one stderr line
|
|
163
|
+
# {CLI} prints per run. Three properties make that safe:
|
|
164
|
+
#
|
|
165
|
+
# * it is asked ONCE per run, so it cannot become a per-batch cost on a
|
|
166
|
+
# large audit. Not by memoizing the answer — {Runner#verify!} re-probes
|
|
167
|
+
# every time it is called, exactly as it re-runs the three file checks
|
|
168
|
+
# beside it — but because {ValidatorBackend.resolve} is the only thing
|
|
169
|
+
# that calls {Runner#verify!} and {CLI} resolves once per run;
|
|
170
|
+
# * the answer is passed through VERBATIM. A line the gem composed about
|
|
171
|
+
# the binary would be a claim by the gem; the point is to carry the
|
|
172
|
+
# binary's own statement, which is the only thing that can distinguish
|
|
173
|
+
# two builds this gem has never heard of;
|
|
174
|
+
# * a binary that cannot answer still validates. `--version` arrived in
|
|
175
|
+
# open-test-intent slice 6; an older build reads it as a filename,
|
|
176
|
+
# reports "no file(s) match" on stderr and exits 1. That must not cost
|
|
177
|
+
# a verdict, so the probe treats a non-zero exit, empty output, or
|
|
178
|
+
# output that cannot be rendered as one line as "identity unavailable"
|
|
179
|
+
# and never as a {ValidatorError}. `SystemCallError` is caught in the
|
|
180
|
+
# probe rather than left to {Runner#run}'s rescue for the same reason.
|
|
181
|
+
#
|
|
182
|
+
# "Unavailable" is then reported IN WORDS by {Runner#provenance}. Dropping
|
|
183
|
+
# the line instead would make a run that could not name its validator look
|
|
184
|
+
# exactly like a run nobody looked at — the silent-omission shape this
|
|
185
|
+
# project keeps naming, arrived at from a third direction.
|
|
186
|
+
#
|
|
187
|
+
# == Asking the answer a question: the schema contract across the seam
|
|
188
|
+
#
|
|
189
|
+
# The identity above was carried through and rendered, and nothing read it.
|
|
190
|
+
# One token in it is not decoration: open-test-intent's `VersionLine`
|
|
191
|
+
# (`cmd/validate-intent/version.go`) ends `schema sha256:<64-hex>`, the
|
|
192
|
+
# digest of the schema COMPILED INTO that binary, and `schema.go`'s
|
|
193
|
+
# `SchemaSHA256` says in as many words why it exists —
|
|
194
|
+
#
|
|
195
|
+
# "a gem that vendors schema A can be pointed at a binary built when
|
|
196
|
+
# canonical was B, and all three guards stay green while the two halves
|
|
197
|
+
# enforce different contracts."
|
|
198
|
+
#
|
|
199
|
+
# Every drift guard in this ecosystem compares a matched pair inside ONE
|
|
200
|
+
# checkout: `schema_test.go` digests the Go embed against the Go tree's
|
|
201
|
+
# `schemas/`, and `spec/specguard/rspec/schema_packaging_spec.rb` digests
|
|
202
|
+
# this gem's vendored copy against this gem's pin. Neither looks at an
|
|
203
|
+
# INSTALLED artifact, and neither can. The seam is ordinary, not contrived:
|
|
204
|
+
# `install.sh` fetches
|
|
205
|
+
# a released binary by version with nothing tying that release's vintage to
|
|
206
|
+
# the gem beside it, and {ENV_VAR} accepts any path on the host.
|
|
207
|
+
#
|
|
208
|
+
# And on this path the gem does not even read its own schema — `CLI#run`
|
|
209
|
+
# loads it only when the backend is nil, deliberately, because with the
|
|
210
|
+
# backend on the binary's copy is what governs. So the two contracts never
|
|
211
|
+
# met. {Runner#verify!} is where they now do, beside the three file checks
|
|
212
|
+
# and before anything is selected or scanned: the same fail-early placement
|
|
213
|
+
# this file already argues for, and for the same reason — a divergence is
|
|
214
|
+
# not a verdict about anyone's annotations.
|
|
215
|
+
#
|
|
216
|
+
# == CARRIED is not ENFORCED, and only one of them is the question
|
|
217
|
+
#
|
|
218
|
+
# The paragraphs above are how this check was first built, and they compare
|
|
219
|
+
# the wrong digest. `--version` reports the schema the artifact CARRIES —
|
|
220
|
+
# `SchemaSHA256` is a pure fold of the compiled-in bytes — while `LoadSchema`
|
|
221
|
+
# (`cmd/validate-intent/fileio.go`) gives a `schemas/open-test-intent.v1.json`
|
|
222
|
+
# found beside the executable priority over that copy and falls back to it
|
|
223
|
+
# only on ENOENT. `--version` returns some thirty lines above that decision
|
|
224
|
+
# and never reaches it; the binary's own `--help` trailer says the digest
|
|
225
|
+
# "is not a claim about what a given run enforced".
|
|
226
|
+
#
|
|
227
|
+
# So the carried digest is the one answer that cannot settle the question,
|
|
228
|
+
# and asking it fails in BOTH directions:
|
|
229
|
+
#
|
|
230
|
+
# * a binary whose embedded schema is ours, sitting beside a `schemas/`
|
|
231
|
+
# file that is not, reports a matching digest — the guard returns
|
|
232
|
+
# `:matched` and stays silent — and then enforces different bytes. That
|
|
233
|
+
# is precisely the case this check exists to refuse, passing;
|
|
234
|
+
# * the mirror: a binary whose embedded schema differs from ours but whose
|
|
235
|
+
# on-disk schema IS ours gets refused, for a run that would have been
|
|
236
|
+
# correct. Planting a schema beside the binary is not exotic — it is how
|
|
237
|
+
# open-test-intent's own release and cross-build checks operate.
|
|
238
|
+
#
|
|
239
|
+
# open-test-intent slice 19 added `--schema-source`, which calls the REAL
|
|
240
|
+
# loader and prints `schema <origin> sha256:<hex>` for the bytes a verdict
|
|
241
|
+
# run on this host would load. It is the only surface that answers the
|
|
242
|
+
# question, so {Runner#verify!} asks it — once per run, beside the identity
|
|
243
|
+
# probe — and the comparison uses the ENFORCED digest whenever one comes
|
|
244
|
+
# back, falling back to the carried digest, unchanged, when it does not.
|
|
245
|
+
#
|
|
246
|
+
# The bands, and the boundaries between them are the whole design:
|
|
247
|
+
#
|
|
248
|
+
# * ENFORCED AND EQUAL — the run proceeds, and {Runner#provenance} says so
|
|
249
|
+
# WITHOUT the hedge below, naming the origin the bytes were loaded from.
|
|
250
|
+
# This arm may state what the run enforces because that is the question
|
|
251
|
+
# `--schema-source` answers.
|
|
252
|
+
#
|
|
253
|
+
# * ENFORCED AND DIFFERENT — {ValidatorError}, exit 2, naming BOTH digests
|
|
254
|
+
# and the origin. This is the one addition to the exit-2 band, and it
|
|
255
|
+
# belongs there for the reason the band exists: a verdict produced under
|
|
256
|
+
# a different contract is not a verdict this gem can stand behind, and
|
|
257
|
+
# nothing downstream can notice — the report is a normal report, the
|
|
258
|
+
# exit code is a normal exit code, and the findings are whatever the
|
|
259
|
+
# other contract implies. Note this is the OPPOSITE of the identity
|
|
260
|
+
# rule above rather than an exception to it: "I could not ask" stays
|
|
261
|
+
# out of the band, "I asked and the answer was wrong" goes in.
|
|
262
|
+
#
|
|
263
|
+
# * CARRIED AND EQUAL, CARRIED AND DIFFERENT — the fallback, reached only
|
|
264
|
+
# when `--schema-source` did not answer, and byte for byte what this
|
|
265
|
+
# file did before it was asked: the same two outcomes, the same two
|
|
266
|
+
# messages, and the hedge kept on the matched arm because on that path
|
|
267
|
+
# it is still true.
|
|
268
|
+
#
|
|
269
|
+
# * NOT REPORTED — never a refusal, in any of its three shapes: a
|
|
270
|
+
# pre-slice-17 build whose `--version` carries no digest token, a binary
|
|
271
|
+
# that could not answer `--version` at all, and this gem being unable to
|
|
272
|
+
# read its own vendored schema. Each says so IN WORDS in its own
|
|
273
|
+
# wording, because "could not check" and "checked and clean" are two
|
|
274
|
+
# different statements and a checker owes both.
|
|
275
|
+
#
|
|
276
|
+
# == The rule the second probe inherits, and the one it cannot
|
|
277
|
+
#
|
|
278
|
+
# A binary predating slice 19 reads `--schema-source` as a filename, writes
|
|
279
|
+
# "no file(s) match" to stderr and exits 1; a binary whose schema exists and
|
|
280
|
+
# cannot be loaded exits 2 with the "could not load schema" diagnostic that
|
|
281
|
+
# belongs to it, which {#check} reaches a moment later anyway. Both are the
|
|
282
|
+
# identity probe's rule unchanged — a non-zero exit, empty output or output
|
|
283
|
+
# this cannot read is "unavailable" and never a {ValidatorError} — so with
|
|
284
|
+
# the flag absent the run is byte-identical to the one before this existed.
|
|
285
|
+
#
|
|
286
|
+
# That rule is also why {SCHEMA_SOURCE_PATTERN} is pinned against RECORDED
|
|
287
|
+
# output of the real binary in `spec/fixtures/validator/schema-source-probes.json`
|
|
288
|
+
# rather than against a hand-written line. A parse bug does not announce
|
|
289
|
+
# itself here: every miss reads as "this binary is too old", the guard
|
|
290
|
+
# silently reverts to comparing the carried digest, and the run goes green —
|
|
291
|
+
# the exact defect this file is closing, re-created inside the fix.
|
|
292
|
+
#
|
|
293
|
+
# What two probes CANNOT promise is what one probe did. {#verify_schema_contract!}
|
|
294
|
+
# reads {#identity} rather than re-asking, so the digest it compared and the
|
|
295
|
+
# name in the provenance line come from the same process; a second probe is
|
|
296
|
+
# a second process, so the enforced digest carries no such guarantee. It
|
|
297
|
+
# says what a run STARTED at that moment would load, which is the strongest
|
|
298
|
+
# thing any answer to this question can say — the schema beside a binary can
|
|
299
|
+
# change between two lines of a shell script — and the identity/carried pair
|
|
300
|
+
# keeps the promise it always had.
|
|
301
|
+
#
|
|
302
|
+
# That third shape is the one judgment call here, so it is recorded rather
|
|
303
|
+
# than left to be re-derived. The precedent was that an unreadable
|
|
304
|
+
# {SCHEMA_PATH} is exit 2 — but that was a schema the run was about to
|
|
305
|
+
# ENFORCE, and this one is not: `CLI#run` skips the load on this path
|
|
306
|
+
# precisely so an unrelated packaging accident cannot fail a run that never
|
|
307
|
+
# reads it, and making the digest fatal here would re-introduce exactly the
|
|
308
|
+
# dependency that comment removed. An unreadable vendored copy is also not
|
|
309
|
+
# what the exit-2 arm is for — divergence is a POSITIVE finding, two digests
|
|
310
|
+
# that differ, and a missing operand is not a difference. So it lands in the
|
|
311
|
+
# third band and is said out loud. (Hence {Digest::SHA256.file} rather than
|
|
312
|
+
# a digest needs bytes, not a parsed and validated document, so only an
|
|
313
|
+
# unreadable file can fail it and a schema this run does not use cannot
|
|
314
|
+
# fail it twice.)
|
|
315
|
+
#
|
|
316
|
+
# The digest is computed from {SCHEMA_PATH} at runtime and never written
|
|
317
|
+
# down here. A constant would be a fourth copy of a hex string that already
|
|
318
|
+
# exists in three places, drifting independently of the file it claims to
|
|
319
|
+
# describe — which is the precise failure this whole check was added to
|
|
320
|
+
# detect, re-created inside the detector.
|
|
321
|
+
module ValidatorBackend
|
|
322
|
+
# Names a `validate-intent` binary to validate through, overriding the
|
|
323
|
+
# default first-run auto-install. Blank or unset means the default:
|
|
324
|
+
# resolve a cached or freshly downloaded prebuilt binary ({Installer}).
|
|
325
|
+
ENV_VAR = "SPECGUARD_VALIDATE_INTENT"
|
|
326
|
+
|
|
327
|
+
# @param env [Hash, ENV]
|
|
328
|
+
# @return [Runner] always a runner — there is no Ruby fallback, so a
|
|
329
|
+
# binary that cannot be resolved raises rather than degrades
|
|
330
|
+
# @raise [ValidatorError] when no usable binary can be resolved
|
|
331
|
+
def self.resolve(env: ENV)
|
|
332
|
+
# Blank means unset, following `Configuration`'s `blank_to_nil` idiom:
|
|
333
|
+
# `SPECGUARD_VALIDATE_INTENT=` in a CI environment file is somebody
|
|
334
|
+
# asking for the default resolution, not for a binary named "".
|
|
335
|
+
path = env[ENV_VAR].to_s.strip
|
|
336
|
+
path = Installer.obtain(env: env) if path.empty?
|
|
337
|
+
|
|
338
|
+
Runner.new(path).tap(&:verify!)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Escapes a POSIX path so the binary's globber matches it literally: wrap
|
|
342
|
+
# each of `*`, `?` and `[` in a character class, which makes it match
|
|
343
|
+
# itself. `]` is not escaped and does not need to be — outside a class it
|
|
344
|
+
# is already a literal.
|
|
345
|
+
#
|
|
346
|
+
# @param path [String]
|
|
347
|
+
# @return [String] a glob pattern matching exactly `path`
|
|
348
|
+
def self.escape_glob(path)
|
|
349
|
+
path.gsub(/([*?\[])/, '[\1]')
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Obtains a `validate-intent` binary for the DEFAULT-ON path: the
|
|
353
|
+
# platform-matched prebuilt asset from open-test-intent's GitHub release,
|
|
354
|
+
# verified against the release's `SHA256SUMS` manifest, cached under the
|
|
355
|
+
# user's cache dir so the network is touched once per machine.
|
|
356
|
+
#
|
|
357
|
+
# == Why the download is verified the way `Runner#verify!` verifies
|
|
358
|
+
#
|
|
359
|
+
# The posture is inherited from the schema-contract check: a downloaded
|
|
360
|
+
# blob is not a binary because we asked for one. The release publishes a
|
|
361
|
+
# `SHA256SUMS` manifest beside its assets, and the gem checks the ONE row
|
|
362
|
+
# that names this platform's asset — the same choice
|
|
363
|
+
# open-test-intent's own `scripts/install.sh` makes, for the reason its
|
|
364
|
+
# header gives (`sha256sum -c` would report the three rows this host did
|
|
365
|
+
# not stage as missing files). Only a row that matches BOTH the digest
|
|
366
|
+
# and the asset name counts, so a manifest that does not describe the
|
|
367
|
+
# fetched bytes is a refusal and not a pass.
|
|
368
|
+
#
|
|
369
|
+
# == Why a failed fetch is an exit 2 naming two remediations
|
|
370
|
+
#
|
|
371
|
+
# There is no Ruby fallback any more, so "no binary" must be loud: the
|
|
372
|
+
# message names the env var (point at a binary you already have) and the
|
|
373
|
+
# `install.sh` curl|sh path (the CI-pinned alternative). A first run with
|
|
374
|
+
# no network is the expected shape of this failure and the message is
|
|
375
|
+
# written for it.
|
|
376
|
+
module Installer
|
|
377
|
+
# The release the gem resolves against. Pinned by tag, not "latest":
|
|
378
|
+
# which validator validated a CI job must not change because somebody
|
|
379
|
+
# published a new release, and {Runner#verify_schema_contract!} pins
|
|
380
|
+
# the fetched binary to this gem's vendored schema anyway.
|
|
381
|
+
RELEASE_TAG = "v0.1.3"
|
|
382
|
+
REPOSITORY = "yatfa-ai/open-test-intent"
|
|
383
|
+
DOWNLOAD_BASE = "https://github.com/#{REPOSITORY}/releases/download/#{RELEASE_TAG}"
|
|
384
|
+
|
|
385
|
+
# The CI-pinned alternative to the auto-install: install the same
|
|
386
|
+
# verified release artifact onto PATH and point the env var at it.
|
|
387
|
+
INSTALL_SH = "curl -fsSL " \
|
|
388
|
+
"https://raw.githubusercontent.com/#{REPOSITORY}/#{RELEASE_TAG}/scripts/install.sh | sh"
|
|
389
|
+
|
|
390
|
+
# Both remediations, spelled once and reused by every refusal, so the
|
|
391
|
+
# message cannot drift between the ways the fetch can fail.
|
|
392
|
+
REMEDIATIONS = "set #{ValidatorBackend::ENV_VAR} to a validate-intent binary, " \
|
|
393
|
+
"or install one with: #{INSTALL_SH}"
|
|
394
|
+
|
|
395
|
+
# Where the cached binary lives when the user has not redirected it.
|
|
396
|
+
# Honours `SPECGUARD_CACHE_DIR` (hermetic CI caches), then XDG, then
|
|
397
|
+
# `~/.cache` for hosts without XDG set.
|
|
398
|
+
CACHE_DIR_VAR = "SPECGUARD_CACHE_DIR"
|
|
399
|
+
|
|
400
|
+
# `RUBY_PLATFORM` -> the release's os/arch vocabulary. Anything outside
|
|
401
|
+
# the four published assets is unsupported: guessable names
|
|
402
|
+
# (`freebsd`?) would produce a 404 masquerading as a policy, so the
|
|
403
|
+
# mapping is closed and the refusal names the platform it was asked
|
|
404
|
+
# about.
|
|
405
|
+
OS_FOR = { /linux/ => "linux", /darwin|mac/ => "darwin" }.freeze
|
|
406
|
+
ARCH_FOR = { /x86_64|amd64|x64/ => "amd64", /aarch64|arm64/ => "arm64" }.freeze
|
|
407
|
+
|
|
408
|
+
# `Net::HTTP` timeouts. Short on purpose: this runs at the front of a
|
|
409
|
+
# lint step, and a first-run fetch that hangs is worse than one that
|
|
410
|
+
# fails fast with the remediation message.
|
|
411
|
+
OPEN_TIMEOUT = 10
|
|
412
|
+
READ_TIMEOUT = 30
|
|
413
|
+
MAX_REDIRECTS = 5
|
|
414
|
+
|
|
415
|
+
class << self
|
|
416
|
+
# @param env [Hash, ENV]
|
|
417
|
+
# @return [String] path to an executable, SHA256SUMS-verified binary
|
|
418
|
+
# @raise [ValidatorError] when the platform is unsupported or the
|
|
419
|
+
# binary cannot be obtained or verified
|
|
420
|
+
def obtain(env: ENV)
|
|
421
|
+
asset = asset_name
|
|
422
|
+
destination = File.join(cache_dir(env), asset)
|
|
423
|
+
|
|
424
|
+
return destination if cached?(destination)
|
|
425
|
+
|
|
426
|
+
install(asset, destination)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# The release asset name for this host.
|
|
430
|
+
#
|
|
431
|
+
# @param platform [String] a `RUBY_PLATFORM`-shaped string;
|
|
432
|
+
# injectable so the mapping is testable off exotic hosts
|
|
433
|
+
# @raise [ValidatorError] on an OS or arch with no published asset
|
|
434
|
+
def asset_name(platform: RUBY_PLATFORM)
|
|
435
|
+
os = OS_FOR.find { |pattern, _| pattern.match?(platform) }&.last
|
|
436
|
+
arch = ARCH_FOR.find { |pattern, _| pattern.match?(platform) }&.last
|
|
437
|
+
|
|
438
|
+
if os.nil? || arch.nil?
|
|
439
|
+
raise ValidatorError,
|
|
440
|
+
"no prebuilt validate-intent release asset for #{platform} " \
|
|
441
|
+
"(#{RELEASE_TAG} publishes #{OS_FOR.values.uniq.product(ARCH_FOR.values.uniq) \
|
|
442
|
+
.map { |o, a| "#{o}/#{a}" }.join(', ')}) — #{REMEDIATIONS}"
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
"validate-intent-#{os}-#{arch}"
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# @param env [Hash, ENV]
|
|
449
|
+
# @return [String]
|
|
450
|
+
def cache_dir(env)
|
|
451
|
+
override = env[CACHE_DIR_VAR].to_s.strip
|
|
452
|
+
xdg = env["XDG_CACHE_HOME"].to_s.strip
|
|
453
|
+
root = override.empty? ? (xdg.empty? ? File.join(Dir.home, ".cache") : xdg) : override
|
|
454
|
+
# Named for the gem, so a cached validator binary survives gem upgrades
|
|
455
|
+
# within one name and is re-downloaded once — and only once — when the
|
|
456
|
+
# gem itself changes name.
|
|
457
|
+
File.join(root, "specguard-ruby", "validate-intent", RELEASE_TAG)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Downloads the asset plus its manifest, verifies, and installs
|
|
461
|
+
# atomically (temp file + rename, chmod 0755) so a killed download
|
|
462
|
+
# can never leave a half-written binary the next run mistakes for
|
|
463
|
+
# good — or worse, executes.
|
|
464
|
+
def install(asset, destination)
|
|
465
|
+
manifest = download("#{DOWNLOAD_BASE}/SHA256SUMS")
|
|
466
|
+
expected = manifest_digest(manifest, asset)
|
|
467
|
+
|
|
468
|
+
bytes = download("#{DOWNLOAD_BASE}/#{asset}")
|
|
469
|
+
actual = Digest::SHA256.hexdigest(bytes)
|
|
470
|
+
unless actual == expected
|
|
471
|
+
raise ValidatorError,
|
|
472
|
+
"downloaded #{DOWNLOAD_BASE}/#{asset} has sha256:#{actual}, but the release " \
|
|
473
|
+
"manifest says sha256:#{expected} — the download is not the artifact the " \
|
|
474
|
+
"release published, so it was not installed; #{REMEDIATIONS}"
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
dir = File.dirname(destination)
|
|
478
|
+
FileUtils.mkdir_p(dir)
|
|
479
|
+
tmp = "#{destination}.tmp.#{Process.pid}"
|
|
480
|
+
File.binwrite(tmp, bytes)
|
|
481
|
+
File.chmod(0o755, tmp)
|
|
482
|
+
File.rename(tmp, destination)
|
|
483
|
+
destination
|
|
484
|
+
rescue ValidatorError
|
|
485
|
+
raise
|
|
486
|
+
rescue StandardError => e
|
|
487
|
+
# Net::HTTP's failures (SocketError, OpenTimeout, EOFError, ...),
|
|
488
|
+
# and any filesystem failure writing the cache. All of them mean
|
|
489
|
+
# "no binary could be obtained", which is the one refusal this
|
|
490
|
+
# module has — exit 2, both remediations.
|
|
491
|
+
raise ValidatorError, "could not obtain validate-intent from #{DOWNLOAD_BASE}: " \
|
|
492
|
+
"#{e.class}: #{e.message}; #{REMEDIATIONS}"
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
private
|
|
496
|
+
|
|
497
|
+
def cached?(path)
|
|
498
|
+
File.file?(path) && File.executable?(path)
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# The one row of `SHA256SUMS` naming this asset:
|
|
502
|
+
# `<64-hex> <asset-name>` (two spaces, the coreutils format the
|
|
503
|
+
# release workflow writes). A manifest without a row for the asset,
|
|
504
|
+
# or with a row this cannot read, is a refusal — an unverified
|
|
505
|
+
# install is the vacuous green this ecosystem keeps refusing.
|
|
506
|
+
def manifest_digest(manifest, asset)
|
|
507
|
+
manifest.each_line do |line|
|
|
508
|
+
match = /\A(?<digest>\h{64})\s+\*?(?<name>\S+)\s*\z/.match(line)
|
|
509
|
+
next if match.nil?
|
|
510
|
+
return match[:digest].downcase if match[:name] == asset
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
raise ValidatorError,
|
|
514
|
+
"the #{RELEASE_TAG} release manifest does not describe #{asset} — " \
|
|
515
|
+
"cannot verify the download, so it was not installed; #{REMEDIATIONS}"
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# GET with redirects (the release download 302s to a signed object
|
|
519
|
+
# URL). Bounds the hop count so a misbehaving mirror cannot loop.
|
|
520
|
+
def download(url, redirects = MAX_REDIRECTS)
|
|
521
|
+
uri = URI(url)
|
|
522
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
523
|
+
open_timeout: OPEN_TIMEOUT,
|
|
524
|
+
read_timeout: READ_TIMEOUT) do |http|
|
|
525
|
+
request = Net::HTTP::Get.new(uri)
|
|
526
|
+
# GitHub's release endpoints require a client that names itself.
|
|
527
|
+
request["User-Agent"] = "specguard-ruby-installer"
|
|
528
|
+
http.request(request)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
case response
|
|
532
|
+
when Net::HTTPRedirection
|
|
533
|
+
raise ValidatorError, "too many redirects fetching #{url}" if redirects.zero?
|
|
534
|
+
|
|
535
|
+
download(response["Location"], redirects - 1)
|
|
536
|
+
when Net::HTTPSuccess
|
|
537
|
+
response.body
|
|
538
|
+
else
|
|
539
|
+
raise ValidatorError,
|
|
540
|
+
"fetching #{url} answered HTTP #{response.code} — " \
|
|
541
|
+
"the release asset is not where the gem looked; #{REMEDIATIONS}"
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Invokes the binary and turns its report into {Linter::Result}s.
|
|
548
|
+
class Runner
|
|
549
|
+
# `kind` -> the gem's equivalent. `no-match` has no equivalent of its
|
|
550
|
+
# own and folds into KIND_READ; see the module comment.
|
|
551
|
+
KINDS = {
|
|
552
|
+
"schema" => Finding::KIND_SCHEMA,
|
|
553
|
+
"extraction" => Finding::KIND_EXTRACTION,
|
|
554
|
+
"parse" => Finding::KIND_PARSE,
|
|
555
|
+
"read" => Finding::KIND_READ,
|
|
556
|
+
"no-match" => Finding::KIND_READ
|
|
557
|
+
}.freeze
|
|
558
|
+
|
|
559
|
+
# Kinds that are a statement about a FILE rather than about an
|
|
560
|
+
# annotation site. They contribute nothing to `summary.annotations`,
|
|
561
|
+
# exactly as `CLI#summary_line` keeps unread files out of the gem's own
|
|
562
|
+
# annotation count.
|
|
563
|
+
#
|
|
564
|
+
# DERIVED from {KINDS}, not transcribed beside it: folding into
|
|
565
|
+
# KIND_READ is what "about a file, not about a site" MEANS here, so the
|
|
566
|
+
# filter is the rule rather than a copy of it. The copy could only go
|
|
567
|
+
# one way — {#failing_result} raises by name on a kind {KINDS} has not
|
|
568
|
+
# been taught, so the port growing its vocabulary forces an edit there
|
|
569
|
+
# and none here, and that one-line edit is exactly what would silence
|
|
570
|
+
# the guard while leaving a hand-written list behind. The set is pinned
|
|
571
|
+
# by name in the spec, so widening it stays a decision someone makes.
|
|
572
|
+
NON_ANNOTATION_KINDS = KINDS.select { |_kind, mapped| mapped == Finding::KIND_READ }.keys.freeze
|
|
573
|
+
|
|
574
|
+
# The report used to be all ASCII: paths, a fixed kind vocabulary, and
|
|
575
|
+
# error prose the tool wrote itself. Since SPGD-340 it also carries
|
|
576
|
+
# `intent` — WHAT THE PAYLOAD PARSED TO — so the DOCUMENT now inherits
|
|
577
|
+
# the PAYLOAD's parser-compatibility domain, and a payload the port
|
|
578
|
+
# accepts can be one Ruby's default reader will not read.
|
|
579
|
+
#
|
|
580
|
+
# ONE OF THE TWO OPTIONS IS LOAD-BEARING; THE OTHER IS BELT AND BRACES.
|
|
581
|
+
# Measured against the binary this gem is shipped alongside:
|
|
582
|
+
#
|
|
583
|
+
# max_nesting: false LOAD-BEARING. PROTOCOL.md §1.1(c) admits a
|
|
584
|
+
# payload nested to depth 100, and a finding WRAPS
|
|
585
|
+
# it, so the report nests two deeper than the
|
|
586
|
+
# payload while `JSON.parse` defaults to 100.
|
|
587
|
+
# Measured: payload depth 98 -> report nests 100,
|
|
588
|
+
# parses; depth 99 -> nests 101, raises
|
|
589
|
+
# JSON::NestingError. So depths 99-100 are
|
|
590
|
+
# PROTOCOL-LEGAL payloads whose report a default
|
|
591
|
+
# reader cannot parse at all.
|
|
592
|
+
#
|
|
593
|
+
# allow_nan: true NOT reachable from a current binary, and kept
|
|
594
|
+
# deliberately. An earlier revision of this
|
|
595
|
+
# comment said `1e400` decodes to inf and is
|
|
596
|
+
# re-emitted as `Infinity`; that was true of the
|
|
597
|
+
# CPython reference and is FALSE of the port,
|
|
598
|
+
# which now echoes a number from its own literal
|
|
599
|
+
# (`1e400` stays `1e400`) precisely because
|
|
600
|
+
# `Infinity` is not JSON and §1.1(b) refuses it.
|
|
601
|
+
# Measured: no probe payload produces a bare
|
|
602
|
+
# NaN/Infinity token anywhere in a report.
|
|
603
|
+
#
|
|
604
|
+
# Both are enabled rather than left to raise, because the alternative is
|
|
605
|
+
# a batch-wide exit 2 over one annotation's payload — and it would be an
|
|
606
|
+
# exit 2 for a payload the schema was going to REJECT anyway (a
|
|
607
|
+
# non-finite is a number and a deep nest is a container; neither can
|
|
608
|
+
# occupy a schema-legal slot, all of which are strings). Parsing them
|
|
609
|
+
# permissively changes no verdict. Refusing to parse them changes every
|
|
610
|
+
# verdict in the batch.
|
|
611
|
+
PARSE_OPTIONS = { allow_nan: true, max_nesting: false }.freeze
|
|
612
|
+
|
|
613
|
+
# A full audit passes every spec file in the repository — thousands of
|
|
614
|
+
# arguments on a large suite — and an argument vector has two separate
|
|
615
|
+
# kernel limits on Linux: `ARG_MAX` caps the TOTAL vector (a quarter of
|
|
616
|
+
# the stack rlimit, so typically ~2 MiB) and `MAX_ARG_STRLEN` caps each
|
|
617
|
+
# SINGLE argument at 128 KiB. Exceeding either is `E2BIG`. This budget
|
|
618
|
+
# is for the first, and is deliberately well under the typical value
|
|
619
|
+
# rather than derived from it: the limit is not portable (it is smaller
|
|
620
|
+
# on some kernels and much smaller on other Unixes), and there is
|
|
621
|
+
# nothing to gain from crowding it. The batches are an implementation
|
|
622
|
+
# detail: {#check} concatenates their findings in argument order, so the
|
|
623
|
+
# caller still gets one list and {CLI} still prints one selection line
|
|
624
|
+
# and one summary line.
|
|
625
|
+
MAX_ARG_BYTES = 96 * 1024
|
|
626
|
+
# A second, independent bound. Some kernels cap the argument *count*
|
|
627
|
+
# (and `MAX_ARG_BYTES` alone would allow ~90k one-character paths).
|
|
628
|
+
MAX_BATCH_FILES = 1_000
|
|
629
|
+
|
|
630
|
+
# The `--version` probe's own guard rail, and the only thing it asserts
|
|
631
|
+
# about the answer's SHAPE. The identity is passed through verbatim, so
|
|
632
|
+
# the question is not "is this the format I expect" — a future build may
|
|
633
|
+
# word it differently and still be telling the truth — but "can this be
|
|
634
|
+
# rendered as the one line {CLI} promises per run". A binary answering
|
|
635
|
+
# `--version` with a report document, a stack trace, or a megabyte of
|
|
636
|
+
# anything is answering a different question, and its output would break
|
|
637
|
+
# the line rather than fill it.
|
|
638
|
+
IDENTITY_MAX_BYTES = 200
|
|
639
|
+
|
|
640
|
+
# The one token of the identity line this file interprets, matched
|
|
641
|
+
# exactly as `cmd/validate-intent/version.go` writes it: the literal
|
|
642
|
+
# `schema sha256:` followed by 64 hex digits. Everything around it stays
|
|
643
|
+
# opaque — the version, the toolchain, the platform and anything a
|
|
644
|
+
# future build appends are still the binary's business.
|
|
645
|
+
#
|
|
646
|
+
# Anchored at both ends. Without the trailing boundary a 65-digit token
|
|
647
|
+
# would match its first 64 and be compared as if it were a digest; with
|
|
648
|
+
# it, such a token matches nothing and the run lands in the "not
|
|
649
|
+
# reported" band, which is the right answer for a line this gem cannot
|
|
650
|
+
# read rather than one it disagrees with.
|
|
651
|
+
#
|
|
652
|
+
# Case-insensitive, and the capture is compared downcased. Go's
|
|
653
|
+
# `hex.EncodeToString` is lower case and {Digest::SHA256#hexdigest} is
|
|
654
|
+
# too, so today this changes nothing; if a future build shouts, that is
|
|
655
|
+
# a spelling of the same digest and must not be reported as divergence.
|
|
656
|
+
SCHEMA_DIGEST_PATTERN = /\bschema sha256:(\h{64})\b/i
|
|
657
|
+
|
|
658
|
+
# The flag that answers what a run ENFORCES. Named as a constant for
|
|
659
|
+
# the reason `schemaSourceFlag` is one on the other side of the seam:
|
|
660
|
+
# the probe passes it, the diagnostics quote it, and the specs assert
|
|
661
|
+
# the argument vector — three places that must spell it the same way.
|
|
662
|
+
SCHEMA_SOURCE_FLAG = "--schema-source"
|
|
663
|
+
|
|
664
|
+
# `--schema-source` writes ONE line: `schema <origin> sha256:<64-hex>`,
|
|
665
|
+
# where the origin is either an absolute path or the literal
|
|
666
|
+
# `<embedded schema>` (`cmd/validate-intent/schemasource.go`,
|
|
667
|
+
# `SchemaSourceLine`).
|
|
668
|
+
#
|
|
669
|
+
# This is a SECOND pattern rather than a reuse of {SCHEMA_DIGEST_PATTERN},
|
|
670
|
+
# and the difference is not cosmetic: that one requires `schema`
|
|
671
|
+
# IMMEDIATELY followed by `sha256:`, which is how `--version` writes it,
|
|
672
|
+
# and the origin interposed here means it matches this surface never.
|
|
673
|
+
# Reusing it would have made every probe unreadable, every run fall back
|
|
674
|
+
# to the carried digest, and nothing go red — see the module comment.
|
|
675
|
+
#
|
|
676
|
+
# Anchored to the WHOLE line, unlike the identity token. There, the
|
|
677
|
+
# digest is one token inside a line that is otherwise the binary's own
|
|
678
|
+
# business; here the line IS the answer, so anything around it means
|
|
679
|
+
# this is not the surface being read and the run belongs in the
|
|
680
|
+
# unavailable band. Both captures matter: the digest is compared, and
|
|
681
|
+
# the origin is what lets {#provenance} say WHICH schema was enforced
|
|
682
|
+
# rather than that one was.
|
|
683
|
+
#
|
|
684
|
+
# The trailing anchor is what makes the digest the LAST `sha256:` token
|
|
685
|
+
# on the line rather than the first — the same reading Go's own comment
|
|
686
|
+
# prescribes for the shell (`${line##* }` "yields `sha256:<hex>`
|
|
687
|
+
# whatever the origin contains"). A path may contain spaces, and one may
|
|
688
|
+
# even be NAMED like this tail; ending the match at the end of the line
|
|
689
|
+
# is what keeps the answer the last token in both cases. (The origin is
|
|
690
|
+
# also captured greedily, but that is not what decides it: only one
|
|
691
|
+
# token can end the line.)
|
|
692
|
+
SCHEMA_SOURCE_PATTERN = /\Aschema (?<origin>\S(?:.*\S)?) sha256:(?<digest>\h{64})\z/i
|
|
693
|
+
|
|
694
|
+
# The `--schema-source` probe's renderability budget, and the reason it
|
|
695
|
+
# is not {IDENTITY_MAX_BYTES}. This line carries a filesystem path,
|
|
696
|
+
# which POSIX allows up to `PATH_MAX` (4096 on Linux) all by itself, so
|
|
697
|
+
# the 200-byte budget that suits a one-line version string would reject
|
|
698
|
+
# legitimate answers from correctly-installed binaries. It still bounds
|
|
699
|
+
# the line, for the same reason the other one does: a binary answering
|
|
700
|
+
# with a report document or a megabyte of anything is answering a
|
|
701
|
+
# different question, and its output would break the line rather than
|
|
702
|
+
# fill it.
|
|
703
|
+
SCHEMA_SOURCE_MAX_BYTES = 8 * 1024
|
|
704
|
+
|
|
705
|
+
attr_reader :path
|
|
706
|
+
|
|
707
|
+
# The binary's own `--version` line, or nil when it could not report
|
|
708
|
+
# one. Populated by {#verify!}; nil before it runs, which is why nothing
|
|
709
|
+
# constructs a Runner without it (see {ValidatorBackend.resolve}).
|
|
710
|
+
#
|
|
711
|
+
# Assigned unconditionally rather than memoized: a second {#verify!}
|
|
712
|
+
# re-probes, in step with the file checks it sits among, which also
|
|
713
|
+
# re-run. "Once per run" is a property of the single {#verify!} call
|
|
714
|
+
# {ValidatorBackend.resolve} makes, not of a cache here.
|
|
715
|
+
#
|
|
716
|
+
# @return [String, nil]
|
|
717
|
+
attr_reader :identity
|
|
718
|
+
|
|
719
|
+
# The result of the schema-contract comparison, one of:
|
|
720
|
+
#
|
|
721
|
+
# :enforced — the binary reported the schema its runs LOAD, and
|
|
722
|
+
# it is ours. The strong arm: this one is an answer
|
|
723
|
+
# about the contract a verdict would be produced
|
|
724
|
+
# under, not about the bytes the artifact carries
|
|
725
|
+
# :matched — `--schema-source` did not answer, so the carried
|
|
726
|
+
# digest was compared instead, and it is ours
|
|
727
|
+
# :unreported — it named itself but carries no digest token
|
|
728
|
+
# :unidentified — it could not name itself at all
|
|
729
|
+
# :unreadable — we could not read our own vendored schema
|
|
730
|
+
#
|
|
731
|
+
# There is no `:diverged` member: both comparisons raise out of
|
|
732
|
+
# {#verify!} on a difference, so no Runner ever exists holding one.
|
|
733
|
+
# Populated by {#verify!} beside {#identity}, and nil before it runs for
|
|
734
|
+
# the same reason.
|
|
735
|
+
#
|
|
736
|
+
# @return [Symbol, nil]
|
|
737
|
+
attr_reader :schema_contract
|
|
738
|
+
|
|
739
|
+
# What `--schema-source` answered: `{origin:, digest:}`, or nil when the
|
|
740
|
+
# binary could not answer it — a build predating open-test-intent slice
|
|
741
|
+
# 19, a schema that exists and will not load, or output this cannot
|
|
742
|
+
# read. Populated by {#verify!} beside {#identity}.
|
|
743
|
+
#
|
|
744
|
+
# The digest is the schema a run STARTED at that moment would enforce.
|
|
745
|
+
# It is a second process from {#identity}, so the two are not guaranteed
|
|
746
|
+
# to describe the same instant — see the module comment for what that
|
|
747
|
+
# does and does not cost.
|
|
748
|
+
#
|
|
749
|
+
# @return [Hash{Symbol => String}, nil]
|
|
750
|
+
attr_reader :enforced_schema
|
|
751
|
+
|
|
752
|
+
# @param path [String] the `validate-intent` binary
|
|
753
|
+
def initialize(path)
|
|
754
|
+
@path = path
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
# Fails before anything is selected or scanned, so "the backend you
|
|
758
|
+
# asked for is not there" is never mistaken for a verdict about
|
|
759
|
+
# anyone's annotations.
|
|
760
|
+
#
|
|
761
|
+
# The two probes ride along for the placement rather than for the
|
|
762
|
+
# check: asking here is what makes them once-per-run and ahead of
|
|
763
|
+
# selection. Neither can fail the run — see the module comment.
|
|
764
|
+
#
|
|
765
|
+
# The schema-contract comparison reads the answers the probes already
|
|
766
|
+
# obtained, so it costs no third process, and it CAN fail the run — see
|
|
767
|
+
# the module comment for why that one band belongs in exit 2 while the
|
|
768
|
+
# identity itself does not.
|
|
769
|
+
#
|
|
770
|
+
# @raise [ValidatorError]
|
|
771
|
+
def verify!
|
|
772
|
+
raise ValidatorError, "#{describe} does not exist#{path_hint}" unless File.exist?(@path)
|
|
773
|
+
raise ValidatorError, "#{describe} is not a file" unless File.file?(@path)
|
|
774
|
+
raise ValidatorError, "#{describe} is not executable" unless File.executable?(@path)
|
|
775
|
+
|
|
776
|
+
@identity = probe_identity
|
|
777
|
+
@enforced_schema = probe_schema_source
|
|
778
|
+
@schema_contract = verify_schema_contract!
|
|
779
|
+
self
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# The active arm of {CLI}'s one-line-per-run provenance statement.
|
|
783
|
+
#
|
|
784
|
+
# The identity half is the binary's own words, uninterpreted. The path
|
|
785
|
+
# is spelled exactly as every diagnostic in this file spells it, so the
|
|
786
|
+
# line naming the validator and any error about it name the same thing.
|
|
787
|
+
#
|
|
788
|
+
# The clause after it is the gem's own, because it is a statement about
|
|
789
|
+
# a COMPARISON the gem made and not about the binary. Each of the five
|
|
790
|
+
# states it can report is worded distinctly: three of them mean the
|
|
791
|
+
# contract was not checked, for three different reasons, and collapsing
|
|
792
|
+
# them into one sentence would leave an operator unable to tell a build
|
|
793
|
+
# too old to answer from an installation missing its own schema. The
|
|
794
|
+
# remaining two are both "checked and clean" and must NOT be collapsed
|
|
795
|
+
# either — one of them answers what the run enforces and the other only
|
|
796
|
+
# what the binary carries, which is the whole distinction this file
|
|
797
|
+
# turns on.
|
|
798
|
+
#
|
|
799
|
+
# Composed from three pieces rather than branched on, because the
|
|
800
|
+
# identity and the schema contract are answers to two different
|
|
801
|
+
# questions and every combination of them is reachable: a binary can
|
|
802
|
+
# fail `--version` and answer `--schema-source`, and the line then has
|
|
803
|
+
# to say both things rather than the first one only.
|
|
804
|
+
#
|
|
805
|
+
# @return [String]
|
|
806
|
+
def provenance
|
|
807
|
+
"validated by #{@identity || 'the binary'} at #{@path} (#{ENV_VAR})" \
|
|
808
|
+
"#{identity_clause}#{schema_contract_clause}"
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
# @param paths [Enumerable<String>] spec files, as the caller named them
|
|
812
|
+
# @return [Array<Linter::Result>] in argument order
|
|
813
|
+
# @raise [ValidatorError] on any failure to obtain a report
|
|
814
|
+
def check(paths)
|
|
815
|
+
paths = paths.to_a
|
|
816
|
+
# `validate-intent --source` with no argument is a usage error (exit
|
|
817
|
+
# 2) — and there is nothing to ask it. `CLI#report_selection` has
|
|
818
|
+
# already warned about the empty selection.
|
|
819
|
+
return [] if paths.empty?
|
|
820
|
+
|
|
821
|
+
batch(paths).flat_map { |group| check_batch(group) }
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
private
|
|
825
|
+
|
|
826
|
+
def describe
|
|
827
|
+
"the validator backend at #{@path} (#{ENV_VAR})"
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# A bare command name is refused rather than resolved against PATH, and
|
|
831
|
+
# the refusal says how to fix it. Which binary a CI job validated
|
|
832
|
+
# against should not depend on what else happens to be installed and in
|
|
833
|
+
# what order — and the failure that arrangement produces is a run that
|
|
834
|
+
# succeeded against a different validator, which nothing downstream can
|
|
835
|
+
# detect. One shell substitution buys a value that means one thing.
|
|
836
|
+
def path_hint
|
|
837
|
+
return "" if @path.include?(File::SEPARATOR)
|
|
838
|
+
|
|
839
|
+
" — #{ENV_VAR} takes a path, not a command name; try #{ENV_VAR}=\"$(command -v #{@path})\""
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
# Asks the binary who it is. Never raises: every way this can go wrong
|
|
843
|
+
# is "identity unavailable", which {#provenance} reports in words.
|
|
844
|
+
#
|
|
845
|
+
# `--version` is position-independent in the port and answers on stdout
|
|
846
|
+
# with exit 0 (`cmd/validate-intent/main.go`), so the whole answer is
|
|
847
|
+
# `stdout` and the exit code is a usable gate. A pre-slice-6 build has
|
|
848
|
+
# no such flag: it reads `--version` as a filename, writes "no file(s)
|
|
849
|
+
# match" to STDERR and exits 1 — caught by the exit check, with the
|
|
850
|
+
# stdout checks behind it in case some other build answers differently.
|
|
851
|
+
#
|
|
852
|
+
# The `SystemCallError` rescue is local rather than shared with {#run}'s
|
|
853
|
+
# because the two mean opposite things: there, a binary that will not
|
|
854
|
+
# execute is a run with no verdict and an exit 2; here it is a question
|
|
855
|
+
# that went unanswered, and {#check} will reach the same failure a
|
|
856
|
+
# moment later with the diagnostic that belongs to it.
|
|
857
|
+
#
|
|
858
|
+
# @return [String, nil]
|
|
859
|
+
def probe_identity
|
|
860
|
+
stdout, _stderr, status = Open3.capture3(@path, "--version")
|
|
861
|
+
return nil unless status.success?
|
|
862
|
+
|
|
863
|
+
identity_line(stdout)
|
|
864
|
+
rescue SystemCallError
|
|
865
|
+
nil
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
# The one shape check, and it is about renderability rather than
|
|
869
|
+
# format — see {IDENTITY_MAX_BYTES}. Anything that survives is returned
|
|
870
|
+
# byte for byte apart from surrounding whitespace, which is the
|
|
871
|
+
# newline `--version` ends with.
|
|
872
|
+
#
|
|
873
|
+
# The encoding test comes FIRST because `String#strip` raises
|
|
874
|
+
# `Encoding::CompatibilityError` on an invalid byte sequence, and an
|
|
875
|
+
# exception here would reach {CLI}'s backstop and turn a binary with an
|
|
876
|
+
# odd `--version` into `internal error:` and an exit 2 — the exact cost
|
|
877
|
+
# this probe is not allowed to have.
|
|
878
|
+
#
|
|
879
|
+
# @return [String, nil]
|
|
880
|
+
def identity_line(stdout)
|
|
881
|
+
return nil unless stdout.to_s.valid_encoding?
|
|
882
|
+
|
|
883
|
+
text = stdout.to_s.strip
|
|
884
|
+
return nil if text.empty? || text.bytesize > IDENTITY_MAX_BYTES
|
|
885
|
+
# A control character — a second line, an ANSI escape, a NUL — would
|
|
886
|
+
# break the single line this becomes, or forge extra ones.
|
|
887
|
+
return nil if text.match?(/[[:cntrl:]]/)
|
|
888
|
+
|
|
889
|
+
text
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
# Asks the binary which schema a run on this host would ENFORCE. Never
|
|
893
|
+
# raises, for the same reasons {#probe_identity} never does and with one
|
|
894
|
+
# more of its own.
|
|
895
|
+
#
|
|
896
|
+
# `--schema-source` is position-independent and answers on stdout with
|
|
897
|
+
# exit 0 (`cmd/validate-intent/schemasource.go`). Three ways it does
|
|
898
|
+
# not, and all three are "unavailable":
|
|
899
|
+
#
|
|
900
|
+
# * a build predating open-test-intent slice 19 reads the flag as a
|
|
901
|
+
# filename, writes `no file(s) match '--schema-source'` to stderr
|
|
902
|
+
# and exits 1. Refusing those runs would be enforcing a contract by
|
|
903
|
+
# breaking every binary too old to state one;
|
|
904
|
+
# * a schema that EXISTS beside the binary and cannot be read,
|
|
905
|
+
# decoded or compiled exits 2 with the "could not load schema"
|
|
906
|
+
# diagnostic. That is a real failure, but it is not this check's to
|
|
907
|
+
# report: {#check} reaches it a moment later, from the verdict path,
|
|
908
|
+
# with the diagnostic that belongs to it. Turning it into a schema
|
|
909
|
+
# CONTRACT error here would rename somebody's broken installation
|
|
910
|
+
# into a divergence that does not exist;
|
|
911
|
+
# * anything this cannot read as the one line the flag documents.
|
|
912
|
+
#
|
|
913
|
+
# @return [Hash{Symbol => String}, nil]
|
|
914
|
+
def probe_schema_source
|
|
915
|
+
stdout, _stderr, status = Open3.capture3(@path, SCHEMA_SOURCE_FLAG)
|
|
916
|
+
return nil unless status.success?
|
|
917
|
+
|
|
918
|
+
schema_source(stdout)
|
|
919
|
+
rescue SystemCallError
|
|
920
|
+
nil
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
# The parse, guarded exactly as {#identity_line} is and for exactly the
|
|
924
|
+
# same reasons — the encoding test first because `String#strip` raises
|
|
925
|
+
# on an invalid byte sequence, the control-character test because a
|
|
926
|
+
# second line or an ANSI escape would break or forge the single line
|
|
927
|
+
# {CLI} prints per run.
|
|
928
|
+
#
|
|
929
|
+
# Returning nil on a shape this does not recognise is the degrade rule,
|
|
930
|
+
# and it is also this method's one hazard: an unreadable answer and an
|
|
931
|
+
# old binary are indistinguishable downstream by design, so a bug here
|
|
932
|
+
# would be silent. Which is why {SCHEMA_SOURCE_PATTERN} is asserted
|
|
933
|
+
# against recorded output of the real binary rather than against a line
|
|
934
|
+
# written to fit it.
|
|
935
|
+
#
|
|
936
|
+
# @return [Hash{Symbol => String}, nil]
|
|
937
|
+
def schema_source(stdout)
|
|
938
|
+
return nil unless stdout.to_s.valid_encoding?
|
|
939
|
+
|
|
940
|
+
text = stdout.to_s.strip
|
|
941
|
+
return nil if text.empty? || text.bytesize > SCHEMA_SOURCE_MAX_BYTES
|
|
942
|
+
return nil if text.match?(/[[:cntrl:]]/)
|
|
943
|
+
|
|
944
|
+
match = SCHEMA_SOURCE_PATTERN.match(text)
|
|
945
|
+
return nil if match.nil?
|
|
946
|
+
|
|
947
|
+
{ origin: match[:origin], digest: match[:digest].downcase }
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
# The comparison itself. Returns the state {#schema_contract} reports,
|
|
951
|
+
# and raises for the one band that is a refusal.
|
|
952
|
+
#
|
|
953
|
+
# The ENFORCED digest wins whenever there is one, because it is the only
|
|
954
|
+
# answer to the question being asked: a schema found beside the binary
|
|
955
|
+
# beats the compiled-in copy at validation time, so the carried digest
|
|
956
|
+
# can match while the run enforces other bytes, and can differ while the
|
|
957
|
+
# run enforces ours. The carried comparison below is what happens when
|
|
958
|
+
# the binary is too old to be asked — unchanged, hedge included.
|
|
959
|
+
#
|
|
960
|
+
# The carried arm reads {#identity}, which {#verify!} has just assigned
|
|
961
|
+
# — not a second `--version` — so the digest it compares and the name in
|
|
962
|
+
# the provenance line are guaranteed to have come from the same process.
|
|
963
|
+
# That promise is intact and this method still keeps it. It does NOT
|
|
964
|
+
# extend to the enforced digest, which is necessarily a second process:
|
|
965
|
+
# what that answer promises is what a run started at that moment would
|
|
966
|
+
# load, which is the most any answer to this question can promise, since
|
|
967
|
+
# the file beside a binary can change between two lines of a script.
|
|
968
|
+
#
|
|
969
|
+
# @return [Symbol]
|
|
970
|
+
# @raise [ValidatorError] when the compared digests are both known and differ
|
|
971
|
+
def verify_schema_contract!
|
|
972
|
+
return verify_enforced_contract! unless @enforced_schema.nil?
|
|
973
|
+
return :unidentified if @identity.nil?
|
|
974
|
+
|
|
975
|
+
carried = @identity[SCHEMA_DIGEST_PATTERN, 1]&.downcase
|
|
976
|
+
return :unreported if carried.nil?
|
|
977
|
+
|
|
978
|
+
verify_carried_contract!(carried)
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
# The strong arm: the binary told us which bytes its runs load.
|
|
982
|
+
def verify_enforced_contract!
|
|
983
|
+
vendored = vendored_schema_digest
|
|
984
|
+
return :unreadable if vendored.nil?
|
|
985
|
+
return :enforced if @enforced_schema[:digest] == vendored
|
|
986
|
+
|
|
987
|
+
# The origin joins the two digests here for the same reason they are
|
|
988
|
+
# both spelled in full: the reader's next question is which half to
|
|
989
|
+
# move, and `<embedded schema>` and `/usr/local/schemas/…` are fixed
|
|
990
|
+
# by entirely different actions — rebuild or reinstall the binary
|
|
991
|
+
# versus delete or replace a file on this host. Without it the
|
|
992
|
+
# message would state a disagreement and withhold the one fact that
|
|
993
|
+
# says where to go.
|
|
994
|
+
raise ValidatorError,
|
|
995
|
+
"#{describe} reports enforcing schema sha256:#{@enforced_schema[:digest]}, loaded from " \
|
|
996
|
+
"#{@enforced_schema[:origin]}, but this gem vendors sha256:#{vendored} — the two halves " \
|
|
997
|
+
"would enforce different contracts, so this run would produce a verdict this gem cannot " \
|
|
998
|
+
"stand behind; #{self_description}"
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
# The fallback, for a binary that cannot be asked what it enforces.
|
|
1002
|
+
# Byte for byte the comparison and the message this file had before
|
|
1003
|
+
# `--schema-source` existed.
|
|
1004
|
+
def verify_carried_contract!(carried)
|
|
1005
|
+
vendored = vendored_schema_digest
|
|
1006
|
+
return :unreadable if vendored.nil?
|
|
1007
|
+
return :matched if carried == vendored
|
|
1008
|
+
|
|
1009
|
+
# Both digests, spelled in full. A message naming only one of them
|
|
1010
|
+
# would send the reader to compute the other by hand, and the whole
|
|
1011
|
+
# point of this diagnostic is that the two halves are in different
|
|
1012
|
+
# places — one inside a binary, one inside an installed gem — and
|
|
1013
|
+
# neither is inspectable from where the other lives.
|
|
1014
|
+
#
|
|
1015
|
+
# The identity is named too, because the question that comes straight
|
|
1016
|
+
# after "these differ" is "WHICH build is this, so I know which half
|
|
1017
|
+
# to move" — and the path alone does not answer it: the same path can
|
|
1018
|
+
# hold a different binary tomorrow. The probe already holds the
|
|
1019
|
+
# version string, and this refusal happens above {#provenance}, so
|
|
1020
|
+
# unless the error says it nothing in the run ever does.
|
|
1021
|
+
raise ValidatorError,
|
|
1022
|
+
"#{describe} reports carrying schema sha256:#{carried}, but this gem vendors " \
|
|
1023
|
+
"sha256:#{vendored} — the two halves would enforce different contracts, so this run " \
|
|
1024
|
+
"would produce a verdict this gem cannot stand behind; the binary identifies itself " \
|
|
1025
|
+
"as #{@identity}"
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
# How a refusal names the build it read a digest from. The carried arm
|
|
1029
|
+
# has an identity by construction; the enforced arm does not, because
|
|
1030
|
+
# the two probes are independent and a binary that answers one can fail
|
|
1031
|
+
# the other. Saying "identifies itself as " and then nothing would be
|
|
1032
|
+
# the worst of the three options.
|
|
1033
|
+
def self_description
|
|
1034
|
+
return "the binary could not identify itself" if @identity.nil?
|
|
1035
|
+
|
|
1036
|
+
"the binary identifies itself as #{@identity}"
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
# This gem's own contract, digested from the file at runtime. Never a
|
|
1040
|
+
# constant: see the module comment.
|
|
1041
|
+
#
|
|
1042
|
+
# nil rather than an exception on an unreadable file, which puts a
|
|
1043
|
+
# broken installation in the "could not check" band instead of failing
|
|
1044
|
+
# a run that does not otherwise read this file at all. `Errno::ENOENT`
|
|
1045
|
+
# and `Errno::EACCES` are `SystemCallError`s; `IOError` covers the rest
|
|
1046
|
+
# of the ways a read can end without one.
|
|
1047
|
+
#
|
|
1048
|
+
# @return [String, nil] 64 lower-case hex digits
|
|
1049
|
+
def vendored_schema_digest
|
|
1050
|
+
Digest::SHA256.file(SCHEMA_PATH).hexdigest
|
|
1051
|
+
rescue SystemCallError, IOError
|
|
1052
|
+
nil
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
# The identity half of the provenance line's tail: present exactly when
|
|
1056
|
+
# the binary could not name itself, and separate from the schema clause
|
|
1057
|
+
# because the two probes fail independently. With no `--schema-source`
|
|
1058
|
+
# answer this composes into the sentence this file printed before that
|
|
1059
|
+
# flag existed; with one, it stops a run whose identity is unknown from
|
|
1060
|
+
# silently dropping the fact.
|
|
1061
|
+
def identity_clause
|
|
1062
|
+
@identity.nil? ? ", which could not report its identity" : ""
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
# The tail of the provenance line, one sentence per state.
|
|
1066
|
+
#
|
|
1067
|
+
# The ENFORCED arm is the only one that may speak about the run: it is
|
|
1068
|
+
# the answer to "which bytes would a verdict be produced under", asked
|
|
1069
|
+
# of the loader itself, and it names the origin so the reader can see
|
|
1070
|
+
# WHICH copy won. The matched (carried) arm below is careful twice over
|
|
1071
|
+
# instead — "reports carrying" attributes the claim to the binary, and
|
|
1072
|
+
# the clause after the dash refuses the stronger reading the reader
|
|
1073
|
+
# would otherwise supply for free. That hedge is kept rather than
|
|
1074
|
+
# retired because on the path that still reaches it — a binary too old
|
|
1075
|
+
# to answer `--schema-source` — it remains exactly true.
|
|
1076
|
+
def schema_contract_clause
|
|
1077
|
+
case @schema_contract
|
|
1078
|
+
when :enforced
|
|
1079
|
+
" — it reports enforcing the schema this gem vendors, loaded from #{@enforced_schema[:origin]}"
|
|
1080
|
+
when :matched
|
|
1081
|
+
", which reports carrying the schema this gem vendors — the contract it carries, " \
|
|
1082
|
+
"not necessarily the one this run enforced"
|
|
1083
|
+
when :unreadable
|
|
1084
|
+
", whose schema contract could not be checked: this gem could not read its own vendored copy"
|
|
1085
|
+
when :unidentified
|
|
1086
|
+
", so the schema contract it carries could not be checked"
|
|
1087
|
+
else
|
|
1088
|
+
", which reports no schema digest, so the contract it carries could not be checked"
|
|
1089
|
+
end
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
# Greedy, order-preserving, and never empty: a single path longer than
|
|
1093
|
+
# the byte budget still gets its own batch rather than being silently
|
|
1094
|
+
# dropped from the run.
|
|
1095
|
+
#
|
|
1096
|
+
# That is a weaker promise than "it still gets checked", and
|
|
1097
|
+
# deliberately so. A path over `MAX_ARG_STRLEN` (128 KiB) is refused by
|
|
1098
|
+
# `execve` however it is batched — no grouping can make one argument
|
|
1099
|
+
# smaller — so it lands in {#run}'s `SystemCallError` rescue and the run
|
|
1100
|
+
# is exit 2. What batching buys is that the failure is LOUD: the file is
|
|
1101
|
+
# never quietly omitted from a report that then calls itself clean,
|
|
1102
|
+
# which is the silent-omission shape this project keeps naming.
|
|
1103
|
+
def batch(paths)
|
|
1104
|
+
groups = [[]]
|
|
1105
|
+
bytes = 0
|
|
1106
|
+
|
|
1107
|
+
paths.each do |path|
|
|
1108
|
+
size = ValidatorBackend.escape_glob(path).bytesize + 1
|
|
1109
|
+
if !groups.last.empty? && (bytes + size > MAX_ARG_BYTES || groups.last.length >= MAX_BATCH_FILES)
|
|
1110
|
+
groups << []
|
|
1111
|
+
bytes = 0
|
|
1112
|
+
end
|
|
1113
|
+
groups.last << path
|
|
1114
|
+
bytes += size
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
groups
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
def check_batch(paths)
|
|
1121
|
+
# The pattern list keeps DUPLICATES. `specguard-lint a a` reports
|
|
1122
|
+
# every annotation twice — the Ruby path checks the files it is
|
|
1123
|
+
# handed, in the order it is handed them, without de-duplicating —
|
|
1124
|
+
# and so does the port. Deriving the argument vector from the
|
|
1125
|
+
# lookup hash below instead would silently collapse them and halve
|
|
1126
|
+
# the report.
|
|
1127
|
+
patterns = paths.map { |path| ValidatorBackend.escape_glob(path) }
|
|
1128
|
+
# escaped pattern -> the path the caller named. Used to undo
|
|
1129
|
+
# {escape_glob} on a `no-match`, whose `file` is the pattern rather
|
|
1130
|
+
# than a path that exists. Collapsing duplicates here is harmless:
|
|
1131
|
+
# the same pattern always maps back to the same path.
|
|
1132
|
+
originals = patterns.zip(paths).to_h
|
|
1133
|
+
|
|
1134
|
+
document = run(patterns)
|
|
1135
|
+
findings = fetch_findings(document)
|
|
1136
|
+
check_annotation_count(document, findings)
|
|
1137
|
+
|
|
1138
|
+
findings.map { |finding| result_for(finding, originals) }
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
# @return [Hash] the parsed report document
|
|
1142
|
+
def run(patterns)
|
|
1143
|
+
# No shell: the argument vector goes straight to execve, so a path
|
|
1144
|
+
# containing a space or a quote needs no quoting and cannot be
|
|
1145
|
+
# re-split. (`--json` is position-independent in the port; it is
|
|
1146
|
+
# written next to `--source` for readability.)
|
|
1147
|
+
stdout, stderr, status = Open3.capture3(@path, "--source", "--json", *patterns)
|
|
1148
|
+
|
|
1149
|
+
check_status(status, stderr)
|
|
1150
|
+
parse_document(stdout, stderr)
|
|
1151
|
+
rescue SystemCallError => e
|
|
1152
|
+
# ENOENT/EACCES between #verify! and here (a binary deleted or
|
|
1153
|
+
# chmod'ed mid-run), ENOEXEC for a file that is not a program, E2BIG
|
|
1154
|
+
# if the batching above is ever outgrown.
|
|
1155
|
+
raise ValidatorError, "#{describe} could not be executed: #{e.message}"
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
# The port's own contract: 0 when everything checked was valid, 1 when
|
|
1159
|
+
# something was not. Anything else — 2 for a schema it could not load
|
|
1160
|
+
# or a mode it refuses, or death by signal — means it produced no
|
|
1161
|
+
# verdict, and neither may this run.
|
|
1162
|
+
def check_status(status, stderr)
|
|
1163
|
+
return if [0, 1].include?(status.exitstatus)
|
|
1164
|
+
|
|
1165
|
+
raise ValidatorError, "#{describe} #{exit_description(status)}#{trailing(stderr)}"
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
def exit_description(status)
|
|
1169
|
+
return "exited #{status.exitstatus}" if status.exitstatus
|
|
1170
|
+
|
|
1171
|
+
"did not exit normally (#{status})"
|
|
1172
|
+
end
|
|
1173
|
+
|
|
1174
|
+
def parse_document(stdout, stderr)
|
|
1175
|
+
document = decode(stdout)
|
|
1176
|
+
unless document.is_a?(Hash)
|
|
1177
|
+
raise ValidatorError, "#{describe} emitted a #{document.class} where a JSON object was expected"
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
# `--source` is the only mode this asks for. A document announcing
|
|
1181
|
+
# anything else means the argument vector above no longer says what
|
|
1182
|
+
# this code thinks it says — and a report read under the wrong mode's
|
|
1183
|
+
# counting rules is worse than no report.
|
|
1184
|
+
mode = document["mode"]
|
|
1185
|
+
raise ValidatorError, "#{describe} reported mode #{mode.inspect}, expected \"source\"" unless mode == "source"
|
|
1186
|
+
|
|
1187
|
+
document
|
|
1188
|
+
rescue JSON::ParserError => e
|
|
1189
|
+
raise ValidatorError,
|
|
1190
|
+
"#{describe} did not emit a JSON document: #{e.message}#{trailing(stderr)}"
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
# An unpaired HIGH surrogate escape in the report text, e.g. `\ud800`
|
|
1194
|
+
# not followed by a low surrogate. Ruby's parser refuses it outright.
|
|
1195
|
+
#
|
|
1196
|
+
# NO CURRENT BINARY CAN EMIT ONE, and this is kept anyway. An earlier
|
|
1197
|
+
# revision of this comment said "CPython decodes it and keeps it, so the
|
|
1198
|
+
# port emits it" — that was true of the deleted Python reference and is
|
|
1199
|
+
# FALSE of the port since SPGD-403, which refuses an unpaired surrogate
|
|
1200
|
+
# escape at PARSE time under PROTOCOL.md §1.1(a) and reports the finding
|
|
1201
|
+
# with `intent: null`. Measured against the shipped binary: a payload
|
|
1202
|
+
# carrying `\ud800` yields `"kind": "parse"`, `"intent": null`, and a
|
|
1203
|
+
# report that parses with a plain `JSON.parse` and no options at all.
|
|
1204
|
+
#
|
|
1205
|
+
# It stays because the gem does not choose which binary it is pointed
|
|
1206
|
+
# at. `SPECGUARD_VALIDATE_INTENT` names an arbitrary path, and a report
|
|
1207
|
+
# from an OLDER build — or from a third-party implementation of the
|
|
1208
|
+
# protocol, which the vendor-neutral wording invites — is exactly the
|
|
1209
|
+
# input this class must not turn into a batch-wide exit 2. The recovery
|
|
1210
|
+
# is unreachable from the binary in this repository and cheap to keep;
|
|
1211
|
+
# deleting it would be trading a real safety net for tidiness.
|
|
1212
|
+
UNPAIRED_HIGH_SURROGATE = /\\u[dD][89abAB][0-9a-fA-F]{2}(?!\\u[dD][c-fC-F][0-9a-fA-F]{2})/
|
|
1213
|
+
private_constant :UNPAIRED_HIGH_SURROGATE
|
|
1214
|
+
|
|
1215
|
+
# What the escape above is rewritten to: U+FFFD REPLACEMENT CHARACTER,
|
|
1216
|
+
# chosen because it is always valid and is the same six characters wide
|
|
1217
|
+
# as the `\udXXX` it stands in for, so byte offsets in any parser error
|
|
1218
|
+
# still point where they did.
|
|
1219
|
+
UNREADABLE_ESCAPE = "\\ufffd"
|
|
1220
|
+
private_constant :UNREADABLE_ESCAPE
|
|
1221
|
+
|
|
1222
|
+
# Parse the report, recovering the VERDICTS from a document whose
|
|
1223
|
+
# PAYLOADS Ruby cannot read.
|
|
1224
|
+
#
|
|
1225
|
+
# Since the report began carrying `intent`, the document inherits the
|
|
1226
|
+
# payload's parser domain, and an unpaired high surrogate makes the
|
|
1227
|
+
# whole thing unparseable here. Letting that raise would cost the batch
|
|
1228
|
+
# an exit 2 — up to MAX_BATCH_FILES files reported as "the validator is
|
|
1229
|
+
# broken" — over one annotation that the port validated successfully and
|
|
1230
|
+
# that this gem, before the key existed, simply shipped as
|
|
1231
|
+
# `unannotated`. That is strictly worse than the behaviour the `intent`
|
|
1232
|
+
# key was added to improve, so it is not allowed to happen.
|
|
1233
|
+
#
|
|
1234
|
+
# THE RECOVERY DROPS EVERY PAYLOAD IN THE DOCUMENT AND KEEPS EVERY
|
|
1235
|
+
# VERDICT. Both halves are deliberate:
|
|
1236
|
+
#
|
|
1237
|
+
# * Keeping the verdicts is safe. `file`, `line`, `ok`, `kind` and
|
|
1238
|
+
# `errors` are the tool's own vocabulary and its own prose — no author
|
|
1239
|
+
# text reaches them — so they are unaffected by whatever the payload
|
|
1240
|
+
# contained. The exit code, the FAIL blocks and the counts come out
|
|
1241
|
+
# exactly as they would have.
|
|
1242
|
+
#
|
|
1243
|
+
# * Dropping ALL the payloads, rather than just the offending one, is
|
|
1244
|
+
# the conservative direction and the only one that cannot corrupt.
|
|
1245
|
+
# The substitution below operates on TEXT, and JSON text can contain
|
|
1246
|
+
# an escaped backslash — an author who literally wrote `\\ud800`
|
|
1247
|
+
# inside their annotation has a perfectly valid payload whose bytes
|
|
1248
|
+
# this regex cannot distinguish from a real escape without
|
|
1249
|
+
# re-implementing the scanner's backslash accounting. Rewriting that
|
|
1250
|
+
# author's payload and then SHIPPING it would put an annotation on the
|
|
1251
|
+
# platform that is not the one they wrote, undetectably (KB SPGD-78 in
|
|
1252
|
+
# value form). Dropping is a lie nobody acts on; corrupting is one
|
|
1253
|
+
# everybody does.
|
|
1254
|
+
#
|
|
1255
|
+
# The blast radius is bounded by never being worse than the status quo:
|
|
1256
|
+
# every annotation in such a document was ALREADY arriving unannotated
|
|
1257
|
+
# before this key existed, so the recovery costs nothing that was
|
|
1258
|
+
# previously being delivered.
|
|
1259
|
+
#
|
|
1260
|
+
# The rewrite SUBSTITUTES an equal-length escape rather than DELETING
|
|
1261
|
+
# the match, and that is structural rather than cosmetic. Where the
|
|
1262
|
+
# regex has landed on an author's literal `\\ud800`, the match begins at
|
|
1263
|
+
# the SECOND backslash — so deleting it strands the first, and an orphan
|
|
1264
|
+
# backslash escapes whatever now follows. Measured on a document
|
|
1265
|
+
# carrying a real lone surrogate and author-typed text together:
|
|
1266
|
+
# `lit\\ud800Xtail` deletes to `lit\Xtail` (`invalid escape character in
|
|
1267
|
+
# string`), and a match at the end of a value strands the backslash onto
|
|
1268
|
+
# the closing quote (`unexpected end of input`). Both raise HERE, inside
|
|
1269
|
+
# the recovery, and so become precisely the exit 2 the recovery exists
|
|
1270
|
+
# to prevent. Substituting keeps the backslash paired; all three cases
|
|
1271
|
+
# parse.
|
|
1272
|
+
#
|
|
1273
|
+
# The corruption objection above does not reach the substituted value,
|
|
1274
|
+
# because {#drop_every_payload} nils every `intent` on the very next
|
|
1275
|
+
# line: the rewritten text is read for its VERDICTS, and the value the
|
|
1276
|
+
# substitution produced is never shipped and never seen. Substitution is
|
|
1277
|
+
# strictly safer than deletion structurally and identical to it on
|
|
1278
|
+
# corruption — so the trade that bullet describes is not being re-made
|
|
1279
|
+
# here, only carried out without the self-inflicted parse error.
|
|
1280
|
+
def decode(stdout)
|
|
1281
|
+
JSON.parse(stdout, **PARSE_OPTIONS)
|
|
1282
|
+
rescue JSON::ParserError
|
|
1283
|
+
# Only a document that actually carries one gets rewritten. A parse
|
|
1284
|
+
# failure with no unpaired surrogate in it is a real one and is
|
|
1285
|
+
# re-raised for the handler above to report.
|
|
1286
|
+
raise unless stdout.match?(UNPAIRED_HIGH_SURROGATE)
|
|
1287
|
+
|
|
1288
|
+
# Block form: in a replacement STRING the backslash would be read as
|
|
1289
|
+
# the start of a backreference escape, which is the same class of bug
|
|
1290
|
+
# as the one being fixed.
|
|
1291
|
+
rewritten = stdout.gsub(UNPAIRED_HIGH_SURROGATE) { UNREADABLE_ESCAPE }
|
|
1292
|
+
drop_every_payload(JSON.parse(rewritten, **PARSE_OPTIONS))
|
|
1293
|
+
end
|
|
1294
|
+
|
|
1295
|
+
# Sets every finding's `intent` to nil in place. Written over the parsed
|
|
1296
|
+
# document rather than by not-parsing the key, because the findings are
|
|
1297
|
+
# what the rest of this class reads and `nil` is already its word for
|
|
1298
|
+
# "no payload here" — so nothing downstream needs to know a recovery
|
|
1299
|
+
# happened.
|
|
1300
|
+
def drop_every_payload(document)
|
|
1301
|
+
findings = document.is_a?(Hash) ? document["findings"] : nil
|
|
1302
|
+
return document unless findings.is_a?(Array)
|
|
1303
|
+
|
|
1304
|
+
findings.each { |finding| finding["intent"] = nil if finding.is_a?(Hash) }
|
|
1305
|
+
document
|
|
1306
|
+
end
|
|
1307
|
+
|
|
1308
|
+
def fetch_findings(document)
|
|
1309
|
+
findings = document["findings"]
|
|
1310
|
+
raise ValidatorError, "#{describe} emitted no `findings` array" unless findings.is_a?(Array)
|
|
1311
|
+
unless findings.all?(Hash)
|
|
1312
|
+
raise ValidatorError, "#{describe} emitted a `findings` entry that is not a JSON object"
|
|
1313
|
+
end
|
|
1314
|
+
|
|
1315
|
+
findings
|
|
1316
|
+
end
|
|
1317
|
+
|
|
1318
|
+
# The port counts ANNOTATION SITES EXAMINED in `summary.annotations`,
|
|
1319
|
+
# and a read failure or a no-match contributes none. So the count and
|
|
1320
|
+
# the findings are two independent statements about the same run, and
|
|
1321
|
+
# comparing them is what catches output truncated by a full pipe or a
|
|
1322
|
+
# killed batch — a case that would otherwise show up as a *smaller*
|
|
1323
|
+
# clean report, which is precisely the shape nobody notices.
|
|
1324
|
+
def check_annotation_count(document, findings)
|
|
1325
|
+
declared = document.dig("summary", "annotations")
|
|
1326
|
+
unless declared.is_a?(Integer)
|
|
1327
|
+
raise ValidatorError, "#{describe} emitted no integer `summary.annotations`"
|
|
1328
|
+
end
|
|
1329
|
+
|
|
1330
|
+
seen = findings.count { |finding| !NON_ANNOTATION_KINDS.include?(finding["kind"]) }
|
|
1331
|
+
return if seen == declared
|
|
1332
|
+
|
|
1333
|
+
raise ValidatorError,
|
|
1334
|
+
"#{describe} reported #{declared} annotation(s) but emitted #{seen} annotation finding(s)"
|
|
1335
|
+
end
|
|
1336
|
+
|
|
1337
|
+
def result_for(finding, originals)
|
|
1338
|
+
file = finding["file"]
|
|
1339
|
+
raise ValidatorError, "#{describe} emitted a finding with no `file`" unless file.is_a?(String)
|
|
1340
|
+
|
|
1341
|
+
kind = finding["kind"]
|
|
1342
|
+
errors = finding_errors(finding, file)
|
|
1343
|
+
# Absent on a binary too old to carry it, which reads as nil — the
|
|
1344
|
+
# same answer as "this finding had no payload". That is the intended
|
|
1345
|
+
# behaviour and not a gap to guard: an annotation whose payload could
|
|
1346
|
+
# not be obtained is `unannotated`, whatever the reason. There is no
|
|
1347
|
+
# Ruby re-parse to fall back to (SPGD-340 owner decision), so there is
|
|
1348
|
+
# no fallback to get wrong.
|
|
1349
|
+
intent = finding["intent"]
|
|
1350
|
+
|
|
1351
|
+
return passing_result(finding, file, kind, errors, intent) if finding["ok"] == true
|
|
1352
|
+
|
|
1353
|
+
failing_result(finding, file, kind, errors, originals, intent)
|
|
1354
|
+
end
|
|
1355
|
+
|
|
1356
|
+
def finding_errors(finding, file)
|
|
1357
|
+
errors = finding["errors"]
|
|
1358
|
+
unless errors.is_a?(Array) && errors.all?(String)
|
|
1359
|
+
raise ValidatorError, "#{describe} emitted a finding on #{file} whose `errors` is not a list of strings"
|
|
1360
|
+
end
|
|
1361
|
+
|
|
1362
|
+
errors
|
|
1363
|
+
end
|
|
1364
|
+
|
|
1365
|
+
def passing_result(finding, file, kind, errors, intent)
|
|
1366
|
+
unless kind.nil? && errors.empty?
|
|
1367
|
+
raise ValidatorError,
|
|
1368
|
+
"#{describe} emitted a passing finding on #{file} carrying kind #{kind.inspect} " \
|
|
1369
|
+
"and #{errors.length} error(s)"
|
|
1370
|
+
end
|
|
1371
|
+
|
|
1372
|
+
Linter::Result.new(file: file, line: line_of(finding, file), intent: intent)
|
|
1373
|
+
end
|
|
1374
|
+
|
|
1375
|
+
def failing_result(finding, file, kind, errors, originals, intent)
|
|
1376
|
+
mapped = KINDS[kind]
|
|
1377
|
+
# An unknown kind is a vocabulary the port grew and this file has not
|
|
1378
|
+
# been taught. Guessing would render it under whichever branch of
|
|
1379
|
+
# #report_failure it fell into by accident; refusing keeps the
|
|
1380
|
+
# divergence visible and costs one exit 2.
|
|
1381
|
+
raise ValidatorError, "#{describe} emitted the unknown kind #{kind.inspect} on #{file}" if mapped.nil?
|
|
1382
|
+
raise ValidatorError, "#{describe} emitted a failing finding on #{file} with no errors" if errors.empty?
|
|
1383
|
+
|
|
1384
|
+
return schema_result(finding, file, errors, intent) if kind == "schema"
|
|
1385
|
+
|
|
1386
|
+
# Extraction, parse, read and no-match findings have no payload by
|
|
1387
|
+
# construction — the port emits null for all four — so the intent is
|
|
1388
|
+
# not threaded past here. Reading it anyway would invent a case the
|
|
1389
|
+
# producer cannot produce.
|
|
1390
|
+
problem_result(finding, file, kind, errors, originals)
|
|
1391
|
+
end
|
|
1392
|
+
|
|
1393
|
+
def schema_result(finding, file, errors, intent)
|
|
1394
|
+
Linter::Result.new(file: file, line: line_of(finding, file),
|
|
1395
|
+
kind: Finding::KIND_SCHEMA, reasons: errors, intent: intent)
|
|
1396
|
+
end
|
|
1397
|
+
|
|
1398
|
+
def problem_result(finding, file, kind, errors, originals)
|
|
1399
|
+
unless errors.length == 1
|
|
1400
|
+
# See the module comment: joining these would collapse several
|
|
1401
|
+
# lines the tool meant to print into one em-dashed sentence.
|
|
1402
|
+
raise ValidatorError,
|
|
1403
|
+
"#{describe} emitted #{errors.length} errors on a #{kind} finding for #{file}, " \
|
|
1404
|
+
"where the report can render exactly one"
|
|
1405
|
+
end
|
|
1406
|
+
|
|
1407
|
+
return no_match_result(file, originals) if kind == "no-match"
|
|
1408
|
+
|
|
1409
|
+
Linter::Result.new(file: file, line: line_of(finding, file),
|
|
1410
|
+
kind: KINDS.fetch(kind), problem: errors.first)
|
|
1411
|
+
end
|
|
1412
|
+
|
|
1413
|
+
# A path that matched nothing. Reported against the path the caller
|
|
1414
|
+
# named — `originals` undoes {ValidatorBackend.escape_glob} — and in
|
|
1415
|
+
# the gem's own words, because Go's "no file(s) match <pattern>" is a
|
|
1416
|
+
# statement about a glob pattern this CLI does not have and would carry
|
|
1417
|
+
# the escaped spelling into the report.
|
|
1418
|
+
def no_match_result(pattern, originals)
|
|
1419
|
+
Linter::Result.new(file: originals.fetch(pattern, pattern), line: 0,
|
|
1420
|
+
kind: Finding::KIND_READ,
|
|
1421
|
+
problem: "could not read file: no file at this path")
|
|
1422
|
+
end
|
|
1423
|
+
|
|
1424
|
+
# `line` is null exactly where the finding is not line-scoped, which is
|
|
1425
|
+
# the same rule `Linter::Result#location` applies from the other side —
|
|
1426
|
+
# it drops the line for KIND_READ. 0 is the sentinel `Scanner` already
|
|
1427
|
+
# uses for a finding that never reached a line.
|
|
1428
|
+
def line_of(finding, file)
|
|
1429
|
+
line = finding["line"]
|
|
1430
|
+
return 0 if line.nil?
|
|
1431
|
+
raise ValidatorError, "#{describe} emitted a non-integer `line` on #{file}" unless line.is_a?(Integer)
|
|
1432
|
+
|
|
1433
|
+
line
|
|
1434
|
+
end
|
|
1435
|
+
|
|
1436
|
+
# The binary's stderr, appended to a diagnostic when it said anything.
|
|
1437
|
+
# A `--json` run that failed to produce a document has usually
|
|
1438
|
+
# explained itself there ("error: could not load schema …"), and
|
|
1439
|
+
# dropping it would leave the operator with "it did not emit a JSON
|
|
1440
|
+
# document" and nothing to act on.
|
|
1441
|
+
def trailing(stderr)
|
|
1442
|
+
text = stderr.to_s.strip
|
|
1443
|
+
return "" if text.empty?
|
|
1444
|
+
|
|
1445
|
+
" — it said: #{text.lines.first.strip}"
|
|
1446
|
+
end
|
|
1447
|
+
end
|
|
1448
|
+
end
|
|
1449
|
+
end
|
|
1450
|
+
end
|