terret 0.0.2 → 0.1.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/config/bundle.yml +189 -0
- data/exe/trt +12 -0
- data/lib/terret/boot.rb +149 -0
- data/lib/terret/cli.rb +269 -11
- data/lib/terret/composition.rb +755 -0
- data/lib/terret/doctor.rb +183 -0
- data/lib/terret/home.rb +85 -0
- data/lib/terret/schema_gems.rb +22 -0
- data/lib/terret/version.rb +14 -0
- data/profiles/headless/patch.yml +27 -0
- data/profiles/headless/profile.yml +57 -0
- metadata +93 -7
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "home"
|
|
5
|
+
|
|
6
|
+
module Terret
|
|
7
|
+
# Bundles ship rows, profiles stack bundles, patches adjust rows
|
|
8
|
+
# (docs/composition.md). Resolution here is pure: YAML in, ordered rows plus
|
|
9
|
+
# provenance out, nothing mounted and no constant resolved. That separation
|
|
10
|
+
# is what lets dump-config and doctor report on a composition they never
|
|
11
|
+
# boot, on a machine with no Docker daemon and no API key.
|
|
12
|
+
module Composition
|
|
13
|
+
Error = Class.new(StandardError)
|
|
14
|
+
|
|
15
|
+
# The three tags that make config dynamic without making it code (§5).
|
|
16
|
+
TAGS = %w[env setting ruby].freeze
|
|
17
|
+
|
|
18
|
+
# YAML's own types, which Psych's restricted schema handles safely and
|
|
19
|
+
# which a config is welcome to use. Anything outside these lists and TAGS
|
|
20
|
+
# is refused by name — including the ruby/* family, which the restricted
|
|
21
|
+
# class loader would also refuse, but later and less clearly.
|
|
22
|
+
#
|
|
23
|
+
# Split by node kind because Psych ignores a core tag that does not suit
|
|
24
|
+
# the node it is on (`!!map hello` is the string "hello") or dies inside
|
|
25
|
+
# its own schema handler (`!!str` on a mapping). Both are the silent drop
|
|
26
|
+
# this reader exists to refuse, one type system down.
|
|
27
|
+
# map and seq are bound to their own node kind rather than to "a
|
|
28
|
+
# collection": !!seq on a mapping is as much a silent drop as !!map on a
|
|
29
|
+
# scalar, and Psych ignores both the same way.
|
|
30
|
+
CORE_SCALAR_TAGS = %w[null bool int float str binary].freeze
|
|
31
|
+
CORE_MAPPING_TAGS = %w[map].freeze
|
|
32
|
+
CORE_SEQUENCE_TAGS = %w[seq].freeze
|
|
33
|
+
CORE_TAGS = (CORE_SCALAR_TAGS + CORE_MAPPING_TAGS + CORE_SEQUENCE_TAGS).freeze
|
|
34
|
+
|
|
35
|
+
YAML_SCHEMA = "tag:yaml.org,2002:"
|
|
36
|
+
|
|
37
|
+
# One tag, four spellings. `!env`, `!!env`, `!<tag:yaml.org,2002:env>` and a
|
|
38
|
+
# `%TAG ! !!` directive over a plain `!env` all reach the visitor as
|
|
39
|
+
# different strings, and a reader that only recognises the first drops the
|
|
40
|
+
# other three on the floor — which is the silent-drop this whole visitor
|
|
41
|
+
# exists to prevent, reintroduced one spelling down. So every non-nil tag
|
|
42
|
+
# gets classified, and nothing falls through unclassified.
|
|
43
|
+
#
|
|
44
|
+
# nil -> untagged
|
|
45
|
+
# [:local, "env"] -> !env
|
|
46
|
+
# [:core, "str"] -> !!str, tag:yaml.org,2002:str
|
|
47
|
+
# [:foreign, raw] -> anything else, including bare "x-private:env"
|
|
48
|
+
def self.tag_kind(raw)
|
|
49
|
+
return nil if raw.nil?
|
|
50
|
+
return [:core, raw.delete_prefix(YAML_SCHEMA)] if raw.start_with?(YAML_SCHEMA)
|
|
51
|
+
return [:core, raw.delete_prefix("!!")] if raw.start_with?("!!")
|
|
52
|
+
return [:local, raw.delete_prefix("!")] if raw.start_with?("!")
|
|
53
|
+
|
|
54
|
+
[:foreign, raw]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A tag left standing. Resolution keeps these unevaluated so dump-config
|
|
58
|
+
# can print `!env OPENROUTER_API_KEY` as written — the resolved value never
|
|
59
|
+
# appears in that output at all. Boot materializes them; nothing else does.
|
|
60
|
+
Tagged = Data.define(:tag, :argument) do
|
|
61
|
+
def to_s = "!#{tag} #{argument}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# One resolved row plus the layers that answer for it. Provenance is per
|
|
65
|
+
# row rather than per key, and that falls straight out of wholesale config
|
|
66
|
+
# replacement: exactly one layer is responsible for what a service receives.
|
|
67
|
+
#
|
|
68
|
+
# plugin_layer is tracked separately from config_layer because a patch may
|
|
69
|
+
# swap plugin: without touching config:, and that swap is the single most
|
|
70
|
+
# consequential edit the format allows — it is how the sandbox gets turned
|
|
71
|
+
# off. Attributing it to the bundle that shipped the row would hide it.
|
|
72
|
+
#
|
|
73
|
+
# And a swap that says nothing about config: FORWARDS THE OLD CONFIG to the
|
|
74
|
+
# new class, whatever that config held: an `!env`-resolved key, a workspace
|
|
75
|
+
# list, a path. That follows from replacement being per-field, and it is
|
|
76
|
+
# why the two layers are reported separately — a row whose plugin and
|
|
77
|
+
# config come from different layers is one worth reading twice. A swap that
|
|
78
|
+
# should not inherit has to say `config: {}` and mean it.
|
|
79
|
+
Row = Data.define(:id, :plugin, :config, :disabled, :row_layer, :plugin_layer, :config_layer)
|
|
80
|
+
|
|
81
|
+
# A gem's config/bundle.yml, parsed. `requires` is this implementation's
|
|
82
|
+
# answer to §2's "it has to make that code available": Bundler puts the
|
|
83
|
+
# dependency on the load path, and these are the files that pull it in
|
|
84
|
+
# before a row's constant has to resolve.
|
|
85
|
+
#
|
|
86
|
+
# `error` is set instead of raising when discovery cannot parse the file:
|
|
87
|
+
# one third-party gem shipping a broken bundle must not break every profile
|
|
88
|
+
# on the machine, only the profiles that name it.
|
|
89
|
+
Bundle = Data.define(:name, :gem_name, :path, :requires, :rows, :error) do
|
|
90
|
+
def self.broken(gem_name:, path:, error:)
|
|
91
|
+
new(name: gem_name, gem_name: gem_name, path: path, requires: [], rows: [], error: error)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
Resolved = Data.define(:profile, :home, :rows, :settings, :plugins, :requires) do
|
|
96
|
+
# Rows in the shape Hames::Loader#layer wants, tags evaluated. `plugin`
|
|
97
|
+
# is still the class NAME: resolving the constant is boot's job, because
|
|
98
|
+
# a row whose constant does not resolve is something doctor reports
|
|
99
|
+
# rather than something a dump-config discovers halfway through.
|
|
100
|
+
def materialize(allow_config_ruby: false)
|
|
101
|
+
values = Composition.materialize_settings(settings, allow_config_ruby: allow_config_ruby)
|
|
102
|
+
rows.map do |r|
|
|
103
|
+
{ id: r.id, plugin: r.plugin, disabled: r.disabled,
|
|
104
|
+
config: Composition.materialize(r.config, settings: values,
|
|
105
|
+
allow_config_ruby: allow_config_ruby,
|
|
106
|
+
where: "row #{r.id.inspect} (config from #{r.config_layer})") }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def row(id) = rows.find { |r| r.id == id.to_s }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A require target from requires:/plugins: is either a load-path FEATURE
|
|
114
|
+
# NAME (`terret/exec`, resolved through $LOAD_PATH, which Bundler populates
|
|
115
|
+
# only from gems the operator installed) or a filesystem PATH (`/opt/evil`,
|
|
116
|
+
# `../evil`, `./evil`, `~/evil`). Loading Ruby by path is code execution with
|
|
117
|
+
# a YAML extension — the same thing !ruby gates behind --allow-config-ruby
|
|
118
|
+
# (§5, docs/security.md). A bundle legitimately names feature names; only a
|
|
119
|
+
# path can reach code the operator never installed, so a path is what the
|
|
120
|
+
# consent gate is for. True for a feature name (safe to require without
|
|
121
|
+
# consent), false for anything path-shaped.
|
|
122
|
+
def self.load_path_feature?(file)
|
|
123
|
+
s = file.to_s
|
|
124
|
+
return false if s.empty?
|
|
125
|
+
return false if s.start_with?("/", "~", "./", "../")
|
|
126
|
+
return false if File.absolute_path?(s) # a Windows drive letter, a UNC path
|
|
127
|
+
return false if s.split("/").include?("..") # traversal in a deeper segment
|
|
128
|
+
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Render a value safe to print in a one-line table cell or provenance column:
|
|
133
|
+
# control characters — a newline forging a fake row, an ANSI escape
|
|
134
|
+
# rewriting the terminal — become visible escapes. dump-config and doctor
|
|
135
|
+
# both print row ids, plugin names, and layer labels that can originate in a
|
|
136
|
+
# patch file, so both pass identifiers through here.
|
|
137
|
+
def self.one_line(str)
|
|
138
|
+
str.to_s.gsub(/[\u0000-\u001f\u007f]/) do |c|
|
|
139
|
+
{ "\n" => "\\n", "\t" => "\\t", "\r" => "\\r" }[c] || format("\\x%02x", c.ord)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Well-known secret shapes, matching terret-base's redactor defaults. Used to
|
|
144
|
+
# redact a LITERAL secret typed into a config value where a human-facing
|
|
145
|
+
# command would otherwise print it in full (dump-config). Detection of known
|
|
146
|
+
# shapes, not a guarantee (docs/security.md).
|
|
147
|
+
SECRET_SHAPES = [
|
|
148
|
+
/sk-[A-Za-z0-9_-]{16,}/,
|
|
149
|
+
/gh[pousr]_[A-Za-z0-9]{20,}/,
|
|
150
|
+
/AKIA[0-9A-Z]{16}/,
|
|
151
|
+
/xox[baprs]-[A-Za-z0-9-]{10,}/
|
|
152
|
+
].freeze
|
|
153
|
+
|
|
154
|
+
def self.redact_secrets(str)
|
|
155
|
+
SECRET_SHAPES.reduce(str.to_s) { |s, shape| s.gsub(shape, "[redacted]") }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# A refusal interpolates attacker-influenced text — an env var name, a
|
|
159
|
+
# !setting path, a !ruby source, a raw tag, a scalar value, an underlying
|
|
160
|
+
# error message. Cap each such fragment so a multi-kilobyte value cannot bury
|
|
161
|
+
# the message it is embedded in or flood a terminal or log.
|
|
162
|
+
CLIP = 200
|
|
163
|
+
def self.clip(text)
|
|
164
|
+
s = text.to_s
|
|
165
|
+
s.length > CLIP ? "#{s[0, CLIP]}… (#{s.length} chars)" : s
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# -- parsing ---------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
# YAML.safe_load DROPS a local tag silently: permitted_classes gates
|
|
171
|
+
# Ruby-object tags like !ruby/object:Foo, not application tags like !env,
|
|
172
|
+
# so a document loaded that way comes back with the tag gone and the bare
|
|
173
|
+
# scalar in its place — `!env OPENROUTER_API_KEY` would resolve to the
|
|
174
|
+
# STRING "OPENROUTER_API_KEY" and boot a service with a literal nonsense
|
|
175
|
+
# key. So resolution is explicit: parse to the node tree, then walk it and
|
|
176
|
+
# resolve our three tags by tag, refusing every other one. The class loader
|
|
177
|
+
# stays restricted throughout, so this is safe_load's safety with our tags
|
|
178
|
+
# intercepted before Psych can drop them. YAML.load appears nowhere.
|
|
179
|
+
class Visitor < Psych::Visitors::ToRuby
|
|
180
|
+
def self.load(text, label:)
|
|
181
|
+
stream = Psych.parse_stream(text)
|
|
182
|
+
docs = stream.children
|
|
183
|
+
if docs.length > 1
|
|
184
|
+
raise Error, "#{label}: #{docs.length} YAML documents; a Terret config is one " \
|
|
185
|
+
"document, and the rest would be dropped without saying so"
|
|
186
|
+
end
|
|
187
|
+
return nil if docs.empty?
|
|
188
|
+
|
|
189
|
+
value = new(label).accept(docs.first)
|
|
190
|
+
Composition.assert_acyclic!(value, label)
|
|
191
|
+
value
|
|
192
|
+
rescue Error
|
|
193
|
+
raise
|
|
194
|
+
rescue StandardError => e
|
|
195
|
+
# Psych's own exceptions, and the ArgumentError/FrozenError/NoMethodError
|
|
196
|
+
# its schema handlers raise on input they cannot honour. Whatever it is,
|
|
197
|
+
# the operator needs the file name more than the class.
|
|
198
|
+
raise Error, "#{label}: #{e.class}: #{e.message}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def initialize(label)
|
|
202
|
+
loader = Psych::ClassLoader::Restricted.new([], [])
|
|
203
|
+
super(Psych::ScalarScanner.new(loader), loader, symbolize_names: true)
|
|
204
|
+
@label = label
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def visit_Psych_Nodes_Scalar(node)
|
|
208
|
+
tag = terret_tag(node, CORE_SCALAR_TAGS)
|
|
209
|
+
return register(node, Tagged.new(tag: tag, argument: node.value)) if tag
|
|
210
|
+
|
|
211
|
+
value = begin
|
|
212
|
+
super
|
|
213
|
+
rescue Psych::DisallowedClass => e
|
|
214
|
+
raise Error, needs_quoting(node, e)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Unlike a Date, a sexagesimal (10:30 -> 37800, 1:2:3 -> 3723) is a plain
|
|
218
|
+
# Integer the restricted loader builds without complaint, so it slips past
|
|
219
|
+
# the DisallowedClass guard above. A colon-bearing plain scalar that came
|
|
220
|
+
# back a number is almost never the base-60 number YAML made of it —
|
|
221
|
+
# refuse it and say to quote it, the same fix the Date guard gives.
|
|
222
|
+
raise Error, sexagesimal_needs_quoting(node) if value.is_a?(Numeric) && node.value.include?(":")
|
|
223
|
+
|
|
224
|
+
value
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# A collection carrying one of our tags is refused rather than silently
|
|
228
|
+
# dropped, which is what the parent visitor does with one.
|
|
229
|
+
def visit_Psych_Nodes_Mapping(node)
|
|
230
|
+
refuse_collection_tag!(node, CORE_MAPPING_TAGS)
|
|
231
|
+
mapping = super
|
|
232
|
+
# Psych merges `<<` when it points at a mapping or at a list of them.
|
|
233
|
+
# Anything else it leaves as a literal "<<" key — a String among
|
|
234
|
+
# symbols, and a merge the author believed had happened. Quoting the
|
|
235
|
+
# key does not opt out: Psych reads `"<<"` as a merge too, so the
|
|
236
|
+
# refusal names the spelling that does.
|
|
237
|
+
if mapping.is_a?(Hash) && mapping.key?("<<")
|
|
238
|
+
raise Error, "#{@label}: a << merge key must point at a mapping or a list of them; " \
|
|
239
|
+
"anything else is not a merge, and would land as a literal \"<<\" key. " \
|
|
240
|
+
"For a key that is genuinely the characters <<, write !!str \"<<\":."
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
mapping
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def visit_Psych_Nodes_Sequence(node)
|
|
247
|
+
refuse_collection_tag!(node, CORE_SEQUENCE_TAGS)
|
|
248
|
+
super
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
private
|
|
252
|
+
|
|
253
|
+
# nil for "not ours, and safe to hand to Psych's schema"; the tag name for
|
|
254
|
+
# one of ours; an exception for everything else.
|
|
255
|
+
def terret_tag(node, core_allowed)
|
|
256
|
+
kind, name = Composition.tag_kind(node.tag)
|
|
257
|
+
return nil if kind.nil?
|
|
258
|
+
return name if kind == :local && TAGS.include?(name)
|
|
259
|
+
return nil if kind == :core && core_allowed.include?(name)
|
|
260
|
+
|
|
261
|
+
raise Error, refusal(name, node.tag, core_allowed)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def refuse_collection_tag!(node, core_allowed)
|
|
265
|
+
name = terret_tag(node, core_allowed) or return
|
|
266
|
+
|
|
267
|
+
# The parser bounds this name to env/setting/ruby today (a longer tag is
|
|
268
|
+
# refused earlier as an unknown tag, already clipped), so the clip is
|
|
269
|
+
# defense in depth — the same "every interpolated fragment goes through
|
|
270
|
+
# CLIP" rule the other refusals follow, so a future change upstream can
|
|
271
|
+
# not reopen an uncapped interpolation here.
|
|
272
|
+
raise Error, "#{@label}: !#{Composition.clip(name)} tags a scalar, not a collection"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# YAML resolves an unquoted 2026-08-19 to a Date, :fake to a Symbol, and
|
|
276
|
+
# so on. A Terret config carries plain data, so the restricted loader
|
|
277
|
+
# refuses to build any of them — but "Tried to load unspecified class:
|
|
278
|
+
# Date" describes the machinery rather than the fix.
|
|
279
|
+
def needs_quoting(node, error)
|
|
280
|
+
klass = error.message[/unspecified class:\s*(\S+)/, 1] || "value"
|
|
281
|
+
value = Composition.clip(node.value)
|
|
282
|
+
"#{@label}: #{value.inspect} reads as a #{klass}, and a Terret config " \
|
|
283
|
+
"carries only plain data — quote it (\"#{value}\") to keep it a string."
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def sexagesimal_needs_quoting(node)
|
|
287
|
+
value = Composition.clip(node.value)
|
|
288
|
+
"#{@label}: #{value.inspect} reads as a base-60 (sexagesimal) number, not the " \
|
|
289
|
+
"text it looks like — a Terret config carries plain data, so quote it " \
|
|
290
|
+
"(\"#{value}\") to keep it a string."
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def refusal(name, raw, core_allowed)
|
|
294
|
+
raw = Composition.clip(raw)
|
|
295
|
+
hint =
|
|
296
|
+
if TAGS.include?(name) then " — !#{name} is the tag you want, and #{raw} is a different one"
|
|
297
|
+
elsif CORE_TAGS.include?(name) then " — !!#{name} does not describe this kind of node"
|
|
298
|
+
else ""
|
|
299
|
+
end
|
|
300
|
+
"#{@label}: unknown config tag #{raw}#{hint}. Here a Terret config may use " \
|
|
301
|
+
"!env, !setting and !ruby, and YAML's own #{core_allowed.join('/')}."
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# An alias can point at a node that contains it, and Psych builds the
|
|
306
|
+
# self-referential Hash without complaint. Everything downstream walks the
|
|
307
|
+
# structure, so the first walk would be the last thing the process did.
|
|
308
|
+
#
|
|
309
|
+
# `path` is the ancestor chain, which is what makes this a cycle check
|
|
310
|
+
# rather than a sharing check — the same anchor twice as siblings is
|
|
311
|
+
# legitimate YAML. `cleared` is what keeps it linear: without it, an alias
|
|
312
|
+
# graph is walked once per PATH, and a two-dozen-line file whose aliases
|
|
313
|
+
# each reference the previous one twice has 2^24 paths through 24 nodes.
|
|
314
|
+
def self.assert_acyclic!(value, label, path = [], cleared = {}.compare_by_identity)
|
|
315
|
+
return unless value.is_a?(Hash) || value.is_a?(Array)
|
|
316
|
+
return if cleared.key?(value)
|
|
317
|
+
raise Error, "#{label}: an alias cycle — a node that contains itself" if path.any? { |seen| seen.equal?(value) }
|
|
318
|
+
|
|
319
|
+
path.push(value)
|
|
320
|
+
(value.is_a?(Hash) ? value.values : value).each { |child| assert_acyclic!(child, label, path, cleared) }
|
|
321
|
+
path.pop
|
|
322
|
+
cleared[value] = true
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def self.parse_file(path, label: nil)
|
|
326
|
+
label ||= path
|
|
327
|
+
raise Error, "#{label}: is a directory, not a config file" if File.directory?(path)
|
|
328
|
+
raise Error, "#{label}: no such file" unless File.file?(path)
|
|
329
|
+
|
|
330
|
+
body = begin
|
|
331
|
+
File.read(path)
|
|
332
|
+
rescue SystemCallError, IOError => e
|
|
333
|
+
raise Error, "#{label}: cannot be read: #{e.message}"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
Visitor.load(body, label: label) || {}
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Every file that carries rows carries them the same way. A patch that is a
|
|
340
|
+
# bare list looks reasonable and is not: `rows:` is what distinguishes a
|
|
341
|
+
# patch from the bundle format, which does accept one.
|
|
342
|
+
def self.rows_in(doc, label)
|
|
343
|
+
return [] if doc.nil?
|
|
344
|
+
raise Error, "#{label}: expected a mapping with a rows: list, got #{doc.class}" unless doc.is_a?(Hash)
|
|
345
|
+
|
|
346
|
+
rows = doc[:rows]
|
|
347
|
+
return [] if rows.nil?
|
|
348
|
+
raise Error, "#{label}: rows: must be a list, got #{rows.class}" unless rows.is_a?(Array)
|
|
349
|
+
|
|
350
|
+
rows
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# -- materialization -------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
# Walks a resolved structure and evaluates the tags left standing. `where`
|
|
356
|
+
# is the row and layer this value came from: a refusal that cannot say
|
|
357
|
+
# which of thirty rows it is about is a refusal an operator cannot act on,
|
|
358
|
+
# and the !ruby refusal in particular is a consent prompt.
|
|
359
|
+
def self.materialize(value, settings:, allow_config_ruby:, where: nil)
|
|
360
|
+
case value
|
|
361
|
+
when Tagged then resolve_tag(value, settings:, allow_config_ruby:, where:)
|
|
362
|
+
when Hash
|
|
363
|
+
value.to_h do |k, v|
|
|
364
|
+
raise Error, "#{where}: #{k} is a tag in key position, which is never resolved" if k.is_a?(Tagged)
|
|
365
|
+
|
|
366
|
+
[k, materialize(v, settings:, allow_config_ruby:, where:)]
|
|
367
|
+
end
|
|
368
|
+
when Array then value.map { |v| materialize(v, settings:, allow_config_ruby:, where:) }
|
|
369
|
+
else value
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# settings: is resolved first and on its own terms — !env and !ruby are
|
|
374
|
+
# fair game inside it, but a !setting there would be reaching into the map
|
|
375
|
+
# it is part of, so it is refused rather than half-defined.
|
|
376
|
+
def self.materialize_settings(settings, allow_config_ruby:)
|
|
377
|
+
raise Error, "a profile's settings: must be a mapping, got #{settings.class}" unless settings.is_a?(Hash)
|
|
378
|
+
|
|
379
|
+
materialize(settings, settings: nil, allow_config_ruby: allow_config_ruby,
|
|
380
|
+
where: "the profile's settings")
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def self.resolve_tag(tagged, settings:, allow_config_ruby:, where: nil)
|
|
384
|
+
case tagged.tag
|
|
385
|
+
when "env" then read_env(tagged.argument, where)
|
|
386
|
+
when "setting" then dig_setting(settings, tagged.argument, where)
|
|
387
|
+
when "ruby" then eval_ruby(tagged.argument, allow_config_ruby, where)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# nil rather than raising when unset, because "no key configured" is a
|
|
392
|
+
# state a service should be allowed to have an opinion about. A name the OS
|
|
393
|
+
# will not accept at all is a different thing and says so.
|
|
394
|
+
def self.read_env(name, where)
|
|
395
|
+
ENV[name]
|
|
396
|
+
rescue StandardError => e
|
|
397
|
+
raise Error, "#{where}: !env #{clip(name).inspect}: #{clip(e.message)}"
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# The asymmetry with !env is intentional (§5): an unset environment
|
|
401
|
+
# variable is an ordinary deployment state, while a !setting pointing at
|
|
402
|
+
# nothing is a typo in a file the profile author controls.
|
|
403
|
+
def self.dig_setting(settings, path, where)
|
|
404
|
+
raise Error, "#{where}: !setting #{clip(path)} may not appear inside a profile's own settings:" if settings.nil?
|
|
405
|
+
|
|
406
|
+
keys = path.to_s.split(".").map(&:to_sym)
|
|
407
|
+
raise Error, "#{where}: !setting with an empty path" if keys.empty?
|
|
408
|
+
|
|
409
|
+
found = keys.reduce(settings) do |node, key|
|
|
410
|
+
unless node.is_a?(Hash) && node.key?(key)
|
|
411
|
+
raise Error, "#{where}: !setting #{clip(path)} resolves to nothing in the profile's settings"
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
node[key]
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# A copy per reference. `workspace:` is read by the fs row and the
|
|
418
|
+
# sandbox row, and handing both the same Array means one service
|
|
419
|
+
# mutating its own config silently rewrites another's.
|
|
420
|
+
deep_dup(found)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def self.deep_dup(value)
|
|
424
|
+
case value
|
|
425
|
+
when Hash then value.to_h { |k, v| [k, deep_dup(v)] }
|
|
426
|
+
when Array then value.map { |v| deep_dup(v) }
|
|
427
|
+
when String then value.dup
|
|
428
|
+
else value
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Config that can execute arbitrary Ruby is code with a YAML extension, so
|
|
433
|
+
# the flag is the consent. A clean binding, because a profile downloaded
|
|
434
|
+
# from anywhere should not be reading this method's locals either.
|
|
435
|
+
def self.eval_ruby(source, allow_config_ruby, where)
|
|
436
|
+
unless allow_config_ruby
|
|
437
|
+
raise Error, "#{where}: !ruby #{clip(source)} is refused; pass allow_config_ruby: true " \
|
|
438
|
+
"(trt --allow-config-ruby) to let this profile run Ruby"
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
begin
|
|
442
|
+
Object.new.instance_eval { binding }.eval(source, "(!ruby)")
|
|
443
|
+
rescue ScriptError, StandardError => e
|
|
444
|
+
# ScriptError is not a StandardError, so a !ruby that does not even
|
|
445
|
+
# parse would otherwise walk past every rescue between here and the
|
|
446
|
+
# operator's terminal.
|
|
447
|
+
raise Error, "#{where}: !ruby #{clip(source)}: #{e.class}: #{clip(e.message.lines.first.to_s.strip)}"
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# -- bundles ---------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
# A bundle file is either a bare list of rows (§2's "an ordered list of
|
|
454
|
+
# rows") or a mapping carrying that list plus a name and its requires.
|
|
455
|
+
def self.load_bundle(path, gem_name:)
|
|
456
|
+
doc = parse_file(path, label: gem_name)
|
|
457
|
+
doc = { rows: doc } if doc.is_a?(Array)
|
|
458
|
+
raise Error, "#{gem_name}: #{path} must be a list of rows or a mapping with rows:" unless doc.is_a?(Hash)
|
|
459
|
+
|
|
460
|
+
Bundle.new(name: (doc[:name] || gem_name).to_s, gem_name: gem_name, path: path,
|
|
461
|
+
requires: Array(doc[:requires]).map(&:to_s), rows: rows_in(doc, gem_name), error: nil)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Discovery walks every gemspec Gem::Specification knows about — under
|
|
465
|
+
# Bundler that is the bundle, and outside it every gem installed on the
|
|
466
|
+
# machine, loaded or not — reads the metadata key, and parses the file it
|
|
467
|
+
# points at. A third-party gem becomes discoverable by shipping normally:
|
|
468
|
+
# nothing to register, nothing to symlink.
|
|
469
|
+
#
|
|
470
|
+
# The meta-gem's own terret-base is seeded from this checkout, and it WINS
|
|
471
|
+
# over an installed gemspec named `terret`: terret-base is the
|
|
472
|
+
# security-deciding bundle (the deny-by-default floor, the sandboxed-by-
|
|
473
|
+
# default rows), so a stale or hostile installed `terret` must not be able to
|
|
474
|
+
# override it. That is why the checkout is seeded LAST, after the installed
|
|
475
|
+
# specs — a monorepo run still resolves `terret` with no gem installation,
|
|
476
|
+
# and when both are present the checkout answers.
|
|
477
|
+
def self.discover_bundles(specs: Gem::Specification)
|
|
478
|
+
found = {}
|
|
479
|
+
|
|
480
|
+
# Everything about a third-party gem is quarantined to that gem. A
|
|
481
|
+
# malformed bundle, an unreadable file, a metadata value of the wrong
|
|
482
|
+
# shape: each becomes a broken entry that only the profiles naming it
|
|
483
|
+
# ever see. One bad gem in the Gemfile must not take out every profile
|
|
484
|
+
# on the machine, which is the whole reason discovery does not raise.
|
|
485
|
+
specs.each do |spec|
|
|
486
|
+
file = nil
|
|
487
|
+
found[spec.name] = begin
|
|
488
|
+
rel = bundle_metadata(spec)
|
|
489
|
+
next unless rel.is_a?(String)
|
|
490
|
+
|
|
491
|
+
# A spec with no gem path used to degenerate to cwd (File.expand_path("")
|
|
492
|
+
# is the working directory), so its relative bundle.yml resolved against
|
|
493
|
+
# cwd and the containment check below passed for whatever the process was
|
|
494
|
+
# sitting next to. Refuse an empty or non-existent root outright.
|
|
495
|
+
gem_root = spec.full_gem_path.to_s
|
|
496
|
+
next if gem_root.empty?
|
|
497
|
+
|
|
498
|
+
root = File.expand_path(gem_root)
|
|
499
|
+
next unless File.directory?(root)
|
|
500
|
+
|
|
501
|
+
file = File.expand_path(rel, root)
|
|
502
|
+
# A gem describes its own bundle, not somebody else's file.
|
|
503
|
+
next unless file.start_with?("#{root}/") && File.file?(file)
|
|
504
|
+
|
|
505
|
+
load_bundle(file, gem_name: spec.name)
|
|
506
|
+
rescue StandardError => e
|
|
507
|
+
Bundle.broken(gem_name: spec.name, path: file || "(unresolved)", error: e)
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
own = File.expand_path("../../config/bundle.yml", __dir__)
|
|
512
|
+
found["terret"] = load_bundle(own, gem_name: "terret") if File.file?(own)
|
|
513
|
+
found
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# RubyGems requires every metadata VALUE to be a String — a gemspec
|
|
517
|
+
# carrying the nested hash docs/composition.md §2 shows will not build
|
|
518
|
+
# ("metadata['terret'] value must be a String"). So the shipped form is the
|
|
519
|
+
# path on its own, and the documented nested form is still accepted, both
|
|
520
|
+
# as a real Hash (an in-memory spec) and as YAML in the string.
|
|
521
|
+
def self.bundle_metadata(spec)
|
|
522
|
+
meta = begin
|
|
523
|
+
spec.metadata["terret"]
|
|
524
|
+
rescue StandardError
|
|
525
|
+
nil # an unreadable gemspec is not a bundle; it is also not our problem
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
case meta
|
|
529
|
+
when Hash then meta["bundle"] || meta[:bundle]
|
|
530
|
+
when String
|
|
531
|
+
parsed = begin
|
|
532
|
+
YAML.safe_load(meta)
|
|
533
|
+
rescue StandardError
|
|
534
|
+
nil
|
|
535
|
+
end
|
|
536
|
+
parsed.is_a?(Hash) ? parsed["bundle"] : meta
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# -- resolution ------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
# The four layers of §4, in order: every bundle in the profile's list,
|
|
543
|
+
# the profile's patch.yml, the home patch.yml, then --patch overlays.
|
|
544
|
+
# Later layers win.
|
|
545
|
+
def self.resolve(profile:, home: nil, patches: [], bundles: nil)
|
|
546
|
+
home = Home.resolve(home)
|
|
547
|
+
spec = load_profile(home, profile)
|
|
548
|
+
catalog = bundles || discover_bundles
|
|
549
|
+
|
|
550
|
+
named = Array(spec[:bundles]).map(&:to_s)
|
|
551
|
+
if (dupes = named.tally.select { |_, n| n > 1 }.keys).any?
|
|
552
|
+
raise Error, "profile #{profile.to_s.inspect} lists #{dupes.join(', ')} more than once; " \
|
|
553
|
+
"a bundle is layered where it is named, and twice is not twice as much"
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
stacked = named.map do |name|
|
|
557
|
+
bundle = catalog[name] or raise Error, unknown_bundle_message(profile, name, catalog)
|
|
558
|
+
if bundle.error
|
|
559
|
+
raise Error, "profile #{profile.to_s.inspect} names #{name}, whose #{bundle.path} " \
|
|
560
|
+
"could not be read: #{bundle.error.message}"
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
bundle
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
layers = stacked.map { |b| [bundle_label(b, stacked), :bundle, b.rows] }
|
|
567
|
+
requires = stacked.flat_map(&:requires)
|
|
568
|
+
|
|
569
|
+
patch_files(home, profile, patches).each do |file, label|
|
|
570
|
+
layers << [label, :patch, rows_in(parse_file(file, label: label), label)]
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
Resolved.new(profile: profile.to_s, home: home, rows: stack(layers),
|
|
574
|
+
settings: spec[:settings] || {},
|
|
575
|
+
plugins: Array(spec[:plugins]).map(&:to_s), requires: requires.uniq)
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# A profile is a directory name under the home, not a path. Anything that
|
|
579
|
+
# would leave profiles/ is a typo at best.
|
|
580
|
+
PROFILE_NAME = /\A[A-Za-z0-9][A-Za-z0-9._-]*\z/
|
|
581
|
+
|
|
582
|
+
def self.load_profile(home, profile)
|
|
583
|
+
unless PROFILE_NAME.match?(profile.to_s)
|
|
584
|
+
raise Error, "#{profile.to_s.inspect} is not a profile name; a profile is a " \
|
|
585
|
+
"directory under #{home.path}/profiles"
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
config, = home.profile_files(profile)
|
|
589
|
+
unless config
|
|
590
|
+
raise Error, "no profile #{profile.to_s.inspect} in #{home.path} " \
|
|
591
|
+
"(looked for #{home.profile_config(profile)}); " \
|
|
592
|
+
"profiles available: #{home.profile_names.join(', ')}"
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
doc = parse_file(config, label: home.label(config))
|
|
596
|
+
raise Error, "#{home.label(config)}: a profile must be a mapping" unless doc.is_a?(Hash)
|
|
597
|
+
|
|
598
|
+
settings = doc[:settings]
|
|
599
|
+
unless settings.nil? || settings.is_a?(Hash)
|
|
600
|
+
raise Error, "#{home.label(config)}: settings: must be a mapping, got #{settings.class}"
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
doc
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# A bundle names itself, and dump-config prints that name — so two bundles
|
|
607
|
+
# in one stack claiming the same name would make provenance a guess. When
|
|
608
|
+
# that happens, both fall back to naming the gem, which is the part a gem
|
|
609
|
+
# author cannot claim on someone else's behalf.
|
|
610
|
+
def self.bundle_label(bundle, stacked)
|
|
611
|
+
return bundle.name if stacked.count { |b| b.name == bundle.name } == 1
|
|
612
|
+
|
|
613
|
+
"#{bundle.gem_name} (#{bundle.name})"
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def self.patch_files(home, profile, patches)
|
|
617
|
+
_, profile_patch = home.profile_files(profile)
|
|
618
|
+
files = []
|
|
619
|
+
files << [profile_patch, home.label(profile_patch)] if profile_patch
|
|
620
|
+
files << [home.patch, home.label(home.patch)] if File.file?(home.patch)
|
|
621
|
+
# A --patch overlay is labelled by the path as given: it is what this
|
|
622
|
+
# invocation decided, and the operator typed it.
|
|
623
|
+
Array(patches).each { |p| files << [p.to_s, p.to_s] }
|
|
624
|
+
files
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def self.unknown_bundle_message(profile, name, catalog)
|
|
628
|
+
"profile #{profile.to_s.inspect} names unknown bundle #{name.inspect}. " \
|
|
629
|
+
"Discovered: #{catalog.keys.sort.join(', ')}. " \
|
|
630
|
+
"Discovery reads every gemspec it can see, so a name missing from that " \
|
|
631
|
+
"list is a gem that is not installed here (or not in this Gemfile), " \
|
|
632
|
+
"or one that ships no terret bundle metadata."
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Fold the layers into one ordered row list. A bundle's rows append in
|
|
636
|
+
# listed order; a patch's row either targets an existing id or is an
|
|
637
|
+
# insertion that has to say where it goes.
|
|
638
|
+
def self.stack(layers)
|
|
639
|
+
ordered = []
|
|
640
|
+
index = {}
|
|
641
|
+
layers.each do |label, kind, rows|
|
|
642
|
+
Array(rows).each { |raw| apply_row(ordered, index, label, kind, raw) }
|
|
643
|
+
end
|
|
644
|
+
ordered
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# A row id addresses a row in a patch and names it in dump-config's
|
|
648
|
+
# provenance column and doctor's table (docs/composition.md §1, §9, §10). It
|
|
649
|
+
# is letters, digits and .-_ — the same shape a profile name takes — so a
|
|
650
|
+
# newline or ANSI escape cannot be smuggled into one to forge or erase a
|
|
651
|
+
# provenance line in either command's output.
|
|
652
|
+
ROW_ID = /\A[A-Za-z0-9][A-Za-z0-9._-]*\z/
|
|
653
|
+
|
|
654
|
+
def self.apply_row(ordered, index, label, kind, raw)
|
|
655
|
+
raise Error, "#{label}: a config row must be a mapping, got #{raw.class}" unless raw.is_a?(Hash)
|
|
656
|
+
|
|
657
|
+
id = raw[:id].to_s
|
|
658
|
+
raise Error, "#{label}: a config row must have an id" if id.empty?
|
|
659
|
+
unless ROW_ID.match?(id)
|
|
660
|
+
raise Error, "#{label}: row id #{id.inspect} is not a valid id; a row id is letters, " \
|
|
661
|
+
"digits and .-_ (it names the row in dump-config and doctor, so it may not " \
|
|
662
|
+
"carry newlines or control characters)"
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
if (existing = index[id])
|
|
666
|
+
replace(ordered, index, label, existing, raw)
|
|
667
|
+
elsif kind == :bundle
|
|
668
|
+
append(ordered, index, label, id, raw)
|
|
669
|
+
else
|
|
670
|
+
insert(ordered, index, label, id, raw)
|
|
671
|
+
end
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
# A patch targeting an existing id replaces that row's config WHOLESALE.
|
|
675
|
+
# It never deep-merges: deep merging makes unsetting a key inexpressible,
|
|
676
|
+
# and makes the effective value of any key a function of the entire stack.
|
|
677
|
+
def self.replace(ordered, index, label, existing, raw)
|
|
678
|
+
if raw.key?(:before) || raw.key?(:after)
|
|
679
|
+
raise Error, "#{label}: row #{existing.id.inspect} already exists " \
|
|
680
|
+
"(from #{existing.row_layer}); before:/after: only positions a new row"
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
updated = existing.with(
|
|
684
|
+
plugin: raw.key?(:plugin) ? raw[:plugin].to_s : existing.plugin,
|
|
685
|
+
plugin_layer: raw.key?(:plugin) ? label : existing.plugin_layer,
|
|
686
|
+
config: raw.key?(:config) ? config_of(label, existing.id, raw) : existing.config,
|
|
687
|
+
disabled: raw.key?(:disabled) ? boolean(label, existing.id, raw[:disabled]) : existing.disabled,
|
|
688
|
+
config_layer: raw.key?(:config) ? label : existing.config_layer
|
|
689
|
+
)
|
|
690
|
+
ordered[ordered.index(existing)] = updated
|
|
691
|
+
index[existing.id] = updated
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def self.append(ordered, index, label, id, raw)
|
|
695
|
+
row = build(label, id, raw)
|
|
696
|
+
ordered << row
|
|
697
|
+
index[id] = row
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
# Position matters for reasons the loader's dependency ordering does not
|
|
701
|
+
# cover — two tools/pre_execute listeners have an order, and that order is
|
|
702
|
+
# policy. So an insertion without an anchor, or with an anchor naming a row
|
|
703
|
+
# that is not in the stack, fails closed rather than landing somewhere
|
|
704
|
+
# plausible.
|
|
705
|
+
def self.insert(ordered, index, label, id, raw)
|
|
706
|
+
if raw.key?(:after) && raw.key?(:before)
|
|
707
|
+
raise Error, "#{label}: row #{id.inspect} names both before: and after:; it goes in one place"
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
anchor_id, offset = if raw.key?(:after) then [raw[:after].to_s, 1]
|
|
711
|
+
elsif raw.key?(:before) then [raw[:before].to_s, 0]
|
|
712
|
+
end
|
|
713
|
+
unless anchor_id
|
|
714
|
+
raise Error, "#{label}: row #{id.inspect} is new and must say where it goes " \
|
|
715
|
+
"with before: or after: naming an existing row"
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
anchor = index[anchor_id]
|
|
719
|
+
unless anchor
|
|
720
|
+
raise Error, "#{label}: row #{id.inspect} anchors #{raw.key?(:after) ? 'after' : 'before'} " \
|
|
721
|
+
"#{anchor_id.inspect}, which is not in the stack"
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
row = build(label, id, raw)
|
|
725
|
+
ordered.insert(ordered.index(anchor) + offset, row)
|
|
726
|
+
index[id] = row
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def self.build(label, id, raw)
|
|
730
|
+
raise Error, "#{label}: row #{id.inspect} has no plugin:" unless raw[:plugin]
|
|
731
|
+
|
|
732
|
+
Row.new(id: id, plugin: raw[:plugin].to_s, config: config_of(label, id, raw),
|
|
733
|
+
disabled: boolean(label, id, raw[:disabled]), row_layer: label,
|
|
734
|
+
plugin_layer: label, config_layer: label)
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def self.config_of(label, id, raw)
|
|
738
|
+
config = raw[:config]
|
|
739
|
+
return {} if config.nil?
|
|
740
|
+
raise Error, "#{label}: row #{id.inspect}: config: must be a mapping, got #{config.class}" unless config.is_a?(Hash)
|
|
741
|
+
|
|
742
|
+
config
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
# A real boolean, not any truthy scalar. `disabled: "false"` reads as off
|
|
746
|
+
# and would mean on, and this is the key that decides whether the approvals
|
|
747
|
+
# row mounts.
|
|
748
|
+
def self.boolean(label, id, value)
|
|
749
|
+
return false if value.nil?
|
|
750
|
+
return value if value == true || value == false
|
|
751
|
+
|
|
752
|
+
raise Error, "#{label}: row #{id.inspect}: disabled: must be true or false, got #{value.inspect}"
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
end
|