datalackeytools 0.3.4

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,638 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright © 2019-2021 Ismo Kärkkäinen
4
+ # Licensed under Universal Permissive License. See LICENSE.txt.
5
+
6
+ require 'json'
7
+ require 'open3'
8
+
9
+ class DatalackeyProcess
10
+ attr_reader :exit_code, :stdout, :stderr, :stdin, :executable
11
+
12
+ def initialize(exe, directory, permissions, memory)
13
+ @exit_code = 0
14
+ if exe.nil?
15
+ exe = DatalackeyProcess.locate_executable(
16
+ 'datalackey', [ '/usr/local/libexec', '/usr/libexec' ])
17
+ raise ArgumentError, 'datalackey not found' if exe.nil?
18
+ elsif !File.exist?(exe) || !File.executable?(exe)
19
+ raise ArgumentError, "Executable not found or not executable: #{exe}"
20
+ end
21
+ @executable = exe
22
+ args = [ exe,
23
+ '--command-in', 'stdin', 'JSON', '--command-out', 'stdout', 'JSON' ]
24
+ args.push('--memory') unless memory.nil?
25
+ args.concat([ '--directory', directory ]) unless directory.nil?
26
+ args.concat([ '--permissions', permissions ]) unless permissions.nil?
27
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(*args)
28
+ end
29
+
30
+ def finish
31
+ @stdin.close
32
+ @wait_thread.join
33
+ @exit_code = @wait_thread.value.exitstatus
34
+ end
35
+ end
36
+
37
+ def DatalackeyProcess.options_for_OptionParser(parser, separator,
38
+ exe_callable, mem_callable, dir_callable, perm_callable, echo_callable)
39
+ unless separator.nil?
40
+ unless separator.is_a? String
41
+ separator = 'Options for case where this process runs datalackey:'
42
+ end
43
+ parser.separator separator
44
+ end
45
+ unless exe_callable.nil?
46
+ parser.on('-l', '--lackey PROGRAM', 'Use specified datalackey executable.') do |e|
47
+ exe_callable.call(e)
48
+ end
49
+ end
50
+ unless mem_callable.nil?
51
+ parser.on('-m', '--memory', 'Store data in memory.') do
52
+ mem_callable.call(true)
53
+ end
54
+ end
55
+ unless dir_callable.nil?
56
+ parser.on('-d', '--directory [DIR]', 'Store data under (working) directory.') do |d|
57
+ dir_callable.call(d || Dir.pwd)
58
+ end
59
+ end
60
+ unless perm_callable.nil?
61
+ parser.on('-p', '--permissions MODE', %i[user group other], 'File permissions cover (user, group, other).') do |p|
62
+ perm_callable.call({ user: '600', group: '660', other: '666' }[p])
63
+ end
64
+ end
65
+ unless echo_callable.nil?
66
+ parser.on('--echo', 'Echo communication with datalackey.') do
67
+ echo_callable.call(true)
68
+ end
69
+ end
70
+ end
71
+
72
+ def DatalackeyProcess.locate_executable(exe_name, dirs_outside_path = [])
73
+ # Absolute file name or found in current working directory.
74
+ return exe_name if File.exist?(exe_name) && File.executable?(exe_name)
75
+ dirs = []
76
+ dirs_outside_path = [ dirs_outside_path ] unless dirs_outside_path.is_a? Array
77
+ dirs.concat dirs_outside_path
78
+ dirs.concat ENV['PATH'].split(File::PATH_SEPARATOR)
79
+ dirs.each do |d|
80
+ exe = File.join(d, exe_name)
81
+ return exe if File.exist?(exe) && File.executable?(exe)
82
+ end
83
+ nil
84
+ end
85
+
86
+ def DatalackeyProcess.verify_directory_permissions_memory(
87
+ directory, permissions, memory)
88
+ if !memory.nil? && !(directory.nil? && permissions.nil?)
89
+ raise ArgumentError, 'Cannot use both memory and directory/permissions.'
90
+ end
91
+ if memory.nil?
92
+ if directory.nil?
93
+ directory = Dir.pwd
94
+ elsif !Dir.exist? directory
95
+ raise ArgumentError, "Given directory does not exist: #{directory}"
96
+ end
97
+ if permissions.nil?
98
+ if (File.umask & 0o77).zero?
99
+ permissions = '666'
100
+ elsif (File.umask & 0o70).zero?
101
+ permissions = '660'
102
+ else
103
+ permissions = '600'
104
+ end
105
+ elsif permissions != '600' && permissions != '660' && permissions != '666'
106
+ raise ArgumentError, 'Permissions not in {600, 660, 666}.'
107
+ end
108
+ end
109
+ [ directory, permissions, memory ]
110
+ end
111
+
112
+
113
+ class DatalackeyParentProcess
114
+ attr_reader :exit_code, :stdout, :stderr, :stdin, :executable
115
+
116
+ def initialize(to_lackey, from_lackey)
117
+ @exit_code = 0
118
+ @stdout = from_lackey
119
+ @stdin = to_lackey
120
+ @stderr = nil
121
+ @executable = nil
122
+ end
123
+
124
+ def finish
125
+ @stdin.close
126
+ end
127
+ end
128
+
129
+
130
+ # Intended to be used internaly when there are no patterns to act on.
131
+ # Instead of using this, pass nil to DatalakceyIO.send
132
+ class NoPatternNoAction
133
+ attr_reader :identifier
134
+ attr_accessor :exit, :command, :status, :message, :generators
135
+
136
+ def initialize
137
+ @exit = nil
138
+ @command = nil
139
+ @status = nil
140
+ @message = nil
141
+ @generators = []
142
+ end
143
+
144
+ def set_identifier(identifier)
145
+ @identifier = identifier
146
+ end
147
+
148
+ def best_match(_)
149
+ [ nil, [] ]
150
+ end
151
+ end
152
+
153
+
154
+ class PatternAction < NoPatternNoAction
155
+ def initialize(action_maps_array, message_callables = [])
156
+ raise ArgumentError, 'action_maps_array is empty' unless action_maps_array.is_a?(Array) && !action_maps_array.empty?
157
+ super()
158
+ @pattern2act = { }
159
+ @fixed2act = { }
160
+ @generators = message_callables.is_a?(Array) ? message_callables.clone : [ message_callables ]
161
+ action_maps_array.each do |m|
162
+ raise ArgumentError, 'Action map is not a map.' unless m.is_a? Hash
163
+ fill_pattern2action_maps([], m)
164
+ end
165
+ @pattern2act.each_value(&:uniq!)
166
+ @fixed2act.each_value(&:uniq!)
167
+ raise ArgumentError, 'No patterns.' if @pattern2act.empty? && @fixed2act.empty?
168
+ end
169
+
170
+ def fill_pattern2action_maps(actionlist, item)
171
+ if item.is_a? Array
172
+ unless item.first.is_a?(Array) || item.first.is_a?(Hash)
173
+ # item is a pattern.
174
+ raise ArgumentError, "Pattern-array must be under action: #{item}" if actionlist.empty?
175
+ wildcards = false
176
+ pattern = [ :identifier ]
177
+ item.each do |element|
178
+ case element
179
+ when '?'
180
+ wildcards = true
181
+ pattern.push :one
182
+ when '*'
183
+ wildcards = true
184
+ pattern.push :rest
185
+ break
186
+ else pattern.push element
187
+ end
188
+ end
189
+ tgt = wildcards ? @pattern2act : @fixed2act
190
+ tgt[pattern] = [] unless tgt.key? pattern
191
+ tgt[pattern].push actionlist
192
+ return
193
+ end
194
+ item.each { |sub| fill_pattern2action_maps(actionlist, sub) }
195
+ elsif item.is_a? Hash
196
+ item.each_pair do |action, sub|
197
+ acts = actionlist.clone
198
+ acts.push action
199
+ fill_pattern2action_maps(acts, sub)
200
+ end
201
+ else
202
+ raise ArgumentError, 'Item not a mapping, array, or pattern-array.'
203
+ end
204
+ end
205
+
206
+ def clone
207
+ gens = @generators
208
+ @generators = nil
209
+ copy = Marshal.load(Marshal.dump(self))
210
+ @generators = gens
211
+ copy.generators = gens.clone
212
+ copy
213
+ end
214
+
215
+ def replace_identifier(identifier, p2a)
216
+ altered = { }
217
+ p2a.each_pair do |pattern, a|
218
+ p = []
219
+ pattern.each { |item| p.push(item == :identifier ? identifier : item) }
220
+ altered[p] = a
221
+ end
222
+ altered
223
+ end
224
+
225
+ def set_identifier(identifier)
226
+ @pattern2act = replace_identifier(identifier, @pattern2act)
227
+ @fixed2act = replace_identifier(identifier, @fixed2act)
228
+ @identifier = identifier
229
+ end
230
+
231
+ def best_match(message_array)
232
+ return [ @fixed2act[message_array], [] ] if @fixed2act.key? message_array
233
+ best_length = 0
234
+ best = nil
235
+ best_vars = []
236
+ @pattern2act.each_pair do |pattern, act|
237
+ next if message_array.length + 1 < pattern.length
238
+ next if pattern.last != :rest && message_array.length != pattern.length
239
+ length = 0
240
+ exact_length = 0
241
+ found = true
242
+ vars = []
243
+ pattern.each_index do |idx|
244
+ if pattern[idx] == :rest
245
+ vars.concat message_array[idx...message_array.length]
246
+ break
247
+ end
248
+ if pattern[idx] == :one
249
+ vars.push message_array[idx]
250
+ length += 1
251
+ next
252
+ end
253
+ found = pattern[idx] == message_array[idx]
254
+ break unless found
255
+ exact_length += 1
256
+ end
257
+ next unless found
258
+ if best_length < exact_length
259
+ best_length = exact_length
260
+ best = act
261
+ best_vars = vars
262
+ elsif best_length < length
263
+ best_length = length
264
+ best = act
265
+ best_vars = vars
266
+ end
267
+ end
268
+ [ best, best_vars ]
269
+ end
270
+ end
271
+
272
+
273
+ class DatalackeyIO
274
+ @@internal_notification_map = {
275
+ error: {
276
+ user_id: [ 'error', 'identifier', '?' ],
277
+ format: [ 'error', 'format' ]
278
+ },
279
+ stored: [ 'data', 'stored', '?', '?' ],
280
+ deleted: [ 'data', 'deleted', '?', '?' ],
281
+ data_error: [ 'data', 'error', '?', '?' ],
282
+ started: [ 'process', 'started', '?', '?' ],
283
+ ended: [ 'process', 'ended', '?', '?' ]
284
+ }
285
+
286
+ @@internal_generic_map = {
287
+ error: {
288
+ syntax: [
289
+ [ 'error', 'missing', '*' ],
290
+ [ 'error', 'not-string', '*' ],
291
+ [ 'error', 'not-string-null', '*' ],
292
+ [ 'error', 'pairless', '*' ],
293
+ [ 'error', 'unexpected', '*' ],
294
+ [ 'error', 'unknown', '*' ],
295
+ [ 'error', 'command', 'missing', '?' ],
296
+ [ 'error', 'command', 'not-string', '?' ],
297
+ [ 'error', 'command', 'unknown', '?' ]
298
+ ]
299
+ },
300
+ done: [ 'done', '' ],
301
+ child: [ 'run', 'running', '?' ]
302
+ }
303
+
304
+ def self.internal_notification_map
305
+ Marshal.load(Marshal.dump(@@internal_notification_map))
306
+ end
307
+
308
+ def self.internal_generic_map
309
+ Marshal.load(Marshal.dump(@@internal_generic_map))
310
+ end
311
+
312
+ attr_reader :syntax, :version
313
+
314
+ def initialize(to_datalackey, from_datalackey, notification_callable = nil,
315
+ to_datalackey_echo_callable = nil, from_datalackey_echo_callable = nil)
316
+ @to_datalackey_mutex = Mutex.new
317
+ @to_datalackey = to_datalackey
318
+ @to_datalackey_echo = to_datalackey_echo_callable
319
+ @from_datalackey = from_datalackey
320
+ @identifier = 0
321
+ @tracked_mutex = Mutex.new
322
+ # Handling of notifications.
323
+ @notify_tracker = PatternAction.new([ @@internal_notification_map ])
324
+ @notify_tracker.set_identifier(nil)
325
+ @internal = PatternAction.new([ @@internal_generic_map ])
326
+ @tracked = Hash.new(nil)
327
+ @waiting = nil
328
+ @return_mutex = Mutex.new
329
+ @return_condition = ConditionVariable.new
330
+ @dataprocess_mutex = Mutex.new
331
+ @data = Hash.new(0)
332
+ @process = { }
333
+ @children = { }
334
+ @version = { }
335
+ @read_datalackey = Thread.new do
336
+ accum = []
337
+ loop do
338
+ begin
339
+ raw = @from_datalackey.readpartial(32768)
340
+ rescue IOError
341
+ break
342
+ rescue EOFError
343
+ break
344
+ end
345
+ loc = raw.index("\n")
346
+ until loc.nil?
347
+ accum.push(raw[0, loc]) if loc.positive? # Newline at start ends line.
348
+ raw = raw[loc + 1, raw.size - loc - 1]
349
+ loc = raw.index("\n")
350
+ joined = accum.join
351
+ accum.clear
352
+ next if joined.empty?
353
+ from_datalackey_echo_callable.call(joined) unless from_datalackey_echo_callable.nil?
354
+ msg = JSON.parse joined
355
+ # See if we are interested in it.
356
+ if msg.first.nil?
357
+ act, vars = @notify_tracker.best_match(msg)
358
+ next if act.nil?
359
+ # We know there is only one action that matches.
360
+ act = act.first
361
+ actionable = nil
362
+ name = vars.first
363
+ id = vars.last
364
+ # Messages from different threads may arrive out of order so
365
+ # new data/process may be in book-keeping when previous should
366
+ # be removed. With data these imply over-writing immediately,
367
+ # with processes re-use of identifier and running back to back.
368
+ case act.first
369
+ when :stored
370
+ @dataprocess_mutex.synchronize do
371
+ if @data[name] < id
372
+ @data[name] = id
373
+ actionable = act
374
+ end
375
+ end
376
+ when :deleted
377
+ @dataprocess_mutex.synchronize do
378
+ if @data.key?(name) && @data[name] <= id
379
+ @data.delete name
380
+ actionable = act
381
+ end
382
+ end
383
+ when :data_error
384
+ @dataprocess_mutex.synchronize do
385
+ @data.delete(name) if @data[name] == id
386
+ end
387
+ actionable = act
388
+ when :started
389
+ @dataprocess_mutex.synchronize { @process[name] = id }
390
+ actionable = act
391
+ when :ended
392
+ @dataprocess_mutex.synchronize do
393
+ if @process[name] == id
394
+ @process.delete(name)
395
+ @children.delete(name)
396
+ end
397
+ end
398
+ actionable = act
399
+ when :error
400
+ case act[1]
401
+ when :format
402
+ @to_datalackey_mutex.synchronize { @to_datalackey.putc 0 }
403
+ when :user_id
404
+ unless @waiting.nil?
405
+ # Does the waited command have invalid id?
406
+ begin
407
+ int = Integer(@waiting)
408
+ fract = @waiting - int
409
+ raise ArgumentError, '' unless fract.zero?
410
+ rescue ArgumentError, TypeError
411
+ unless @waiting.is_a? String
412
+ @tracked_mutex.synchronize do
413
+ trackers = @tracked[@waiting]
414
+ trackers.first.message = msg
415
+ trackers.first.exit = [ act ]
416
+ @tracked.delete(@waiting)
417
+ @waiting = nil
418
+ end
419
+ @return_mutex.synchronize { @return_condition.signal }
420
+ end
421
+ end
422
+ end
423
+ end
424
+ actionable = act
425
+ end
426
+ next if notification_callable.nil? || actionable.nil?
427
+ notification_callable.call(actionable, msg, vars)
428
+ next
429
+ end
430
+ # Not a notification.
431
+ trackers = @tracked_mutex.synchronize { @tracked[msg[0]] }
432
+ next if trackers.nil?
433
+ finish = false
434
+ last = nil
435
+ # Deal with user-provided PatternAction (or NoPatternNoAction).
436
+ tracker = trackers.first
437
+ act, vars = tracker.best_match(msg)
438
+ unless act.nil?
439
+ act.each do |item|
440
+ tracker.generators.each do |p|
441
+ break if p.call(item, msg, vars)
442
+ end
443
+ next unless msg.first == @waiting
444
+ case item.first
445
+ when :return, 'return'
446
+ finish = true
447
+ last = act if last.nil?
448
+ when :error, 'error'
449
+ finish = true
450
+ last = act
451
+ end
452
+ end
453
+ end
454
+ # Check internal PatternAction.
455
+ internal = trackers.last
456
+ act, vars = internal.best_match(msg)
457
+ unless act.nil?
458
+ act = act.first # We know patterns are all unique in mapping.
459
+ if act.first == :child
460
+ @dataprocess_mutex.synchronize { @children[msg[0]] = vars.first }
461
+ elsif msg.first == @waiting
462
+ finish = true
463
+ if act.first == :done
464
+ @tracked_mutex.synchronize { @tracked.delete(msg[0]) }
465
+ elsif act.first == :error
466
+ last = [ act ]
467
+ end
468
+ end
469
+ end
470
+ if finish
471
+ tracker.message = msg
472
+ tracker.exit = last
473
+ @tracked_mutex.synchronize { @waiting = nil }
474
+ @return_mutex.synchronize { @return_condition.signal }
475
+ end
476
+ end
477
+ accum.push(raw) unless raw.empty?
478
+ end
479
+ @from_datalackey.close
480
+ @return_mutex.synchronize { @return_condition.signal }
481
+ end
482
+ # Outside thread block.
483
+ send(PatternAction.new([{ version: [ 'version', '', '?' ] }], [
484
+ proc do |action, message, vars|
485
+ if action.first == :version
486
+ @syntax = vars.first['commands']
487
+ @version = { }
488
+ vars.first.each_pair do |key, value|
489
+ @version[key] = value if value.is_a? Integer
490
+ end
491
+ true
492
+ else false
493
+ end
494
+ end
495
+ ]), ['version'])
496
+ end
497
+
498
+ def data
499
+ @dataprocess_mutex.synchronize { return @data.clone }
500
+ end
501
+
502
+ def process
503
+ @dataprocess_mutex.synchronize { return @process.clone }
504
+ end
505
+
506
+ def launched
507
+ @dataprocess_mutex.synchronize { return @children.clone }
508
+ end
509
+
510
+ def closed?
511
+ @from_datalackey.closed?
512
+ end
513
+
514
+ def close
515
+ @to_datalackey_mutex.synchronize { @to_datalackey.close }
516
+ end
517
+
518
+ def finish
519
+ @read_datalackey.join
520
+ end
521
+
522
+ # Pass nil pattern_action if you are not interested in doing anything.
523
+ def send(pattern_action, command, user_id = false)
524
+ return nil if @to_datalackey_mutex.synchronize { @to_datalackey.closed? }
525
+ if user_id
526
+ id = command[0]
527
+ else
528
+ id = @identifier
529
+ @identifier += 1
530
+ command.prepend id
531
+ end
532
+ tracker = pattern_action.nil? ? NoPatternNoAction.new : pattern_action.clone
533
+ tracker.set_identifier(id)
534
+ tracker.command = JSON.generate(command)
535
+ internal = @internal.clone
536
+ internal.set_identifier(id)
537
+ @tracked_mutex.synchronize do
538
+ @tracked[id] = [ tracker, internal ] unless id.nil?
539
+ @waiting = id
540
+ end
541
+ dump(tracker.command)
542
+ return tracker if id.nil? # There will be no responses.
543
+ @return_mutex.synchronize { @return_condition.wait(@return_mutex) }
544
+ tracker.status = true
545
+ unless tracker.exit.nil?
546
+ tracker.exit.each do |item|
547
+ tracker.status = false if item.first == :error || item.first == 'error'
548
+ end
549
+ end
550
+ tracker
551
+ end
552
+
553
+ def dump(json_as_string)
554
+ @to_datalackey_mutex.synchronize do
555
+ @to_datalackey.write json_as_string
556
+ @to_datalackey.flush
557
+ @to_datalackey_echo.call(json_as_string) unless @to_datalackey_echo.nil?
558
+ rescue Errno::EPIPE
559
+ # Should do something in this case. Child process died?
560
+ end
561
+ end
562
+
563
+ def verify(command)
564
+ @syntax.nil? ? nil : true
565
+ end
566
+ end
567
+
568
+
569
+ class StoringReader
570
+ def initialize(input)
571
+ @input = input
572
+ @output_mutex = Mutex.new
573
+ @output = [] # Contains list of lines from input.
574
+ @reader = Thread.new do
575
+ accum = []
576
+ loop do
577
+ begin
578
+ raw = @input.readpartial(32768)
579
+ rescue IOError
580
+ break # It is possible that close happens in another thread.
581
+ rescue EOFError
582
+ break
583
+ end
584
+ loc = raw.index("\n")
585
+ until loc.nil?
586
+ accum.push(raw[0, loc]) if loc.positive? # Newline begins?
587
+ unless accum.empty?
588
+ @output_mutex.synchronize { @output.push(accum.join) }
589
+ accum.clear
590
+ end
591
+ raw = raw[loc + 1, raw.size - loc - 1]
592
+ loc = raw.index("\n")
593
+ end
594
+ accum.push(raw) unless raw.empty?
595
+ end
596
+ end
597
+ end
598
+
599
+ def close
600
+ @input.close
601
+ @reader.join
602
+ end
603
+
604
+ def getlines
605
+ @output_mutex.synchronize do
606
+ result = @output
607
+ @output = []
608
+ return result
609
+ end
610
+ end
611
+ end
612
+
613
+
614
+ class DiscardReader
615
+ def initialize(input)
616
+ @input = input
617
+ return if input.nil?
618
+ @reader = Thread.new do
619
+ loop do
620
+ @input.readpartial(32768)
621
+ rescue IOError
622
+ break # It is possible that close happens in another thread.
623
+ rescue EOFError
624
+ break
625
+ end
626
+ end
627
+ end
628
+
629
+ def close
630
+ return if @input.nil?
631
+ @input.close
632
+ @reader.join
633
+ end
634
+
635
+ def getlines
636
+ []
637
+ end
638
+ end
metadata ADDED
@@ -0,0 +1,68 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: datalackeytools
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.4
5
+ platform: ruby
6
+ authors:
7
+ - Ismo Kärkkäinen
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-10-15 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |
14
+ Tools for using datalackey.
15
+
16
+ For examples of use, see https://github.com/ismo-karkkainen/datalackeytools
17
+ directory examples.
18
+
19
+ Requires separate datalackey executable installed into
20
+ /usr/local/libexec, /usr/libexec, or into a directory in $PATH.
21
+
22
+ Datalackey: https://github.com/ismo-karkkainen/datalackey
23
+
24
+ Licensed under Universal Permissive License, see LICENSE.txt.
25
+ email: ismokarkkainen@icloud.com
26
+ executables:
27
+ - datalackey-make
28
+ - datalackey-run
29
+ - datalackey-shell
30
+ - datalackey-state
31
+ - files2object
32
+ - object2files
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - LICENSE.txt
37
+ - bin/datalackey-make
38
+ - bin/datalackey-run
39
+ - bin/datalackey-shell
40
+ - bin/datalackey-state
41
+ - bin/files2object
42
+ - bin/object2files
43
+ - lib/common.rb
44
+ - lib/datalackeylib.rb
45
+ homepage: https://xn--ismo-krkkinen-gfbd.fi/datalackeytools/index.html
46
+ licenses:
47
+ - UPL-1.0
48
+ metadata: {}
49
+ post_install_message:
50
+ rdoc_options: []
51
+ require_paths:
52
+ - lib
53
+ required_ruby_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '0'
58
+ required_rubygems_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ requirements: []
64
+ rubygems_version: 3.1.2
65
+ signing_key:
66
+ specification_version: 4
67
+ summary: Tools for using datalackey.
68
+ test_files: []