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.
@@ -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