libtmux 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +109 -0
- data/lib/libtmux/capture.rb +146 -0
- data/lib/libtmux/catalog.rb +116 -0
- data/lib/libtmux/child.rb +156 -0
- data/lib/libtmux/control.rb +885 -0
- data/lib/libtmux/criteria.rb +410 -0
- data/lib/libtmux/endpoint.rb +213 -0
- data/lib/libtmux/entity.rb +262 -0
- data/lib/libtmux/errors.rb +54 -0
- data/lib/libtmux/group.rb +59 -0
- data/lib/libtmux/metadata.rb +85 -0
- data/lib/libtmux/operations.rb +412 -0
- data/lib/libtmux/options.rb +180 -0
- data/lib/libtmux/owned.rb +252 -0
- data/lib/libtmux/process.rb +327 -0
- data/lib/libtmux/process_wait.rb +66 -0
- data/lib/libtmux/selection.rb +95 -0
- data/lib/libtmux/server.rb +534 -0
- data/lib/libtmux/snapshot.rb +428 -0
- data/lib/libtmux/socket_readiness.rb +232 -0
- data/lib/libtmux/source_query.rb +57 -0
- data/lib/libtmux/terminal.rb +197 -0
- data/lib/libtmux/version.rb +5 -0
- data/lib/libtmux.rb +18 -0
- data/schema/where-v1.json +2291 -0
- data/sig/control.rbs +75 -0
- data/sig/criteria.rbs +36 -0
- data/sig/fields.rbs +72 -0
- data/sig/group.rbs +14 -0
- data/sig/libtmux.rbs +186 -0
- data/sig/operations.rbs +91 -0
- data/sig/owned.rbs +7 -0
- data/sig/snapshot.rbs +70 -0
- data/sig/terminal.rbs +15 -0
- metadata +145 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "catalog"
|
|
6
|
+
|
|
7
|
+
module LibTmux
|
|
8
|
+
# A schema-validated predicate over captured records; evaluation performs no I/O.
|
|
9
|
+
class FilterExpr
|
|
10
|
+
# Runtime validation also enforces byte, node, depth and duplicate-key limits.
|
|
11
|
+
def self.json_schema
|
|
12
|
+
JSON.parse(File.binread(File.expand_path("../../schema/where-v1.json", __dir__)), freeze: true)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
PROFILE = "libtmux-ruby.where"
|
|
16
|
+
VERSION = 1
|
|
17
|
+
OMITTED = Object.new.freeze
|
|
18
|
+
OPERATORS = {
|
|
19
|
+
equals: "equals", not: "not", in: "in", lt: "lt", lte: "lte",
|
|
20
|
+
gt: "gt", gte: "gte", contains: "contains", starts_with: "startsWith",
|
|
21
|
+
ends_with: "endsWith", some: "some", every: "every", none: "none",
|
|
22
|
+
is: "is", is_not: "isNot"
|
|
23
|
+
}.freeze
|
|
24
|
+
MAX_DEPTH = 32
|
|
25
|
+
MAX_NODES = 2048
|
|
26
|
+
MAX_MEMBERS = 1024
|
|
27
|
+
MAX_STRING_BYTES = 65_536
|
|
28
|
+
MAX_BYTES = 262_144
|
|
29
|
+
private_constant :OMITTED, :OPERATORS, :MAX_DEPTH, :MAX_NODES, :MAX_MEMBERS,
|
|
30
|
+
:MAX_STRING_BYTES, :MAX_BYTES
|
|
31
|
+
|
|
32
|
+
attr_reader :entity
|
|
33
|
+
|
|
34
|
+
def self.build(entity, criteria = OMITTED, **keywords, &block)
|
|
35
|
+
raise ArgumentError, "criteria do not accept a block" if block
|
|
36
|
+
unless Internal::Catalog.kinds.include?(entity)
|
|
37
|
+
raise InvalidFilterError.new(path: "$.entity", expected: "declared entity schema")
|
|
38
|
+
end
|
|
39
|
+
unless criteria.equal?(OMITTED) || keywords.empty?
|
|
40
|
+
raise ArgumentError, "use positional or keyword criteria, not both"
|
|
41
|
+
end
|
|
42
|
+
criteria = keywords if criteria.equal?(OMITTED)
|
|
43
|
+
if criteria.is_a?(self)
|
|
44
|
+
unless criteria.entity == entity
|
|
45
|
+
raise InvalidFilterError.new(entity: entity, expected: "matching entity schema")
|
|
46
|
+
end
|
|
47
|
+
return criteria
|
|
48
|
+
end
|
|
49
|
+
schema = Internal::Catalog.entity(entity)
|
|
50
|
+
tree = Normalizer.new(schema.kind).normalize(schema, criteria)
|
|
51
|
+
new(schema.kind, tree)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.from_json(input)
|
|
55
|
+
unless input.is_a?(String) && input.bytesize <= MAX_BYTES
|
|
56
|
+
raise InvalidFilterError.new(expected: "JSON string of at most #{MAX_BYTES} bytes")
|
|
57
|
+
end
|
|
58
|
+
data = JSON.parse(input, max_nesting: MAX_DEPTH * 2 + 4,
|
|
59
|
+
allow_nan: false, allow_duplicate_key: false)
|
|
60
|
+
required = %w[profile version entity where]
|
|
61
|
+
unless data.is_a?(Hash) && data.keys.sort == required.sort &&
|
|
62
|
+
data["profile"] == PROFILE && data["version"].is_a?(Integer) && data["version"] == VERSION
|
|
63
|
+
raise InvalidFilterError.new(expected: "#{PROFILE} version #{VERSION} envelope")
|
|
64
|
+
end
|
|
65
|
+
entity = %i[session window pane window_link client].find do |kind|
|
|
66
|
+
Internal::Catalog.entity(kind).wire_entity == data["entity"]
|
|
67
|
+
end
|
|
68
|
+
raise InvalidFilterError.new(path: "$.entity", expected: "declared entity") unless entity
|
|
69
|
+
|
|
70
|
+
schema = Internal::Catalog.entity(entity)
|
|
71
|
+
new(entity, Normalizer.new(entity, wire: true).normalize(schema, data["where"]))
|
|
72
|
+
rescue JSON::ParserError, JSON::NestingError, EncodingError
|
|
73
|
+
raise InvalidFilterError.new(expected: "bounded UTF-8 JSON object"), cause: nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def initialize(entity, tree)
|
|
77
|
+
@entity = entity
|
|
78
|
+
@tree = tree
|
|
79
|
+
freeze
|
|
80
|
+
end
|
|
81
|
+
private_class_method :new
|
|
82
|
+
|
|
83
|
+
def and(other)
|
|
84
|
+
compose(:and, other)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def or(other)
|
|
88
|
+
compose(:or, other)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def not
|
|
92
|
+
self.class.build(entity, not: @tree)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def call(record)
|
|
96
|
+
preflight([record])
|
|
97
|
+
matches(record, @tree)
|
|
98
|
+
end
|
|
99
|
+
alias === call
|
|
100
|
+
|
|
101
|
+
def to_proc
|
|
102
|
+
method(:call).to_proc
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def to_h
|
|
106
|
+
{"profile" => PROFILE, "version" => VERSION,
|
|
107
|
+
"entity" => Internal::Catalog.entity(entity).wire_entity,
|
|
108
|
+
"where" => wire_tree(Internal::Catalog.entity(entity), @tree)}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def to_json(*arguments)
|
|
112
|
+
output = JSON.generate(to_h, *arguments)
|
|
113
|
+
if output.bytesize > MAX_BYTES
|
|
114
|
+
raise InvalidFilterError.new(entity: entity, expected: "wire envelope of at most #{MAX_BYTES} bytes", phase: :serialize)
|
|
115
|
+
end
|
|
116
|
+
output
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def inspect
|
|
120
|
+
"#<#{self.class} entity=#{entity} profile=#{PROFILE} version=#{VERSION}>"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def compose(operator, other)
|
|
126
|
+
unless other.is_a?(FilterExpr) && other.entity == entity
|
|
127
|
+
raise InvalidFilterError.new(entity: entity, expected: "matching FilterExpr")
|
|
128
|
+
end
|
|
129
|
+
self.class.build(entity, operator => [@tree, other.instance_variable_get(:@tree)])
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def preflight(records)
|
|
133
|
+
records.each do |record|
|
|
134
|
+
unless record.respond_to?(:entity_kind, true) && record.__send__(:entity_kind) == entity
|
|
135
|
+
raise InvalidFilterError.new(entity: entity, expected: "captured #{entity} record")
|
|
136
|
+
end
|
|
137
|
+
preflight_tree(record, Internal::Catalog.entity(entity), @tree)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def select_records(records)
|
|
142
|
+
preflight(records)
|
|
143
|
+
records.select { |record| matches(record, @tree) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def preflight_tree(record, schema, tree, path = "$")
|
|
147
|
+
tree.each do |name, condition|
|
|
148
|
+
child_path = "#{path}.#{name}"
|
|
149
|
+
case name
|
|
150
|
+
when :and, :or
|
|
151
|
+
condition.each_with_index { |child, index| preflight_tree(record, schema, child, "#{child_path}[#{index}]") }
|
|
152
|
+
when :not
|
|
153
|
+
preflight_tree(record, schema, condition, child_path)
|
|
154
|
+
else
|
|
155
|
+
if schema.fields.key?(name)
|
|
156
|
+
require_complete(record, :field, name, child_path)
|
|
157
|
+
begin
|
|
158
|
+
record.__send__(:read_field, name)
|
|
159
|
+
rescue FieldDecodeError
|
|
160
|
+
raise FieldDecodeError.new("captured field cannot be decoded at #{child_path}",
|
|
161
|
+
entity: entity, path: child_path, expected: "valid captured #{schema.fields.fetch(name).type}", phase: :evaluate)
|
|
162
|
+
end
|
|
163
|
+
else
|
|
164
|
+
relation = schema.relations.fetch(name)
|
|
165
|
+
require_complete(record, :relation, name, child_path)
|
|
166
|
+
value = record.__send__(:read_relation, name)
|
|
167
|
+
children = relation.cardinality == :many ? value : [value].compact
|
|
168
|
+
condition.each_pair do |operator, child_tree|
|
|
169
|
+
next if child_tree.nil?
|
|
170
|
+
|
|
171
|
+
children.each do |child|
|
|
172
|
+
preflight_tree(child, Internal::Catalog.entity(relation.target), child_tree, "#{child_path}.#{operator}")
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def require_complete(record, category, name, path)
|
|
181
|
+
return if record.__send__(:"#{category}_coverage", name) == :complete
|
|
182
|
+
|
|
183
|
+
raise IncompleteSnapshotError.new("required #{category} was not completely captured at #{path}",
|
|
184
|
+
entity: entity, path: path, expected: "complete captured #{category}", phase: :evaluate)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def matches(record, tree)
|
|
188
|
+
schema = Internal::Catalog.entity(record.__send__(:entity_kind))
|
|
189
|
+
tree.all? do |name, condition|
|
|
190
|
+
case name
|
|
191
|
+
when :and then condition.all? { |child| matches(record, child) }
|
|
192
|
+
when :or then condition.any? { |child| matches(record, child) }
|
|
193
|
+
when :not then !matches(record, condition)
|
|
194
|
+
else
|
|
195
|
+
if schema.fields.key?(name)
|
|
196
|
+
scalar_matches(record.__send__(:read_field, name), condition)
|
|
197
|
+
else
|
|
198
|
+
value = record.__send__(:read_relation, name)
|
|
199
|
+
condition.all? do |operator, child|
|
|
200
|
+
case operator
|
|
201
|
+
when :some then value.any? { |item| matches(item, child) }
|
|
202
|
+
when :every then value.all? { |item| matches(item, child) }
|
|
203
|
+
when :none then value.none? { |item| matches(item, child) }
|
|
204
|
+
when :is then child.nil? ? value.nil? : !value.nil? && matches(value, child)
|
|
205
|
+
when :is_not then child.nil? ? !value.nil? : value.nil? || !matches(value, child)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def scalar_matches(value, condition)
|
|
214
|
+
condition.all? do |operator, expected|
|
|
215
|
+
case operator
|
|
216
|
+
when :equals then value == expected
|
|
217
|
+
when :not then !scalar_matches(value, expected)
|
|
218
|
+
when :in then expected.include?(value)
|
|
219
|
+
when :lt then !value.nil? && value < expected
|
|
220
|
+
when :lte then !value.nil? && value <= expected
|
|
221
|
+
when :gt then !value.nil? && value > expected
|
|
222
|
+
when :gte then !value.nil? && value >= expected
|
|
223
|
+
when :contains then !value.nil? && value.include?(expected)
|
|
224
|
+
when :starts_with then !value.nil? && value.start_with?(expected)
|
|
225
|
+
when :ends_with then !value.nil? && value.end_with?(expected)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def wire_tree(schema, tree)
|
|
231
|
+
tree.to_h do |name, condition|
|
|
232
|
+
case name
|
|
233
|
+
when :and, :or then [name.to_s, condition.map { |child| wire_tree(schema, child) }]
|
|
234
|
+
when :not then ["not", wire_tree(schema, condition)]
|
|
235
|
+
else
|
|
236
|
+
if (field = schema.fields[name])
|
|
237
|
+
[field.wire_name, wire_scalar(condition)]
|
|
238
|
+
else
|
|
239
|
+
relation = schema.relations.fetch(name)
|
|
240
|
+
child_schema = Internal::Catalog.entity(relation.target)
|
|
241
|
+
[relation.wire_name, condition.to_h { |op, child| [OPERATORS.fetch(op), child.nil? ? nil : wire_tree(child_schema, child)] }]
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def wire_scalar(condition)
|
|
248
|
+
condition.to_h { |op, value| [OPERATORS.fetch(op), op == :not ? wire_scalar(value) : duplicate_value(value)] }
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def duplicate_value(value)
|
|
252
|
+
case value
|
|
253
|
+
when Array then value.map { |item| duplicate_value(item) }
|
|
254
|
+
when String then value.dup
|
|
255
|
+
else value
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
class Normalizer
|
|
260
|
+
def initialize(entity, wire: false)
|
|
261
|
+
@entity = entity
|
|
262
|
+
@wire = wire
|
|
263
|
+
@nodes = 0
|
|
264
|
+
@bytes = 0
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def normalize(schema, input, path = "$", depth = 0)
|
|
268
|
+
visit(path, depth)
|
|
269
|
+
fail_at(path, "criteria object") unless input.is_a?(Hash)
|
|
270
|
+
output = {}
|
|
271
|
+
input.each do |key, value|
|
|
272
|
+
name = lookup(key, schema)
|
|
273
|
+
fail_at(path, "declared field, relation or Boolean operator") unless name
|
|
274
|
+
child_path = "#{path}.#{name}"
|
|
275
|
+
fail_at(child_path, "one spelling of each key") if output.key?(name)
|
|
276
|
+
output[name] = case name
|
|
277
|
+
when :and, :or
|
|
278
|
+
fail_at(child_path, "array of criteria") unless value.is_a?(Array)
|
|
279
|
+
fail_at(child_path, "bounded criteria array") if value.length > MAX_NODES
|
|
280
|
+
value.each_with_index.map { |child, index| normalize(schema, child, "#{child_path}[#{index}]", depth + 1) }.freeze
|
|
281
|
+
when :not then normalize(schema, value, child_path, depth + 1)
|
|
282
|
+
else
|
|
283
|
+
if (field = schema.fields[name])
|
|
284
|
+
scalar(field, value, child_path, depth + 1)
|
|
285
|
+
else
|
|
286
|
+
relation(schema.relations.fetch(name), value, child_path, depth + 1)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
output.freeze
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
private
|
|
294
|
+
|
|
295
|
+
def visit(path, depth)
|
|
296
|
+
@nodes += 1
|
|
297
|
+
fail_at(path, "depth <= #{MAX_DEPTH} and nodes <= #{MAX_NODES}") if depth > MAX_DEPTH || @nodes > MAX_NODES
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def lookup(key, schema)
|
|
301
|
+
return unless key.is_a?(String) || key.is_a?(Symbol)
|
|
302
|
+
|
|
303
|
+
text = key.to_s
|
|
304
|
+
return text.to_sym if %w[and or not].include?(text)
|
|
305
|
+
|
|
306
|
+
schema.fields.each_value do |field|
|
|
307
|
+
return field.name if (!@wire && text == field.name.to_s) || text == field.wire_name
|
|
308
|
+
end
|
|
309
|
+
schema.relations.each_value do |relation|
|
|
310
|
+
return relation.name if (!@wire && text == relation.name.to_s) || text == relation.wire_name
|
|
311
|
+
end
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def scalar(field, input, path, depth)
|
|
316
|
+
visit(path, depth)
|
|
317
|
+
input = {equals: input} unless input.is_a?(Hash)
|
|
318
|
+
fail_at(path, "nonempty scalar operator object") if input.empty?
|
|
319
|
+
output = {}
|
|
320
|
+
input.each do |key, value|
|
|
321
|
+
op = operator(key)
|
|
322
|
+
fail_at(path, "declared #{field.type} operator") unless op && field.operators.include?(op)
|
|
323
|
+
current_path = "#{path}.#{op}"
|
|
324
|
+
fail_at(current_path, "one spelling of each operator") if output.key?(op)
|
|
325
|
+
output[op] = case op
|
|
326
|
+
when :not then scalar(field, value, current_path, depth + 1)
|
|
327
|
+
when :in
|
|
328
|
+
unless value.is_a?(Array) && value.length <= MAX_MEMBERS
|
|
329
|
+
fail_at(current_path, "array of at most #{MAX_MEMBERS} members")
|
|
330
|
+
end
|
|
331
|
+
value.each_with_index.map { |item, index| literal(field, item, "#{current_path}[#{index}]", nullable: true) }.freeze
|
|
332
|
+
else literal(field, value, current_path, nullable: op == :equals)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
output.freeze
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def relation(relation, input, path, depth)
|
|
339
|
+
visit(path, depth)
|
|
340
|
+
fail_at(path, "nonempty relation operator object") unless input.is_a?(Hash) && !input.empty?
|
|
341
|
+
allowed = relation.cardinality == :many ? %i[some every none] : %i[is is_not]
|
|
342
|
+
output = {}
|
|
343
|
+
input.each do |key, value|
|
|
344
|
+
op = operator(key)
|
|
345
|
+
fail_at(path, allowed.join(" or ")) unless allowed.include?(op)
|
|
346
|
+
child_path = "#{path}.#{op}"
|
|
347
|
+
fail_at(child_path, "one spelling of each operator") if output.key?(op)
|
|
348
|
+
if value.nil?
|
|
349
|
+
fail_at(child_path, "criteria object for nonnullable relation") unless relation.nullable && relation.cardinality == :one
|
|
350
|
+
output[op] = nil
|
|
351
|
+
else
|
|
352
|
+
output[op] = normalize(Internal::Catalog.entity(relation.target), value, child_path, depth + 1)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
output.freeze
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def operator(key)
|
|
359
|
+
return unless key.is_a?(String) || key.is_a?(Symbol)
|
|
360
|
+
|
|
361
|
+
OPERATORS.each_pair do |name, wire|
|
|
362
|
+
return name if (!@wire && key.to_s == name.to_s) || key.to_s == wire
|
|
363
|
+
end
|
|
364
|
+
nil
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def literal(field, value, path, nullable:)
|
|
368
|
+
@nodes += 1
|
|
369
|
+
fail_at(path, "bounded node count") if @nodes > MAX_NODES
|
|
370
|
+
return nil if value.nil? && nullable && field.nullable
|
|
371
|
+
|
|
372
|
+
case field.type
|
|
373
|
+
when :integer
|
|
374
|
+
unless value.is_a?(Integer) && (!field.min || value >= field.min) && (!field.max || value <= field.max)
|
|
375
|
+
fail_at(path, "integer within catalog bounds")
|
|
376
|
+
end
|
|
377
|
+
value
|
|
378
|
+
when :boolean
|
|
379
|
+
fail_at(path, "Boolean") unless value.equal?(true) || value.equal?(false)
|
|
380
|
+
value
|
|
381
|
+
when :string, :text, :id
|
|
382
|
+
fail_at(path, "UTF-8 string") unless value.is_a?(String)
|
|
383
|
+
text = value.dup.force_encoding(Encoding::UTF_8)
|
|
384
|
+
fail_at(path, "UTF-8 string") unless text.valid_encoding?
|
|
385
|
+
@bytes += text.bytesize
|
|
386
|
+
if text.bytesize > MAX_STRING_BYTES || @bytes > MAX_BYTES
|
|
387
|
+
fail_at(path, "bounded UTF-8 string")
|
|
388
|
+
end
|
|
389
|
+
text.freeze
|
|
390
|
+
else
|
|
391
|
+
fail_at(path, "queryable catalog field")
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def fail_at(path, expected)
|
|
396
|
+
raise InvalidFilterError.new(entity: @entity, path: path, expected: expected)
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
private_constant :Normalizer
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
{SessionWhere: :session, WindowWhere: :window, PaneWhere: :pane,
|
|
403
|
+
WindowLinkWhere: :window_link, ClientWhere: :client}.each do |name, entity|
|
|
404
|
+
builder = Module.new
|
|
405
|
+
builder.define_singleton_method(:build) do |*arguments, **keywords, &block|
|
|
406
|
+
FilterExpr.build(entity, *arguments, **keywords, &block)
|
|
407
|
+
end
|
|
408
|
+
const_set(name, builder.freeze)
|
|
409
|
+
end
|
|
410
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module LibTmux
|
|
8
|
+
# Explicit executable and socket selection. Construction starts no clients.
|
|
9
|
+
class Endpoint
|
|
10
|
+
attr_reader :executable, :socket_path
|
|
11
|
+
|
|
12
|
+
def initialize(socket_path: nil, socket_name: nil, executable: "tmux", socket_directory: nil)
|
|
13
|
+
unless [socket_path, socket_name].count { |value| !value.nil? } == 1
|
|
14
|
+
raise ArgumentError, "choose exactly one socket_path or socket_name"
|
|
15
|
+
end
|
|
16
|
+
if socket_name
|
|
17
|
+
validate_string(socket_name, "socket_name")
|
|
18
|
+
if socket_name.include?("/") || [".", ".."].include?(socket_name)
|
|
19
|
+
raise ArgumentError, "socket_name must be a single filename"
|
|
20
|
+
end
|
|
21
|
+
directory = socket_directory || ENV["TMUX_TMPDIR"] || "/tmp"
|
|
22
|
+
validate_string(directory, "socket_directory")
|
|
23
|
+
socket_path = File.join(directory, "tmux-#{Process.uid}", socket_name)
|
|
24
|
+
elsif socket_directory
|
|
25
|
+
raise ArgumentError, "socket_directory applies only to socket_name"
|
|
26
|
+
end
|
|
27
|
+
validate_string(socket_path, "socket_path")
|
|
28
|
+
validate_string(executable, "executable")
|
|
29
|
+
@socket_path = File.expand_path(socket_path).freeze
|
|
30
|
+
@executable = resolve_executable(executable).freeze
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.from_env(env = ENV, **options)
|
|
35
|
+
if env["TMUX"] && !env["TMUX"].empty?
|
|
36
|
+
new(socket_path: env["TMUX"].split(",", 3).first, **options)
|
|
37
|
+
else
|
|
38
|
+
new(socket_name: "default", socket_directory: env["TMUX_TMPDIR"], **options)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ==(other)
|
|
43
|
+
other.is_a?(Endpoint) && executable == other.executable && socket_path == other.socket_path
|
|
44
|
+
end
|
|
45
|
+
alias eql? ==
|
|
46
|
+
|
|
47
|
+
def hash
|
|
48
|
+
[self.class, executable, socket_path].hash
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def inspect
|
|
52
|
+
"#<#{self.class} explicit Unix socket>"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def validate_string(value, name)
|
|
58
|
+
unless value.is_a?(String) && !value.empty? && !value.include?("\0")
|
|
59
|
+
raise ArgumentError, "#{name} must be a nonempty String without NUL"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resolve_executable(value)
|
|
64
|
+
candidates = if value.include?(File::SEPARATOR)
|
|
65
|
+
[File.expand_path(value)]
|
|
66
|
+
else
|
|
67
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).map do |directory|
|
|
68
|
+
File.expand_path(value, directory.empty? ? Dir.pwd : directory)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
candidates.find { |path| File.file?(path) && File.executable?(path) } ||
|
|
72
|
+
raise(ArgumentError, "tmux executable is unavailable")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
module Internal
|
|
77
|
+
# Keep the socket inode alive and route through it; PID timestamps can repeat.
|
|
78
|
+
class SocketIdentity
|
|
79
|
+
attr_reader :key
|
|
80
|
+
|
|
81
|
+
def initialize(endpoint)
|
|
82
|
+
@endpoint = endpoint
|
|
83
|
+
@owner_pid = Process.pid
|
|
84
|
+
@mutex = Mutex.new
|
|
85
|
+
@closed = false
|
|
86
|
+
@key = SecureRandom.hex(16).freeze
|
|
87
|
+
error = nil
|
|
88
|
+
cleanup_attempted = false
|
|
89
|
+
begin
|
|
90
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
91
|
+
begin
|
|
92
|
+
source = File.realpath(endpoint.socket_path)
|
|
93
|
+
unless File.lstat(source).socket?
|
|
94
|
+
raise TargetNotFoundError.new("the selected endpoint is not a Unix socket", phase: :bind)
|
|
95
|
+
end
|
|
96
|
+
make_route(source)
|
|
97
|
+
@stat = File.lstat(@route)
|
|
98
|
+
unless @stat.socket?
|
|
99
|
+
raise TargetNotFoundError.new("the endpoint changed while binding", phase: :bind)
|
|
100
|
+
end
|
|
101
|
+
@prefix = [endpoint.executable, "-u", "-N", "-S", @route].map(&:freeze).freeze
|
|
102
|
+
Thread.handle_interrupt(Exception => :immediate) { nil }
|
|
103
|
+
rescue Exception => failure
|
|
104
|
+
error = binding_error(failure)
|
|
105
|
+
cleanup_after_failure(error)
|
|
106
|
+
cleanup_attempted = true
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
rescue Exception => deferred
|
|
110
|
+
error ||= binding_error(deferred)
|
|
111
|
+
end
|
|
112
|
+
if error
|
|
113
|
+
begin
|
|
114
|
+
Thread.handle_interrupt(Exception => :never) { cleanup_after_failure(error) } unless cleanup_attempted
|
|
115
|
+
rescue Exception
|
|
116
|
+
# A second deferred cancellation cannot replace the original failure.
|
|
117
|
+
end
|
|
118
|
+
raise error, cause: nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def command_prefix
|
|
123
|
+
@mutex.synchronize do
|
|
124
|
+
if @closed || Process.pid != @owner_pid
|
|
125
|
+
raise ClosedError.new("the server binding is closed or belongs to another process", phase: :admission)
|
|
126
|
+
end
|
|
127
|
+
current = File.lstat(@route)
|
|
128
|
+
unless current.socket? && [current.dev, current.ino] == [@stat.dev, @stat.ino]
|
|
129
|
+
raise TargetNotFoundError.new("the retained server route changed", phase: :admission)
|
|
130
|
+
end
|
|
131
|
+
@prefix
|
|
132
|
+
rescue Errno::ENOENT
|
|
133
|
+
raise TargetNotFoundError.new("the retained server route is unavailable", phase: :admission)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def close
|
|
138
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
139
|
+
@mutex.synchronize do
|
|
140
|
+
return if @closed
|
|
141
|
+
# A fork inherits references, not permission to retire the parent's route.
|
|
142
|
+
remove_route if Process.pid == @owner_pid
|
|
143
|
+
@closed = true
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def inspect
|
|
150
|
+
"#<#{self.class} #{@closed ? 'closed' : 'bound'}>"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def cleanup_after_failure(error)
|
|
156
|
+
remove_route
|
|
157
|
+
rescue Exception => cleanup
|
|
158
|
+
if error.is_a?(Error)
|
|
159
|
+
error.send(:attach_cleanup_errors, ["private route cleanup failed (#{cleanup.class})"])
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def binding_error(failure)
|
|
164
|
+
case failure
|
|
165
|
+
when Errno::ENOENT, Errno::ECONNREFUSED
|
|
166
|
+
TargetNotFoundError.new("the selected tmux endpoint is unavailable", phase: :bind)
|
|
167
|
+
when SystemCallError
|
|
168
|
+
UnsupportedFeatureError.new("cannot retain a private route to this Unix socket", phase: :bind)
|
|
169
|
+
else
|
|
170
|
+
failure
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def make_route(source)
|
|
175
|
+
# Prefer an independent temporary directory on the same filesystem.
|
|
176
|
+
roots = [Dir.tmpdir, File.dirname(source)].uniq
|
|
177
|
+
roots.each_with_index do |root, index|
|
|
178
|
+
@directory = Dir.mktmpdir("libtmux-ruby-route-", root)
|
|
179
|
+
@route = File.join(@directory, "socket").freeze
|
|
180
|
+
if @route.bytesize > 103
|
|
181
|
+
remove_route
|
|
182
|
+
next if index < roots.length - 1
|
|
183
|
+
raise UnsupportedFeatureError.new("the private Unix socket path exceeds the platform limit", phase: :bind)
|
|
184
|
+
end
|
|
185
|
+
begin
|
|
186
|
+
File.link(source, @route)
|
|
187
|
+
return
|
|
188
|
+
rescue Errno::EXDEV
|
|
189
|
+
remove_route
|
|
190
|
+
raise if index == roots.length - 1
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def remove_route
|
|
196
|
+
if @route
|
|
197
|
+
begin
|
|
198
|
+
File.unlink(@route)
|
|
199
|
+
rescue Errno::ENOENT
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
if @directory
|
|
204
|
+
begin
|
|
205
|
+
Dir.rmdir(@directory)
|
|
206
|
+
rescue Errno::ENOENT
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|