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