asterism 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,424 @@
1
+ # The command line of the gem (exe/asterism): look at an Asterism network
2
+ # from CRuby, and call an object when asked to.
3
+ #
4
+ # asterism nodes --router tcp/192.0.2.2:7447
5
+ # asterism tree --credentials admin.tar
6
+ # asterism objects 'games/games/*'
7
+ # asterism watch # joins, leaves, statuses as they come
8
+ # asterism ping games/games/othello1 # the time of a round trip (meta)
9
+ # asterism call games/games/othello1 view '"<token>"'
10
+ # asterism mcp --allow 'games/othello1.*' # an MCP server on stdio
11
+ #
12
+ # It is one more Asterism node: it connects with Asterism.connect (as the
13
+ # certificate's name with --credentials, as cli-<6 hex digits> otherwise)
14
+ # and reads what docs/protocol.md lets anyone read. It calls a method only
15
+ # with `call` (and the MCP bridge only the methods --allow names). What it
16
+ # can see is what the router lets it see: with a console's .tar, its role.
17
+ require "asterism"
18
+ require "optparse"
19
+ require "json"
20
+ require "securerandom"
21
+ require_relative "cli/literal"
22
+ require_relative "cli/survey"
23
+ require_relative "cli/allow"
24
+
25
+ module Asterism
26
+ class CLI
27
+ USAGE = <<~TEXT
28
+ Usage: asterism COMMAND [options]
29
+
30
+ Commands (they only read, except call):
31
+ nodes the nodes, their applications and profiles
32
+ objects [PATTERN] the objects and their methods (PATTERN: node/app/object, * and **)
33
+ tree nodes > applications (with their status) > objects (with their methods)
34
+ watch nodes, applications and objects coming and going, and status changes
35
+ ping PATH round trips: PATH node/app/object (its meta), or node (its profile);
36
+ --call METHOD [ARG...] times a call instead (echo is the same command)
37
+ call PATH METHOD [ARG...]
38
+ calls a method; each ARG is JSON, else a Ruby literal, else text
39
+ mcp an MCP server on stdio (needs the mcp gem): the listings as tools,
40
+ and the methods --allow names
41
+ version the gem's version
42
+
43
+ Where the network is (one of them):
44
+ --router LOCATOR a router without TLS, e.g. tcp/192.0.2.2:7447
45
+ --credentials TAR a console's .tar (mutual TLS; its router and name)
46
+ TEXT
47
+
48
+ def self.start(argv, out: $stdout, err: $stderr)
49
+ new(out: out, err: err).run(argv.dup)
50
+ end
51
+
52
+ def initialize(out: $stdout, err: $stderr)
53
+ @out = out
54
+ @err = err
55
+ @opts = { timeout: 2.0, settle: 1.5, json: false, count: 5, interval: 0.2, allow: [] }
56
+ end
57
+
58
+ def run(argv)
59
+ cmd = argv.shift
60
+ case cmd
61
+ when nil, "help", "-h", "--help"
62
+ @out.puts USAGE
63
+ return cmd.nil? ? 2 : 0
64
+ when "version", "--version", "-v"
65
+ @out.puts "asterism #{::Asterism::VERSION}"
66
+ return 0
67
+ end
68
+ unless %w[nodes objects tree watch ping echo call mcp].include?(cmd)
69
+ @err.puts "asterism: unknown command #{cmd.inspect}\n\n#{USAGE}"
70
+ return 2
71
+ end
72
+ rest = parser(cmd).parse(argv)
73
+ send("cmd_#{cmd == 'echo' ? 'ping' : cmd}", rest)
74
+ rescue OptionParser::ParseError, ArgumentError => e
75
+ @err.puts "asterism: #{e.message}"
76
+ 2
77
+ rescue ::Asterism::Disconnected => e
78
+ @err.puts "asterism: cannot reach the network: #{e.message}"
79
+ 1
80
+ rescue ::Asterism::Error => e
81
+ @err.puts "asterism: #{e.class}: #{e.message}"
82
+ 1
83
+ rescue Interrupt
84
+ 0
85
+ end
86
+
87
+ def parser(cmd)
88
+ OptionParser.new do |o|
89
+ o.banner = "Usage: asterism #{cmd} [options]"
90
+ o.on("--router LOCATOR", "a router (tcp/HOST:7447)") { @opts[:router] = _1 }
91
+ o.on("--credentials TAR", "a console's .tar") { @opts[:credentials] = _1 }
92
+ o.on("--peer LOCATOR", "a direct peer link to a node listening there (no router)") { @opts[:peer] = _1 }
93
+ o.on("--node NAME", "this node's ID (default: the certificate's name, or cli-<6 hex>)") { @opts[:node] = _1 }
94
+ o.on("--app NAME", "this application's name (default: cli-<4 hex>)") { @opts[:app] = _1 }
95
+ o.on("--json", "JSON instead of text (watch: one JSON object a line)") { @opts[:json] = true }
96
+ o.on("--timeout SECONDS", Float, "time limit of each get or call (2.0)") { @opts[:timeout] = _1 }
97
+ o.on("--settle SECONDS", Float, "how long to wait for the tokens at connect (1.5)") { @opts[:settle] = _1 }
98
+ case cmd
99
+ when "watch"
100
+ o.on("--duration SECONDS", Float, "stop after this long (default: until Ctrl-C)") { @opts[:duration] = _1 }
101
+ when "ping", "echo"
102
+ o.on("--count N", Integer, "round trips (5)") { @opts[:count] = _1 }
103
+ o.on("--interval SECONDS", Float, "pause between them (0.2)") { @opts[:interval] = _1 }
104
+ o.on("--call METHOD", "time a call of METHOD (with the ARGs after PATH) instead of the meta") { @opts[:call] = _1 }
105
+ when "call"
106
+ o.on("--kwargs VALUE", "keyword arguments: a JSON object or a Ruby Hash literal") { @opts[:kwargs] = _1 }
107
+ when "mcp"
108
+ o.on("--allow PATTERN", "methods the MCP client may call: [node/][app/]object.method, * as a wildcard " \
109
+ "(repeatable)") { @opts[:allow] << _1 }
110
+ end
111
+ end
112
+ end
113
+
114
+ # ------------------------------------------------------------ connecting
115
+
116
+ # Connects, starts the receiving thread, waits for the tokens and yields
117
+ # the Net and a Survey.
118
+ def connect(app_prefix: "cli")
119
+ router = @opts[:router]
120
+ creds = @opts[:credentials]
121
+ peer = @opts[:peer]
122
+ if router.nil? && creds.nil? && peer.nil?
123
+ raise ArgumentError, "where is the network? --router tcp/HOST:7447 or --credentials FILE.tar"
124
+ end
125
+ node = @opts[:node]
126
+ node ||= "#{app_prefix}-#{SecureRandom.hex(3)}" unless creds
127
+ app = @opts[:app] || "#{app_prefix}-#{SecureRandom.hex(2)}"
128
+ kw = { node: node, app: app }
129
+ kw[:credentials] = creds if creds
130
+ locator = router
131
+ if peer
132
+ locator = peer
133
+ kw[:mode] = :peer
134
+ end
135
+ ::Asterism.profile(name: app_prefix == "mcp" ? "asterism MCP" : "asterism CLI")
136
+ ::Asterism.connect(locator, **kw) do |net|
137
+ net.on_error { |e, where| @err.puts "asterism: #{where}: #{e.class}: #{e.message}" }
138
+ net.start
139
+ survey = Survey.new(timeout: @opts[:timeout])
140
+ survey.settle(@opts[:settle])
141
+ yield net, survey
142
+ end
143
+ end
144
+
145
+ def emit_json(v)
146
+ @out.puts JSON.pretty_generate(v)
147
+ end
148
+
149
+ # ------------------------------------------------------------ nodes
150
+
151
+ def cmd_nodes(_rest)
152
+ connect do |_net, sv|
153
+ tree = sv.tree(meta: false)
154
+ if @opts[:json]
155
+ emit_json(tree.map { |n| n.merge("apps" => n["apps"].map { |a| a.except("objects") }) })
156
+ elsif tree.empty?
157
+ @out.puts "(no other node is visible)"
158
+ else
159
+ tree.each do |n|
160
+ @out.puts "#{n['node']}#{profile_text(n['profile'])}"
161
+ n["apps"].each do |a|
162
+ @out.puts " #{a['app']}#{" #{a['objects'].size} objects" unless a['objects'].empty?}" \
163
+ "#{" status #{compact(a['status'])}" if a.key?('status')}"
164
+ end
165
+ end
166
+ end
167
+ end
168
+ 0
169
+ end
170
+
171
+ # ------------------------------------------------------------ objects
172
+
173
+ def cmd_objects(rest)
174
+ pattern = rest.shift || "**"
175
+ connect do |_net, sv|
176
+ paths = sv.objects(pattern)
177
+ metas = sv.metas(paths)
178
+ if @opts[:json]
179
+ emit_json(paths.map do |p|
180
+ h = { "path" => p, "methods" => metas[p] }
181
+ h["doc"] = sv.object_doc(p) if sv.object_doc(p)
182
+ h
183
+ end)
184
+ elsif paths.empty?
185
+ @out.puts "(no object matches #{pattern})"
186
+ else
187
+ paths.each do |p|
188
+ @out.puts "#{p} #{metas.key?(p) ? methods_text(metas[p]) : '(no meta: not answered in time, or not allowed)'}"
189
+ @out.puts " #{sv.object_doc(p)}" if sv.object_doc(p)
190
+ # The methods that tell what they do, one a line (doc's first line).
191
+ (metas[p] || []).each do |m|
192
+ @out.puts " #{signature(m)} #{m['doc'].lines.first.strip}" if m["doc"]
193
+ end
194
+ end
195
+ end
196
+ end
197
+ 0
198
+ end
199
+
200
+ # ------------------------------------------------------------ tree
201
+
202
+ def cmd_tree(_rest)
203
+ connect do |_net, sv|
204
+ tree = sv.tree
205
+ if @opts[:json]
206
+ emit_json(tree)
207
+ elsif tree.empty?
208
+ @out.puts "(no other node is visible)"
209
+ else
210
+ tree.each do |n|
211
+ @out.puts "#{n['node']}#{profile_text(n['profile'])}"
212
+ n["apps"].each do |a|
213
+ @out.puts " #{a['app']}#{" status #{compact(a['status'])}" if a.key?('status')}"
214
+ a["objects"].each do |o|
215
+ @out.puts " #{o['name']} #{o['methods'] ? methods_text(o['methods']) : '(no meta)'}"
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
221
+ 0
222
+ end
223
+
224
+ # ------------------------------------------------------------ watch
225
+
226
+ # Prints the changes until Ctrl-C (or --duration): nodes (Asterism's
227
+ # on_join / on_leave), applications and objects (their liveliness
228
+ # tokens), statuses (their puts) and profiles (their puts). The
229
+ # statuses there at the start come first.
230
+ def cmd_watch(_rest)
231
+ connect do |net, sv|
232
+ own = "#{net.node_id}/#{net.app}"
233
+ sv.statuses.sort.each { |path, st| event("status", path, status: st, now: true) }
234
+ net.on_join { |n| event("join", n, what: "node") }
235
+ net.on_leave { |n| event("leave", n, what: "node") }
236
+ net.session.liveliness_watch("asterism/**") do |key, alive|
237
+ # The tokens alive at the start come first, in one burst: not news.
238
+ next unless @watch_started
239
+ path = key.delete_prefix("asterism/")
240
+ next if path == own || path.start_with?("#{own}/")
241
+ what = { 2 => "app", 3 => "object" }[path.count("/") + 1]
242
+ event(alive ? "join" : "leave", path, what: what) if what
243
+ end
244
+ net.session.subscribe(Survey::STATUS_ALL) do |sample|
245
+ got = ::Asterism::Status.parse(sample.key, sample.payload)
246
+ event("status", "#{got[0]}/#{got[1]}", status: got[2]) if got && "#{got[0]}/#{got[1]}" != own
247
+ end
248
+ net.session.subscribe(Survey::PROFILES_ALL) do |sample|
249
+ node = sample.key.split("/")[1]
250
+ pr = ::Asterism::Profile.check(::Asterism::Codec.unpack(sample.payload))
251
+ event("profile", node, profile: pr) if pr && node != net.node_id
252
+ end
253
+ sleep 0.3
254
+ @watch_started = true
255
+ @err.puts "asterism: watching (Ctrl-C to stop)" unless @opts[:json]
256
+ deadline = @opts[:duration] && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + @opts[:duration])
257
+ loop do
258
+ break if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
259
+ break unless net.connected?
260
+ sleep 0.05
261
+ end
262
+ end
263
+ 0
264
+ end
265
+
266
+ def event(kind, path, what: nil, status: nil, profile: nil, now: false)
267
+ t = Time.now
268
+ if @opts[:json]
269
+ h = { "t" => t.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "event" => kind, "path" => path }
270
+ h["what"] = what if what
271
+ h["status"] = status if status
272
+ h["profile"] = profile if profile
273
+ h["now"] = true if now
274
+ @out.puts JSON.generate(h)
275
+ else
276
+ mark = { "join" => "+", "leave" => "-", "status" => "~", "profile" => "*" }[kind]
277
+ text = case kind
278
+ when "status" then "status #{path} #{compact(status)}#{' (now)' if now}"
279
+ when "profile" then "profile #{path}#{profile_text(profile)}"
280
+ else "#{what} #{path}"
281
+ end
282
+ @out.puts "#{t.strftime('%H:%M:%S.%L')} #{mark} #{text}"
283
+ end
284
+ @out.flush
285
+ end
286
+
287
+ # ------------------------------------------------------------ ping
288
+
289
+ # Round trips to PATH: the meta of an object (no method runs), the
290
+ # profile of a node, or with --call a method (with the ARGs given).
291
+ def cmd_ping(rest)
292
+ path = rest.shift or raise ArgumentError, "ping: which node or object? (asterism ping node/app/object)"
293
+ args = rest.map { Literal.parse(_1) }
294
+ chunks = path.split("/")
295
+ raise ArgumentError, "ping: PATH is node or node/app/object" unless [1, 3].include?(chunks.size)
296
+ raise ArgumentError, "ping: --call needs node/app/object" if @opts[:call] && chunks.size != 3
297
+ what = @opts[:call] ? "call #{@opts[:call]}" : (chunks.size == 3 ? "meta" : "profile")
298
+ times = []
299
+ connect do |_net, _sv|
300
+ ms = (@opts[:timeout] * 1000).round
301
+ @opts[:count].times do |i|
302
+ sleep @opts[:interval] if i.positive?
303
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
304
+ ok = begin
305
+ ping_once(path, chunks, args, ms)
306
+ true
307
+ rescue ::Asterism::RemoteError => e
308
+ @err.puts "asterism: #{path}: #{e.remote_kind || 'error'}: #{e.message}" if i.zero?
309
+ true # it answered
310
+ rescue ::Asterism::TimeoutError => e
311
+ @err.puts "asterism: #{e.message}"
312
+ false
313
+ end
314
+ dt = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000
315
+ times << dt if ok
316
+ @out.puts "#{path} #{what}: #{ok ? format('%.1f ms', dt) : 'no answer'}" unless @opts[:json]
317
+ end
318
+ end
319
+ if @opts[:json]
320
+ emit_json({ "path" => path, "what" => what, "count" => @opts[:count], "answered" => times.size,
321
+ "ms" => times.map { _1.round(2) } })
322
+ elsif times.any?
323
+ @out.puts format("%d of %d answered: min %.1f / avg %.1f / max %.1f ms", times.size, @opts[:count],
324
+ times.min, times.sum / times.size, times.max)
325
+ end
326
+ times.empty? ? 1 : 0
327
+ end
328
+
329
+ def ping_once(path, chunks, args, ms)
330
+ if @opts[:call]
331
+ ::Asterism[path, timeout_ms: ms].asterism_call(@opts[:call], *args)
332
+ elsif chunks.size == 3
333
+ raw = ::Asterism.meta(path, ms)
334
+ raise ::Asterism::Error, "malformed meta from #{path}" unless raw.is_a?(Hash)
335
+ else
336
+ ::Asterism.profile_async(path, false, nil, ms).raw_value
337
+ end
338
+ end
339
+
340
+ # ------------------------------------------------------------ call
341
+
342
+ def cmd_call(rest)
343
+ path = rest.shift
344
+ method = rest.shift
345
+ raise ArgumentError, "call: asterism call node/app/object METHOD [ARG...]" unless path && method
346
+ ::Asterism.split_path(path)
347
+ args = rest.map { Literal.parse(_1) }
348
+ kwargs = @opts[:kwargs] ? Literal.parse(@opts[:kwargs]) : {}
349
+ raise ArgumentError, "--kwargs: expected a JSON object or a Ruby Hash" unless kwargs.is_a?(Hash)
350
+ kwargs = kwargs.transform_keys(&:to_sym)
351
+ code = 0
352
+ connect do |_net, _sv|
353
+ px = ::Asterism[path, timeout: @opts[:timeout]]
354
+ begin
355
+ v = px.asterism_call(method, *args, **kwargs)
356
+ @opts[:json] ? @out.puts(JSON.generate({ "value" => json_safe(v) })) : @out.puts(v.inspect)
357
+ rescue ::Asterism::RemoteError => e
358
+ if @opts[:json]
359
+ @out.puts JSON.generate({ "error" => { "kind" => e.remote_kind, "class" => e.remote_class,
360
+ "message" => e.remote_message } })
361
+ else
362
+ @err.puts "asterism: #{path}.#{method}: #{e.remote_kind || 'error'}: #{e.message}"
363
+ end
364
+ code = 1
365
+ rescue ::Asterism::TimeoutError => e
366
+ @err.puts "asterism: #{e.message}" \
367
+ "#{"\n (with a console's .tar, the router refuses a call its role does not allow; " \
368
+ 'it looks the same as no answer)' if @opts[:credentials]}"
369
+ code = 1
370
+ end
371
+ end
372
+ code
373
+ end
374
+
375
+ # ------------------------------------------------------------ mcp
376
+
377
+ def cmd_mcp(_rest)
378
+ begin
379
+ require_relative "cli/mcp"
380
+ rescue LoadError => e
381
+ raise unless e.message.include?("mcp")
382
+ @err.puts "asterism mcp needs the mcp gem: gem install mcp (#{e.message})"
383
+ return 1
384
+ end
385
+ connect(app_prefix: "mcp") do |net, sv|
386
+ MCPBridge.new(net, sv, allow: @opts[:allow], log: @err).run
387
+ end
388
+ 0
389
+ end
390
+
391
+ # ------------------------------------------------------------ text
392
+
393
+ def profile_text(pr)
394
+ return "" unless pr
395
+ parts = [pr["kind"], pr["host"]].compact.join("/")
396
+ name = pr["name"] ? " \"#{pr['name']}\"" : ""
397
+ " (#{parts})#{name}"
398
+ end
399
+
400
+ def methods_text(list)
401
+ list.map { |m| signature(m) }.join(" ")
402
+ end
403
+
404
+ # name(arg, arg) with the names the meta tells, else name(2) / name(*).
405
+ def signature(m)
406
+ return "#{m['name']}(#{m['args'].join(', ')}#{', *' if m['arity'].negative?})" if m["args"]
407
+ m["arity"].negative? ? "#{m['name']}(*)" : "#{m['name']}(#{m['arity']})"
408
+ end
409
+
410
+ def compact(v)
411
+ JSON.generate(json_safe(v))
412
+ end
413
+
414
+ # A value as JSON can hold it (bytes as a note).
415
+ def json_safe(v)
416
+ case v
417
+ when Hash then v.to_h { |k, x| [k.to_s, json_safe(x)] }
418
+ when Array then v.map { json_safe(_1) }
419
+ when String then v.encoding == Encoding::BINARY && !v.ascii_only? ? "(#{v.bytesize} bytes)" : v
420
+ else v
421
+ end
422
+ end
423
+ end
424
+ end
@@ -1,4 +1,5 @@
1
- # The credentials of one node or router as one file (CRuby only): the .tar
1
+ # The credentials of one node or router as one file, CRuby's part (the
2
+ # class itself is mrblib/credentials.rb, shared with the boards): the .tar
2
3
  # an Asterism console hands out when it issues a certificate ("Save all
3
4
  # (.tar)" on the certificate page; the archive is made in the browser that
4
5
  # made the key). It holds, at its top or in one directory:
@@ -23,130 +24,74 @@ require "json"
23
24
  require "stringio"
24
25
 
25
26
  module Asterism
27
+ # The portable class is mrblib/credentials.rb (the boards read the .tar
28
+ # with it); CRuby reads the file with the standard library and checks
29
+ # more: who may read the file, and the key pair (prepended, so the
30
+ # portable methods are not redefined).
26
31
  class Credentials
27
- # What a credentials file is missing or holds wrong.
28
- class Error < ::Asterism::Error; end
32
+ module CRuby
33
+ private
29
34
 
30
- TLS = "transport/link/tls"
31
- # The largest file read (the real ones are a few KB).
32
- MAX_SIZE = 1024 * 1024
33
-
34
- attr_reader :path, :name, :kind, :router, :not_after, :key_pem, :cert_pem, :ca_pem, :info
35
-
36
- # Reads path (a String or Pathname of a .tar). warn: false skips the
37
- # check of who may read the file.
38
- def self.load(path, warn: true)
39
- new(path, warn: warn)
40
- end
41
-
42
- # A credentials file (a path or a Credentials) and the arguments of a
43
- # connection -> [locator, config Hash, credentials]. The locator given
44
- # wins over asterism.json's router; config given (a Hash) is laid over
45
- # the TLS keys. @api private
46
- def self.merge(credentials, locator, config)
47
- creds = credentials.is_a?(Credentials) ? credentials : load(credentials)
48
- if config.is_a?(String)
49
- raise ArgumentError, "credentials: and a config String cannot be combined; pass config: as a Hash"
35
+ def b64(pem)
36
+ [pem].pack("m0")
50
37
  end
51
- merged = creds.zenoh_config.merge(config || {})
52
- [locator || creds.router, merged, creds]
53
- end
54
-
55
- def initialize(path, warn: true)
56
- @path = path.to_s
57
- check_mode if warn
58
- files = read_tar
59
- @info = parse_info(files["asterism.json"])
60
- keys = files.keys.select { _1.end_with?(".key") }
61
- raise Error, "#{@path}: no private key (<name>.key) in it" if keys.empty?
62
- raise Error, "#{@path}: more than one private key in it (#{keys.sort.join(', ')})" if keys.size > 1
63
- @name = @info["name"].to_s
64
- @name = File.basename(keys.first, ".key") if @name.empty?
65
- @key_pem = files["#{@name}.key"] or raise Error, "#{@path}: the key is not named after #{@name} (#{keys.first})"
66
- @cert_pem = files["#{@name}.pem"] or raise Error, "#{@path}: no certificate #{@name}.pem in it"
67
- @ca_pem = files["ca.pem"] or raise Error, "#{@path}: no ca.pem in it"
68
- @kind = @info["kind"]
69
- @router = @info["router"].to_s.empty? ? nil : @info["router"].to_s
70
- @not_after = @info["not_after"]
71
- check_pair
72
- end
73
-
74
- # The zenoh configuration keys (a Hash for Session.open's config:):
75
- # the CA, the certificate and the key as base64, and mutual TLS.
76
- def zenoh_config
77
- {
78
- "#{TLS}/root_ca_certificate_base64" => b64(@ca_pem),
79
- "#{TLS}/connect_certificate_base64" => b64(@cert_pem),
80
- "#{TLS}/connect_private_key_base64" => b64(@key_pem),
81
- "#{TLS}/enable_mtls" => true
82
- }
83
- end
84
-
85
- def inspect
86
- "#<Asterism::Credentials #{@name} #{@router || '(no router)'}#{@not_after ? " until #{@not_after}" : ''}>"
87
- end
88
- alias to_s inspect
89
-
90
- private
91
38
 
92
- def b64(pem)
93
- [pem].pack("m0")
94
- end
95
-
96
- # Like ssh with a private key: a file that the group or others may read
97
- # is told about (it still works).
98
- def check_mode
99
- st = File.stat(@path)
100
- return if Gem.win_platform? || (st.mode & 0o077).zero?
101
- Kernel.warn("asterism: #{@path} holds a private key and can be read by others " \
102
- "(mode #{format('%04o', st.mode & 0o7777)}); chmod 600 #{@path}")
103
- rescue SystemCallError => e
104
- raise Error, "#{@path}: #{e.message}"
105
- end
39
+ # Like ssh with a private key: a file that the group or others may read
40
+ # is told about (it still works).
41
+ def check_mode
42
+ st = File.stat(@path)
43
+ return if Gem.win_platform? || (st.mode & 0o077).zero?
44
+ Kernel.warn("asterism: #{@path} holds a private key and can be read by others " \
45
+ "(mode #{format('%04o', st.mode & 0o7777)}); chmod 600 #{@path}")
46
+ rescue SystemCallError => e
47
+ raise Error, "#{@path}: #{e.message}"
48
+ end
106
49
 
107
- # {basename => content} of the regular files, at the top or in one
108
- # directory.
109
- def read_tar
110
- data = File.binread(@path)
111
- raise Error, "#{@path}: too large for a credentials file" if data.bytesize > MAX_SIZE
112
- files = {}
113
- Gem::Package::TarReader.new(StringIO.new(data)) do |tar|
114
- tar.each do |entry|
115
- next unless entry.file?
116
- base = File.basename(entry.full_name)
117
- raise Error, "#{@path}: #{base} is in it twice" if files.key?(base)
118
- files[base] = entry.read.to_s
50
+ # {basename => content} of the regular files, at the top or in one
51
+ # directory.
52
+ def read_tar
53
+ data = File.binread(@path)
54
+ raise Error, "#{@path}: too large for a credentials file" if data.bytesize > MAX_SIZE
55
+ files = {}
56
+ Gem::Package::TarReader.new(StringIO.new(data)) do |tar|
57
+ tar.each do |entry|
58
+ next unless entry.file?
59
+ base = File.basename(entry.full_name)
60
+ raise Error, "#{@path}: #{base} is in it twice" if files.key?(base)
61
+ files[base] = entry.read.to_s
62
+ end
119
63
  end
64
+ raise Error, "#{@path}: not a tar file with credentials in it" if files.empty?
65
+ files
66
+ rescue SystemCallError => e
67
+ raise Error, "#{@path}: #{e.message}"
68
+ rescue Gem::Package::TarInvalidError, ArgumentError => e
69
+ raise Error, "#{@path}: not a tar file (#{e.message})"
120
70
  end
121
- raise Error, "#{@path}: not a tar file with credentials in it" if files.empty?
122
- files
123
- rescue SystemCallError => e
124
- raise Error, "#{@path}: #{e.message}"
125
- rescue Gem::Package::TarInvalidError, ArgumentError => e
126
- raise Error, "#{@path}: not a tar file (#{e.message})"
127
- end
128
71
 
129
- def parse_info(text)
130
- return {} if text.nil?
131
- h = JSON.parse(text)
132
- raise Error, "#{@path}: asterism.json is not an object" unless h.is_a?(Hash)
133
- h
134
- rescue JSON::ParserError => e
135
- raise Error, "#{@path}: asterism.json: #{e.message}"
136
- end
72
+ def parse_info(text)
73
+ return {} if text.nil?
74
+ h = JSON.parse(text)
75
+ raise Error, "#{@path}: asterism.json is not an object" unless h.is_a?(Hash)
76
+ h
77
+ rescue JSON::ParserError => e
78
+ raise Error, "#{@path}: asterism.json: #{e.message}"
79
+ end
137
80
 
138
- # The key belongs to the certificate, and the certificate has not
139
- # expired (a warning: zenoh refuses it anyway). Skipped without openssl.
140
- def check_pair
141
- require "openssl"
142
- cert = OpenSSL::X509::Certificate.new(@cert_pem)
143
- key = OpenSSL::PKey.read(@key_pem)
144
- raise Error, "#{@path}: #{@name}.key is not the key of #{@name}.pem" unless cert.check_private_key(key)
145
- Kernel.warn("asterism: #{@path}: the certificate expired at #{cert.not_after.utc}") if cert.not_after < Time.now
146
- rescue LoadError
147
- nil
148
- rescue OpenSSL::OpenSSLError => e
149
- raise Error, "#{@path}: #{e.message}"
81
+ # The key belongs to the certificate, and the certificate has not
82
+ # expired (a warning: zenoh refuses it anyway). Skipped without openssl.
83
+ def check_pair
84
+ require "openssl"
85
+ cert = OpenSSL::X509::Certificate.new(@cert_pem)
86
+ key = OpenSSL::PKey.read(@key_pem)
87
+ raise Error, "#{@path}: #{@name}.key is not the key of #{@name}.pem" unless cert.check_private_key(key)
88
+ Kernel.warn("asterism: #{@path}: the certificate expired at #{cert.not_after.utc}") if cert.not_after < Time.now
89
+ rescue LoadError
90
+ nil
91
+ rescue OpenSSL::OpenSSLError => e
92
+ raise Error, "#{@path}: #{e.message}"
93
+ end
150
94
  end
95
+ prepend CRuby
151
96
  end
152
97
  end