libtmux-workspace 0.1.0.alpha.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.
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "libtmux/workspace"
4
+ require "optparse"
5
+
6
+ module LibTmux
7
+ class Workspace
8
+ class CLI
9
+ def self.run(arguments, out: $stdout, err: $stderr, directory: Dir.pwd, environment: ENV)
10
+ new(arguments, out, err, directory, environment).run
11
+ end
12
+
13
+ def initialize(arguments, out, err, directory, environment)
14
+ @arguments, @out, @err, @directory, @environment = arguments.dup, out, err, directory, environment
15
+ @json = @arguments.include?("--json")
16
+ @options = {timeout: 5.0, environment: {}, expand_environment: false, compensate: false}
17
+ end
18
+ private_class_method :new
19
+
20
+ def run
21
+ return help if @arguments == ["--help"] || @arguments == ["-h"]
22
+ return version if @arguments == ["--version"]
23
+
24
+ command = @arguments.shift
25
+ raise ArgumentError unless %w[validate plan load].include?(command)
26
+
27
+ @arguments.take_while { |argument| argument != "--" }.each_with_index do |argument, index|
28
+ next unless argument == "--switch"
29
+ value = @arguments[index + 1]
30
+ raise OptionParser::MissingArgument, "--switch" if !value || value.start_with?("-")
31
+ end
32
+ parser.parse!(@arguments)
33
+ return help if @options[:help]
34
+ return version if @options[:version]
35
+
36
+ validate_arguments(command)
37
+ file = configuration_file
38
+ workspace = Workspace.load(file, expand_environment: @options.fetch(:expand_environment), environment: @options.fetch(:environment))
39
+ case command
40
+ when "validate"
41
+ emit({"valid" => true, "profile" => PROFILE, "version" => CONFIG_VERSION}, "Workspace configuration is valid.")
42
+ when "plan"
43
+ if @options[:live]
44
+ plan = with_server { |server| workspace.plan(snapshot: server.snapshot(timeout: @options.fetch(:timeout))) }
45
+ emit(plan.to_h, human_plan(plan))
46
+ else
47
+ plan = workspace.plan
48
+ emit(plan.to_h, human_plan(plan))
49
+ end
50
+ when "load"
51
+ with_server do |server|
52
+ @result = workspace.plan.apply(server: server, timeout: @options.fetch(:timeout), compensate: @options.fetch(:compensate))
53
+ if @options[:switch]
54
+ server.switch_client(client: @options.fetch(:switch), session: @result.created_refs.fetch("session"),
55
+ timeout: @options.fetch(:timeout))
56
+ end
57
+ if @options[:attach]
58
+ File.open("/dev/tty", "r+") do |terminal|
59
+ result = server.attach(session: @result.created_refs.fetch("session"), terminal: terminal, term: @environment.fetch("TERM"))
60
+ raise TransportError.new("attached terminal client failed", phase: :terminal) unless result.success?
61
+ end
62
+ end
63
+ end
64
+ emit(@result.to_h, "Workspace created; #{@result.completed_steps.length} steps completed. Shell commands were dispatched.")
65
+ end
66
+ 0
67
+ rescue Workspace::ConfigError => error
68
+ report(error, kind: "configuration", status: 2)
69
+ rescue Workspace::ApplyError => error
70
+ @result = error.result
71
+ interrupted = ["Interrupt", "LibTmux::Cancelled"].include?(error.failure_class)
72
+ status = interrupted ? 130 : (@result.uncertain? || !@result.effects.empty? ? 3 : 1)
73
+ report(error, kind: interrupted ? "interrupted" : "application", status: status)
74
+ rescue OptionParser::ParseError, ArgumentError, KeyError
75
+ report(nil, kind: "arguments", status: @result ? 3 : 2)
76
+ rescue Interrupt, LibTmux::Cancelled => error
77
+ report(error, kind: "interrupted", status: 130)
78
+ rescue LibTmux::Error, SystemCallError, IOError => error
79
+ report(error, kind: "execution", status: @result ? 3 : 1)
80
+ end
81
+
82
+ private
83
+
84
+ def parser
85
+ @parser ||= OptionParser.new do |options|
86
+ options.banner = "Usage: libtmux-workspace validate|plan|load [options] [FILE]"
87
+ options.on("--json", "Write structured JSON to stdout") { @json = true }
88
+ options.on("--socket PATH", "Explicit existing tmux socket for load or plan --live") { |value| @options[:socket] = value }
89
+ options.on("--live", "Acquire a snapshot before planning") { @options[:live] = true }
90
+ options.on("--timeout SECONDS", Float, "Per-operation apply/capture/switch deadline (default: 5)") { |value| @options[:timeout] = value }
91
+ options.on("--compensate", "Attempt guarded cleanup of positively created resources on failure") { @options[:compensate] = true }
92
+ options.on("--attach", "Attach this CLI terminal after successful load") { @options[:attach] = true }
93
+ options.on("--switch CLIENT", "Switch the explicit current client after successful load") { |value| @options[:switch] = value }
94
+ options.on("--expand-environment", "Expand ${NAME} in paths/environment using explicit --env values") { @options[:expand_environment] = true }
95
+ options.on("--env NAME=VALUE", "Add an explicit expansion value; shell command text is unchanged") do |value|
96
+ name, contents = value.split("=", 2)
97
+ raise ArgumentError unless contents && /\A[A-Za-z_][A-Za-z0-9_]*\z/.match?(name)
98
+ raise ArgumentError if @options.fetch(:environment).key?(name)
99
+
100
+ @options.fetch(:environment)[name] = contents
101
+ end
102
+ options.on("-h", "--help", "Show supported commands and options") { @options[:help] = true }
103
+ options.on("--version", "Show the installed gem version") { @options[:version] = true }
104
+ end
105
+ end
106
+
107
+ def help
108
+ @out.puts(parser)
109
+ 0
110
+ end
111
+
112
+ def version
113
+ @out.puts(VERSION)
114
+ 0
115
+ end
116
+
117
+ def validate_arguments(command)
118
+ raise ArgumentError if @arguments.length > 1
119
+ raise ArgumentError unless @options.fetch(:timeout).finite? && @options.fetch(:timeout).positive?
120
+ raise ArgumentError if @options[:attach] && @options[:switch]
121
+ if @options[:switch]
122
+ client = @options.fetch(:switch)
123
+ raise ArgumentError unless client.bytesize.between?(1, 1024) && !client.b.include?("\0")
124
+ end
125
+ raise ArgumentError if command != "load" && (@options[:attach] || @options[:switch] || @options[:compensate])
126
+ raise ArgumentError if @options[:live] && command != "plan"
127
+ live = command == "load" || @options[:live]
128
+ raise ArgumentError if live && (!@options[:socket].is_a?(String) || @options[:socket].empty?)
129
+ raise ArgumentError if !live && @options[:socket]
130
+ if @options[:attach]
131
+ term = @environment["TERM"]
132
+ raise ArgumentError unless term.is_a?(String) && /\A[A-Za-z0-9][A-Za-z0-9_.+-]{0,127}\z/.match?(term)
133
+ end
134
+ end
135
+
136
+ def configuration_file
137
+ return File.expand_path(@arguments.first, @directory) if @arguments.first
138
+
139
+ candidates = %w[.tmuxp.yaml .tmuxp.yml .tmuxp.json].map { |name| File.join(@directory, name) }.select { |path| File.exist?(path) }
140
+ unless candidates.length == 1
141
+ @argument_message = "Provide a configuration file; discovery requires exactly one .tmuxp.yaml, .tmuxp.yml or .tmuxp.json."
142
+ raise ArgumentError
143
+ end
144
+ candidates.first
145
+ end
146
+
147
+ def with_server(&block)
148
+ LibTmux::Server.open(socket_path: File.expand_path(@options.fetch(:socket), @directory), &block)
149
+ end
150
+
151
+ def human_plan(plan)
152
+ (["#{plan.mode}: #{plan.steps.length} ordered steps"] +
153
+ plan.steps.map { |step| "#{step.id}. #{step.operation} #{step.target} (#{step.effect})" }).join("\n")
154
+ end
155
+
156
+ def emit(value, human)
157
+ @out.puts(@json ? JSON.generate(value) : human)
158
+ end
159
+
160
+ def report(error, kind:, status:)
161
+ message = if kind == "arguments"
162
+ @argument_message || "Invalid arguments; use --help for supported options."
163
+ elsif error.is_a?(Workspace::ConfigError) || error.is_a?(Workspace::ApplyError)
164
+ error.message
165
+ else
166
+ "Workspace #{kind} failed#{error ? " (#{error.class})" : ''}."
167
+ end
168
+ value = {"error" => {"kind" => kind, "message" => message, "exit_status" => status}}
169
+ if error.is_a?(LibTmux::Error)
170
+ value.fetch("error")["delivery"] = error.delivery.to_s
171
+ value.fetch("error")["phase"] = error.phase&.to_s
172
+ end
173
+ value.fetch("error")["failure_class"] = error.failure_class if error.is_a?(Workspace::ApplyError)
174
+ value["result"] = @result.to_h if @result
175
+ if @json
176
+ @out.puts(JSON.generate(value))
177
+ else
178
+ @err.puts(message)
179
+ @err.puts(JSON.generate(@result.to_h)) if @result
180
+ end
181
+ status
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LibTmux
4
+ class Workspace
5
+ # JSON-compatible scalar rules avoid YAML object loading and implicit dates.
6
+ class Document
7
+ class YAMLHandler < Psych::Handler
8
+ attr_reader :result
9
+
10
+ def initialize(limits)
11
+ @limits, @stack, @nodes, @documents = limits, [], 0, 0
12
+ end
13
+
14
+ def start_document(*)
15
+ @documents += 1
16
+ fail_input("exactly one YAML document") unless @documents == 1
17
+ end
18
+
19
+ def start_mapping(anchor, tag, *)
20
+ container({}, anchor, tag)
21
+ end
22
+
23
+ def start_sequence(anchor, tag, *)
24
+ container([], anchor, tag)
25
+ end
26
+
27
+ def end_mapping
28
+ frame = @stack.pop
29
+ fail_input("complete YAML mapping") unless frame[:key].nil?
30
+ end
31
+
32
+ def end_sequence
33
+ @stack.pop
34
+ end
35
+
36
+ def scalar(value, anchor, tag, plain, quoted, *)
37
+ fail_input("YAML without tags or anchors") if anchor || tag
38
+ visit
39
+ fail_input("bounded UTF-8 scalar") if value.bytesize > @limits.fetch(:max_string_bytes)
40
+ decoded = if !plain || quoted
41
+ value
42
+ else
43
+ case value
44
+ when "true" then true
45
+ when "false" then false
46
+ when "null", "~", "" then nil
47
+ when /\A-?(?:0|[1-9][0-9]*)\z/
48
+ fail_input("bounded integer token") if value.bytesize > 20
49
+ Integer(value, 10)
50
+ when /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\z/
51
+ fail_input("bounded scalar token") if value.bytesize > 32
52
+ Float(value)
53
+ else value
54
+ end
55
+ end
56
+ append(decoded)
57
+ end
58
+
59
+ def alias(*)
60
+ fail_input("YAML without aliases")
61
+ end
62
+
63
+ private
64
+
65
+ def container(value, anchor, tag)
66
+ fail_input("YAML without tags or anchors") if anchor || tag
67
+ visit
68
+ fail_input("bounded document depth") if @stack.length >= @limits.fetch(:max_depth)
69
+ append(value)
70
+ @stack << {value: value, key: nil}
71
+ end
72
+
73
+ def visit
74
+ @nodes += 1
75
+ fail_input("bounded document node count") if @nodes > @limits.fetch(:max_nodes)
76
+ end
77
+
78
+ def append(value)
79
+ if @stack.empty?
80
+ @result = value
81
+ elsif @stack.last[:value].is_a?(Array)
82
+ @stack.last[:value] << value
83
+ else
84
+ frame = @stack.last
85
+ if frame[:key].nil?
86
+ fail_input("string mapping keys") unless value.is_a?(String)
87
+ fail_input("unique mapping keys") if frame[:value].key?(value)
88
+ frame[:key] = value
89
+ else
90
+ frame[:value][frame[:key]] = value
91
+ frame[:key] = nil
92
+ end
93
+ end
94
+ end
95
+
96
+ def fail_input(expected)
97
+ raise ConfigError.new("invalid workspace document", path: "$", expected: expected)
98
+ end
99
+ end
100
+ private_constant :YAMLHandler
101
+
102
+ def initialize(limits)
103
+ @limits = limits
104
+ end
105
+
106
+ def parse(bytes, format)
107
+ unless bytes.is_a?(String) && bytes.bytesize <= @limits.fetch(:max_bytes)
108
+ invalid("bounded document bytes")
109
+ end
110
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
111
+ invalid("UTF-8 document") unless text.valid_encoding?
112
+ value = case format
113
+ when :json
114
+ preflight_json(text)
115
+ JSON.parse(text, max_nesting: @limits.fetch(:max_depth), allow_nan: false, allow_duplicate_key: false)
116
+ when :yaml
117
+ handler = YAMLHandler.new(@limits)
118
+ Psych::Parser.new(handler).parse(text)
119
+ handler.result
120
+ else
121
+ raise ArgumentError, "workspace format must be :json or :yaml"
122
+ end
123
+ validate_tree(value)
124
+ value
125
+ rescue JSON::ParserError, JSON::NestingError, Psych::Exception, EncodingError
126
+ raise ConfigError.new("invalid workspace syntax", path: "$", expected: "data-only bounded JSON or YAML"), cause: nil
127
+ end
128
+
129
+ def validate_tree(value)
130
+ @nodes = 0
131
+ validate(value)
132
+ end
133
+
134
+ private
135
+
136
+ # Bound allocation before JSON creates its object tree or large integers.
137
+ def preflight_json(text)
138
+ index, nodes, depth = 0, 0, 0
139
+ while index < text.bytesize
140
+ byte = text.getbyte(index)
141
+ case byte
142
+ when 34
143
+ nodes += 1
144
+ index += 1
145
+ while index < text.bytesize
146
+ current = text.getbyte(index)
147
+ index += 1
148
+ break if current == 34
149
+ index += 1 if current == 92
150
+ end
151
+ index -= 1
152
+ when 123, 91
153
+ nodes += 1
154
+ depth += 1
155
+ invalid("bounded document depth") if depth > @limits.fetch(:max_depth)
156
+ when 125, 93 then depth -= 1
157
+ when 32, 9, 10, 13, 44, 58 then nil
158
+ else
159
+ nodes += 1
160
+ first = index
161
+ index += 1 while index < text.bytesize && ![32, 9, 10, 13, 44, 93, 125].include?(text.getbyte(index))
162
+ invalid("bounded scalar token") if index - first > 32
163
+ index -= 1
164
+ end
165
+ invalid("bounded document node count") if nodes > @limits.fetch(:max_nodes)
166
+ index += 1
167
+ end
168
+ end
169
+
170
+ def validate(value, depth = 0)
171
+ @nodes += 1
172
+ invalid("bounded document node count") if @nodes > @limits.fetch(:max_nodes)
173
+ invalid("bounded document depth") if depth > @limits.fetch(:max_depth)
174
+ case value
175
+ when Hash
176
+ value.each do |key, child|
177
+ invalid("string mapping keys") unless key.is_a?(String)
178
+ validate(key, depth + 1)
179
+ validate(child, depth + 1)
180
+ end
181
+ when Array then value.each { |child| validate(child, depth + 1) }
182
+ when String
183
+ invalid("bounded UTF-8 scalar") if value.bytesize > @limits.fetch(:max_string_bytes) || !value.valid_encoding?
184
+ when Integer, Float, true, false, nil then nil
185
+ else invalid("plain data values")
186
+ end
187
+ end
188
+
189
+ def invalid(expected)
190
+ raise ConfigError.new("invalid workspace document", path: "$", expected: expected)
191
+ end
192
+ end
193
+ private_constant :Document
194
+ end
195
+ end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LibTmux
4
+ class Workspace
5
+ class Normalizer
6
+ COMMON = %w[start_directory environment shell_command shell_command_before].freeze
7
+ ROOT = (COMMON + %w[profile version session_name windows options window_options]).freeze
8
+ WINDOW = (COMMON + %w[window_name window_index panes focus layout options]).freeze
9
+ PANE = (COMMON + %w[focus split size]).freeze
10
+ LAYOUTS = %w[even-horizontal even-vertical main-horizontal main-vertical tiled].freeze
11
+ SESSION_OPTIONS = {"base-index" => :index, "status" => :boolean, "mouse" => :boolean,
12
+ "renumber-windows" => :boolean, "history-limit" => :index,
13
+ "status-interval" => :index, "status-position" => %w[top bottom],
14
+ "status-justify" => %w[left centre right absolute-centre],
15
+ "default-terminal" => :unsupported}.freeze
16
+ WINDOW_OPTIONS = {"automatic-rename" => :boolean, "allow-rename" => :boolean,
17
+ "remain-on-exit" => :boolean, "synchronize-panes" => :boolean,
18
+ "aggressive-resize" => :boolean, "pane-base-index" => :pane_index,
19
+ "main-pane-width" => :index, "main-pane-height" => :index,
20
+ "window-status-format" => :text, "window-status-current-format" => :text,
21
+ "pane-border-status" => %w[off top bottom]}.freeze
22
+ private_constant :COMMON, :ROOT, :WINDOW, :PANE, :LAYOUTS, :SESSION_OPTIONS, :WINDOW_OPTIONS
23
+
24
+ def initialize(base, expand, environment, limits)
25
+ @limits, @expand, @expanded_bytes = limits, expand, 0
26
+ @normalized_bytes, @normalized_nodes = 0, 0
27
+ unless base.is_a?(String) && !base.empty? && !base.include?("\0")
28
+ raise ArgumentError, "workspace base_directory must be a nonempty String without NUL"
29
+ end
30
+ unless expand.equal?(true) || expand.equal?(false)
31
+ raise ArgumentError, "expand_environment must be Boolean"
32
+ end
33
+ @base = File.expand_path(base)
34
+ @environment = environment_map(environment, "$environment", expand: false)
35
+ @pane_count = 0
36
+ end
37
+
38
+ def normalize(input)
39
+ object(input, ROOT, "$")
40
+ if input.key?("profile") || input.key?("version")
41
+ unless input["profile"] == PROFILE && input["version"].is_a?(Integer) && input["version"] == CONFIG_VERSION
42
+ fail_at("$", "#{PROFILE} version #{CONFIG_VERSION}, with both envelope fields")
43
+ end
44
+ end
45
+ name = name(input.fetch("session_name") { fail_at("$.session_name", "session name") }, "$.session_name")
46
+ root = common(input, {"start_directory" => @base, "environment" => {},
47
+ "shell_command_before" => [], "shell_command" => []}, "$")
48
+ root_options = options(input.fetch("options", {}), SESSION_OPTIONS, "$.options")
49
+ inherited_options = options(input.fetch("window_options", {}), WINDOW_OPTIONS, "$.window_options")
50
+ windows = input["windows"]
51
+ unless windows.is_a?(Array) && windows.length.between?(1, @limits.fetch(:max_windows))
52
+ fail_at("$.windows", "nonempty bounded window array")
53
+ end
54
+ indexes, explicit = {}, {}
55
+ windows.each_with_index do |window, position|
56
+ path = "$.windows[#{position}]"
57
+ object(window, WINDOW, path)
58
+ next unless window.key?("window_index")
59
+
60
+ value = index(window["window_index"], "#{path}.window_index")
61
+ fail_at("#{path}.window_index", "unique window index") if explicit.key?(value)
62
+ explicit[value] = true
63
+ end
64
+ next_index = root_options.fetch("base-index", 0)
65
+ normalized = windows.each_with_index.map do |window, position|
66
+ path = "$.windows[#{position}]"
67
+ if window.key?("window_index")
68
+ assigned = window.fetch("window_index")
69
+ else
70
+ next_index += 1 while explicit.key?(next_index) || indexes.key?(next_index)
71
+ assigned = index(next_index, "#{path}.window_index")
72
+ next_index += 1
73
+ end
74
+ indexes[assigned] = true
75
+ normalize_window(window, root, inherited_options, assigned, path)
76
+ end
77
+ choose_focus(normalized, "$.windows")
78
+ # Canonical exports carry commands only at leaves. Reloading must not
79
+ # prepend already inherited commands a second time.
80
+ root["shell_command_before"] = []
81
+ root["shell_command"] = []
82
+ normalized.each do |window|
83
+ window["shell_command_before"] = []
84
+ window["shell_command"] = []
85
+ end
86
+ freeze_tree(root.merge("profile" => PROFILE, "version" => CONFIG_VERSION,
87
+ "session_name" => name, "options" => root_options,
88
+ "window_options" => inherited_options, "windows" => normalized))
89
+ end
90
+
91
+ private
92
+
93
+ def normalize_window(input, parent, inherited_options, assigned, path)
94
+ value = common(input, parent, path)
95
+ value["window_name"] = name(input.fetch("window_name") { fail_at("#{path}.window_name", "window name") }, "#{path}.window_name")
96
+ value["window_index"] = assigned
97
+ value["focus"] = boolean(input.fetch("focus", false), "#{path}.focus")
98
+ value["options"] = inherited_options.merge(options(input.fetch("options", {}), WINDOW_OPTIONS, "#{path}.options"))
99
+ layout = input["layout"]
100
+ fail_at("#{path}.layout", "supported named layout") if input.key?("layout") && !LAYOUTS.include?(layout)
101
+ value["layout"] = layout if layout
102
+ panes = input["panes"]
103
+ fail_at("#{path}.panes", "nonempty pane array") unless panes.is_a?(Array) && !panes.empty?
104
+ @pane_count += panes.length
105
+ fail_at("#{path}.panes", "bounded total pane count") if @pane_count > @limits.fetch(:max_panes)
106
+ value["panes"] = panes.each_with_index.map do |pane, position|
107
+ child_path = "#{path}.panes[#{position}]"
108
+ pane = {"shell_command" => pane} if pane.is_a?(String)
109
+ object(pane, PANE, child_path)
110
+ child = common(pane, value, child_path)
111
+ child["focus"] = boolean(pane.fetch("focus", false), "#{child_path}.focus")
112
+ if position.zero? && (pane.key?("split") || pane.key?("size"))
113
+ fail_at(child_path, "split geometry only for subsequent panes")
114
+ end
115
+ direction = pane.fetch("split", "vertical")
116
+ fail_at("#{child_path}.split", "horizontal or vertical") unless %w[horizontal vertical].include?(direction)
117
+ child["split"] = direction unless position.zero?
118
+ if pane.key?("size")
119
+ size = pane["size"]
120
+ valid = (size.is_a?(Integer) && size.between?(1, (1 << 31) - 1)) ||
121
+ (size.is_a?(String) && /\A(?:[1-9]|[1-9][0-9])%\z/.match?(size))
122
+ fail_at("#{child_path}.size", "positive cell count or percentage between 1% and 99%") unless valid
123
+ fail_at("#{child_path}.size", "split size without a final named layout") if layout
124
+ child["size"] = size
125
+ end
126
+ child
127
+ end
128
+ choose_focus(value["panes"], "#{path}.panes")
129
+ value
130
+ end
131
+
132
+ def common(input, parent, path)
133
+ directory = if input.key?("start_directory")
134
+ value = expand(text(input["start_directory"], "#{path}.start_directory", empty: false), "#{path}.start_directory")
135
+ # Prefixing relative paths avoids File.expand_path's ambient ~ expansion.
136
+ File.expand_path(value.start_with?(File::SEPARATOR) ? value : File.join(@base, value))
137
+ else parent.fetch("start_directory")
138
+ end
139
+ inherited_environment = parent.fetch("environment").merge(environment_map(input.fetch("environment", {}), "#{path}.environment"))
140
+ before = parent.fetch("shell_command_before") + commands(input.fetch("shell_command_before", []), "#{path}.shell_command_before")
141
+ command = input.key?("shell_command") ? commands(input["shell_command"], "#{path}.shell_command") : parent.fetch("shell_command")
142
+ result = {"start_directory" => directory, "environment" => inherited_environment,
143
+ "shell_command_before" => before, "shell_command" => command}
144
+ account(result, path)
145
+ result
146
+ end
147
+
148
+ def object(value, allowed, path)
149
+ fail_at(path, "configuration object") unless value.is_a?(Hash)
150
+ unknown = value.keys - allowed
151
+ unless unknown.empty?
152
+ known_unsupported = %w[before_script plugins hooks erb callback callbacks].find { |key| unknown.include?(key) }
153
+ failure_path = known_unsupported ? "#{path}.#{known_unsupported}" : path
154
+ fail_at(failure_path, "supported data-only fields; move callbacks or hooks into explicit application code")
155
+ end
156
+ end
157
+
158
+ def environment_map(value, path, expand: @expand)
159
+ fail_at(path, "environment object") unless value.is_a?(Hash)
160
+ fail_at(path, "bounded environment map") if value.length > @limits.fetch(:max_nodes)
161
+ bytes = 0
162
+ value.to_h do |key, item|
163
+ unless key.is_a?(String) && /\A[A-Za-z_][A-Za-z0-9_]*\z/.match?(key)
164
+ fail_at(path, "portable environment variable names")
165
+ end
166
+ item = text(item, path)
167
+ bytes += key.bytesize + item.bytesize
168
+ fail_at(path, "bounded environment bytes") if bytes > @limits.fetch(:max_bytes)
169
+ [key.dup, expand ? self.expand(item, path) : item]
170
+ end
171
+ end
172
+
173
+ def expand(value, path)
174
+ return value unless @expand
175
+
176
+ output, offset = +"", 0
177
+ append = lambda do |chunk|
178
+ fail_at(path, "bounded expanded value") if output.bytesize + chunk.bytesize > @limits.fetch(:max_string_bytes)
179
+ output << chunk
180
+ end
181
+ value.scan(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/) do
182
+ match = Regexp.last_match
183
+ append.call(value[offset...match.begin(0)])
184
+ replacement = @environment.fetch(match[1]) { fail_at(path, "explicit substitution value for every variable") }
185
+ append.call(replacement)
186
+ offset = match.end(0)
187
+ end
188
+ append.call(value[offset..])
189
+ @expanded_bytes += output.bytesize
190
+ if output.bytesize > @limits.fetch(:max_string_bytes) || @expanded_bytes > @limits.fetch(:max_bytes)
191
+ fail_at(path, "bounded expanded values")
192
+ end
193
+ output
194
+ end
195
+
196
+ def account(value, path)
197
+ @normalized_nodes += 1
198
+ fail_at(path, "bounded normalized node count") if @normalized_nodes > @limits.fetch(:max_nodes)
199
+ case value
200
+ when Hash then value.each { |key, child| account(key, path); account(child, path) }
201
+ when Array then value.each { |child| account(child, path) }
202
+ when String
203
+ @normalized_bytes += value.bytesize
204
+ fail_at(path, "bounded normalized bytes") if @normalized_bytes > @limits.fetch(:max_bytes)
205
+ end
206
+ end
207
+
208
+ def text(value, path, empty: true)
209
+ fail_at(path, "UTF-8 text") unless value.is_a?(String)
210
+ result = value.dup.force_encoding(Encoding::UTF_8)
211
+ unless result.valid_encoding? && !result.include?("\0") && (empty || !result.empty?) && result.bytesize <= @limits.fetch(:max_string_bytes)
212
+ fail_at(path, "bounded UTF-8 text without NUL")
213
+ end
214
+ result
215
+ end
216
+
217
+ def name(value, path)
218
+ result = text(value, path, empty: false)
219
+ unless !result.match?(/[\x00-\x1f\x7f:.]/) && !result.strip.empty?
220
+ fail_at(path, "name without controls, colon or period")
221
+ end
222
+ result
223
+ end
224
+
225
+ def commands(value, path)
226
+ value = [value] if value.is_a?(String)
227
+ fail_at(path, "shell command string or ordered string array") unless value.is_a?(Array)
228
+ fail_at(path, "bounded command array") if value.length > @limits.fetch(:max_nodes)
229
+ value.map { |command| text(command, path) }
230
+ end
231
+
232
+ def boolean(value, path)
233
+ fail_at(path, "Boolean") unless value.equal?(true) || value.equal?(false)
234
+ value
235
+ end
236
+
237
+ def index(value, path, maximum = (1 << 31) - 1)
238
+ fail_at(path, "integer index from zero through #{maximum}") unless value.is_a?(Integer) && value.between?(0, maximum)
239
+ value
240
+ end
241
+
242
+ def options(value, catalog, path)
243
+ fail_at(path, "typed option object") unless value.is_a?(Hash)
244
+ value.to_h do |key, item|
245
+ type = catalog[key]
246
+ fail_at(path, "supported options for this scope") unless type && type != :unsupported
247
+ converted = case type
248
+ when :boolean then boolean(item, "#{path}.#{key}")
249
+ when :index then index(item, "#{path}.#{key}")
250
+ when :pane_index then index(item, "#{path}.#{key}", 65535)
251
+ when :text then text(item, "#{path}.#{key}")
252
+ else
253
+ fail_at("#{path}.#{key}", "declared option choice") unless type.include?(item)
254
+ item.dup
255
+ end
256
+ [key.dup, converted]
257
+ end
258
+ end
259
+
260
+ def choose_focus(values, path)
261
+ focused = values.count { |item| item.fetch("focus") }
262
+ fail_at(path, "at most one focused item") if focused > 1
263
+ values.first["focus"] = true if focused.zero?
264
+ end
265
+
266
+ def freeze_tree(value)
267
+ case value
268
+ when Hash then value.to_h { |key, item| [key.dup.freeze, freeze_tree(item)] }.freeze
269
+ when Array then value.map { |item| freeze_tree(item) }.freeze
270
+ when String then value.dup.freeze
271
+ else value
272
+ end
273
+ end
274
+
275
+ def fail_at(path, expected)
276
+ raise ConfigError.new(path: path, expected: expected)
277
+ end
278
+ end
279
+ private_constant :Normalizer
280
+ end
281
+ end